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

@@ -326,7 +326,19 @@ When answering questions:
2. If asked about something outside your tools (weather, news, websites), explain that you can only access the user's local blog content.
3. Be concise and helpful. Format post information clearly when displaying it.
4. If a search returns no results, suggest alternative queries or filters.
5. When asked to describe or analyze an image, use the view_image tool to see the actual image content.`;
5. When asked to describe or analyze an image, use the view_image tool to see the actual image content.
Agentic UI Contract:
- You may include structured UI payloads in your assistant response so the app can render interactive widgets.
- You DO have the ability to return interactive AGUI payloads (including bar charts) as JSON, even though you cannot draw bitmap images.
- When the user asks for a chart or guided workflow, prefer returning a valid AGUI payload over refusing.
- Use JSON with specVersion: "1" and an elements array.
- Prefer actionable widgets (cards, forms, tabs, inputs, metrics, tables, charts) when they reduce follow-up friction.
- Keep textual guidance and UI semantically consistent.
- Include only valid, supported action names. Supported actions include: openSettings, openPost, openMedia, openPanel, setActiveView, toggleSidebar, togglePanel, toggleAssistantSidebar.
- Supported element types include: text, metric, list, table, action, chart, form, input, datePicker, card, image, tabs.
- For tabs elements, include each tab with id, label, and nested elements.
- Never invent unsupported specVersion values or unsupported element/action names.`;
}
/**

View File

@@ -66,6 +66,9 @@ export interface ModelInfo {
}
export interface SendMessageOptions {
metadata?: {
surface?: 'tab' | 'sidebar';
};
onDelta?: (delta: string) => void;
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
onToolResult?: (result: { name: string; result: unknown }) => void;
@@ -237,7 +240,7 @@ export class OpenCodeManager {
userMessage: string,
options: SendMessageOptions = {}
): Promise<SendMessageResult> {
const { onDelta, onToolCall, onToolResult } = options;
const { metadata, onDelta, onToolCall, onToolResult } = options;
try {
const readyCheck = await this.checkReady();
@@ -272,11 +275,15 @@ export class OpenCodeManager {
// Build message history from DB (excluding system messages)
const dbMessages = conversation.messages.filter(m => m.role !== 'system');
const surfaceHint = metadata?.surface
? `\n\n[Client UI surface: ${metadata.surface}. Render response UI for this surface while keeping content functionally equivalent.]`
: '';
const userMessageForModel = `${userMessage}${surfaceHint}`;
// Add the new user message
dbMessages.push({
conversationId,
role: 'user',
content: userMessage,
content: userMessageForModel,
createdAt: new Date(),
});

View File

@@ -256,12 +256,13 @@ export function registerChatHandlers(): void {
// ============ Chat Messaging ============
// Send a message
ipcMain.handle('chat:sendMessage', async (_, conversationId: string, message: string) => {
ipcMain.handle('chat:sendMessage', async (_, conversationId: string, message: string, metadata?: { surface?: 'tab' | 'sidebar' }) => {
try {
const manager = await getOpenCodeManager();
const mainWindow = mainWindowGetter?.();
const result = await manager.sendMessage(conversationId, message, {
metadata,
onDelta: (delta) => {
if (mainWindow) {
mainWindow.webContents.send('chat-stream-delta', { conversationId, delta });
@@ -286,6 +287,22 @@ export function registerChatHandlers(): void {
}
});
ipcMain.handle('chat:addSystemEvent', async (_, conversationId: string, content: string) => {
try {
const engine = getChatEngine();
await engine.addMessage({
conversationId,
role: 'system',
content,
createdAt: new Date(),
});
return { success: true };
} catch (error) {
console.error('[Chat IPC] Error adding system event:', error);
return { success: false, error: (error as Error).message };
}
});
// Abort a running message
ipcMain.handle('chat:abortMessage', async (_, conversationId: string) => {
try {

View File

@@ -84,7 +84,11 @@ function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean {
sender.selectAll?.();
return true;
case 'toggleDevTools':
sender.toggleDevTools?.();
if (sender.isDevToolsOpened?.()) {
sender.closeDevTools?.();
} else {
sender.openDevTools?.({ mode: 'detach' });
}
return true;
case 'reload':
sender.reload?.();

View File

@@ -49,6 +49,20 @@ interface Rectangle {
// Check if dev server is likely running (only in development)
const isDev = process.env.NODE_ENV === 'development';
function toggleDetachedDevTools(targetWindow: BrowserWindow | null): void {
const webContents = targetWindow?.webContents;
if (!webContents) {
return;
}
if (webContents.isDevToolsOpened()) {
webContents.closeDevTools();
return;
}
webContents.openDevTools({ mode: 'detach' });
}
function getWindowStatePath(): string | null {
if (typeof app.getPath !== 'function') {
return null;
@@ -246,7 +260,7 @@ function createWindow(): void {
// F12 or Ctrl+Shift+I to toggle DevTools
if (input.key === 'F12' ||
(input.control && input.shift && input.key.toLowerCase() === 'i')) {
mainWindow?.webContents.toggleDevTools();
toggleDetachedDevTools(mainWindow);
event.preventDefault();
}
});
@@ -255,13 +269,13 @@ function createWindow(): void {
const rendererPath = path.join(__dirname, '../renderer/index.html');
if (isDev) {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
mainWindow.webContents.openDevTools({ mode: 'detach' });
} else if (fs.existsSync(rendererPath)) {
mainWindow.loadFile(rendererPath);
} else {
// Fallback to dev server if built files don't exist
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
mainWindow.webContents.openDevTools({ mode: 'detach' });
}
// Forward events to renderer
@@ -571,6 +585,11 @@ function createApplicationMenu(): Menu {
return;
}
if (action === 'toggleDevTools') {
toggleDetachedDevTools(mainWindow);
return;
}
if (action === 'viewOnGitHub') {
void shell.openExternal('https://github.com/rfc1437/bDS');
return;

View File

@@ -299,7 +299,8 @@ export const electronAPI: ElectronAPI = {
deleteConversation: (id: string) => ipcRenderer.invoke('chat:deleteConversation', id),
// Messaging
sendMessage: (conversationId: string, message: string) => ipcRenderer.invoke('chat:sendMessage', conversationId, message),
sendMessage: (conversationId: string, message: string, metadata?: { surface?: 'tab' | 'sidebar' }) => ipcRenderer.invoke('chat:sendMessage', conversationId, message, metadata),
addSystemEvent: (conversationId: string, content: string) => ipcRenderer.invoke('chat:addSystemEvent', conversationId, content),
abortMessage: (conversationId: string) => ipcRenderer.invoke('chat:abortMessage', conversationId),
getHistory: (conversationId: string) => ipcRenderer.invoke('chat:getHistory', conversationId),
clearMessages: (conversationId: string) => ipcRenderer.invoke('chat:clearMessages', conversationId),

View File

@@ -431,6 +431,10 @@ export interface ChatTitleUpdate {
title: string;
}
export interface ChatSendMetadata {
surface?: 'tab' | 'sidebar';
}
export interface SiteValidationReport {
sitemapPath: string;
sitemapChanged: boolean;
@@ -726,7 +730,8 @@ export interface ElectronAPI {
deleteConversation: (id: string) => Promise<boolean>;
// Messaging
sendMessage: (conversationId: string, message: string) => Promise<{ success: boolean; message?: string; error?: string }>;
sendMessage: (conversationId: string, message: string, metadata?: ChatSendMetadata) => Promise<{ success: boolean; message?: string; error?: string }>;
addSystemEvent: (conversationId: string, content: string) => Promise<{ success: boolean; error?: string }>;
abortMessage: (conversationId: string) => Promise<void>;
getHistory: (conversationId: string) => Promise<ChatMessage[]>;
clearMessages: (conversationId: string) => Promise<void>;

View File

@@ -24,6 +24,7 @@
"menu.item.viewMedia": "Medien",
"menu.item.toggleSidebar": "Seitenleiste umschalten",
"menu.item.togglePanel": "Panel umschalten",
"menu.item.toggleAssistantSidebar": "Assistenz-Seitenleiste umschalten",
"menu.item.toggleDevTools": "Entwicklerwerkzeuge umschalten",
"menu.item.reload": "Neu laden",
"menu.item.forceReload": "Erzwungen neu laden",

View File

@@ -24,6 +24,7 @@
"menu.item.viewMedia": "Media",
"menu.item.toggleSidebar": "Toggle Sidebar",
"menu.item.togglePanel": "Toggle Panel",
"menu.item.toggleAssistantSidebar": "Toggle Assistant Sidebar",
"menu.item.toggleDevTools": "Toggle Developer Tools",
"menu.item.reload": "Reload",
"menu.item.forceReload": "Force Reload",

View File

@@ -24,6 +24,7 @@
"menu.item.viewMedia": "Medios",
"menu.item.toggleSidebar": "Alternar barra lateral",
"menu.item.togglePanel": "Alternar panel",
"menu.item.toggleAssistantSidebar": "Alternar barra del asistente",
"menu.item.toggleDevTools": "Alternar herramientas de desarrollo",
"menu.item.reload": "Recargar",
"menu.item.forceReload": "Forzar recarga",

View File

@@ -24,6 +24,7 @@
"menu.item.viewMedia": "Médias",
"menu.item.toggleSidebar": "Basculer la barre latérale",
"menu.item.togglePanel": "Basculer le panneau",
"menu.item.toggleAssistantSidebar": "Basculer le panneau Assistant",
"menu.item.toggleDevTools": "Basculer les outils de développement",
"menu.item.reload": "Recharger",
"menu.item.forceReload": "Forcer le rechargement",

View File

@@ -24,6 +24,7 @@
"menu.item.viewMedia": "Contenuti media",
"menu.item.toggleSidebar": "Attiva/disattiva barra laterale",
"menu.item.togglePanel": "Attiva/disattiva pannello",
"menu.item.toggleAssistantSidebar": "Attiva/disattiva barra assistente",
"menu.item.toggleDevTools": "Attiva/disattiva strumenti sviluppatore",
"menu.item.reload": "Ricarica",
"menu.item.forceReload": "Forza ricaricamento",

View File

@@ -19,6 +19,7 @@ export type AppMenuAction =
| 'viewMedia'
| 'toggleSidebar'
| 'togglePanel'
| 'toggleAssistantSidebar'
| 'toggleDevTools'
| 'reload'
| 'forceReload'
@@ -103,6 +104,7 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
{ label: 'menu.item.viewMedia', action: 'viewMedia', accelerator: 'CmdOrCtrl+2' },
{ label: 'menu.item.toggleSidebar', action: 'toggleSidebar', accelerator: 'CmdOrCtrl+B' },
{ label: 'menu.item.togglePanel', action: 'togglePanel', accelerator: 'CmdOrCtrl+J' },
{ label: 'menu.item.toggleAssistantSidebar', action: 'toggleAssistantSidebar', accelerator: 'CmdOrCtrl+\\' },
{ label: 'menu.item.toggleDevTools', action: 'toggleDevTools', accelerator: 'CmdOrCtrl+Shift+I' },
{ label: '', action: 'view-separator-1', separator: true },
{ label: 'menu.item.reload', action: 'reload' },
@@ -156,6 +158,7 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial<Record<AppMenuAction, string>> =
viewMedia: 'menu:viewMedia',
toggleSidebar: 'menu:toggleSidebar',
togglePanel: 'menu:togglePanel',
toggleAssistantSidebar: 'menu:toggleAssistantSidebar',
toggleDevTools: 'menu:toggleDevTools',
previewPost: 'menu:previewPost',
publishSelected: 'menu:publishSelected',

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