feat: finally a good working state

This commit is contained in:
2026-02-26 11:55:21 +01:00
parent cf57879d1f
commit 121aa6a9f7
12 changed files with 585 additions and 44 deletions

View File

@@ -0,0 +1,153 @@
/**
* InlineSurface component.
*
* Wraps A2UIRenderer with expand/collapse and dismiss controls.
* Renders inline within the chat transcript, anchored to the
* assistant message turn that created the surface.
*/
import React, { useEffect, useState } from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../main/a2ui/types';
import { A2UIRenderer } from './A2UIRenderer';
import './InlineSurface.css';
interface InlineSurfaceProps {
surfaceId: string;
tree: A2UIResolvedComponent[];
isExpanded: boolean;
onDismiss?: (surfaceId: string) => void;
onAction?: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
}
/**
* Derive a display title from the surface's component tree.
* Tries the root component's `title` or `label` property,
* then falls back to the capitalized component type.
*/
export function deriveSurfaceTitle(tree: A2UIResolvedComponent[]): string {
if (tree.length === 0) {
return 'Surface';
}
const root = tree[0];
const title = root.properties?.title as string | undefined;
if (title) {
return title;
}
const label = root.properties?.label as string | undefined;
if (label) {
return label;
}
return root.type.charAt(0).toUpperCase() + root.type.slice(1);
}
/**
* Get an icon character for the surface based on the root component type.
*/
export function getSurfaceIcon(tree: A2UIResolvedComponent[]): string {
if (tree.length === 0) {
return '\u25A0';
}
const type = tree[0].type;
const icons: Record<string, string> = {
chart: '\u{1F4CA}',
table: '\u{1F4CB}',
form: '\u{1F4DD}',
card: '\u{1F4C4}',
metric: '\u{1F4CF}',
list: '\u{1F4CB}',
tabs: '\u{1F4C2}',
};
return icons[type] || '\u25A0';
}
export const InlineSurface: React.FC<InlineSurfaceProps> = ({
surfaceId,
tree,
isExpanded: defaultExpanded,
onDismiss,
onAction,
onDataChange,
}) => {
const [expanded, setExpanded] = useState(defaultExpanded);
// Auto-collapse/expand when the parent changes which surface is latest
useEffect(() => {
setExpanded(defaultExpanded);
}, [defaultExpanded]);
const surfaceTitle = deriveSurfaceTitle(tree);
const surfaceIcon = getSurfaceIcon(tree);
if (!expanded) {
return (
<div
className="inline-surface collapsed"
onClick={() => setExpanded(true)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
setExpanded(true);
}
}}
>
<span className="inline-surface-icon">{surfaceIcon}</span>
<span className="inline-surface-title">{surfaceTitle}</span>
<button
className="inline-surface-expand"
onClick={(e) => {
e.stopPropagation();
setExpanded(true);
}}
aria-label="Expand surface"
>
{'\u25B6'}
</button>
{onDismiss && (
<button
className="inline-surface-dismiss"
onClick={(e) => {
e.stopPropagation();
onDismiss(surfaceId);
}}
aria-label="Dismiss surface"
>
{'\u2715'}
</button>
)}
</div>
);
}
return (
<div className="inline-surface expanded">
<div className="inline-surface-header">
<span className="inline-surface-icon">{surfaceIcon}</span>
<span className="inline-surface-title">{surfaceTitle}</span>
<button
className="inline-surface-collapse"
onClick={() => setExpanded(false)}
aria-label="Collapse surface"
>
{'\u25BC'}
</button>
{onDismiss && (
<button
className="inline-surface-dismiss"
onClick={() => onDismiss(surfaceId)}
aria-label="Dismiss surface"
>
{'\u2715'}
</button>
)}
</div>
<A2UIRenderer
surfaceId={surfaceId}
tree={tree}
onAction={onAction!}
onDataChange={onDataChange}
/>
</div>
);
};