wip: agui integration
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export const AssistantPanelControls: React.FC<AssistantPanelControlsProps> = ({ elements, onAction }) => {
|
||||
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 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={() => onAction(element.action!, { ...(element.payload ?? {}), [element.key]: currentValue })}
|
||||
>
|
||||
{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={() => onAction(element.action!, { ...(element.payload ?? {}), [element.key]: currentValue })}
|
||||
>
|
||||
{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;
|
||||
}, {});
|
||||
|
||||
onAction(element.action, {
|
||||
...(element.payload ?? {}),
|
||||
formId: element.formId,
|
||||
values,
|
||||
});
|
||||
};
|
||||
|
||||
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={() => onAction(action.action, action.payload)}
|
||||
>
|
||||
{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) {
|
||||
onAction(element.action, element.payload);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{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={() => onAction(element.action, element.payload)}>
|
||||
{element.label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="assistant-panel-controls chat-surface-section">
|
||||
{elements.map((element, index) => renderPanelElement(element, `${index}`))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssistantPanelControls;
|
||||
1
src/renderer/components/AssistantPanelControls/index.ts
Normal file
1
src/renderer/components/AssistantPanelControls/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AssistantPanelControls } from './AssistantPanelControls';
|
||||
250
src/renderer/components/AssistantSidebar/AssistantSidebar.css
Normal file
250
src/renderer/components/AssistantSidebar/AssistantSidebar.css
Normal file
@@ -0,0 +1,250 @@
|
||||
.assistant-sidebar {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
color: var(--vscode-sideBar-foreground);
|
||||
}
|
||||
|
||||
.assistant-sidebar-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-sidebar-header p {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.assistant-sidebar-context {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--vscode-sideBarSectionHeader-border, var(--vscode-panel-border));
|
||||
border-radius: 6px;
|
||||
background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.assistant-sidebar-context-label {
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.assistant-sidebar-context-value {
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.assistant-sidebar-prompt {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.assistant-sidebar-start-button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.assistant-sidebar-error {
|
||||
margin: 0;
|
||||
color: var(--vscode-errorForeground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-panel-output {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-metric-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.assistant-sidebar-metric-value {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.assistant-sidebar-table th,
|
||||
.assistant-sidebar-table td {
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
padding: 6px;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.assistant-sidebar-raw-message {
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
padding-top: 8px;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.assistant-sidebar-widget-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-widget-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.assistant-sidebar-widget-input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-chart-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-sidebar-chart-type {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.assistant-sidebar-chart-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(48px, auto) 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-chart-item progress {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.assistant-sidebar-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-form-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-sidebar-card {
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-card h4,
|
||||
.assistant-sidebar-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.assistant-sidebar-card-subtitle {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.assistant-sidebar-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.assistant-sidebar-image {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-image img {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.assistant-sidebar-image figcaption {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.assistant-sidebar-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-tab-strip {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.assistant-sidebar-tab-button.active {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.assistant-sidebar-tab-panel {
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
239
src/renderer/components/AssistantSidebar/AssistantSidebar.tsx
Normal file
239
src/renderer/components/AssistantSidebar/AssistantSidebar.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
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 { ensureConversationId } from '../../navigation/chatSession';
|
||||
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
|
||||
import { useChatMessageSender } from '../../navigation/useChatMessageSender';
|
||||
import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
|
||||
import { ChatTranscript } from '../ChatSurface';
|
||||
import { AssistantPanelControls } from '../AssistantPanelControls';
|
||||
import { useI18n } from '../../i18n';
|
||||
import '../../styles/chatSurface.css';
|
||||
import './AssistantSidebar.css';
|
||||
|
||||
export const AssistantSidebar: React.FC = () => {
|
||||
const { t: tr } = useI18n();
|
||||
const surfaceMode = getChatSurfaceMode('sidebar');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
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 [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
tabs,
|
||||
activeTabId,
|
||||
posts,
|
||||
media,
|
||||
setSelectedPost,
|
||||
setSelectedMedia,
|
||||
openTab,
|
||||
setActiveView,
|
||||
toggleSidebar,
|
||||
togglePanel,
|
||||
toggleAssistantSidebar,
|
||||
} = useAppStore();
|
||||
const { sendMessage: sendChatMessage } = useChatMessageSender({
|
||||
chatService: window.electronAPI?.chat,
|
||||
});
|
||||
const {
|
||||
messages,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
toolEvents,
|
||||
beginUserTurn,
|
||||
finalizeAssistantTurn,
|
||||
appendAssistantMessage,
|
||||
stopStreaming,
|
||||
} = useChatSurfaceState();
|
||||
const activeTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]);
|
||||
|
||||
const editorContext = useMemo(
|
||||
() => resolveAssistantEditorContext({ activeTab, posts, media }),
|
||||
[activeTab, posts, media],
|
||||
);
|
||||
|
||||
const contextSummary = useMemo(() => {
|
||||
if (!editorContext) {
|
||||
return tr('assistantSidebar.context.none');
|
||||
}
|
||||
|
||||
const title = editorContext.title ? ` • ${editorContext.title}` : '';
|
||||
const id = editorContext.id ? ` (${editorContext.id})` : '';
|
||||
return `${editorContext.tabType}${id}${title}`;
|
||||
}, [editorContext, tr]);
|
||||
|
||||
const persistActionEvent = async (message: string) => {
|
||||
if (!conversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electronAPI?.chat.addSystemEvent(conversationId, message);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist assistant action event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
const trimmed = prompt.trim();
|
||||
if (!trimmed || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const chatService = window.electronAPI?.chat;
|
||||
if (!chatService) {
|
||||
throw new Error('Chat service unavailable');
|
||||
}
|
||||
|
||||
const resolvedConversationId = await ensureConversationId({
|
||||
currentConversationId: conversationId,
|
||||
createTitle: tr('assistantSidebar.conversationTitle'),
|
||||
chatService,
|
||||
});
|
||||
|
||||
if (!conversationId) {
|
||||
setConversationId(resolvedConversationId);
|
||||
}
|
||||
|
||||
const requestPlan = planAssistantRequest({
|
||||
conversationId,
|
||||
userPrompt: trimmed,
|
||||
context: editorContext,
|
||||
});
|
||||
|
||||
beginUserTurn(resolvedConversationId, trimmed);
|
||||
|
||||
const sendResult = await sendChatMessage({
|
||||
conversationId: resolvedConversationId,
|
||||
message: requestPlan.outboundMessage,
|
||||
metadata: { surface: 'sidebar' },
|
||||
});
|
||||
|
||||
if (!sendResult.success) {
|
||||
appendAssistantMessage(
|
||||
resolvedConversationId,
|
||||
tr('chat.errorPrefix', { error: sendResult.error || tr('chat.errorNoResponse') }),
|
||||
);
|
||||
stopStreaming();
|
||||
throw new Error(sendResult.error || 'Failed to send assistant message');
|
||||
}
|
||||
|
||||
if (sendResult.message) {
|
||||
const parsedResponse = extractAssistantResponseContent(sendResult.message);
|
||||
finalizeAssistantTurn(resolvedConversationId, parsedResponse.displayText);
|
||||
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
|
||||
} else {
|
||||
appendAssistantMessage(resolvedConversationId, tr('chat.errorEmptyResponse'));
|
||||
stopStreaming();
|
||||
}
|
||||
|
||||
setPrompt('');
|
||||
} catch (error) {
|
||||
console.error('Failed to start assistant conversation:', error);
|
||||
setErrorMessage(tr('assistantSidebar.error.startFailed'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => {
|
||||
const result = dispatchAssistantAction(
|
||||
{
|
||||
action,
|
||||
payload,
|
||||
},
|
||||
{
|
||||
setSelectedPost,
|
||||
setSelectedMedia,
|
||||
openTab,
|
||||
setActiveView,
|
||||
toggleSidebar,
|
||||
togglePanel,
|
||||
toggleAssistantSidebar,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.handled) {
|
||||
setActionError(result.error || tr('assistantSidebar.error.actionFailed'));
|
||||
void persistActionEvent(
|
||||
`Assistant action failed: ${action}${result.error ? ` (${result.error})` : ''}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setActionError(null);
|
||||
void persistActionEvent(
|
||||
`Assistant action executed: ${action}${payload ? ` ${JSON.stringify(payload)}` : ''}`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="assistant-sidebar chat-surface">
|
||||
<div className="assistant-sidebar-header">
|
||||
<h3>{tr('assistantSidebar.title')}</h3>
|
||||
<p>{tr('assistantSidebar.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="assistant-sidebar-context chat-surface-section">
|
||||
<span className="assistant-sidebar-context-label">{tr('assistantSidebar.context.label')}</span>
|
||||
<span className="assistant-sidebar-context-value">{contextSummary}</span>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="assistant-sidebar-prompt chat-surface-input"
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={tr('assistantSidebar.prompt.placeholder')}
|
||||
rows={6}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="assistant-sidebar-start-button"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => void handleStart()}
|
||||
>
|
||||
{isSubmitting ? tr('assistantSidebar.button.starting') : tr('assistantSidebar.button.start')}
|
||||
</button>
|
||||
|
||||
{errorMessage && <p className="assistant-sidebar-error chat-surface-error">{errorMessage}</p>}
|
||||
|
||||
{actionError && <p className="assistant-sidebar-error chat-surface-error">{actionError}</p>}
|
||||
|
||||
{surfaceMode.showWelcomeTips && messages.length === 0 && !isStreaming && (
|
||||
<div className="assistant-sidebar-raw-message chat-surface-section">
|
||||
{tr('chat.welcomeDescription')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.length > 0 && (
|
||||
<div className="assistant-sidebar-raw-message chat-surface-section">
|
||||
<ChatTranscript
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
streamingContent={streamingContent}
|
||||
toolEvents={toolEvents}
|
||||
assistantRoleLabel={tr('chat.role.assistant')}
|
||||
userRoleLabel={tr('chat.role.you')}
|
||||
showToolMarkers={surfaceMode.showToolMarkers}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panelElements.length > 0 && (
|
||||
<AssistantPanelControls elements={panelElements} onAction={handleAssistantAction} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssistantSidebar;
|
||||
1
src/renderer/components/AssistantSidebar/index.ts
Normal file
1
src/renderer/components/AssistantSidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AssistantSidebar } from './AssistantSidebar';
|
||||
@@ -1,7 +1,15 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import Markdown from 'marked-react';
|
||||
import type { ChatMessage, ChatConversation, ChatModel } from '../../types/electron';
|
||||
import type { ChatConversation, ChatModel } from '../../types/electron';
|
||||
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 { useAppStore } from '../../store';
|
||||
import { ChatTranscript } from '../ChatSurface';
|
||||
import { AssistantPanelControls } from '../AssistantPanelControls';
|
||||
import { useI18n } from '../../i18n';
|
||||
import '../../styles/chatSurface.css';
|
||||
import './ChatPanel.css';
|
||||
|
||||
interface ChatPanelProps {
|
||||
@@ -10,22 +18,47 @@ interface ChatPanelProps {
|
||||
|
||||
export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
const { t: tr } = useI18n();
|
||||
const surfaceMode = getChatSurfaceMode('tab');
|
||||
const [conversation, setConversation] = useState<ChatConversation | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const [toolEvents, setToolEvents] = useState<Array<{ type: 'call' | 'result'; name: string; args?: unknown; timestamp: number }>>([]);
|
||||
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
|
||||
const [showModelSelector, setShowModelSelector] = useState(false);
|
||||
const [needsApiKey, setNeedsApiKey] = useState(false);
|
||||
const [apiKeyInput, setApiKeyInput] = useState('');
|
||||
const [apiKeyError, setApiKeyError] = useState('');
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [panelElements, setPanelElements] = useState<AssistantPanelElement[]>([]);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const streamingRef = useRef('');
|
||||
const toolEventsRef = useRef<Array<{ name: string; args?: unknown }>>([]);
|
||||
const {
|
||||
setSelectedPost,
|
||||
setSelectedMedia,
|
||||
openTab,
|
||||
setActiveView,
|
||||
toggleSidebar,
|
||||
togglePanel,
|
||||
toggleAssistantSidebar,
|
||||
} = useAppStore();
|
||||
const { sendMessage: sendChatMessage } = useChatMessageSender({
|
||||
chatService: window.electronAPI?.chat,
|
||||
});
|
||||
const {
|
||||
messages,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
toolEvents,
|
||||
setMessages,
|
||||
beginUserTurn,
|
||||
appendStreamDelta,
|
||||
recordToolCall,
|
||||
recordToolResult,
|
||||
appendAssistantMessage,
|
||||
finalizeAssistantTurn,
|
||||
stopStreaming,
|
||||
abortStreaming,
|
||||
getStreamingContent,
|
||||
} = useChatSurfaceState();
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
const scrollToBottom = useCallback(() => {
|
||||
@@ -70,8 +103,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
// Subscribe to stream events
|
||||
const unsubDelta = window.electronAPI?.chat.onStreamDelta((data) => {
|
||||
if (data.conversationId === conversationId) {
|
||||
streamingRef.current += data.delta;
|
||||
setStreamingContent(streamingRef.current);
|
||||
appendStreamDelta(data.delta);
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
@@ -80,8 +112,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
console.log('[ChatPanel] Tool call received:', data);
|
||||
if (data.conversationId === conversationId) {
|
||||
const toolCall = data.toolCall as { name: string; arguments: Record<string, unknown> };
|
||||
toolEventsRef.current.push({ name: toolCall.name, args: toolCall.arguments });
|
||||
setToolEvents(prev => [...prev, { type: 'call', name: toolCall.name, args: toolCall.arguments, timestamp: Date.now() }]);
|
||||
recordToolCall(toolCall.name, toolCall.arguments);
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
@@ -90,7 +121,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
console.log('[ChatPanel] Tool result received:', data);
|
||||
if (data.conversationId === conversationId) {
|
||||
const result = data.result as { name: string; result: unknown };
|
||||
setToolEvents(prev => [...prev, { type: 'result', name: result.name, timestamp: Date.now() }]);
|
||||
recordToolResult(result.name);
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
@@ -107,7 +138,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
unsubToolResult?.();
|
||||
unsubTitle?.();
|
||||
};
|
||||
}, [conversationId, loadData, scrollToBottom, checkReady]);
|
||||
}, [conversationId, loadData, scrollToBottom, checkReady, appendStreamDelta, recordToolCall, recordToolResult]);
|
||||
|
||||
// Scroll on new messages or streaming content
|
||||
useEffect(() => {
|
||||
@@ -146,76 +177,89 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = 'auto';
|
||||
}
|
||||
setIsStreaming(true);
|
||||
streamingRef.current = '';
|
||||
setStreamingContent('');
|
||||
setToolEvents([]);
|
||||
toolEventsRef.current = [];
|
||||
|
||||
// Add user message optimistically
|
||||
const userMessage: ChatMessage = {
|
||||
id: `temp-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'user',
|
||||
content: message,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
beginUserTurn(conversationId, message);
|
||||
|
||||
try {
|
||||
// Send message and wait for complete response
|
||||
const result = await window.electronAPI?.chat.sendMessage(conversationId, message);
|
||||
const result = await sendChatMessage({
|
||||
conversationId,
|
||||
message,
|
||||
metadata: { surface: 'tab' },
|
||||
});
|
||||
|
||||
// Use the streamed content we accumulated via onStreamDelta
|
||||
// Fall back to the backend result message if streaming didn't capture the content
|
||||
const assistantContent = streamingRef.current || (result?.success ? result.message : '');
|
||||
const assistantContent = getStreamingContent() || (result.success ? result.message : '');
|
||||
|
||||
if (assistantContent) {
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
toolCalls: toolEventsRef.current.length > 0 ? JSON.stringify(toolEventsRef.current) : undefined,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
} else if (result && !result.success) {
|
||||
const parsedResponse = extractAssistantResponseContent(assistantContent);
|
||||
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
|
||||
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
|
||||
} else if (!result.success) {
|
||||
// Backend returned an error (API failure, model unavailable, etc.)
|
||||
const errorMessage: ChatMessage = {
|
||||
id: `error-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
appendAssistantMessage(conversationId, tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }));
|
||||
stopStreaming();
|
||||
setPanelElements([]);
|
||||
} 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
|
||||
const noContentMessage: ChatMessage = {
|
||||
id: `empty-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: tr('chat.errorEmptyResponse'),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, noContentMessage]);
|
||||
appendAssistantMessage(conversationId, tr('chat.errorEmptyResponse'));
|
||||
stopStreaming();
|
||||
setPanelElements([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
const errorMessage: ChatMessage = {
|
||||
id: `error-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: tr('chat.errorGeneric'),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
appendAssistantMessage(conversationId, tr('chat.errorGeneric'));
|
||||
stopStreaming();
|
||||
setPanelElements([]);
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
if (isStreaming) {
|
||||
stopStreaming();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const persistActionEvent = async (message: string) => {
|
||||
try {
|
||||
await window.electronAPI?.chat.addSystemEvent(conversationId, message);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist chat action event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => {
|
||||
const result = dispatchAssistantAction(
|
||||
{
|
||||
action,
|
||||
payload,
|
||||
},
|
||||
{
|
||||
setSelectedPost,
|
||||
setSelectedMedia,
|
||||
openTab,
|
||||
setActiveView,
|
||||
toggleSidebar,
|
||||
togglePanel,
|
||||
toggleAssistantSidebar,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.handled) {
|
||||
setActionError(result.error || tr('assistantSidebar.error.actionFailed'));
|
||||
void persistActionEvent(`Assistant action failed: ${action}${result.error ? ` (${result.error})` : ''}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setActionError(null);
|
||||
void persistActionEvent(`Assistant action executed: ${action}${payload ? ` ${JSON.stringify(payload)}` : ''}`);
|
||||
};
|
||||
|
||||
const handleModelChange = async (modelId: string) => {
|
||||
try {
|
||||
await window.electronAPI?.chat.setConversationModel(conversationId, modelId);
|
||||
setConversation((previous) => (previous ? { ...previous, model: modelId } : null));
|
||||
setShowModelSelector(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to change model:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -232,140 +276,18 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
} catch (error) {
|
||||
console.error('Failed to abort:', error);
|
||||
} finally {
|
||||
// Keep any streamed content as a visible message
|
||||
const partialContent = streamingRef.current;
|
||||
setIsStreaming(false);
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
|
||||
if (partialContent) {
|
||||
const partialMessage: ChatMessage = {
|
||||
id: `partial-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: `${partialContent}\n\n*(${tr('chat.cancelledSuffix')})*`,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, partialMessage]);
|
||||
}
|
||||
abortStreaming(conversationId, tr('chat.cancelledSuffix'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelChange = async (modelId: string) => {
|
||||
try {
|
||||
await window.electronAPI?.chat.setConversationModel(conversationId, modelId);
|
||||
setConversation(prev => prev ? { ...prev, model: modelId } : null);
|
||||
setShowModelSelector(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to change model:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderToolMarkers = (events: Array<{ type: 'call' | 'result'; name: string; args?: unknown; timestamp: number }>) => {
|
||||
if (events.length === 0) return null;
|
||||
|
||||
// Group into pairs: call + result for each tool invocation
|
||||
const markers: Array<{ name: string; args?: unknown; completed: boolean }> = [];
|
||||
const pendingCalls = new Map<string, number>();
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type === 'call') {
|
||||
markers.push({ name: event.name, args: event.args, completed: false });
|
||||
const count = pendingCalls.get(event.name) || 0;
|
||||
pendingCalls.set(event.name, count + 1);
|
||||
} else if (event.type === 'result') {
|
||||
// Find the last uncompleted marker for this tool
|
||||
for (let i = markers.length - 1; i >= 0; i--) {
|
||||
if (markers[i].name === event.name && !markers[i].completed) {
|
||||
markers[i].completed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tool-markers">
|
||||
{markers.map((marker, i) => {
|
||||
const argsPreview = marker.args
|
||||
? Object.entries(marker.args as Record<string, unknown>)
|
||||
.map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v.length > 30 ? v.slice(0, 30) + '...' : v}"` : JSON.stringify(v)}`)
|
||||
.join(', ')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div key={i} className={`tool-marker ${marker.completed ? 'completed' : 'pending'}`}>
|
||||
<span className="tool-marker-icon">{marker.completed ? '\u2713' : '\u25CF'}</span>
|
||||
<span className="tool-marker-name">{marker.name}</span>
|
||||
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMessage = (msg: ChatMessage) => {
|
||||
if (msg.role === 'system' || msg.role === 'tool') return null;
|
||||
|
||||
// Parse tool calls from stored message data
|
||||
const storedToolCalls: Array<{ name: string; args?: unknown; completed: boolean }> = [];
|
||||
if (msg.role === 'assistant' && msg.toolCalls) {
|
||||
try {
|
||||
const calls = JSON.parse(msg.toolCalls) as Array<{ name: string; args?: unknown }>;
|
||||
calls.forEach(c => storedToolCalls.push({ name: c.name, args: c.args, completed: true }));
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={`chat-message ${msg.role}`}>
|
||||
<div className="chat-message-avatar">
|
||||
{msg.role === 'user' ? '\u{1F464}' : '\u{1F916}'}
|
||||
</div>
|
||||
<div className="chat-message-content">
|
||||
<div className="chat-message-header">
|
||||
<span className="chat-message-role">
|
||||
{msg.role === 'user' ? tr('chat.role.you') : tr('chat.role.assistant')}
|
||||
</span>
|
||||
</div>
|
||||
{storedToolCalls.length > 0 && (
|
||||
<div className="tool-markers">
|
||||
{storedToolCalls.map((marker, i) => {
|
||||
const argsPreview = marker.args
|
||||
? Object.entries(marker.args as Record<string, unknown>)
|
||||
.map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v.length > 30 ? v.slice(0, 30) + '...' : v}"` : JSON.stringify(v)}`)
|
||||
.join(', ')
|
||||
: '';
|
||||
return (
|
||||
<div key={i} className="tool-marker completed">
|
||||
<span className="tool-marker-icon">{'\u2713'}</span>
|
||||
<span className="tool-marker-name">{marker.name}</span>
|
||||
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-message-text">
|
||||
{msg.role === 'assistant' ? (
|
||||
<Markdown gfm>{msg.content}</Markdown>
|
||||
) : (
|
||||
msg.content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// API key setup screen
|
||||
if (needsApiKey) {
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-panel chat-surface">
|
||||
<div className="chat-panel-header">
|
||||
<div className="chat-panel-title">{tr('chat.setupTitle')}</div>
|
||||
</div>
|
||||
<div className="chat-messages">
|
||||
<div className="chat-messages chat-surface-scroll">
|
||||
<div className="chat-welcome">
|
||||
<div className="chat-welcome-icon">{'\u{1F511}'}</div>
|
||||
<h2>{tr('chat.apiKeyRequiredTitle')}</h2>
|
||||
@@ -396,37 +318,39 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-panel chat-surface">
|
||||
<div className="chat-panel-header">
|
||||
<div className="chat-panel-title">
|
||||
{conversation?.title || tr('chat.newChat')}
|
||||
</div>
|
||||
<div className="chat-panel-model">
|
||||
<button
|
||||
className="model-selector-button"
|
||||
onClick={() => setShowModelSelector(!showModelSelector)}
|
||||
>
|
||||
{conversation?.model || 'claude-sonnet-4'}
|
||||
<span className="model-dropdown-icon">{'\u25BE'}</span>
|
||||
</button>
|
||||
{showModelSelector && (
|
||||
<div className="model-dropdown">
|
||||
{availableModels.map(model => (
|
||||
<button
|
||||
key={model.id}
|
||||
className={`model-option ${conversation?.model === model.id ? 'active' : ''}`}
|
||||
onClick={() => handleModelChange(model.id)}
|
||||
>
|
||||
{model.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{surfaceMode.showModelSelector && (
|
||||
<div className="chat-panel-model">
|
||||
<button
|
||||
className="model-selector-button"
|
||||
onClick={() => setShowModelSelector(!showModelSelector)}
|
||||
>
|
||||
{conversation?.model || 'claude-sonnet-4'}
|
||||
<span className="model-dropdown-icon">{'\u25BE'}</span>
|
||||
</button>
|
||||
{showModelSelector && (
|
||||
<div className="model-dropdown">
|
||||
{availableModels.map(model => (
|
||||
<button
|
||||
key={model.id}
|
||||
className={`model-option ${conversation?.model === model.id ? 'active' : ''}`}
|
||||
onClick={() => handleModelChange(model.id)}
|
||||
>
|
||||
{model.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="chat-messages">
|
||||
{messages.length === 0 && !isStreaming && (
|
||||
<div className="chat-messages chat-surface-scroll">
|
||||
{surfaceMode.showWelcomeTips && messages.length === 0 && !isStreaming && (
|
||||
<div className="chat-welcome">
|
||||
<div className="chat-welcome-icon">{'\u{1F916}'}</div>
|
||||
<h2>{tr('chat.welcomeTitle')}</h2>
|
||||
@@ -441,38 +365,22 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map(renderMessage)}
|
||||
<ChatTranscript
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
streamingContent={streamingContent}
|
||||
toolEvents={toolEvents}
|
||||
assistantRoleLabel={tr('chat.role.assistant')}
|
||||
userRoleLabel={tr('chat.role.you')}
|
||||
showToolMarkers={surfaceMode.showToolMarkers}
|
||||
endRef={messagesEndRef}
|
||||
/>
|
||||
|
||||
{isStreaming && (streamingContent || toolEvents.length > 0) && (
|
||||
<div className="chat-message assistant streaming">
|
||||
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
||||
<div className="chat-message-content">
|
||||
<div className="chat-message-header">
|
||||
<span className="chat-message-role">{tr('chat.role.assistant')}</span>
|
||||
<span className="streaming-indicator">{'\u25CF'}</span>
|
||||
</div>
|
||||
{renderToolMarkers(toolEvents)}
|
||||
{streamingContent && (
|
||||
<div className="chat-message-text">
|
||||
<Markdown gfm>{streamingContent}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{panelElements.length > 0 && (
|
||||
<AssistantPanelControls elements={panelElements} onAction={handleAssistantAction} />
|
||||
)}
|
||||
|
||||
{isStreaming && !streamingContent && toolEvents.length === 0 && (
|
||||
<div className="chat-message assistant thinking">
|
||||
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
||||
<div className="chat-message-content">
|
||||
<div className="chat-thinking-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
{actionError && <p className="chat-surface-error">{actionError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="chat-input-container">
|
||||
@@ -484,7 +392,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
<div className="chat-input-wrapper">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="chat-input"
|
||||
className="chat-input chat-surface-input"
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
|
||||
154
src/renderer/components/ChatSurface/ChatTranscript.tsx
Normal file
154
src/renderer/components/ChatSurface/ChatTranscript.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React from 'react';
|
||||
import Markdown from 'marked-react';
|
||||
import type { ChatMessage } from '../../types/electron';
|
||||
import type { ChatToolEvent } from '../../navigation/useChatSurfaceState';
|
||||
|
||||
interface ChatTranscriptProps {
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
toolEvents: ChatToolEvent[];
|
||||
assistantRoleLabel: string;
|
||||
userRoleLabel: string;
|
||||
showToolMarkers?: boolean;
|
||||
endRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const ChatTranscript: React.FC<ChatTranscriptProps> = ({
|
||||
messages,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
toolEvents,
|
||||
assistantRoleLabel,
|
||||
userRoleLabel,
|
||||
showToolMarkers = true,
|
||||
endRef,
|
||||
}) => {
|
||||
const renderToolMarkers = (events: ChatToolEvent[]) => {
|
||||
if (events.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const markers: Array<{ name: string; args?: unknown; completed: boolean }> = [];
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type === 'call') {
|
||||
markers.push({ name: event.name, args: event.args, completed: false });
|
||||
} else if (event.type === 'result') {
|
||||
for (let markerIndex = markers.length - 1; markerIndex >= 0; markerIndex -= 1) {
|
||||
if (markers[markerIndex].name === event.name && !markers[markerIndex].completed) {
|
||||
markers[markerIndex].completed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tool-markers">
|
||||
{markers.map((marker, index) => {
|
||||
const argsPreview = marker.args
|
||||
? Object.entries(marker.args as Record<string, unknown>)
|
||||
.map(([key, value]) => `${key}: ${typeof value === 'string' ? `"${value.length > 30 ? value.slice(0, 30) + '...' : value}"` : JSON.stringify(value)}`)
|
||||
.join(', ')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div key={`${marker.name}-${index}`} className={`tool-marker ${marker.completed ? 'completed' : 'pending'}`}>
|
||||
<span className="tool-marker-icon">{marker.completed ? '\u2713' : '\u25CF'}</span>
|
||||
<span className="tool-marker-name">{marker.name}</span>
|
||||
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMessage = (message: ChatMessage) => {
|
||||
if (message.role === 'system' || message.role === 'tool') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storedToolCalls: Array<{ name: string; args?: unknown; completed: boolean }> = [];
|
||||
if (message.role === 'assistant' && message.toolCalls) {
|
||||
try {
|
||||
const parsedToolCalls = JSON.parse(message.toolCalls) as Array<{ name: string; args?: unknown }>;
|
||||
parsedToolCalls.forEach((toolCall) => storedToolCalls.push({ name: toolCall.name, args: toolCall.args, completed: true }));
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={message.id} className={`chat-message ${message.role}`}>
|
||||
<div className="chat-message-avatar">
|
||||
{message.role === 'user' ? '\u{1F464}' : '\u{1F916}'}
|
||||
</div>
|
||||
<div className="chat-message-content">
|
||||
<div className="chat-message-header">
|
||||
<span className="chat-message-role">{message.role === 'user' ? userRoleLabel : assistantRoleLabel}</span>
|
||||
</div>
|
||||
{showToolMarkers && storedToolCalls.length > 0 && (
|
||||
<div className="tool-markers">
|
||||
{storedToolCalls.map((marker, markerIndex) => {
|
||||
const argsPreview = marker.args
|
||||
? Object.entries(marker.args as Record<string, unknown>)
|
||||
.map(([key, value]) => `${key}: ${typeof value === 'string' ? `"${value.length > 30 ? value.slice(0, 30) + '...' : value}"` : JSON.stringify(value)}`)
|
||||
.join(', ')
|
||||
: '';
|
||||
return (
|
||||
<div key={`${marker.name}-${markerIndex}`} className="tool-marker completed">
|
||||
<span className="tool-marker-icon">{'\u2713'}</span>
|
||||
<span className="tool-marker-name">{marker.name}</span>
|
||||
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-message-text">
|
||||
{message.role === 'assistant' ? <Markdown gfm>{message.content}</Markdown> : message.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{messages.map(renderMessage)}
|
||||
|
||||
{isStreaming && (streamingContent || (showToolMarkers && toolEvents.length > 0)) && (
|
||||
<div className="chat-message assistant streaming">
|
||||
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
||||
<div className="chat-message-content">
|
||||
<div className="chat-message-header">
|
||||
<span className="chat-message-role">{assistantRoleLabel}</span>
|
||||
<span className="streaming-indicator">{'\u25CF'}</span>
|
||||
</div>
|
||||
{showToolMarkers ? renderToolMarkers(toolEvents) : null}
|
||||
{streamingContent && (
|
||||
<div className="chat-message-text">
|
||||
<Markdown gfm>{streamingContent}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isStreaming && !streamingContent && (!showToolMarkers || toolEvents.length === 0) && (
|
||||
<div className="chat-message assistant thinking">
|
||||
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
||||
<div className="chat-message-content">
|
||||
<div className="chat-thinking-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={endRef} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
src/renderer/components/ChatSurface/index.ts
Normal file
1
src/renderer/components/ChatSurface/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ChatTranscript } from './ChatTranscript';
|
||||
@@ -236,6 +236,41 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.window-titlebar-assistant-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 2px;
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.window-titlebar-assistant-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 66.6667%;
|
||||
width: 1.5px;
|
||||
transform: translateX(-50%);
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
.window-titlebar-assistant-pane {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 33.3333%;
|
||||
height: 100%;
|
||||
background-color: currentColor;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.window-titlebar-assistant-icon.is-inactive .window-titlebar-assistant-pane {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.window-titlebar-action-button:hover {
|
||||
background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ type WindowControlsOverlayLike = {
|
||||
|
||||
export const WindowTitleBar: React.FC = () => {
|
||||
const { language, t } = useI18n();
|
||||
const { sidebarVisible, panelVisible, toggleSidebar, togglePanel } = useAppStore();
|
||||
const { sidebarVisible, panelVisible, assistantSidebarVisible, toggleSidebar, togglePanel, toggleAssistantSidebar } = useAppStore();
|
||||
const [windowTitle, setWindowTitle] = useState<string>(document.title || 'Blogging Desktop Server');
|
||||
const [openMenu, setOpenMenu] = useState<{ label: string; left: number } | null>(null);
|
||||
const [showMnemonics, setShowMnemonics] = useState<boolean>(false);
|
||||
@@ -456,6 +456,20 @@ export const WindowTitleBar: React.FC = () => {
|
||||
<span className="window-titlebar-panel-pane" data-shape="bottom-half" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="window-titlebar-action-button"
|
||||
aria-label={t('windowTitleBar.toggleAssistantSidebar')}
|
||||
onClick={toggleAssistantSidebar}
|
||||
title={assistantSidebarVisible ? t('windowTitleBar.hideAssistantSidebar') : t('windowTitleBar.showAssistantSidebar')}
|
||||
>
|
||||
<span
|
||||
className={`window-titlebar-assistant-icon ${assistantSidebarVisible ? 'is-active' : 'is-inactive'}`}
|
||||
data-shape="frame-square"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="window-titlebar-assistant-pane" data-shape="right-half" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{!isMac && openMenu && activeMenu && (
|
||||
<div
|
||||
|
||||
@@ -27,3 +27,5 @@ export { WindowTitleBar } from './WindowTitleBar';
|
||||
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