wip: complete rework first round
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
export { AssistantPanelControls } from './AssistantPanelControls';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user