wip: agui integration

This commit is contained in:
2026-02-25 19:51:58 +01:00
parent 5efbcfe03a
commit fcdf869a7c
59 changed files with 3467 additions and 267 deletions

View File

@@ -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;
}

View File

@@ -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;

View File

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

View 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;
}

View 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;

View File

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

View File

@@ -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);

View 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} />
</>
);
};

View File

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

View File

@@ -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));
}

View File

@@ -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

View File

@@ -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';