mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-23 18:14:53 +02:00
[eric] chat: rich outputs copy as what they are, tables as TSV, visuals as PNG, code as source
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user