[eric] chat: rich outputs copy as what they are, tables as TSV, visuals as PNG, code as source

This commit is contained in:
ciregenz
2026-08-03 21:09:48 -07:00
parent c44dea3597
commit 0826b72686
3 changed files with 120 additions and 3 deletions
@@ -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<DefaultToolBubbleProps> = ({
}) => {
const c = useClaudeTokens();
const tc = useTermColors();
const richWidgetRef = React.useRef<HTMLDivElement>(null);
// Auto-elevated rendering: builtin coding tools map onto the vendored terminal/code components by schema, no ShowUI involved; null keeps the classic colorized <pre>. 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<DefaultToolBubbleProps> = ({
<Collapse in={showBody && canToggleDetails}>
{richRender ? (
<Box sx={{ p: 1, bgcolor: tc.TERM_BG, borderTop: `1px solid ${tc.TERM_BORDER}` }}>
<VendoredToolUi name={richRender.name} props={richRender.props} />
<Box sx={{ p: 1, bgcolor: tc.TERM_BG, borderTop: `1px solid ${tc.TERM_BORDER}`, position: 'relative', '&:hover .osw-widget-copy': { opacity: 1 } }}>
<WidgetCopyChip component={richRender.name} props={richRender.props} containerRef={richWidgetRef} />
<Box ref={richWidgetRef}>
<VendoredToolUi name={richRender.name} props={richRender.props} />
</Box>
{parsedResult?.platformNote && (
<Typography sx={{ mt: 0.5, px: 0.5, fontSize: '0.6875rem', color: c.text.tertiary }}>
{parsedResult.platformNote}
@@ -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<HTMLDivElement>(null);
const payload = useMemo(
() => (rawPayload ? freezeIfDone(rawPayload, sessionRunning) : null),
[rawPayload, sessionRunning],
@@ -27,8 +29,10 @@ function ToolUiBubble({ pair, sessionId, isPending, suppressReveal, sessionRunni
}
return (
<Box
ref={widgetRef}
sx={{
my: 1,
position: 'relative',
contain: 'layout style',
// One-shot entrance (assistant-ui's fade + rise + blur-in): the card arrives, it doesn't pop.
animation: 'toolUiEnter 240ms cubic-bezier(0.32, 0.72, 0, 1)',
@@ -37,11 +41,17 @@ function ToolUiBubble({ pair, sessionId, isPending, suppressReveal, sessionRunni
to: { opacity: 1, transform: 'translateY(0)', filter: 'blur(0)' },
},
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
'&:hover .osw-widget-copy': { opacity: 1 },
}}
data-select-type="tool-ui"
data-select-id={pair.id}
data-select-meta={JSON.stringify({ component: payload.component })}
>
<WidgetCopyChip
component={payload.component === 'vendored' ? payload.name : payload.component}
props={payload.props as Record<string, unknown>}
containerRef={widgetRef}
/>
<ShowUiWidgetView payload={payload} />
</Box>
);
@@ -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<string, unknown>;
/** The rendered widget's container, for image capture. */
containerRef: RefObject<HTMLElement | null>;
}
function tableToTsv(props: Record<string, unknown>): string | null {
const columns = props.columns as Array<{ key: string; label: string }> | undefined;
const data = props.data as Array<Record<string, unknown>> | 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<string, unknown>)[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<WidgetCopyChipProps> = ({ component, props, containerRef }) => {
const c = useClaudeTokens();
const [copied, setCopied] = useState(false);
const resetTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<Box
role="button"
aria-label={copied ? 'Copied' : 'Copy'}
onClick={handleCopy}
className="osw-widget-copy"
sx={{
position: 'absolute',
top: 6,
right: 6,
zIndex: 5,
width: 24,
height: 24,
borderRadius: '7px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.medium}`,
color: copied ? c.status.success : c.text.tertiary,
cursor: 'pointer',
opacity: 0,
transition: 'opacity 0.15s ease, color 0.15s ease',
'&:hover': { color: copied ? c.status.success : c.text.primary },
}}
>
{copied ? <CheckIcon sx={{ fontSize: 14 }} /> : <ContentCopyIcon sx={{ fontSize: 13 }} />}
</Box>
);
};
export default WidgetCopyChip;