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

@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast, ResizablePanel, WindowTitleBar } from './components';
import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast, ResizablePanel, WindowTitleBar, AssistantSidebar } from './components';
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
import { loadTabsForProject, saveTabsForProject } from './utils';
import { openSingletonToolTab } from './navigation/tabPolicy';
@@ -33,6 +33,7 @@ const App: React.FC = () => {
setLoading,
toggleSidebar,
togglePanel,
toggleAssistantSidebar,
setActiveView,
setSelectedPost,
setActiveProject,
@@ -307,6 +308,12 @@ const App: React.FC = () => {
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:toggleAssistantSidebar', () => {
toggleAssistantSidebar();
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:viewPosts', () => {
const state = useAppStore.getState();
@@ -538,7 +545,7 @@ const App: React.FC = () => {
};
}, []);
const { sidebarVisible } = useAppStore();
const { sidebarVisible, assistantSidebarVisible } = useAppStore();
return (
<div className="app">
@@ -562,6 +569,18 @@ const App: React.FC = () => {
<Editor />
<Panel />
</div>
{assistantSidebarVisible && (
<ResizablePanel
direction="horizontal"
initialSize={360}
minSize={280}
maxSize={640}
storageKey="assistant-sidebar-width"
resizerPosition="start"
>
<AssistantSidebar />
</ResizablePanel>
)}
</div>
<StatusBar />
<ToastContainer />

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

View File

@@ -830,6 +830,19 @@
"windowTitleBar.togglePanel": "Panel umschalten",
"windowTitleBar.hidePanel": "Panel ausblenden (Ctrl+J)",
"windowTitleBar.showPanel": "Panel anzeigen (Ctrl+J)",
"windowTitleBar.toggleAssistantSidebar": "Assistenz-Seitenleiste umschalten",
"windowTitleBar.hideAssistantSidebar": "Assistenz-Seitenleiste ausblenden (Ctrl+\\)",
"windowTitleBar.showAssistantSidebar": "Assistenz-Seitenleiste anzeigen (Ctrl+\\)",
"assistantSidebar.title": "KI-Assistent",
"assistantSidebar.description": "Starten Sie mit einem gezielten Prompt inklusive aktuellem Editor-Kontext.",
"assistantSidebar.context.label": "Aktueller Kontext",
"assistantSidebar.context.none": "Kein aktiver Editor-Kontext",
"assistantSidebar.prompt.placeholder": "Fragen Sie den Assistenten nach Analyse oder Abfragen Ihres aktuellen Stands…",
"assistantSidebar.button.start": "Mit Kontext starten",
"assistantSidebar.button.starting": "Startet…",
"assistantSidebar.conversationTitle": "Assistent-Sitzung",
"assistantSidebar.error.startFailed": "Assistent-Sitzung konnte nicht gestartet werden",
"assistantSidebar.error.actionFailed": "Assistent-Aktion konnte nicht ausgeführt werden",
"tagInput.alreadyAdded": "Tag bereits hinzugefügt",
"tagInput.remove": "{tag} entfernen",
"tagInput.createdTag": "Tag \"{name}\" erstellt",

View File

@@ -830,6 +830,19 @@
"windowTitleBar.togglePanel": "Toggle Panel",
"windowTitleBar.hidePanel": "Hide Panel (Ctrl+J)",
"windowTitleBar.showPanel": "Show Panel (Ctrl+J)",
"windowTitleBar.toggleAssistantSidebar": "Toggle Assistant Sidebar",
"windowTitleBar.hideAssistantSidebar": "Hide Assistant Sidebar (Ctrl+\\)",
"windowTitleBar.showAssistantSidebar": "Show Assistant Sidebar (Ctrl+\\)",
"assistantSidebar.title": "AI Assistant",
"assistantSidebar.description": "Start with a focused prompt and include your current editor context.",
"assistantSidebar.context.label": "Current context",
"assistantSidebar.context.none": "No active editor context",
"assistantSidebar.prompt.placeholder": "Ask the assistant to analyze or query your current work…",
"assistantSidebar.button.start": "Start with context",
"assistantSidebar.button.starting": "Starting…",
"assistantSidebar.conversationTitle": "Assistant Session",
"assistantSidebar.error.startFailed": "Failed to start assistant session",
"assistantSidebar.error.actionFailed": "Assistant action could not be executed",
"tagInput.alreadyAdded": "Tag already added",
"tagInput.remove": "Remove {tag}",
"tagInput.createdTag": "Tag \"{name}\" created",

View File

@@ -830,6 +830,19 @@
"windowTitleBar.togglePanel": "Alternar panel",
"windowTitleBar.hidePanel": "Ocultar panel",
"windowTitleBar.showPanel": "Mostrar panel",
"windowTitleBar.toggleAssistantSidebar": "Alternar barra del asistente",
"windowTitleBar.hideAssistantSidebar": "Ocultar barra del asistente (Ctrl+\\)",
"windowTitleBar.showAssistantSidebar": "Mostrar barra del asistente (Ctrl+\\)",
"assistantSidebar.title": "Asistente IA",
"assistantSidebar.description": "Comienza con un prompt enfocado y enriquecido con el contexto actual del editor.",
"assistantSidebar.context.label": "Contexto actual",
"assistantSidebar.context.none": "Sin contexto de editor activo",
"assistantSidebar.prompt.placeholder": "Pide al asistente analizar o consultar tu trabajo actual…",
"assistantSidebar.button.start": "Iniciar con contexto",
"assistantSidebar.button.starting": "Iniciando…",
"assistantSidebar.conversationTitle": "Sesión de asistente",
"assistantSidebar.error.startFailed": "No se pudo iniciar la sesión del asistente",
"assistantSidebar.error.actionFailed": "No se pudo ejecutar la acción del asistente",
"tagInput.alreadyAdded": "La etiqueta “{tag}” ya está añadida",
"tagInput.remove": "Quitar",
"tagInput.createdTag": "Etiqueta “{tag}” creada",

View File

@@ -830,6 +830,19 @@
"windowTitleBar.togglePanel": "Basculer le panneau",
"windowTitleBar.hidePanel": "Masquer le panneau",
"windowTitleBar.showPanel": "Afficher le panneau",
"windowTitleBar.toggleAssistantSidebar": "Basculer le panneau Assistant",
"windowTitleBar.hideAssistantSidebar": "Masquer le panneau Assistant (Ctrl+\\)",
"windowTitleBar.showAssistantSidebar": "Afficher le panneau Assistant (Ctrl+\\)",
"assistantSidebar.title": "Assistant IA",
"assistantSidebar.description": "Commencez avec une requête ciblée enrichie du contexte éditeur actuel.",
"assistantSidebar.context.label": "Contexte actuel",
"assistantSidebar.context.none": "Aucun contexte éditeur actif",
"assistantSidebar.prompt.placeholder": "Demandez à lassistant danalyser ou dinterroger votre travail en cours…",
"assistantSidebar.button.start": "Démarrer avec contexte",
"assistantSidebar.button.starting": "Démarrage…",
"assistantSidebar.conversationTitle": "Session Assistant",
"assistantSidebar.error.startFailed": "Impossible de démarrer la session assistant",
"assistantSidebar.error.actionFailed": "Laction assistant na pas pu être exécutée",
"tagInput.alreadyAdded": "Le tag « {tag} » est déjà ajouté",
"tagInput.remove": "Supprimer",
"tagInput.createdTag": "Tag « {tag} » créé",

View File

@@ -830,6 +830,19 @@
"windowTitleBar.togglePanel": "Mostra/Nascondi pannello",
"windowTitleBar.hidePanel": "Nascondi pannello",
"windowTitleBar.showPanel": "Mostra pannello",
"windowTitleBar.toggleAssistantSidebar": "Mostra/Nascondi barra assistente",
"windowTitleBar.hideAssistantSidebar": "Nascondi barra assistente (Ctrl+\\)",
"windowTitleBar.showAssistantSidebar": "Mostra barra assistente (Ctrl+\\)",
"assistantSidebar.title": "Assistente IA",
"assistantSidebar.description": "Inizia con un prompt mirato arricchito dal contesto editor corrente.",
"assistantSidebar.context.label": "Contesto attuale",
"assistantSidebar.context.none": "Nessun contesto editor attivo",
"assistantSidebar.prompt.placeholder": "Chiedi allassistente di analizzare o interrogare il lavoro corrente…",
"assistantSidebar.button.start": "Avvia con contesto",
"assistantSidebar.button.starting": "Avvio…",
"assistantSidebar.conversationTitle": "Sessione assistente",
"assistantSidebar.error.startFailed": "Impossibile avviare la sessione assistente",
"assistantSidebar.error.actionFailed": "Impossibile eseguire lazione dellassistente",
"tagInput.alreadyAdded": "Il tag “{tag}” è già stato aggiunto",
"tagInput.remove": "Rimuovi",
"tagInput.createdTag": "Tag “{tag}” creato",

View File

@@ -0,0 +1,129 @@
import type { SidebarView } from './sidebarViewRegistry';
import type { TabType } from '../store/appStore';
import { isSidebarView } from './sidebarViewRegistry';
import { z } from 'zod';
export interface AssistantActionInput {
action: string;
payload?: Record<string, unknown>;
}
export interface AssistantActionDependencies {
setSelectedPost: (id: string | null) => void;
setSelectedMedia: (id: string | null) => void;
openTab: (tab: { type: TabType; id: string; isTransient: boolean }) => void;
setActiveView: (view: SidebarView) => void;
toggleSidebar: () => void;
togglePanel: () => void;
toggleAssistantSidebar: () => void;
}
export interface AssistantActionResult {
handled: boolean;
error?: string;
}
const openPostPayloadSchema = z.object({
postId: z.string().min(1),
});
const openMediaPayloadSchema = z.object({
mediaId: z.string().min(1),
});
const switchViewPayloadSchema = z
.object({
view: z.string().min(1),
})
.refine((payload) => isSidebarView(payload.view), {
message: 'view must be a valid sidebar view',
});
const openChatPayloadSchema = z.object({
conversationId: z.string().min(1),
});
function invalidPayloadError(action: string): AssistantActionResult {
return {
handled: false,
error: `Invalid payload for ${action} action`,
};
}
export function dispatchAssistantAction(
input: AssistantActionInput,
dependencies: AssistantActionDependencies,
): AssistantActionResult {
const payload = input.payload ?? {};
if (input.action === 'openPost') {
const parsed = openPostPayloadSchema.safeParse(payload);
if (!parsed.success) {
return invalidPayloadError('openPost');
}
const { postId } = parsed.data;
dependencies.setActiveView('posts');
dependencies.setSelectedPost(postId);
dependencies.openTab({ type: 'post', id: postId, isTransient: false });
return { handled: true };
}
if (input.action === 'openMedia') {
const parsed = openMediaPayloadSchema.safeParse(payload);
if (!parsed.success) {
return invalidPayloadError('openMedia');
}
const { mediaId } = parsed.data;
dependencies.setActiveView('media');
dependencies.setSelectedMedia(mediaId);
dependencies.openTab({ type: 'media', id: mediaId, isTransient: false });
return { handled: true };
}
if (input.action === 'switchView') {
const parsed = switchViewPayloadSchema.safeParse(payload);
if (!parsed.success) {
return invalidPayloadError('switchView');
}
const { view } = parsed.data;
dependencies.setActiveView(view as SidebarView);
return { handled: true };
}
if (input.action === 'openChat') {
const parsed = openChatPayloadSchema.safeParse(payload);
if (!parsed.success) {
return invalidPayloadError('openChat');
}
dependencies.setActiveView('chat');
dependencies.openTab({ type: 'chat', id: parsed.data.conversationId, isTransient: false });
return { handled: true };
}
if (input.action === 'openSettings') {
dependencies.setActiveView('settings');
dependencies.openTab({ type: 'settings', id: 'settings', isTransient: false });
return { handled: true };
}
if (input.action === 'toggleSidebar') {
dependencies.toggleSidebar();
return { handled: true };
}
if (input.action === 'togglePanel') {
dependencies.togglePanel();
return { handled: true };
}
if (input.action === 'toggleAssistantSidebar') {
dependencies.toggleAssistantSidebar();
return { handled: true };
}
return { handled: false, error: `Unsupported action: ${input.action}` };
}

View File

@@ -0,0 +1,30 @@
import type { AssistantEditorContext } from './assistantPromptContext';
import { buildAssistantStartPrompt } from './assistantPromptContext';
export interface AssistantRequestPlan {
shouldCreateConversation: boolean;
outboundMessage: string;
}
export function planAssistantRequest(input: {
conversationId: string | null;
userPrompt: string;
context: AssistantEditorContext | null;
}): AssistantRequestPlan {
const userPrompt = input.userPrompt.trim();
if (input.conversationId) {
return {
shouldCreateConversation: false,
outboundMessage: userPrompt,
};
}
return {
shouldCreateConversation: true,
outboundMessage: buildAssistantStartPrompt({
userPrompt,
context: input.context,
}),
};
}

View File

@@ -0,0 +1,391 @@
import { z } from 'zod';
const textElementSchema = z.object({
type: z.literal('text'),
text: z.string().min(1),
});
const metricElementSchema = z.object({
type: z.literal('metric'),
label: z.string().min(1),
value: z.string().min(1),
});
const listElementSchema = z.object({
type: z.literal('list'),
title: z.string().optional(),
items: z.array(z.string().min(1)).min(1),
});
const tableElementSchema = z.object({
type: z.literal('table'),
columns: z.array(z.string().min(1)).min(1),
rows: z.array(z.array(z.string())).min(1),
});
const actionElementSchema = z.object({
type: z.literal('action'),
label: z.string().min(1),
action: z.string().min(1),
payload: z.record(z.string(), z.unknown()).optional(),
});
const chartElementSchema = z.object({
type: z.literal('chart'),
chartType: z.enum(['bar', 'line', 'pie']),
title: z.string().min(1).optional(),
series: z.array(
z.object({
label: z.string().min(1),
value: z.number(),
}),
).min(1),
});
const inputTypeSchema = z.enum(['text', 'textarea', 'select', 'checkbox', 'date', 'number']);
const inputOptionSchema = z.object({
label: z.string().min(1),
value: z.string(),
});
const inputElementSchema = z.object({
type: z.literal('input'),
key: z.string().min(1),
label: z.string().min(1),
inputType: inputTypeSchema,
placeholder: z.string().optional(),
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(),
options: z.array(inputOptionSchema).optional(),
action: z.string().min(1).optional(),
submitLabel: z.string().min(1).optional(),
payload: z.record(z.string(), z.unknown()).optional(),
});
const datePickerElementSchema = z.object({
type: z.literal('datePicker'),
key: z.string().min(1),
label: z.string().min(1),
defaultValue: z.string().optional(),
min: z.string().optional(),
max: z.string().optional(),
action: z.string().min(1).optional(),
submitLabel: z.string().min(1).optional(),
payload: z.record(z.string(), z.unknown()).optional(),
});
const formFieldSchema = z.object({
key: z.string().min(1),
label: z.string().min(1),
inputType: inputTypeSchema,
placeholder: z.string().optional(),
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(),
options: z.array(inputOptionSchema).optional(),
required: z.boolean().optional(),
});
const formElementSchema = z.object({
type: z.literal('form'),
formId: z.string().min(1),
title: z.string().optional(),
submitLabel: z.string().min(1),
action: z.string().min(1),
payload: z.record(z.string(), z.unknown()).optional(),
fields: z.array(formFieldSchema).min(1),
});
const cardActionSchema = z.object({
label: z.string().min(1),
action: z.string().min(1),
payload: z.record(z.string(), z.unknown()).optional(),
});
const cardElementSchema = z.object({
type: z.literal('card'),
title: z.string().min(1),
body: z.string().min(1),
subtitle: z.string().optional(),
actions: z.array(cardActionSchema).optional(),
});
const imageElementSchema = z.object({
type: z.literal('image'),
src: z.string().min(1),
alt: z.string().optional(),
caption: z.string().optional(),
action: z.string().min(1).optional(),
payload: z.record(z.string(), z.unknown()).optional(),
});
let assistantPanelElementSchemaRef: z.ZodTypeAny;
const tabsElementSchema: z.ZodTypeAny = z.lazy(() => z.object({
type: z.literal('tabs'),
widgetId: z.string().min(1).optional(),
defaultTabId: z.string().min(1).optional(),
tabs: z.array(
z.object({
id: z.string().min(1),
label: z.string().min(1),
elements: z.array(assistantPanelElementSchemaRef).min(1),
}),
).min(1),
}));
assistantPanelElementSchemaRef = z.discriminatedUnion('type', [
textElementSchema,
metricElementSchema,
listElementSchema,
tableElementSchema,
actionElementSchema,
chartElementSchema,
inputElementSchema,
formElementSchema,
datePickerElementSchema,
cardElementSchema,
imageElementSchema,
tabsElementSchema,
]);
export const assistantPanelElementSchema = assistantPanelElementSchemaRef;
export const assistantPanelSpecSchema = z.object({
specVersion: z.literal('1'),
elements: z.array(assistantPanelElementSchema).min(1),
});
export type AssistantPanelElement = z.infer<typeof assistantPanelElementSchema>;
export type AssistantPanelSpec = z.infer<typeof assistantPanelSpecSchema>;
export interface AssistantResponseContent {
displayText: string;
panelSpec: AssistantPanelSpec | null;
}
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function normalizeChartElement(record: Record<string, unknown>): Record<string, unknown> | null {
const normalized: Record<string, unknown> = {
...record,
};
const dataRecord = toRecord(record.data);
if (Array.isArray(record.series)) {
return normalized;
}
if (!dataRecord) {
return normalized;
}
const labels = Array.isArray(dataRecord.labels) ? dataRecord.labels : [];
const datasets = Array.isArray(dataRecord.datasets) ? dataRecord.datasets : [];
const firstDataset = toRecord(datasets[0]);
const values = Array.isArray(firstDataset?.data) ? firstDataset?.data : [];
if (labels.length === 0 || values.length === 0) {
return normalized;
}
const series = labels
.map((label, index) => ({
label: String(label),
value: Number(values[index]),
}))
.filter((entry) => Number.isFinite(entry.value));
if (series.length === 0) {
return normalized;
}
normalized.series = series;
delete normalized.data;
return normalized;
}
function normalizeTabContent(tabValue: unknown): Record<string, unknown>[] {
if (Array.isArray(tabValue)) {
return tabValue.map((entry) => normalizeElement(entry)).filter((entry): entry is Record<string, unknown> => Boolean(entry));
}
const normalized = normalizeElement(tabValue);
return normalized ? [normalized] : [];
}
function normalizeTabsElement(record: Record<string, unknown>): Record<string, unknown> | null {
const tabs = Array.isArray(record.tabs) ? record.tabs : [];
const normalizedTabs = tabs
.map((tabValue, tabIndex) => {
const tabRecord = toRecord(tabValue);
if (!tabRecord) {
return null;
}
const id = typeof tabRecord.id === 'string' && tabRecord.id.trim().length > 0
? tabRecord.id
: `tab-${tabIndex + 1}`;
const label = typeof tabRecord.label === 'string' && tabRecord.label.trim().length > 0
? tabRecord.label
: typeof tabRecord.title === 'string' && tabRecord.title.trim().length > 0
? tabRecord.title
: id;
const elements = Array.isArray(tabRecord.elements)
? normalizeTabContent(tabRecord.elements)
: normalizeTabContent(tabRecord.content);
if (elements.length === 0) {
return null;
}
return {
id,
label,
elements,
};
})
.filter((entry): entry is { id: string; label: string; elements: Record<string, unknown>[] } => Boolean(entry));
if (normalizedTabs.length === 0) {
return null;
}
return {
...record,
tabs: normalizedTabs,
};
}
function normalizeElement(value: unknown): Record<string, unknown> | null {
const record = toRecord(value);
if (!record) {
return null;
}
const type = typeof record.type === 'string' ? record.type : '';
if (type === 'markdown') {
const textValue = typeof record.content === 'string' ? record.content : typeof record.text === 'string' ? record.text : '';
if (!textValue.trim()) {
return null;
}
return {
type: 'text',
text: textValue,
};
}
if (type === 'chart') {
return normalizeChartElement(record);
}
if (type === 'tabs') {
return normalizeTabsElement(record);
}
return record;
}
function normalizeCandidate(parsed: unknown): AssistantPanelSpec | null {
const canonicalResult = assistantPanelSpecSchema.safeParse(parsed);
if (canonicalResult.success) {
return canonicalResult.data;
}
const record = toRecord(parsed);
if (!record) {
return null;
}
if (record.type === 'tab' && record.content) {
return normalizeCandidate(record.content);
}
if (record.type === 'tabs') {
const tabsElement = normalizeTabsElement(record);
if (!tabsElement) {
return null;
}
const asSpec = {
specVersion: '1',
elements: [tabsElement],
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
if (Array.isArray(record.elements)) {
const normalizedElements = record.elements
.map((element) => normalizeElement(element))
.filter((element): element is Record<string, unknown> => Boolean(element));
if (normalizedElements.length === 0) {
return null;
}
const asSpec = {
specVersion: '1',
elements: normalizedElements,
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
const normalizedElement = normalizeElement(record);
if (!normalizedElement) {
return null;
}
const asSpec = {
specVersion: '1',
elements: [normalizedElement],
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
function parseSpecCandidate(raw: string): AssistantPanelSpec | null {
try {
const parsed = JSON.parse(raw);
return normalizeCandidate(parsed);
} catch {
return null;
}
}
export function extractAssistantPanelSpec(message: string): AssistantPanelSpec | null {
return extractAssistantResponseContent(message).panelSpec;
}
export function extractAssistantResponseContent(message: string): AssistantResponseContent {
const trimmed = message.trim();
const fencedMatches = [...trimmed.matchAll(/```(json)?\s*([\s\S]*?)```/gi)];
for (const match of fencedMatches) {
const candidate = match[2]?.trim();
if (!candidate) {
continue;
}
const parsed = parseSpecCandidate(candidate);
if (parsed) {
const displayText = trimmed.replace(match[0], '').trim();
return {
displayText,
panelSpec: parsed,
};
}
}
const parsedWholeMessage = parseSpecCandidate(trimmed);
return {
displayText: parsedWholeMessage ? '' : trimmed,
panelSpec: parsedWholeMessage,
};
}

View File

@@ -0,0 +1,67 @@
import type { MediaData, PostData, Tab } from '../store/appStore';
export interface AssistantEditorContext {
tabType: Tab['type'] | 'none';
id?: string;
title?: string;
}
export function resolveAssistantEditorContext(input: {
activeTab: Tab | null;
posts: PostData[];
media: MediaData[];
}): AssistantEditorContext | null {
const { activeTab, posts, media } = input;
if (!activeTab) {
return null;
}
if (activeTab.type === 'post') {
const currentPost = posts.find((post) => post.id === activeTab.id);
return {
tabType: 'post',
id: activeTab.id,
title: currentPost?.title,
};
}
if (activeTab.type === 'media') {
const currentMedia = media.find((item) => item.id === activeTab.id);
return {
tabType: 'media',
id: activeTab.id,
title: currentMedia?.originalName || currentMedia?.filename || currentMedia?.title,
};
}
return {
tabType: activeTab.type,
id: activeTab.id,
title: activeTab.id,
};
}
export function buildAssistantStartPrompt(input: {
userPrompt: string;
context: AssistantEditorContext | null;
}): string {
const userPrompt = input.userPrompt.trim();
const context = input.context;
const lines = [
`User request: ${userPrompt}`,
`Current editor context type: ${context?.tabType ?? 'none'}`,
];
if (context?.id) {
lines.push(`Current editor context id: ${context.id}`);
}
if (context?.title) {
lines.push(`Current editor context title: ${context.title}`);
}
lines.push('Use this context when analyzing data and proposing UI updates.');
return lines.join('\n');
}

View File

@@ -0,0 +1,73 @@
export interface ChatService {
createConversation: (title?: string, model?: string) => Promise<{ id: string } | null | undefined>;
sendMessage: (
conversationId: string,
message: string,
metadata?: SendMessageMetadata,
) => Promise<{ success: boolean; message?: string; error?: string } | null | undefined>;
}
export interface SendMessageMetadata {
surface?: 'tab' | 'sidebar';
}
export interface EnsureConversationIdInput {
currentConversationId: string | null;
createTitle: string;
chatService: Pick<ChatService, 'createConversation'>;
}
export interface SendConversationMessageInput {
conversationId: string;
message: string;
metadata?: SendMessageMetadata;
chatService: Pick<ChatService, 'sendMessage'>;
}
export interface SendConversationMessageResult {
success: boolean;
message: string;
error?: string;
}
export async function ensureConversationId(input: EnsureConversationIdInput): Promise<string> {
if (input.currentConversationId) {
return input.currentConversationId;
}
const conversation = await input.chatService.createConversation(input.createTitle);
if (!conversation?.id) {
throw new Error('No conversation id returned');
}
return conversation.id;
}
export async function sendConversationMessage(
input: SendConversationMessageInput,
): Promise<SendConversationMessageResult> {
const result = input.metadata
? await input.chatService.sendMessage(input.conversationId, input.message, input.metadata)
: await input.chatService.sendMessage(input.conversationId, input.message);
if (result?.success === false) {
return {
success: false,
message: '',
error: result.error || 'Failed to send message',
};
}
if (!result) {
return {
success: false,
message: '',
error: 'No response returned',
};
}
return {
success: true,
message: result.message || '',
};
}

View File

@@ -0,0 +1,24 @@
export type ChatSurfaceModeId = 'tab' | 'sidebar';
export interface ChatSurfaceMode {
showModelSelector: boolean;
showWelcomeTips: boolean;
showToolMarkers: boolean;
}
const CHAT_SURFACE_MODE_REGISTRY: Record<ChatSurfaceModeId, ChatSurfaceMode> = {
tab: {
showModelSelector: true,
showWelcomeTips: true,
showToolMarkers: true,
},
sidebar: {
showModelSelector: false,
showWelcomeTips: false,
showToolMarkers: true,
},
};
export function getChatSurfaceMode(modeId: ChatSurfaceModeId): ChatSurfaceMode {
return CHAT_SURFACE_MODE_REGISTRY[modeId];
}

View File

@@ -0,0 +1,56 @@
import { useCallback, useState } from 'react';
import { sendConversationMessage, type ChatService, type SendMessageMetadata } from './chatSession';
interface UseChatMessageSenderInput {
chatService: Pick<ChatService, 'sendMessage'> | null | undefined;
}
interface UseChatMessageSenderParams {
conversationId: string;
message: string;
metadata?: SendMessageMetadata;
}
export function useChatMessageSender(input: UseChatMessageSenderInput) {
const [lastError, setLastError] = useState<string | null>(null);
const sendMessage = useCallback(
async (params: UseChatMessageSenderParams) => {
if (!input.chatService) {
const error = 'Chat service unavailable';
setLastError(error);
return {
success: false as const,
message: '',
error,
};
}
const result = await sendConversationMessage({
conversationId: params.conversationId,
message: params.message,
metadata: params.metadata,
chatService: input.chatService,
});
if (!result.success) {
setLastError(result.error || 'Failed to send message');
return result;
}
setLastError(null);
return result;
},
[input.chatService],
);
const clearError = useCallback(() => {
setLastError(null);
}, []);
return {
sendMessage,
lastError,
clearError,
};
}

View File

@@ -0,0 +1,127 @@
import { useCallback, useRef, useState } from 'react';
import type { ChatMessage } from '../types/electron';
export interface ChatToolEvent {
type: 'call' | 'result';
name: string;
args?: unknown;
timestamp: number;
}
export function useChatSurfaceState() {
const [messages, setMessagesState] = useState<ChatMessage[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [toolEvents, setToolEvents] = useState<ChatToolEvent[]>([]);
const streamingRef = useRef('');
const toolEventsRef = useRef<Array<{ name: string; args?: unknown }>>([]);
const setMessages = useCallback((nextMessages: ChatMessage[]) => {
setMessagesState(nextMessages);
}, []);
const beginUserTurn = useCallback((conversationId: string, content: string) => {
const userMessage: ChatMessage = {
id: `temp-${Date.now()}`,
conversationId,
role: 'user',
content,
createdAt: new Date().toISOString(),
};
setMessagesState((prev) => [...prev, userMessage]);
setIsStreaming(true);
streamingRef.current = '';
setStreamingContent('');
setToolEvents([]);
toolEventsRef.current = [];
}, []);
const appendStreamDelta = useCallback((delta: string) => {
streamingRef.current += delta;
setStreamingContent(streamingRef.current);
}, []);
const recordToolCall = useCallback((name: string, args?: unknown) => {
toolEventsRef.current.push({ name, args });
setToolEvents((prev) => [...prev, { type: 'call', name, args, timestamp: Date.now() }]);
}, []);
const recordToolResult = useCallback((name: string) => {
setToolEvents((prev) => [...prev, { type: 'result', name, timestamp: Date.now() }]);
}, []);
const stopStreaming = useCallback(() => {
setIsStreaming(false);
setStreamingContent('');
streamingRef.current = '';
}, []);
const appendAssistantMessage = useCallback((conversationId: string, content: string) => {
const assistantMessage: ChatMessage = {
id: `assistant-${Date.now()}`,
conversationId,
role: 'assistant',
content,
createdAt: new Date().toISOString(),
};
setMessagesState((prev) => [...prev, assistantMessage]);
}, []);
const finalizeAssistantTurn = useCallback((conversationId: string, content: string) => {
if (content) {
const assistantMessage: ChatMessage = {
id: `assistant-${Date.now()}`,
conversationId,
role: 'assistant',
content,
toolCalls: toolEventsRef.current.length > 0 ? JSON.stringify(toolEventsRef.current) : undefined,
createdAt: new Date().toISOString(),
};
setMessagesState((prev) => [...prev, assistantMessage]);
}
setIsStreaming(false);
setStreamingContent('');
streamingRef.current = '';
}, []);
const abortStreaming = useCallback((conversationId: string, cancelledSuffix: string) => {
const partialContent = streamingRef.current;
setIsStreaming(false);
setStreamingContent('');
streamingRef.current = '';
if (!partialContent) {
return;
}
const partialMessage: ChatMessage = {
id: `partial-${Date.now()}`,
conversationId,
role: 'assistant',
content: `${partialContent}\n\n*(${cancelledSuffix})*`,
createdAt: new Date().toISOString(),
};
setMessagesState((prev) => [...prev, partialMessage]);
}, []);
const getStreamingContent = useCallback(() => streamingRef.current, []);
return {
messages,
isStreaming,
streamingContent,
toolEvents,
setMessages,
beginUserTurn,
appendStreamDelta,
recordToolCall,
recordToolResult,
appendAssistantMessage,
finalizeAssistantTurn,
stopStreaming,
abortStreaming,
getStreamingContent,
};
}

View File

@@ -69,6 +69,7 @@ interface AppState {
activeView: SidebarView;
sidebarVisible: boolean;
panelVisible: boolean;
assistantSidebarVisible: boolean;
panelActiveTab: PanelTab;
panelOutputEntries: PanelOutputEntry[];
selectedPostId: string | null;
@@ -119,6 +120,7 @@ interface AppState {
setActiveView: (view: SidebarView) => void;
toggleSidebar: () => void;
togglePanel: () => void;
toggleAssistantSidebar: () => void;
setPanelActiveTab: (tab: PanelTab) => void;
appendPanelOutputEntry: (entry: PanelOutputEntry) => void;
clearPanelOutputEntries: () => void;
@@ -175,6 +177,7 @@ export const useAppStore = create<AppState>()(
activeView: 'posts',
sidebarVisible: true,
panelVisible: false,
assistantSidebarVisible: false,
panelActiveTab: 'tasks',
panelOutputEntries: [],
selectedPostId: null,
@@ -300,6 +303,7 @@ export const useAppStore = create<AppState>()(
setActiveView: (view) => set({ activeView: view }),
toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })),
togglePanel: () => set((state) => ({ panelVisible: !state.panelVisible })),
toggleAssistantSidebar: () => set((state) => ({ assistantSidebarVisible: !state.assistantSidebarVisible })),
setPanelActiveTab: (panelActiveTab) => set({ panelActiveTab }),
appendPanelOutputEntry: (entry) => set((state) => ({
panelOutputEntries: [...state.panelOutputEntries, entry],

View File

@@ -0,0 +1,27 @@
.chat-surface {
min-height: 0;
}
.chat-surface-scroll {
min-height: 0;
overflow-y: auto;
}
.chat-surface-input {
border-radius: 6px;
border: 1px solid var(--vscode-input-border, transparent);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font: inherit;
}
.chat-surface-error {
margin: 0;
color: var(--vscode-errorForeground);
font-size: 12px;
}
.chat-surface-section {
border-top: 1px solid var(--vscode-panel-border);
padding-top: 8px;
}