wip: complete rework first round

This commit is contained in:
2026-02-26 09:27:22 +01:00
parent c70f4b9154
commit affd62ca79
78 changed files with 2635 additions and 4053 deletions

View File

@@ -0,0 +1,101 @@
/**
* A2UI Renderer
*
* Maps A2UI resolved component trees to React components.
* Uses the component catalog to look up renderers for each component type.
*/
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../main/a2ui/types';
import { A2UIText } from './components/A2UIText';
import { A2UIButton } from './components/A2UIButton';
import { A2UICard } from './components/A2UICard';
import { A2UIChart } from './components/A2UIChart';
import { A2UITable } from './components/A2UITable';
import { A2UIForm } from './components/A2UIForm';
import { A2UITextField } from './components/A2UITextField';
import { A2UICheckBox } from './components/A2UICheckBox';
import { A2UIDateTimeInput } from './components/A2UIDateTimeInput';
import { A2UIChoicePicker } from './components/A2UIChoicePicker';
import { A2UIImage } from './components/A2UIImage';
import { A2UITabs } from './components/A2UITabs';
import { A2UIMetric } from './components/A2UIMetric';
import { A2UIList } from './components/A2UIList';
import { A2UIRow } from './components/A2UIRow';
import { A2UIColumn } from './components/A2UIColumn';
import { A2UIDivider } from './components/A2UIDivider';
export interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
type ComponentRenderer = React.FC<A2UIComponentProps>;
const COMPONENT_REGISTRY: Record<string, ComponentRenderer> = {
text: A2UIText,
button: A2UIButton,
card: A2UICard,
chart: A2UIChart,
table: A2UITable,
form: A2UIForm,
textField: A2UITextField,
checkBox: A2UICheckBox,
dateTimeInput: A2UIDateTimeInput,
choicePicker: A2UIChoicePicker,
image: A2UIImage,
tabs: A2UITabs,
metric: A2UIMetric,
list: A2UIList,
row: A2UIRow,
column: A2UIColumn,
divider: A2UIDivider,
};
interface A2UIRendererProps {
surfaceId: string;
tree: A2UIResolvedComponent[];
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
}
export const A2UIRenderer: React.FC<A2UIRendererProps> = ({
surfaceId,
tree,
onAction,
onDataChange,
}) => {
const renderComponent = (component: A2UIResolvedComponent): React.ReactNode => {
const Renderer = COMPONENT_REGISTRY[component.type];
if (!Renderer) {
return null;
}
const renderChildren = (children: A2UIResolvedComponent[]): React.ReactNode =>
children.map(renderComponent);
return (
<Renderer
key={component.id}
component={component}
surfaceId={surfaceId}
onAction={onAction}
onDataChange={onDataChange}
renderChildren={renderChildren}
/>
);
};
if (tree.length === 0) {
return null;
}
return (
<div className="a2ui-surface assistant-panel-controls chat-surface-section">
{tree.map(renderComponent)}
</div>
);
};

View File

@@ -0,0 +1,244 @@
/**
* A2UI Surface Manager
*
* Client-side state manager that processes incoming A2UI server messages
* and maintains surface state (component buffer, data model, component tree).
*
* This is a pure state manager with no React dependency — it can be tested
* independently and wrapped by a React hook.
*/
import type {
A2UIServerMessage,
A2UISurfaceState,
A2UIResolvedComponent,
} from '../../main/a2ui/types';
export type SurfaceChangeListener = (surfaceId: string) => void;
export class A2UISurfaceManager {
private surfaces = new Map<string, A2UISurfaceState>();
private listeners: SurfaceChangeListener[] = [];
/**
* Process an incoming A2UI server message.
*/
processMessage(message: A2UIServerMessage): void {
switch (message.type) {
case 'createSurface':
this.surfaces.set(message.surfaceId, {
surfaceId: message.surfaceId,
conversationId: message.conversationId,
components: new Map(),
rootIds: [],
dataModel: {},
metadata: message.metadata,
});
this.notify(message.surfaceId);
break;
case 'updateComponents': {
const surface = this.surfaces.get(message.surfaceId);
if (!surface) {
return;
}
for (const component of message.components) {
surface.components.set(component.id, component);
}
if (message.rootIds) {
surface.rootIds = message.rootIds;
}
this.notify(message.surfaceId);
break;
}
case 'updateDataModel': {
const surface = this.surfaces.get(message.surfaceId);
if (!surface) {
return;
}
setValueAtPointer(surface.dataModel, message.path, message.value);
this.notify(message.surfaceId);
break;
}
case 'deleteSurface':
this.surfaces.delete(message.surfaceId);
this.notify(message.surfaceId);
break;
}
}
/**
* Get all active surface IDs for a conversation.
*/
getSurfaceIds(conversationId: string): string[] {
const ids: string[] = [];
for (const [surfaceId, state] of this.surfaces) {
if (state.conversationId === conversationId) {
ids.push(surfaceId);
}
}
return ids;
}
/**
* Get raw surface state.
*/
getSurface(surfaceId: string): A2UISurfaceState | undefined {
return this.surfaces.get(surfaceId);
}
/**
* Resolve the component tree for a surface.
* Converts flat component buffer + ID references into a nested tree.
*/
resolveTree(surfaceId: string): A2UIResolvedComponent[] {
const surface = this.surfaces.get(surfaceId);
if (!surface) {
return [];
}
return surface.rootIds
.map((id) => this.resolveComponent(surface, id))
.filter((c): c is A2UIResolvedComponent => c !== null);
}
/**
* Update the local data model value (for input binding).
*/
updateLocalData(surfaceId: string, path: string, value: unknown): void {
const surface = this.surfaces.get(surfaceId);
if (!surface) {
return;
}
setValueAtPointer(surface.dataModel, path, value);
this.notify(surfaceId);
}
/**
* Get the data model for a surface.
*/
getDataModel(surfaceId: string): Record<string, unknown> {
return this.surfaces.get(surfaceId)?.dataModel ?? {};
}
/**
* Delete all surfaces for a conversation.
*/
clearConversation(conversationId: string): void {
const toDelete: string[] = [];
for (const [surfaceId, state] of this.surfaces) {
if (state.conversationId === conversationId) {
toDelete.push(surfaceId);
}
}
for (const surfaceId of toDelete) {
this.surfaces.delete(surfaceId);
this.notify(surfaceId);
}
}
/**
* Subscribe to surface changes.
*/
onChange(listener: SurfaceChangeListener): () => void {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
}
private notify(surfaceId: string): void {
for (const listener of this.listeners) {
listener(surfaceId);
}
}
private resolveComponent(
surface: A2UISurfaceState,
componentId: string,
): A2UIResolvedComponent | null {
const component = surface.components.get(componentId);
if (!component) {
return null;
}
const children = (component.children ?? [])
.map((childId) => this.resolveComponent(surface, childId))
.filter((c): c is A2UIResolvedComponent => c !== null);
let boundValue: unknown = undefined;
if (component.dataBinding) {
boundValue = getValueAtPointer(surface.dataModel, component.dataBinding);
}
return {
id: component.id,
type: component.type,
properties: component.properties,
dataBinding: component.dataBinding,
boundValue,
actions: component.actions,
children,
};
}
}
/**
* Get a value from a JSON object using a JSON Pointer (RFC 6901).
*/
export function getValueAtPointer(
obj: Record<string, unknown>,
pointer: string,
): unknown {
if (!pointer || pointer === '/') {
return obj;
}
const parts = pointer.split('/').filter(Boolean);
let current: unknown = obj;
for (const part of parts) {
const key = part.replace(/~1/g, '/').replace(/~0/g, '~');
if (current === null || current === undefined || typeof current !== 'object') {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
}
/**
* Set a value in a JSON object using a JSON Pointer (RFC 6901).
*/
export function setValueAtPointer(
obj: Record<string, unknown>,
pointer: string,
value: unknown,
): void {
if (!pointer || pointer === '/') {
return;
}
const parts = pointer.split('/').filter(Boolean);
let current: Record<string, unknown> = obj;
for (let i = 0; i < parts.length - 1; i++) {
const key = parts[i].replace(/~1/g, '/').replace(/~0/g, '~');
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key] as Record<string, unknown>;
}
const lastKey = parts[parts.length - 1].replace(/~1/g, '/').replace(/~0/g, '~');
current[lastKey] = value;
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIButton: React.FC<A2UIComponentProps> = ({ component, surfaceId, onAction }) => {
const label = String(component.properties.label ?? '');
const handleClick = () => {
const actionDef = component.actions?.[0];
if (!actionDef) {
return;
}
if (actionDef.policy === 'confirm' || actionDef.policy === 'danger') {
const confirmed = window.confirm(label || actionDef.action);
if (!confirmed) {
return;
}
}
onAction({
surfaceId,
componentId: component.id,
action: actionDef.action,
payload: actionDef.payload,
});
};
return (
<button type="button" onClick={handleClick}>
{label}
</button>
);
};

View File

@@ -0,0 +1,54 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UICard: React.FC<A2UIComponentProps> = ({ component, surfaceId, onAction }) => {
const title = String(component.properties.title ?? '');
const body = String(component.properties.body ?? '');
const subtitle = component.properties.subtitle as string | undefined;
const actions = component.actions ?? [];
const triggerAction = (actionDef: typeof actions[number]) => {
if (actionDef.policy === 'confirm' || actionDef.policy === 'danger') {
const confirmed = window.confirm(actionDef.action);
if (!confirmed) {
return;
}
}
onAction({
surfaceId,
componentId: component.id,
action: actionDef.action,
payload: actionDef.payload,
});
};
return (
<article className="assistant-panel-card">
<h4>{title}</h4>
{subtitle && <p className="assistant-panel-card-subtitle">{subtitle}</p>}
<p>{body}</p>
{actions.length > 0 && (
<div className="assistant-panel-card-actions">
{actions.map((actionDef, index) => (
<button
key={`${component.id}-action-${index}`}
type="button"
onClick={() => triggerAction(actionDef)}
>
{String(actionDef.payload?.label ?? actionDef.action)}
</button>
))}
</div>
)}
</article>
);
};

View File

@@ -0,0 +1,36 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
interface SeriesEntry {
label: string;
value: number;
}
export const A2UIChart: React.FC<A2UIComponentProps> = ({ component }) => {
const chartType = String(component.properties.chartType ?? 'bar');
const title = component.properties.title as string | undefined;
const series = (component.boundValue as SeriesEntry[]) ?? [];
const maxValue = Math.max(...series.map((entry) => entry.value), 0);
return (
<div className="assistant-panel-chart">
{title && <p className="assistant-panel-chart-title">{title}</p>}
<div className="assistant-panel-chart-type">{chartType}</div>
{series.map((entry, index) => (
<div key={`${component.id}-series-${index}`} className="assistant-panel-chart-item">
<span>{entry.label}</span>
<progress value={entry.value} max={maxValue || 1} />
<span>{entry.value}</span>
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,32 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UICheckBox: React.FC<A2UIComponentProps> = ({ component, surfaceId, onDataChange }) => {
const label = String(component.properties.label ?? '');
const checked = Boolean(component.boundValue ?? false);
const handleChange = (newChecked: boolean) => {
if (onDataChange && component.dataBinding) {
onDataChange(surfaceId, component.dataBinding, newChecked);
}
};
return (
<label className="assistant-panel-checkbox">
<input
type="checkbox"
checked={checked}
onChange={(e) => handleChange(e.target.checked)}
/>
<span>{label}</span>
</label>
);
};

View File

@@ -0,0 +1,44 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
interface ChoiceOption {
label: string;
value: string;
}
export const A2UIChoicePicker: React.FC<A2UIComponentProps> = ({ component, surfaceId, onDataChange }) => {
const label = String(component.properties.label ?? '');
const options = (component.properties.options as ChoiceOption[]) ?? [];
const value = String(component.boundValue ?? options[0]?.value ?? '');
const handleChange = (newValue: string) => {
if (onDataChange && component.dataBinding) {
onDataChange(surfaceId, component.dataBinding, newValue);
}
};
return (
<div className="assistant-panel-widget-block">
<label className="assistant-panel-widget-label">{label}</label>
<select
className="assistant-panel-widget-input chat-surface-input"
value={value}
onChange={(e) => handleChange(e.target.value)}
>
{options.map((option) => (
<option key={`${component.id}-opt-${option.value}`} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
};

View File

@@ -0,0 +1,18 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIColumn: React.FC<A2UIComponentProps> = ({ component, renderChildren }) => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{renderChildren?.(component.children)}
</div>
);
};

View File

@@ -0,0 +1,37 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIDateTimeInput: React.FC<A2UIComponentProps> = ({ component, surfaceId, onDataChange }) => {
const label = String(component.properties.label ?? '');
const min = component.properties.min as string | undefined;
const max = component.properties.max as string | undefined;
const value = String(component.boundValue ?? '');
const handleChange = (newValue: string) => {
if (onDataChange && component.dataBinding) {
onDataChange(surfaceId, component.dataBinding, newValue);
}
};
return (
<div className="assistant-panel-widget-block">
<label className="assistant-panel-widget-label">{label}</label>
<input
className="assistant-panel-widget-input chat-surface-input"
type="date"
value={value}
min={min}
max={max}
onChange={(e) => handleChange(e.target.value)}
/>
</div>
);
};

View File

@@ -0,0 +1,14 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIDivider: React.FC<A2UIComponentProps> = () => {
return <hr />;
};

View File

@@ -0,0 +1,21 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIForm: React.FC<A2UIComponentProps> = ({ component, renderChildren }) => {
const title = component.properties.title as string | undefined;
return (
<div className="assistant-panel-form">
{title && <p className="assistant-panel-form-title">{title}</p>}
{renderChildren?.(component.children)}
</div>
);
};

View File

@@ -0,0 +1,48 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIImage: React.FC<A2UIComponentProps> = ({ component, surfaceId, onAction }) => {
const src = String(component.properties.src ?? '');
const alt = String(component.properties.alt ?? '');
const caption = component.properties.caption as string | undefined;
const actionDef = component.actions?.[0];
const handleClick = () => {
if (!actionDef) {
return;
}
if (actionDef.policy === 'confirm' || actionDef.policy === 'danger') {
const confirmed = window.confirm(caption || alt || actionDef.action);
if (!confirmed) {
return;
}
}
onAction({
surfaceId,
componentId: component.id,
action: actionDef.action,
payload: actionDef.payload,
});
};
return (
<figure className="assistant-panel-image">
<img
src={src}
alt={alt}
onClick={handleClick}
/>
{caption && <figcaption>{caption}</figcaption>}
</figure>
);
};

View File

@@ -0,0 +1,26 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIList: React.FC<A2UIComponentProps> = ({ component }) => {
const title = component.properties.title as string | undefined;
const items = (component.boundValue as string[]) ?? [];
return (
<div>
{title && <p>{title}</p>}
<ul>
{items.map((item, index) => (
<li key={`${component.id}-item-${index}`}>{item}</li>
))}
</ul>
</div>
);
};

View File

@@ -0,0 +1,22 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIMetric: React.FC<A2UIComponentProps> = ({ component }) => {
const label = String(component.properties.label ?? '');
const value = String(component.properties.value ?? '');
return (
<div className="assistant-panel-metric">
<span className="assistant-panel-metric-label">{label}</span>
<strong className="assistant-panel-metric-value">{value}</strong>
</div>
);
};

View File

@@ -0,0 +1,18 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIRow: React.FC<A2UIComponentProps> = ({ component, renderChildren }) => {
return (
<div style={{ display: 'flex', flexDirection: 'row', gap: '8px' }}>
{renderChildren?.(component.children)}
</div>
);
};

View File

@@ -0,0 +1,40 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UITable: React.FC<A2UIComponentProps> = ({ component }) => {
const columns = (component.properties.columns as string[]) ?? [];
const rows = (component.boundValue as string[][]) ?? [];
const title = component.properties.title as string | undefined;
return (
<div>
{title && <p>{title}</p>}
<table className="assistant-panel-table">
<thead>
<tr>
{columns.map((column, colIndex) => (
<th key={`${component.id}-col-${colIndex}`}>{column}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={`${component.id}-row-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td key={`${component.id}-cell-${rowIndex}-${cellIndex}`}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};

View File

@@ -0,0 +1,37 @@
import React, { useState } from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UITabs: React.FC<A2UIComponentProps> = ({ component, renderChildren }) => {
const tabLabels = (component.properties.tabLabels as string[]) ?? [];
const [activeTab, setActiveTab] = useState(0);
const children = component.children;
return (
<div className="assistant-panel-tabs">
<div className="assistant-panel-tab-strip">
{tabLabels.map((label, index) => (
<button
key={`${component.id}-tab-${index}`}
type="button"
className={`assistant-panel-tab-button ${index === activeTab ? 'active' : ''}`}
onClick={() => setActiveTab(index)}
>
{label}
</button>
))}
</div>
<div className="assistant-panel-tab-panel">
{children[activeTab] && renderChildren?.([children[activeTab]])}
</div>
</div>
);
};

View File

@@ -0,0 +1,16 @@
import React from 'react';
import Markdown from 'marked-react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIText: React.FC<A2UIComponentProps> = ({ component }) => {
const text = String(component.properties.text ?? '');
return <Markdown>{text}</Markdown>;
};

View File

@@ -0,0 +1,51 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UITextField: React.FC<A2UIComponentProps> = ({ component, surfaceId, onDataChange }) => {
const label = String(component.properties.label ?? '');
const placeholder = (component.properties.placeholder as string) ?? '';
const inputType = component.properties.inputType as string | undefined;
const value = String(component.boundValue ?? '');
const handleChange = (newValue: string) => {
if (onDataChange && component.dataBinding) {
onDataChange(surfaceId, component.dataBinding, newValue);
}
};
if (inputType === 'textarea') {
return (
<div className="assistant-panel-widget-block">
<label className="assistant-panel-widget-label">{label}</label>
<textarea
className="assistant-panel-widget-input chat-surface-input"
value={value}
placeholder={placeholder}
onChange={(e) => handleChange(e.target.value)}
rows={3}
/>
</div>
);
}
return (
<div className="assistant-panel-widget-block">
<label className="assistant-panel-widget-label">{label}</label>
<input
className="assistant-panel-widget-input chat-surface-input"
type="text"
value={value}
placeholder={placeholder}
onChange={(e) => handleChange(e.target.value)}
/>
</div>
);
};

View File

@@ -0,0 +1,17 @@
export { A2UIText } from './A2UIText';
export { A2UIButton } from './A2UIButton';
export { A2UICard } from './A2UICard';
export { A2UIChart } from './A2UIChart';
export { A2UITable } from './A2UITable';
export { A2UIForm } from './A2UIForm';
export { A2UITextField } from './A2UITextField';
export { A2UICheckBox } from './A2UICheckBox';
export { A2UIDateTimeInput } from './A2UIDateTimeInput';
export { A2UIChoicePicker } from './A2UIChoicePicker';
export { A2UIImage } from './A2UIImage';
export { A2UITabs } from './A2UITabs';
export { A2UIMetric } from './A2UIMetric';
export { A2UIList } from './A2UIList';
export { A2UIRow } from './A2UIRow';
export { A2UIColumn } from './A2UIColumn';
export { A2UIDivider } from './A2UIDivider';

View File

@@ -0,0 +1,111 @@
/**
* React hook for A2UI surface state.
*
* Wraps A2UISurfaceManager and provides reactive state for React components.
* Subscribes to IPC events and feeds messages into the surface manager.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { A2UISurfaceManager } from './A2UISurfaceManager';
import type { A2UIResolvedComponent, A2UIServerMessage, A2UIClientAction } from '../../main/a2ui/types';
interface UseA2UISurfaceInput {
conversationId: string | null;
}
interface UseA2UISurfaceResult {
/** All active surface trees for this conversation */
surfaces: Array<{ surfaceId: string; tree: A2UIResolvedComponent[] }>;
/** Dispatch an action back to the main process */
dispatchAction: (action: A2UIClientAction) => void;
/** Update a local data binding (for form inputs) */
updateLocalData: (surfaceId: string, path: string, value: unknown) => void;
/** Get the data model for a surface */
getDataModel: (surfaceId: string) => Record<string, unknown>;
/** Clear all surfaces for the conversation */
clearSurfaces: () => void;
}
export function useA2UISurface(input: UseA2UISurfaceInput): UseA2UISurfaceResult {
const { conversationId } = input;
const managerRef = useRef<A2UISurfaceManager>(new A2UISurfaceManager());
const [renderTick, setRenderTick] = useState(0);
// Subscribe to surface changes
useEffect(() => {
const manager = managerRef.current;
const unsubscribe = manager.onChange(() => {
setRenderTick((prev) => prev + 1);
});
return unsubscribe;
}, []);
// Subscribe to A2UI IPC events
useEffect(() => {
if (!conversationId) {
return;
}
const unsubscribe = window.electronAPI?.chat.onA2UIMessage?.((data: { conversationId: string; message: A2UIServerMessage }) => {
if (data.conversationId === conversationId) {
managerRef.current.processMessage(data.message);
}
});
return () => {
unsubscribe?.();
};
}, [conversationId]);
// Clear surfaces when conversation changes
useEffect(() => {
return () => {
if (conversationId) {
managerRef.current.clearConversation(conversationId);
}
};
}, [conversationId]);
const surfaces = useMemo(() => {
// renderTick ensures this recalculates on surface changes
void renderTick;
if (!conversationId) {
return [];
}
const manager = managerRef.current;
const surfaceIds = manager.getSurfaceIds(conversationId);
return surfaceIds.map((surfaceId) => ({
surfaceId,
tree: manager.resolveTree(surfaceId),
}));
}, [conversationId, renderTick]);
const dispatchAction = useCallback((action: A2UIClientAction) => {
window.electronAPI?.chat.dispatchA2UIAction?.(action);
}, []);
const updateLocalData = useCallback((surfaceId: string, path: string, value: unknown) => {
managerRef.current.updateLocalData(surfaceId, path, value);
}, []);
const getDataModel = useCallback((surfaceId: string) => {
return managerRef.current.getDataModel(surfaceId);
}, []);
const clearSurfaces = useCallback(() => {
if (conversationId) {
managerRef.current.clearConversation(conversationId);
}
}, [conversationId]);
return {
surfaces,
dispatchAction,
updateLocalData,
getDataModel,
clearSurfaces,
};
}

View File

@@ -1,173 +0,0 @@
.assistant-panel-controls {
display: flex;
flex-direction: column;
gap: 8px;
}
.assistant-panel-metric {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 8px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
}
.assistant-panel-metric-label {
font-size: 12px;
opacity: 0.85;
}
.assistant-panel-metric-value {
font-size: 14px;
}
.assistant-panel-table {
width: 100%;
border-collapse: collapse;
}
.assistant-panel-table th,
.assistant-panel-table td {
border: 1px solid var(--vscode-panel-border);
padding: 6px;
font-size: 12px;
text-align: left;
}
.assistant-panel-widget-block {
display: flex;
flex-direction: column;
gap: 6px;
}
.assistant-panel-widget-label {
font-size: 12px;
opacity: 0.9;
}
.assistant-panel-widget-input {
width: 100%;
padding: 8px;
}
.assistant-panel-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.assistant-panel-chart {
display: flex;
flex-direction: column;
gap: 6px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 8px;
}
.assistant-panel-chart-title {
margin: 0;
font-weight: 600;
}
.assistant-panel-chart-type {
font-size: 11px;
text-transform: uppercase;
opacity: 0.7;
}
.assistant-panel-chart-item {
display: grid;
grid-template-columns: minmax(48px, auto) 1fr auto;
gap: 8px;
align-items: center;
font-size: 12px;
}
.assistant-panel-chart-item progress {
width: 100%;
}
.assistant-panel-form {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 8px;
}
.assistant-panel-form-title {
margin: 0;
font-weight: 600;
}
.assistant-panel-card {
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.assistant-panel-card h4,
.assistant-panel-card p {
margin: 0;
}
.assistant-panel-card-subtitle {
font-size: 12px;
opacity: 0.8;
}
.assistant-panel-card-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.assistant-panel-image {
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.assistant-panel-image img {
max-width: 100%;
border-radius: 6px;
border: 1px solid var(--vscode-panel-border);
}
.assistant-panel-image figcaption {
font-size: 12px;
opacity: 0.85;
}
.assistant-panel-tabs {
display: flex;
flex-direction: column;
gap: 8px;
}
.assistant-panel-tab-strip {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.assistant-panel-tab-button.active {
border-color: var(--vscode-focusBorder);
}
.assistant-panel-tab-panel {
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -1,310 +0,0 @@
import React, { useState } from 'react';
import type { AssistantPanelElement } from '../../navigation/assistantPanelSpec';
import './AssistantPanelControls.css';
interface AssistantPanelControlsProps {
elements: AssistantPanelElement[];
onAction: (action: string, payload?: Record<string, unknown>) => void;
actionPolicies?: Record<string, 'silent' | 'confirm' | 'danger'>;
}
export const AssistantPanelControls: React.FC<AssistantPanelControlsProps> = ({ elements, onAction, actionPolicies = {} }) => {
const [widgetValues, setWidgetValues] = useState<Record<string, unknown>>({});
const [activeTabByWidget, setActiveTabByWidget] = useState<Record<string, string>>({});
const setWidgetValue = (key: string, value: unknown) => {
setWidgetValues((previous) => ({
...previous,
[key]: value,
}));
};
const getWidgetValue = (key: string, defaultValue?: unknown) =>
Object.prototype.hasOwnProperty.call(widgetValues, key) ? widgetValues[key] : defaultValue;
const triggerAction = (action: string, payload?: Record<string, unknown>, label?: string) => {
const policy = actionPolicies[action] || 'silent';
if (policy !== 'silent') {
const confirmationText = label || action;
const confirmed = window.confirm(confirmationText);
if (!confirmed) {
return;
}
}
onAction(action, payload);
};
const renderInputControl = (
key: string,
label: string,
inputType: 'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number',
options?: Array<{ label: string; value: string }>,
placeholder?: string,
defaultValue?: string | number | boolean,
) => {
if (inputType === 'textarea') {
return (
<textarea
className="assistant-panel-widget-input chat-surface-input"
value={String(getWidgetValue(key, defaultValue ?? ''))}
placeholder={placeholder}
onChange={(event) => setWidgetValue(key, event.target.value)}
rows={3}
/>
);
}
if (inputType === 'select') {
return (
<select
className="assistant-panel-widget-input chat-surface-input"
value={String(getWidgetValue(key, defaultValue ?? (options?.[0]?.value ?? '')))}
onChange={(event) => setWidgetValue(key, event.target.value)}
>
{(options ?? []).map((option) => (
<option key={`${key}-${option.value}`} value={option.value}>{option.label}</option>
))}
</select>
);
}
if (inputType === 'checkbox') {
return (
<label className="assistant-panel-checkbox">
<input
type="checkbox"
checked={Boolean(getWidgetValue(key, defaultValue ?? false))}
onChange={(event) => setWidgetValue(key, event.target.checked)}
/>
<span>{label}</span>
</label>
);
}
const type = inputType === 'number' ? 'number' : inputType === 'date' ? 'date' : 'text';
return (
<input
className="assistant-panel-widget-input chat-surface-input"
type={type}
value={String(getWidgetValue(key, defaultValue ?? ''))}
placeholder={placeholder}
onChange={(event) => setWidgetValue(key, event.target.value)}
/>
);
};
const renderPanelElement = (element: AssistantPanelElement, indexPath: string): React.ReactNode => {
if (element.type === 'text') {
return <p key={`assistant-element-${indexPath}`}>{element.text}</p>;
}
if (element.type === 'metric') {
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-metric">
<span className="assistant-panel-metric-label">{element.label}</span>
<strong className="assistant-panel-metric-value">{element.value}</strong>
</div>
);
}
if (element.type === 'list') {
return (
<div key={`assistant-element-${indexPath}`}>
{element.title && <p>{element.title}</p>}
<ul>
{element.items.map((item, itemIndex) => <li key={`assistant-list-item-${indexPath}-${itemIndex}`}>{item}</li>)}
</ul>
</div>
);
}
if (element.type === 'table') {
return (
<table key={`assistant-element-${indexPath}`} className="assistant-panel-table">
<thead>
<tr>
{element.columns.map((column, columnIndex) => <th key={`assistant-table-column-${indexPath}-${columnIndex}`}>{column}</th>)}
</tr>
</thead>
<tbody>
{element.rows.map((row, rowIndex) => (
<tr key={`assistant-table-row-${indexPath}-${rowIndex}`}>
{row.map((cell, cellIndex) => <td key={`assistant-table-cell-${indexPath}-${rowIndex}-${cellIndex}`}>{cell}</td>)}
</tr>
))}
</tbody>
</table>
);
}
if (element.type === 'chart') {
const maxValue = Math.max(...element.series.map((item) => item.value), 0);
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-chart">
{element.title && <p className="assistant-panel-chart-title">{element.title}</p>}
<div className="assistant-panel-chart-type">{element.chartType}</div>
{element.series.map((entry, seriesIndex) => (
<div key={`assistant-chart-item-${indexPath}-${seriesIndex}`} className="assistant-panel-chart-item">
<span>{entry.label}</span>
<progress value={entry.value} max={maxValue || 1}></progress>
<span>{entry.value}</span>
</div>
))}
</div>
);
}
if (element.type === 'input') {
const currentValue = getWidgetValue(element.key, element.defaultValue);
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-widget-block">
{element.inputType !== 'checkbox' && <label className="assistant-panel-widget-label">{element.label}</label>}
{renderInputControl(element.key, element.label, element.inputType, element.options, element.placeholder, element.defaultValue)}
{element.action && element.submitLabel && (
<button
type="button"
onClick={() => triggerAction(element.action!, { ...(element.payload ?? {}), [element.key]: currentValue }, element.submitLabel)}
>
{element.submitLabel}
</button>
)}
</div>
);
}
if (element.type === 'datePicker') {
const currentValue = String(getWidgetValue(element.key, element.defaultValue ?? ''));
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-widget-block">
<label className="assistant-panel-widget-label">{element.label}</label>
<input
className="assistant-panel-widget-input chat-surface-input"
type="date"
min={element.min}
max={element.max}
value={currentValue}
onChange={(event) => setWidgetValue(element.key, event.target.value)}
/>
{element.action && element.submitLabel && (
<button
type="button"
onClick={() => triggerAction(element.action!, { ...(element.payload ?? {}), [element.key]: currentValue }, element.submitLabel)}
>
{element.submitLabel}
</button>
)}
</div>
);
}
if (element.type === 'form') {
const onSubmit = () => {
const values = element.fields.reduce<Record<string, unknown>>((accumulator, field) => {
accumulator[field.key] = getWidgetValue(field.key, field.defaultValue);
return accumulator;
}, {});
triggerAction(element.action, {
...(element.payload ?? {}),
formId: element.formId,
values,
}, element.submitLabel);
};
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-form">
{element.title && <p className="assistant-panel-form-title">{element.title}</p>}
{element.fields.map((field, fieldIndex) => (
<div key={`assistant-form-field-${indexPath}-${fieldIndex}`} className="assistant-panel-widget-block">
{field.inputType !== 'checkbox' && <label className="assistant-panel-widget-label">{field.label}</label>}
{renderInputControl(field.key, field.label, field.inputType, field.options, field.placeholder, field.defaultValue)}
</div>
))}
<button type="button" onClick={onSubmit}>{element.submitLabel}</button>
</div>
);
}
if (element.type === 'card') {
return (
<article key={`assistant-element-${indexPath}`} className="assistant-panel-card">
<h4>{element.title}</h4>
{element.subtitle && <p className="assistant-panel-card-subtitle">{element.subtitle}</p>}
<p>{element.body}</p>
{element.actions && element.actions.length > 0 && (
<div className="assistant-panel-card-actions">
{element.actions.map((action, actionIndex) => (
<button
key={`assistant-card-action-${indexPath}-${actionIndex}`}
type="button"
onClick={() => triggerAction(action.action, action.payload, action.label)}
>
{action.label}
</button>
))}
</div>
)}
</article>
);
}
if (element.type === 'image') {
return (
<figure key={`assistant-element-${indexPath}`} className="assistant-panel-image">
<img
src={element.src}
alt={element.alt || ''}
onClick={() => {
if (element.action) {
triggerAction(element.action, element.payload, element.caption || element.alt || element.action);
}
}}
/>
{element.caption && <figcaption>{element.caption}</figcaption>}
</figure>
);
}
if (element.type === 'tabs') {
const widgetKey = element.widgetId || `tabs-${indexPath}`;
const activeTabId = activeTabByWidget[widgetKey] || element.defaultTabId || element.tabs[0].id;
const activeTab = element.tabs.find((tab) => tab.id === activeTabId) ?? element.tabs[0];
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-tabs">
<div className="assistant-panel-tab-strip">
{element.tabs.map((tab) => (
<button
key={`assistant-tab-${indexPath}-${tab.id}`}
type="button"
className={`assistant-panel-tab-button ${tab.id === activeTab.id ? 'active' : ''}`}
onClick={() => setActiveTabByWidget((previous) => ({ ...previous, [widgetKey]: tab.id }))}
>
{tab.label}
</button>
))}
</div>
<div className="assistant-panel-tab-panel">
{activeTab.elements.map((childElement, childIndex) => renderPanelElement(childElement, `${indexPath}-tab-${childIndex}`))}
</div>
</div>
);
}
return (
<button key={`assistant-element-${indexPath}`} type="button" onClick={() => triggerAction(element.action, element.payload, element.label)}>
{element.label}
</button>
);
};
return (
<div className="assistant-panel-controls chat-surface-section">
{elements.map((element, index) => renderPanelElement(element, `${index}`))}
</div>
);
};
export default AssistantPanelControls;

View File

@@ -1 +0,0 @@
export { AssistantPanelControls } from './AssistantPanelControls';

View File

@@ -3,15 +3,13 @@ import { useAppStore } from '../../store';
import { resolveAssistantEditorContext } from '../../navigation/assistantPromptContext';
import { planAssistantRequest } from '../../navigation/assistantConversation';
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
import { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec';
import { toClarificationElements } from '../../navigation/protocolNeedsInput';
import { buildActionPoliciesFromEnvelope } from '../../navigation/protocolActionPolicies';
import { ensureConversationId } from '../../navigation/chatSession';
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
import { useChatMessageSender } from '../../navigation/useChatMessageSender';
import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
import { useA2UISurface } from '../../a2ui/useA2UISurface';
import { A2UIRenderer } from '../../a2ui/A2UIRenderer';
import { ChatTranscript } from '../ChatSurface';
import { AssistantPanelControls } from '../AssistantPanelControls';
import { useI18n } from '../../i18n';
import '../../styles/chatSurface.css';
import './AssistantSidebar.css';
@@ -23,8 +21,6 @@ export const AssistantSidebar: React.FC = () => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [conversationId, setConversationId] = useState<string | null>(null);
const [panelElements, setPanelElements] = useState<AssistantPanelElement[]>([]);
const [actionPolicies, setActionPolicies] = useState<Record<string, 'silent' | 'confirm' | 'danger'>>({});
const [actionError, setActionError] = useState<string | null>(null);
const {
@@ -57,6 +53,10 @@ export const AssistantSidebar: React.FC = () => {
stopStreaming,
getStreamingContent,
} = useChatSurfaceState();
// A2UI surface rendering
const { surfaces, dispatchAction, updateLocalData } = useA2UISurface({ conversationId });
const activeTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]);
const editorContext = useMemo(
@@ -169,24 +169,12 @@ export const AssistantSidebar: React.FC = () => {
throw new Error(sendResult.error || 'Failed to send assistant message');
}
if (sendResult.envelope) {
finalizeAssistantTurn(resolvedConversationId, sendResult.envelope.assistantText);
const uiElements = Array.isArray(sendResult.envelope.ui?.elements)
? (sendResult.envelope.ui?.elements as AssistantPanelElement[])
: toClarificationElements(sendResult.envelope.needsInput);
setPanelElements(uiElements);
setActionPolicies(buildActionPoliciesFromEnvelope(sendResult.envelope));
const assistantContent = getStreamingContent() || sendResult.message;
if (assistantContent) {
finalizeAssistantTurn(resolvedConversationId, assistantContent);
} else {
const assistantContent = getStreamingContent() || sendResult.message;
if (assistantContent) {
const parsedResponse = extractAssistantResponseContent(assistantContent);
finalizeAssistantTurn(resolvedConversationId, parsedResponse.displayText);
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
setActionPolicies({});
} else {
appendAssistantMessage(resolvedConversationId, tr('chat.errorEmptyResponse'));
stopStreaming();
}
appendAssistantMessage(resolvedConversationId, tr('chat.errorEmptyResponse'));
stopStreaming();
}
setPrompt('');
@@ -199,57 +187,6 @@ export const AssistantSidebar: React.FC = () => {
};
const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => {
if (action === 'submitNeedsInput' && conversationId) {
const values = payload?.values;
if (!values || typeof values !== 'object') {
setActionError(tr('assistantSidebar.error.actionFailed'));
return;
}
const clarificationMessage = `needs_input_response: ${JSON.stringify(values)}`;
beginUserTurn(conversationId, clarificationMessage);
void sendChatMessage({
conversationId,
message: clarificationMessage,
metadata: { surface: 'sidebar' },
}).then((sendResult) => {
if (!sendResult.success) {
appendAssistantMessage(
conversationId,
tr('chat.errorPrefix', { error: sendResult.error || tr('chat.errorNoResponse') }),
);
stopStreaming();
return;
}
if (sendResult.envelope) {
finalizeAssistantTurn(conversationId, sendResult.envelope.assistantText);
const uiElements = Array.isArray(sendResult.envelope.ui?.elements)
? (sendResult.envelope.ui?.elements as AssistantPanelElement[])
: toClarificationElements(sendResult.envelope.needsInput);
setPanelElements(uiElements);
setActionPolicies(buildActionPoliciesFromEnvelope(sendResult.envelope));
return;
}
const assistantContent = getStreamingContent() || sendResult.message;
if (assistantContent) {
const parsedResponse = extractAssistantResponseContent(assistantContent);
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
setActionPolicies({});
}
}).catch((error) => {
console.error('Failed to submit assistant clarification:', error);
appendAssistantMessage(conversationId, tr('chat.errorGeneric'));
stopStreaming();
});
return;
}
const result = dispatchAssistantAction(
{
action,
@@ -333,9 +270,15 @@ export const AssistantSidebar: React.FC = () => {
</div>
)}
{panelElements.length > 0 && (
<AssistantPanelControls elements={panelElements} onAction={handleAssistantAction} actionPolicies={actionPolicies} />
)}
{surfaces.map((surface) => (
<A2UIRenderer
key={surface.surfaceId}
surfaceId={surface.surfaceId}
tree={surface.tree}
onAction={dispatchAction}
onDataChange={updateLocalData}
/>
))}
</div>
);
};

View File

@@ -4,12 +4,10 @@ import { useChatMessageSender } from '../../navigation/useChatMessageSender';
import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
import { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec';
import { toClarificationElements } from '../../navigation/protocolNeedsInput';
import { buildActionPoliciesFromEnvelope } from '../../navigation/protocolActionPolicies';
import { useA2UISurface } from '../../a2ui/useA2UISurface';
import { A2UIRenderer } from '../../a2ui/A2UIRenderer';
import { useAppStore } from '../../store';
import { ChatTranscript } from '../ChatSurface';
import { AssistantPanelControls } from '../AssistantPanelControls';
import { useI18n } from '../../i18n';
import '../../styles/chatSurface.css';
import './ChatPanel.css';
@@ -29,8 +27,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
const [apiKeyInput, setApiKeyInput] = useState('');
const [apiKeyError, setApiKeyError] = useState('');
const [isValidating, setIsValidating] = useState(false);
const [panelElements, setPanelElements] = useState<AssistantPanelElement[]>([]);
const [actionPolicies, setActionPolicies] = useState<Record<string, 'silent' | 'confirm' | 'danger'>>({});
const [actionError, setActionError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -63,6 +59,9 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
getStreamingContent,
} = useChatSurfaceState();
// A2UI surface rendering
const { surfaces, dispatchAction, updateLocalData } = useA2UISurface({ conversationId });
// Scroll to bottom when messages change
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -193,37 +192,19 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
// Fall back to the backend result message if streaming didn't capture the content
const assistantContent = getStreamingContent() || (result.success ? result.message : '');
if (result.envelope) {
finalizeAssistantTurn(conversationId, result.envelope.assistantText);
const uiElements = Array.isArray(result.envelope.ui?.elements)
? (result.envelope.ui?.elements as AssistantPanelElement[])
: toClarificationElements(result.envelope.needsInput);
setPanelElements(uiElements);
setActionPolicies(buildActionPoliciesFromEnvelope(result.envelope));
} else if (assistantContent) {
const parsedResponse = extractAssistantResponseContent(assistantContent);
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
setActionPolicies({});
if (assistantContent) {
finalizeAssistantTurn(conversationId, assistantContent);
} else if (!result.success) {
// Backend returned an error (API failure, model unavailable, etc.)
appendAssistantMessage(conversationId, tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }));
stopStreaming();
setPanelElements([]);
setActionPolicies({});
} else {
// No content from streaming AND no error, but also no success message
// This can happen with some models that don't return content properly
appendAssistantMessage(conversationId, tr('chat.errorEmptyResponse'));
stopStreaming();
setPanelElements([]);
setActionPolicies({});
}
} catch (error) {
console.error('Failed to send message:', error);
appendAssistantMessage(conversationId, tr('chat.errorGeneric'));
stopStreaming();
setPanelElements([]);
} finally {
if (isStreaming) {
stopStreaming();
@@ -239,59 +220,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
}
};
const handleNeedsInputSubmit = async (payload?: Record<string, unknown>) => {
const values = payload?.values;
if (!values || typeof values !== 'object') {
setActionError(tr('assistantSidebar.error.actionFailed'));
return;
}
const clarificationMessage = `needs_input_response: ${JSON.stringify(values)}`;
beginUserTurn(conversationId, clarificationMessage);
try {
const result = await sendChatMessage({
conversationId,
message: clarificationMessage,
metadata: { surface: 'tab' },
});
if (!result.success) {
appendAssistantMessage(conversationId, tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }));
stopStreaming();
return;
}
if (result.envelope) {
finalizeAssistantTurn(conversationId, result.envelope.assistantText);
const uiElements = Array.isArray(result.envelope.ui?.elements)
? (result.envelope.ui?.elements as AssistantPanelElement[])
: toClarificationElements(result.envelope.needsInput);
setPanelElements(uiElements);
setActionPolicies(buildActionPoliciesFromEnvelope(result.envelope));
return;
}
const assistantContent = getStreamingContent() || result.message;
if (assistantContent) {
const parsedResponse = extractAssistantResponseContent(assistantContent);
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
setActionPolicies({});
}
} catch (error) {
console.error('Failed to submit clarification:', error);
appendAssistantMessage(conversationId, tr('chat.errorGeneric'));
stopStreaming();
}
};
const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => {
if (action === 'submitNeedsInput') {
void handleNeedsInputSubmit(payload);
return;
}
const result = dispatchAssistantAction(
{
action,
@@ -441,9 +370,15 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
endRef={messagesEndRef}
/>
{panelElements.length > 0 && (
<AssistantPanelControls elements={panelElements} onAction={handleAssistantAction} actionPolicies={actionPolicies} />
)}
{surfaces.map((surface) => (
<A2UIRenderer
key={surface.surfaceId}
surfaceId={surface.surfaceId}
tree={surface.tree}
onAction={dispatchAction}
onDataChange={updateLocalData}
/>
))}
{actionError && <p className="chat-surface-error">{actionError}</p>}
</div>

View File

@@ -1478,12 +1478,6 @@ interface CategoryCount {
count: number;
}
interface DashboardProtocolHealth {
blockedActionCount: number;
parseValidityRate: number;
fallbackTurns: number;
}
const Dashboard: React.FC = () => {
const { t: tr, language } = useI18n();
const { posts, media } = useAppStore();
@@ -1492,7 +1486,6 @@ const Dashboard: React.FC = () => {
const [tagCounts, setTagCounts] = useState<TagCount[]>([]);
const [tagColors, setTagColors] = useState<Map<string, string>>(new Map());
const [categoryCounts, setCategoryCounts] = useState<CategoryCount[]>([]);
const [protocolHealth, setProtocolHealth] = useState<DashboardProtocolHealth | null>(null);
const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en;
const monthFormatter = useMemo(
@@ -1503,25 +1496,17 @@ const Dashboard: React.FC = () => {
useEffect(() => {
const loadStats = async () => {
try {
const [ds, ym, tc, cc, colorMap, protocolHealthSnapshot] = await Promise.all([
const [ds, ym, tc, cc, colorMap] = await Promise.all([
window.electronAPI?.posts.getDashboardStats(),
window.electronAPI?.posts.getByYearMonth(),
window.electronAPI?.posts.getTagsWithCounts(),
window.electronAPI?.posts.getCategoriesWithCounts(),
loadTagColorMap(),
window.electronAPI?.chat.getProtocolHealth(),
]);
if (ds) setStats(ds);
if (ym) setYearMonthData(ym);
if (tc) setTagCounts(tc);
if (cc) setCategoryCounts(cc);
if (protocolHealthSnapshot) {
setProtocolHealth({
blockedActionCount: protocolHealthSnapshot.blockedActionCount,
parseValidityRate: protocolHealthSnapshot.parseValidityRate,
fallbackTurns: protocolHealthSnapshot.fallbackTurns,
});
}
setTagColors(colorMap);
} catch (e) {
console.error('Failed to load dashboard stats:', e);
@@ -1566,9 +1551,6 @@ const Dashboard: React.FC = () => {
const displayDraftCount = stats?.draftCount ?? 0;
const displayPublishedCount = stats?.publishedCount ?? 0;
const displayArchivedCount = stats?.archivedCount ?? 0;
const parseValidityPercent = protocolHealth
? `${Math.round(protocolHealth.parseValidityRate * 100)}%`
: '—';
const getPostCountLabel = useCallback((count: number) => {
return tr(count === 1 ? 'dashboard.postCount.one' : 'dashboard.postCount.other', { count });
@@ -1615,14 +1597,6 @@ const Dashboard: React.FC = () => {
<span className="stat-tag">{tr('dashboard.stats.categories', { count: categoryCounts.length })}</span>
</div>
</div>
<div className="stat-card">
<div className="stat-number">{parseValidityPercent}</div>
<div className="stat-label">{tr('dashboard.stats.protocolHealth')}</div>
<div className="stat-breakdown">
<span className="stat-tag">{tr('dashboard.stats.blockedActions', { count: protocolHealth?.blockedActionCount ?? 0 })}</span>
<span className="stat-tag">{tr('dashboard.stats.fallbackTurns', { count: protocolHealth?.fallbackTurns ?? 0 })}</span>
</div>
</div>
</div>
{timelineEntries.length > 0 && (

View File

@@ -28,4 +28,3 @@ export { DocumentationView } from './DocumentationView/DocumentationView';
export { SiteValidationView } from './SiteValidationView';
export { ScriptsView } from './ScriptsView/ScriptsView';
export { AssistantSidebar } from './AssistantSidebar';
export { AssistantPanelControls } from './AssistantPanelControls';

View File

@@ -156,276 +156,3 @@ export const assistantPanelSpecSchema = z.object({
export type AssistantPanelElement = z.infer<typeof assistantPanelElementSchema>;
export type AssistantPanelSpec = z.infer<typeof assistantPanelSpecSchema>;
export interface AssistantResponseContent {
displayText: string;
panelSpec: AssistantPanelSpec | null;
}
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function normalizeChartElement(record: Record<string, unknown>): Record<string, unknown> | null {
const chartType = record.chartType;
const normalized: Record<string, unknown> = {
type: 'chart',
chartType: chartType === 'line' || chartType === 'pie' ? chartType : 'bar',
};
if (typeof record.title === 'string' && record.title.trim().length > 0) {
normalized.title = record.title;
}
if (Array.isArray(record.series)) {
const series = record.series
.map((entry) => {
const item = toRecord(entry);
if (!item || typeof item.label !== 'string' || typeof item.value !== 'number') {
return null;
}
return { label: item.label, value: item.value };
})
.filter((entry): entry is { label: string; value: number } => Boolean(entry));
if (series.length > 0) {
normalized.series = series;
return normalized;
}
}
const dataRecord = toRecord(record.data);
if (!dataRecord) {
return normalized;
}
const labels = Array.isArray(dataRecord.labels) ? dataRecord.labels : [];
const datasets = Array.isArray(dataRecord.datasets) ? dataRecord.datasets : [];
const firstDataset = toRecord(datasets[0]);
const values = Array.isArray(firstDataset?.data) ? firstDataset?.data : [];
if (labels.length === 0 || values.length === 0) {
return normalized;
}
const series = labels
.map((label, index) => ({
label: String(label),
value: Number(values[index]),
}))
.filter((entry) => Number.isFinite(entry.value));
if (series.length === 0) {
return normalized;
}
normalized.series = series;
return normalized;
}
function normalizeTabContent(tabValue: unknown): Record<string, unknown>[] {
if (Array.isArray(tabValue)) {
return tabValue.map((entry) => normalizeElement(entry)).filter((entry): entry is Record<string, unknown> => Boolean(entry));
}
const normalized = normalizeElement(tabValue);
return normalized ? [normalized] : [];
}
function normalizeTabsElement(record: Record<string, unknown>): Record<string, unknown> | null {
const tabs = Array.isArray(record.tabs) ? record.tabs : [];
const normalizedTabs = tabs
.map((tabValue, tabIndex) => {
const tabRecord = toRecord(tabValue);
if (!tabRecord) {
return null;
}
const id = typeof tabRecord.id === 'string' && tabRecord.id.trim().length > 0
? tabRecord.id
: `tab-${tabIndex + 1}`;
const label = typeof tabRecord.label === 'string' && tabRecord.label.trim().length > 0
? tabRecord.label
: typeof tabRecord.title === 'string' && tabRecord.title.trim().length > 0
? tabRecord.title
: id;
const elements = Array.isArray(tabRecord.elements)
? normalizeTabContent(tabRecord.elements)
: normalizeTabContent(tabRecord.content);
if (elements.length === 0) {
return null;
}
return {
id,
label,
elements,
};
})
.filter((entry): entry is { id: string; label: string; elements: Record<string, unknown>[] } => Boolean(entry));
if (normalizedTabs.length === 0) {
return null;
}
return {
...record,
tabs: normalizedTabs,
};
}
function normalizeElement(value: unknown): Record<string, unknown> | null {
const record = toRecord(value);
if (!record) {
return null;
}
const type = typeof record.type === 'string' ? record.type : '';
if (type === 'text' && typeof record.content === 'string' && typeof record.text !== 'string') {
return { type: 'text', text: record.content };
}
if (type === 'markdown') {
const textValue = typeof record.content === 'string' ? record.content : typeof record.text === 'string' ? record.text : '';
if (!textValue.trim()) {
return null;
}
return {
type: 'text',
text: textValue,
};
}
if (type === 'chart') {
return normalizeChartElement(record);
}
if (type === 'tabs') {
return normalizeTabsElement(record);
}
return record;
}
function normalizeCandidate(parsed: unknown): AssistantPanelSpec | null {
const canonicalResult = assistantPanelSpecSchema.safeParse(parsed);
if (canonicalResult.success) {
return canonicalResult.data;
}
const record = toRecord(parsed);
if (!record) {
return null;
}
if (record.protocolVersion === '2.0' && record.ui) {
return normalizeCandidate(record.ui);
}
if (record.type === 'tab' && record.content) {
return normalizeCandidate(record.content);
}
if (record.type === 'tabs') {
const tabsElement = normalizeTabsElement(record);
if (!tabsElement) {
return null;
}
const asSpec = {
specVersion: '1',
elements: [tabsElement],
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
if (Array.isArray(record.elements)) {
const normalizedElements = record.elements
.map((element) => normalizeElement(element))
.filter((element): element is Record<string, unknown> => Boolean(element));
if (normalizedElements.length === 0) {
return null;
}
const asSpec = {
specVersion: '1',
elements: normalizedElements,
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
const normalizedElement = normalizeElement(record);
if (!normalizedElement) {
return null;
}
const asSpec = {
specVersion: '1',
elements: [normalizedElement],
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
function parseSpecCandidate(raw: string): AssistantPanelSpec | null {
try {
const parsed = JSON.parse(raw);
return normalizeCandidate(parsed);
} catch {
return null;
}
}
export function extractAssistantPanelSpec(message: string): AssistantPanelSpec | null {
return extractAssistantResponseContent(message).panelSpec;
}
export function extractAssistantResponseContent(message: string): AssistantResponseContent {
const trimmed = message.trim();
const fencedMatches = [...trimmed.matchAll(/```(?:[a-zA-Z0-9_-]+)?\s*([\s\S]*?)```/gi)];
for (const match of fencedMatches) {
const candidate = match[1]?.trim();
if (!candidate) {
continue;
}
const parsed = parseSpecCandidate(candidate);
if (parsed) {
const displayText = trimmed.replace(match[0], '').trim();
return {
displayText,
panelSpec: parsed,
};
}
}
const parsedWholeMessage = parseSpecCandidate(trimmed);
let displayText = parsedWholeMessage ? '' : trimmed;
if (parsedWholeMessage) {
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
if (parsed.protocolVersion === '2.0' && typeof parsed.assistantText === 'string') {
displayText = parsed.assistantText;
}
} catch {
// no-op
}
}
return {
displayText,
panelSpec: parsedWholeMessage,
};
}

View File

@@ -1,12 +1,10 @@
import type { ProtocolResponseEnvelope } from '../types/electron';
export interface ChatService {
createConversation: (title?: string, model?: string) => Promise<{ id: string } | null | undefined>;
sendMessage: (
conversationId: string,
message: string,
metadata?: SendMessageMetadata,
) => Promise<{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string } | null | undefined>;
) => Promise<{ success: boolean; message?: string; error?: string } | null | undefined>;
}
export interface SendMessageMetadata {
@@ -29,10 +27,6 @@ export interface SendConversationMessageInput {
export interface SendConversationMessageResult {
success: boolean;
message: string;
envelope?: ProtocolResponseEnvelope;
protocolVersion?: '2.0';
traceId?: string;
warnings?: string[];
error?: string;
}
@@ -75,9 +69,5 @@ export async function sendConversationMessage(
return {
success: true,
message: result.message || '',
envelope: result.envelope,
protocolVersion: result.protocolVersion,
traceId: result.traceId,
warnings: result.warnings,
};
}

View File

@@ -1,18 +0,0 @@
import type { ProtocolResponseEnvelope } from '../types/electron';
export type ActionPolicyLevel = 'silent' | 'confirm' | 'danger';
export function buildActionPoliciesFromEnvelope(
envelope: Pick<ProtocolResponseEnvelope, 'actions' | 'needsInput'>,
): Record<string, ActionPolicyLevel> {
const policies = envelope.actions.reduce<Record<string, ActionPolicyLevel>>((accumulator, action) => {
accumulator[action.action] = action.policy;
return accumulator;
}, {});
if (envelope.needsInput.required && envelope.needsInput.fields.length > 0 && !policies.submitNeedsInput) {
policies.submitNeedsInput = 'confirm';
}
return policies;
}

View File

@@ -1,38 +0,0 @@
import type { ProtocolNeedsInputField, ProtocolResponseEnvelope } from '../types/electron';
import type { AssistantPanelElement } from './assistantPanelSpec';
function toFormField(field: ProtocolNeedsInputField): {
key: string;
label: string;
inputType: 'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number';
placeholder?: string;
defaultValue?: string | number | boolean;
options?: Array<{ label: string; value: string }>;
required?: boolean;
} {
return {
key: field.key,
label: field.label,
inputType: field.inputType,
placeholder: field.placeholder,
defaultValue: field.defaultValue,
options: field.options,
required: field.required,
};
}
export function toClarificationElements(
needsInput: ProtocolResponseEnvelope['needsInput'],
): AssistantPanelElement[] {
if (!needsInput.required || needsInput.fields.length === 0) {
return [];
}
return [{
type: 'form',
formId: 'agui-needs-input',
submitLabel: needsInput.fields[0].label,
action: 'submitNeedsInput',
fields: needsInput.fields.map(toFormField),
}];
}

View File

@@ -176,7 +176,6 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('chat.validateApiKey', 'Validate chat API key and list available models.', [requiredString('apiKey')], '{ isValid: boolean; models: ChatModel[] }'),
method('chat.setApiKey', 'Store chat API key.', [requiredString('apiKey')], '{ success: boolean; error?: string }'),
method('chat.getApiKey', 'Get stored chat API key status.', [], 'ChatApiKeyStatus'),
method('chat.getProtocolHealth', 'Get AGUI protocol telemetry health snapshot.', [], 'ProtocolTelemetrySnapshot'),
method('chat.getAvailableModels', 'Get available chat models and selected default.', [], '{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }'),
method('chat.setDefaultModel', 'Set default chat model.', [requiredString('modelId')], '{ success: boolean; error?: string }'),
method('chat.getSystemPrompt', 'Get configured system prompt.', [], '{ success: boolean; prompt?: string; error?: string }'),
@@ -186,7 +185,7 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('chat.getConversation', 'Fetch one chat conversation by id.', [requiredString('id')], 'ChatConversation | null'),
method('chat.updateConversation', 'Update chat conversation metadata.', [requiredString('id'), requiredObject('updates')], 'ChatConversation | null'),
method('chat.deleteConversation', 'Delete chat conversation by id.', [requiredString('id')], 'boolean'),
method('chat.sendMessage', 'Send message to chat conversation.', [requiredString('conversationId'), requiredString('message'), optionalObject('metadata')], "{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string }"),
method('chat.sendMessage', 'Send message to chat conversation.', [requiredString('conversationId'), requiredString('message'), optionalObject('metadata')], '{ success: boolean; message?: string; error?: string }'),
method('chat.abortMessage', 'Abort active streaming chat response.', [requiredString('conversationId')], 'void'),
method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'),
method('chat.clearMessages', 'Clear messages for conversation.', [requiredString('conversationId')], 'void'),
@@ -385,34 +384,6 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{ name: 'requiresConfirmation', type: 'boolean', required: true, description: 'Whether confirmation is required before dispatch.' },
],
},
{
name: 'ProtocolResponseEnvelope',
description: 'Canonical AGUI response envelope returned from chat.sendMessage.',
fields: [
{ name: 'protocolVersion', type: "'2.0'", required: true, description: 'Envelope protocol version.' },
{ name: 'assistantText', type: 'string', required: true, description: 'Assistant text content rendered in transcript.' },
{ name: 'ui', type: "{ specVersion: '1'; elements: unknown[] }", required: false, description: 'Optional structured UI payload.' },
{ name: 'intent', type: "'analyze' | 'ask_input' | 'propose_action' | 'execute_action' | 'summarize'", required: true, description: 'Turn intent classification.' },
{ name: 'needsInput', type: '{ required: boolean; fields: ProtocolNeedsInputField[] }', required: true, description: 'Clarification requirements for next step.' },
{ name: 'actions', type: 'ProtocolAction[]', required: true, description: 'Declarative actions available for this turn.' },
{ name: 'confidence', type: 'number', required: true, description: 'Model confidence score from 0 to 1.' },
{ name: 'traceId', type: 'string', required: true, description: 'Trace id for observability and debugging.' },
],
},
{
name: 'ProtocolTelemetrySnapshot',
description: 'Aggregated protocol telemetry metrics for AGUI response health.',
fields: [
{ name: 'totalTurns', type: 'number', required: true, description: 'Total number of recorded assistant turns.' },
{ name: 'validEnvelopeTurns', type: 'number', required: true, description: 'Turns with schema-valid protocol envelopes.' },
{ name: 'repairAttempts', type: 'number', required: true, description: 'Number of response repair attempts.' },
{ name: 'fallbackTurns', type: 'number', required: true, description: 'Turns that used protocol fallback response.' },
{ name: 'blockedActionCount', type: 'number', required: true, description: 'Count of actions blocked by policy.' },
{ name: 'parseValidityRate', type: 'number', required: true, description: 'Ratio of valid envelopes to total turns.' },
{ name: 'repairRate', type: 'number', required: true, description: 'Ratio of repair attempts to total turns.' },
{ name: 'fallbackRate', type: 'number', required: true, description: 'Ratio of fallback turns to total turns.' },
],
},
];
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {