diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx index 1cf56ce7..a21c1775 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx @@ -24,6 +24,7 @@ import { McpResultCard } from '../mcp-cards/McpResultCard'; import { domainFromUrl } from './SourceFavicons'; import { DomainIcon } from './DomainIcon'; import VendoredToolUi from '@toolui/VendoredToolUi'; +import WidgetCopyChip from '../tool-ui/WidgetCopyChip'; interface DefaultToolBubbleProps { call: AgentMessage; @@ -59,6 +60,7 @@ export const DefaultToolBubble: React.FC = ({ }) => { const c = useClaudeTokens(); const tc = useTermColors(); + const richWidgetRef = React.useRef(null); // Auto-elevated rendering: builtin coding tools map onto the vendored terminal/code components by schema, no ShowUI involved; null keeps the classic colorized
. Streaming stays on the classic path (partial args are unparseable).
   const richRender = React.useMemo(
     () => (!isStreaming && result ? resolveRichRender(toolName, input ?? {}, parsedResult, resultElapsedMs, getToolData(call).toolId || call.id) : null),
@@ -218,8 +220,11 @@ export const DefaultToolBubble: React.FC = ({
 
         
           {richRender ? (
-            
-              
+            
+              
+              
+                
+              
               {parsedResult?.platformNote && (
                 
                   {parsedResult.platformNote}
diff --git a/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx b/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx
index 59a68b68..1ce0712c 100644
--- a/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx
+++ b/frontend/src/app/pages/AgentChat/tool-ui/ToolUiBubble.tsx
@@ -1,9 +1,10 @@
-import React, { useMemo } from 'react';
+import React, { useMemo, useRef } from 'react';
 import Box from '@mui/material/Box';
 import ToolCallBubble from '../tool-bubbles/ToolCallBubble';
 import type { ToolPair } from '../tool-bubbles/ToolCallBubble';
 import { parseShowUiPayload, freezeIfDone } from './showUiPayload';
 import ShowUiWidgetView from './ShowUiWidgetView';
+import WidgetCopyChip from './WidgetCopyChip';
 
 interface ToolUiBubbleProps {
   pair: ToolPair;
@@ -16,6 +17,7 @@ interface ToolUiBubbleProps {
 /** Renders a ShowUI call as its inline component; any schema mismatch falls back to the plain tool bubble. */
 function ToolUiBubble({ pair, sessionId, isPending, suppressReveal, sessionRunning = false }: ToolUiBubbleProps): React.ReactElement {
   const rawPayload = parseShowUiPayload(pair);
+  const widgetRef = useRef(null);
   const payload = useMemo(
     () => (rawPayload ? freezeIfDone(rawPayload, sessionRunning) : null),
     [rawPayload, sessionRunning],
@@ -27,8 +29,10 @@ function ToolUiBubble({ pair, sessionId, isPending, suppressReveal, sessionRunni
   }
   return (
     
+      }
+        containerRef={widgetRef}
+      />
       
     
   );
diff --git a/frontend/src/app/pages/AgentChat/tool-ui/WidgetCopyChip.tsx b/frontend/src/app/pages/AgentChat/tool-ui/WidgetCopyChip.tsx
new file mode 100644
index 00000000..be35623d
--- /dev/null
+++ b/frontend/src/app/pages/AgentChat/tool-ui/WidgetCopyChip.tsx
@@ -0,0 +1,102 @@
+import React, { useCallback, useRef, useState, type RefObject } from 'react';
+import Box from '@mui/material/Box';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import CheckIcon from '@mui/icons-material/Check';
+import { toPng } from 'html-to-image';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+
+interface WidgetCopyChipProps {
+  /** The vendored component name ('data-table', 'chart', ...) or a ShowUI alias. */
+  component: string;
+  props: Record;
+  /** The rendered widget's container, for image capture. */
+  containerRef: RefObject;
+}
+
+function tableToTsv(props: Record): string | null {
+  const columns = props.columns as Array<{ key: string; label: string }> | undefined;
+  const data = props.data as Array> | undefined;
+  if (!Array.isArray(columns) || !Array.isArray(data)) return null;
+  const head = columns.map((col) => String(col.label ?? col.key)).join('\t');
+  const rows = data.map((row) => columns.map((col) => {
+    const v = (row as Record)[col.key];
+    return v === null || v === undefined ? '' : String(v);
+  }).join('\t'));
+  return [head, ...rows].join('\n');
+}
+
+// Non-text widget outputs need their own copy: a table copies as TSV (pastes straight into
+// Sheets/Excel), a visual copies as a PNG, code copies its source. Hover chip, top-right.
+const WidgetCopyChip: React.FC = ({ component, props, containerRef }) => {
+  const c = useClaudeTokens();
+  const [copied, setCopied] = useState(false);
+  const resetTimer = useRef | null>(null);
+
+  const flashCopied = useCallback(() => {
+    setCopied(true);
+    if (resetTimer.current) clearTimeout(resetTimer.current);
+    resetTimer.current = setTimeout(() => setCopied(false), 1200);
+  }, []);
+
+  const handleCopy = useCallback(async (e: React.MouseEvent) => {
+    e.stopPropagation();
+    try {
+      if (component === 'data-table') {
+        const tsv = tableToTsv(props);
+        if (tsv) { await navigator.clipboard.writeText(tsv); flashCopied(); return; }
+      }
+      if (component === 'code-block' && typeof props.code === 'string') {
+        await navigator.clipboard.writeText(props.code); flashCopied(); return;
+      }
+      if (component === 'code-diff') {
+        const body = typeof props.patch === 'string' ? props.patch : `${props.oldCode ?? ''}\n---\n${props.newCode ?? ''}`;
+        await navigator.clipboard.writeText(body); flashCopied(); return;
+      }
+      const node = containerRef.current;
+      if (node) {
+        // Visuals (chart, stats, map, image...) copy as a real image; 2x for retina-crisp pastes.
+        const dataUrl = await toPng(node as HTMLElement, { pixelRatio: 2 });
+        const blob = await (await fetch(dataUrl)).blob();
+        await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
+        flashCopied();
+        return;
+      }
+      await navigator.clipboard.writeText(JSON.stringify(props, null, 2));
+      flashCopied();
+    } catch {
+      try { await navigator.clipboard.writeText(JSON.stringify(props, null, 2)); flashCopied(); } catch { /* clipboard denied: chip just doesn't flash */ }
+    }
+  }, [component, props, containerRef, flashCopied]);
+
+  return (
+    
+      {copied ?  : }
+    
+  );
+};
+
+export default WidgetCopyChip;