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

@@ -11,6 +11,7 @@
- [Working with media](#working-with-media)
- [Using macros](#using-macros)
- [Using scripting (early access)](#using-scripting-early-access)
- [Using assistant panel widgets](#using-assistant-panel-widgets)
- [Organizing with tags](#organizing-with-tags)
- [Importing from WordPress (WXR)](#importing-from-wordpress-wxr)
- [Using Git (Source Control)](#using-git-source-control)
@@ -255,6 +256,165 @@ Notes:
---
## Using assistant panel widgets
The assistant sidebar can render structured panel widgets when the AI response includes a valid JSON panel spec. This is useful when you want the assistant to return actionable UI instead of plain text only.
Use this envelope:
```json
{
"specVersion": "1",
"elements": []
}
```
### Supported widget types
- `text`
- `metric`
- `list`
- `table`
- `action`
- `chart`
- `input`
- `form`
- `datePicker`
- `card`
- `image`
- `tabs`
### Example snippets
```json
{ "type": "text", "text": "Review complete." }
```
```json
{ "type": "metric", "label": "Draft posts", "value": "12" }
```
```json
{ "type": "list", "title": "Next steps", "items": ["Refine title", "Add tags"] }
```
```json
{
"type": "table",
"columns": ["Post", "Status"],
"rows": [["Roadmap", "Draft"], ["Release", "Published"]]
}
```
```json
{
"type": "action",
"label": "Open tags",
"action": "switchView",
"payload": { "view": "tags" }
}
```
```json
{
"type": "chart",
"chartType": "bar",
"title": "Posts by month",
"series": [
{ "label": "Jan", "value": 10 },
{ "label": "Feb", "value": 14 }
]
}
```
```json
{
"type": "input",
"key": "query",
"label": "Search",
"inputType": "text",
"placeholder": "Find post...",
"submitLabel": "Run",
"action": "openChat"
}
```
```json
{
"type": "form",
"formId": "meta-form",
"title": "Update metadata",
"submitLabel": "Apply",
"action": "openSettings",
"fields": [
{ "key": "title", "label": "Title", "inputType": "text" },
{ "key": "isDraft", "label": "Draft", "inputType": "checkbox" }
]
}
```
```json
{
"type": "datePicker",
"key": "publishDate",
"label": "Publish date",
"submitLabel": "Set",
"action": "openSettings"
}
```
```json
{
"type": "card",
"title": "Suggestion",
"subtitle": "Editorial",
"body": "Add one category and two tags.",
"actions": [
{ "label": "Open tags", "action": "switchView", "payload": { "view": "tags" } }
]
}
```
```json
{
"type": "image",
"src": "https://example.com/preview.png",
"alt": "Generated preview",
"caption": "Current preview snapshot"
}
```
```json
{
"type": "tabs",
"defaultTabId": "summary",
"tabs": [
{
"id": "summary",
"label": "Summary",
"elements": [{ "type": "text", "text": "Short summary" }]
},
{
"id": "actions",
"label": "Actions",
"elements": [
{ "type": "action", "label": "Open settings", "action": "openSettings" }
]
}
]
}
```
### Notes
- `tabs` are panel-local UI tabs inside one assistant response; they are not editor tabs.
- Unknown or invalid widget payloads are ignored by the parser.
- Actions are restricted to supported safe action names in the app.
[↑ Back to In this article](#in-this-article)
---
## Organizing with tags
Tags are your precision taxonomy tool. Over time, even well-managed projects accumulate near-duplicate tags, naming inconsistencies, and labels that no longer serve users. The Tags section exists to keep taxonomy useful and prevent search and filtering quality from degrading.

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

View File

@@ -639,6 +639,10 @@ describe('ChatEngine', () => {
expect(result).toContain('Blogging Desktop Server');
expect(result).toContain('Available Tools');
expect(result).toContain('Agentic UI Contract');
expect(result).toContain('specVersion');
expect(result).toContain('tabs');
expect(result).toContain('openSettings');
});
it('should return built-in prompt when saved prompt is empty', async () => {

View File

@@ -1638,14 +1638,27 @@ describe('IPC Handlers', () => {
expect(send).not.toHaveBeenCalled();
});
it('should execute toggleDevTools on sender when action is toggleDevTools', async () => {
const toggleDevTools = vi.fn();
it('should open detached devtools on sender when action is toggleDevTools and devtools are closed', async () => {
const openDevTools = vi.fn();
const isDevToolsOpened = vi.fn(() => false);
const send = vi.fn();
const event = { sender: { toggleDevTools, send } };
const event = { sender: { openDevTools, isDevToolsOpened, send } };
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'toggleDevTools');
expect(toggleDevTools).toHaveBeenCalled();
expect(openDevTools).toHaveBeenCalledWith({ mode: 'detach' });
expect(send).not.toHaveBeenCalled();
});
it('should close devtools on sender when action is toggleDevTools and devtools are open', async () => {
const closeDevTools = vi.fn();
const isDevToolsOpened = vi.fn(() => true);
const send = vi.fn();
const event = { sender: { closeDevTools, isDevToolsOpened, send } };
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'toggleDevTools');
expect(closeDevTools).toHaveBeenCalled();
expect(send).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
describe('AssistantSidebar styles', () => {
const cssPath = path.resolve(
__dirname,
'../../../src/renderer/components/AssistantSidebar/AssistantSidebar.css'
);
it('keeps the sidebar container scrollable for long assistant content', () => {
const css = fs.readFileSync(cssPath, 'utf8');
expect(css).toMatch(/\.assistant-sidebar\s*\{[^}]*min-height:\s*0;[^}]*overflow-y:\s*auto;[^}]*\}/s);
});
});

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
describe('Chat surface shared styles', () => {
const sharedCssPath = path.resolve(
__dirname,
'../../../src/renderer/styles/chatSurface.css'
);
const chatPanelPath = path.resolve(
__dirname,
'../../../src/renderer/components/ChatPanel/ChatPanel.tsx'
);
const assistantSidebarPath = path.resolve(
__dirname,
'../../../src/renderer/components/AssistantSidebar/AssistantSidebar.tsx'
);
it('defines reusable surface primitives', () => {
const css = fs.readFileSync(sharedCssPath, 'utf8');
expect(css).toContain('.chat-surface');
expect(css).toContain('.chat-surface-scroll');
expect(css).toContain('.chat-surface-input');
expect(css).toContain('.chat-surface-error');
expect(css).toContain('.chat-surface-section');
});
it('applies shared surface class names in both chat renderers', () => {
const chatPanel = fs.readFileSync(chatPanelPath, 'utf8');
const assistantSidebar = fs.readFileSync(assistantSidebarPath, 'utf8');
expect(chatPanel).toContain('chat-surface');
expect(chatPanel).toContain('chat-surface-scroll');
expect(assistantSidebar).toContain('chat-surface');
expect(assistantSidebar).toContain('chat-surface-input');
expect(assistantSidebar).toContain('chat-surface-error');
expect(assistantSidebar).toContain('chat-surface-section');
});
});

View File

@@ -16,6 +16,7 @@ describe('WindowTitleBar', () => {
useAppStore.setState({
sidebarVisible: true,
panelVisible: false,
assistantSidebarVisible: false,
});
});
@@ -32,6 +33,7 @@ describe('WindowTitleBar', () => {
expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull();
expect(screen.getByLabelText('Toggle Sidebar')).toBeInTheDocument();
expect(screen.getByLabelText('Toggle Panel')).toBeInTheDocument();
expect(screen.getByLabelText('Toggle Assistant Sidebar')).toBeInTheDocument();
});
it('does not request macOS title bar metrics when simulated title bar is disabled', async () => {
@@ -139,9 +141,23 @@ describe('WindowTitleBar', () => {
const actionButtons = Array.from(document.querySelectorAll('.window-titlebar-actions .window-titlebar-action-button'));
expect(actionButtons).toHaveLength(2);
expect(actionButtons).toHaveLength(3);
expect(actionButtons[0]).toHaveAttribute('aria-label', 'Toggle Sidebar');
expect(actionButtons[1]).toHaveAttribute('aria-label', 'Toggle Panel');
expect(actionButtons[2]).toHaveAttribute('aria-label', 'Toggle Assistant Sidebar');
});
it('renders a right-side assistant sidebar toggle button and toggles assistant sidebar visibility', () => {
render(<WindowTitleBar />);
const toggleButton = screen.getByLabelText('Toggle Assistant Sidebar');
expect(toggleButton).toBeInTheDocument();
expect(toggleButton).toHaveAttribute('title', 'Show Assistant Sidebar (Ctrl+\\)');
fireEvent.click(toggleButton);
expect(useAppStore.getState().assistantSidebarVisible).toBe(true);
expect(toggleButton).toHaveAttribute('title', 'Hide Assistant Sidebar (Ctrl+\\)');
});
it('updates overlay inset CSS variables when window controls geometry changes', () => {
@@ -248,6 +264,7 @@ describe('WindowTitleBar', () => {
expect(screen.getByRole('button', { name: 'Media Ctrl+2' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Toggle Sidebar Ctrl+B' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Toggle Panel Ctrl+J' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Toggle Assistant Sidebar Ctrl+\\' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Blog' }));
expect(screen.getByRole('button', { name: 'Publish Selected Ctrl+Shift+P' })).toBeInTheDocument();

View File

@@ -0,0 +1,195 @@
import { describe, expect, it, vi } from 'vitest';
import { dispatchAssistantAction } from '../../../src/renderer/navigation/assistantActionDispatcher';
describe('assistantActionDispatcher', () => {
it('opens a post from action payload', () => {
const setSelectedPost = vi.fn();
const openTab = vi.fn();
const setActiveView = vi.fn();
const result = dispatchAssistantAction(
{
action: 'openPost',
payload: { postId: 'post-123' },
},
{
setSelectedPost,
setSelectedMedia: vi.fn(),
openTab,
setActiveView,
toggleSidebar: vi.fn(),
togglePanel: vi.fn(),
toggleAssistantSidebar: vi.fn(),
},
);
expect(result.handled).toBe(true);
expect(setActiveView).toHaveBeenCalledWith('posts');
expect(setSelectedPost).toHaveBeenCalledWith('post-123');
expect(openTab).toHaveBeenCalledWith({ type: 'post', id: 'post-123', isTransient: false });
});
it('opens media from action payload', () => {
const setSelectedMedia = vi.fn();
const openTab = vi.fn();
const setActiveView = vi.fn();
const result = dispatchAssistantAction(
{
action: 'openMedia',
payload: { mediaId: 'media-321' },
},
{
setSelectedPost: vi.fn(),
setSelectedMedia,
openTab,
setActiveView,
toggleSidebar: vi.fn(),
togglePanel: vi.fn(),
toggleAssistantSidebar: vi.fn(),
},
);
expect(result.handled).toBe(true);
expect(setActiveView).toHaveBeenCalledWith('media');
expect(setSelectedMedia).toHaveBeenCalledWith('media-321');
expect(openTab).toHaveBeenCalledWith({ type: 'media', id: 'media-321', isTransient: false });
});
it('switches sidebar view', () => {
const setActiveView = vi.fn();
const result = dispatchAssistantAction(
{
action: 'switchView',
payload: { view: 'tags' },
},
{
setSelectedPost: vi.fn(),
setSelectedMedia: vi.fn(),
openTab: vi.fn(),
setActiveView,
toggleSidebar: vi.fn(),
togglePanel: vi.fn(),
toggleAssistantSidebar: vi.fn(),
},
);
expect(result.handled).toBe(true);
expect(setActiveView).toHaveBeenCalledWith('tags');
});
it('rejects switchView payload when view is invalid', () => {
const setActiveView = vi.fn();
const result = dispatchAssistantAction(
{
action: 'switchView',
payload: { view: 'not-a-view' },
},
{
setSelectedPost: vi.fn(),
setSelectedMedia: vi.fn(),
openTab: vi.fn(),
setActiveView,
toggleSidebar: vi.fn(),
togglePanel: vi.fn(),
toggleAssistantSidebar: vi.fn(),
},
);
expect(result.handled).toBe(false);
expect(result.error).toContain('Invalid payload');
expect(setActiveView).not.toHaveBeenCalled();
});
it('opens chat tab for openChat action', () => {
const setActiveView = vi.fn();
const openTab = vi.fn();
const result = dispatchAssistantAction(
{
action: 'openChat',
payload: { conversationId: 'conversation-42' },
},
{
setSelectedPost: vi.fn(),
setSelectedMedia: vi.fn(),
openTab,
setActiveView,
toggleSidebar: vi.fn(),
togglePanel: vi.fn(),
toggleAssistantSidebar: vi.fn(),
},
);
expect(result.handled).toBe(true);
expect(setActiveView).toHaveBeenCalledWith('chat');
expect(openTab).toHaveBeenCalledWith({ type: 'chat', id: 'conversation-42', isTransient: false });
});
it('opens settings tab for openSettings action', () => {
const setActiveView = vi.fn();
const openTab = vi.fn();
const result = dispatchAssistantAction(
{
action: 'openSettings',
},
{
setSelectedPost: vi.fn(),
setSelectedMedia: vi.fn(),
openTab,
setActiveView,
toggleSidebar: vi.fn(),
togglePanel: vi.fn(),
toggleAssistantSidebar: vi.fn(),
},
);
expect(result.handled).toBe(true);
expect(setActiveView).toHaveBeenCalledWith('settings');
expect(openTab).toHaveBeenCalledWith({ type: 'settings', id: 'settings', isTransient: false });
});
it('rejects invalid payload for openChat action', () => {
const result = dispatchAssistantAction(
{
action: 'openChat',
payload: {},
},
{
setSelectedPost: vi.fn(),
setSelectedMedia: vi.fn(),
openTab: vi.fn(),
setActiveView: vi.fn(),
toggleSidebar: vi.fn(),
togglePanel: vi.fn(),
toggleAssistantSidebar: vi.fn(),
},
);
expect(result.handled).toBe(false);
expect(result.error).toContain('Invalid payload');
});
it('returns an error for unknown actions', () => {
const result = dispatchAssistantAction(
{
action: 'doesNotExist',
},
{
setSelectedPost: vi.fn(),
setSelectedMedia: vi.fn(),
openTab: vi.fn(),
setActiveView: vi.fn(),
toggleSidebar: vi.fn(),
togglePanel: vi.fn(),
toggleAssistantSidebar: vi.fn(),
},
);
expect(result.handled).toBe(false);
expect(result.error).toContain('Unsupported action');
});
});

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { planAssistantRequest } from '../../../src/renderer/navigation/assistantConversation';
describe('assistantConversation', () => {
it('creates enriched first message when no conversation exists yet', () => {
const result = planAssistantRequest({
conversationId: null,
userPrompt: 'Find weak tags',
context: {
tabType: 'post',
id: 'post-1',
title: 'Launch Notes',
},
});
expect(result.shouldCreateConversation).toBe(true);
expect(result.outboundMessage).toContain('User request: Find weak tags');
expect(result.outboundMessage).toContain('Current editor context type: post');
});
it('sends plain follow-up message when conversation already exists', () => {
const result = planAssistantRequest({
conversationId: 'conv-1',
userPrompt: 'What next?',
context: {
tabType: 'post',
id: 'post-1',
title: 'Launch Notes',
},
});
expect(result.shouldCreateConversation).toBe(false);
expect(result.outboundMessage).toBe('What next?');
});
});

View File

@@ -0,0 +1,195 @@
import { describe, expect, it } from 'vitest';
import { extractAssistantPanelSpec, extractAssistantResponseContent } from '../../../src/renderer/navigation/assistantPanelSpec';
describe('assistantPanelSpec', () => {
it('extracts valid spec from fenced json block', () => {
const raw = [
'Here is the analysis summary.',
'```json',
'{"specVersion":"1","elements":[{"type":"metric","label":"Drafts","value":"12"}]}',
'```',
].join('\n');
const result = extractAssistantPanelSpec(raw);
expect(result).not.toBeNull();
expect(result?.specVersion).toBe('1');
expect(result?.elements).toHaveLength(1);
expect(result?.elements[0]).toEqual({ type: 'metric', label: 'Drafts', value: '12' });
});
it('returns null for invalid schema payload', () => {
const raw = '{"specVersion":"1","elements":[{"type":"table","columns":[]}]}';
const result = extractAssistantPanelSpec(raw);
expect(result).toBeNull();
});
it('ignores yaml payloads to keep the protocol JSON-only', () => {
const raw = [
'Here is your chart.',
'```yaml',
'specVersion: "1"',
'elements:',
' - type: chart',
' chartType: bar',
' title: Posts by Month',
' series:',
' - label: Jan',
' value: 10',
' - label: Feb',
' value: 20',
'```',
].join('\n');
const result = extractAssistantPanelSpec(raw);
expect(result).toBeNull();
});
it('extracts text plus ui payload from mixed assistant response', () => {
const raw = [
'I found two weak months. Please confirm how to proceed.',
'```json',
'{"specVersion":"1","elements":[{"type":"chart","chartType":"bar","title":"Posts by Month","series":[{"label":"Jan","value":10},{"label":"Feb","value":20}]}]}',
'```',
].join('\n\n');
const result = extractAssistantResponseContent(raw);
expect(result.displayText).toContain('I found two weak months');
expect(result.panelSpec).not.toBeNull();
expect(result.panelSpec?.elements[0]).toMatchObject({ type: 'chart', chartType: 'bar' });
});
it('normalizes tab-channel envelope payloads into canonical panel spec', () => {
const raw = JSON.stringify({
type: 'tab',
title: 'Posts mit Tag spielen',
id: 'spielen-tag-analysis',
content: {
type: 'tabs',
tabs: [
{
id: 'yearly-chart',
title: 'Jahresübersicht',
content: {
type: 'chart',
chartType: 'bar',
data: {
labels: ['2011', '2013'],
datasets: [{ data: [2, 8] }],
},
},
},
],
},
});
const result = extractAssistantPanelSpec(raw);
expect(result).not.toBeNull();
expect(result?.specVersion).toBe('1');
expect(result?.elements[0]).toMatchObject({ type: 'tabs' });
});
it('normalizes chartjs-like chart payloads to series format', () => {
const raw = JSON.stringify({
specVersion: '1',
elements: [
{
type: 'chart',
chartType: 'bar',
data: {
labels: ['Jan', 'Feb', 'Mar'],
datasets: [{ data: [23, 10, 14] }],
},
},
],
});
const result = extractAssistantPanelSpec(raw);
expect(result).not.toBeNull();
expect(result?.elements[0]).toMatchObject({
type: 'chart',
chartType: 'bar',
series: [
{ label: 'Jan', value: 23 },
{ label: 'Feb', value: 10 },
{ label: 'Mar', value: 14 },
],
});
});
it('parses extended widgets including chart, form, datePicker, card, image, input and tabs', () => {
const raw = JSON.stringify({
specVersion: '1',
elements: [
{
type: 'chart',
chartType: 'bar',
title: 'Posts by Month',
series: [
{ label: 'Jan', value: 10 },
{ label: 'Feb', value: 20 },
],
},
{
type: 'input',
key: 'query',
label: 'Search Query',
inputType: 'text',
placeholder: 'Find post',
},
{
type: 'datePicker',
key: 'publishDate',
label: 'Publish Date',
},
{
type: 'form',
formId: 'meta-form',
title: 'Update Metadata',
submitLabel: 'Apply',
action: 'updatePostMetadata',
fields: [
{ key: 'title', label: 'Title', inputType: 'text' },
{ key: 'isDraft', label: 'Draft', inputType: 'checkbox' },
],
},
{
type: 'card',
title: 'Suggestion',
body: 'Consider adding tags.',
actions: [
{ label: 'Open Tags', action: 'switchView', payload: { view: 'tags' } },
],
},
{
type: 'image',
src: 'https://example.com/image.png',
alt: 'Preview',
caption: 'Generated preview',
},
{
type: 'tabs',
tabs: [
{
id: 'summary',
label: 'Summary',
elements: [{ type: 'text', text: 'Summary text' }],
},
{
id: 'details',
label: 'Details',
elements: [{ type: 'metric', label: 'Count', value: '42' }],
},
],
},
],
});
const result = extractAssistantPanelSpec(raw);
expect(result).not.toBeNull();
expect(result?.elements).toHaveLength(7);
});
});

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { buildAssistantStartPrompt } from '../../../src/renderer/navigation/assistantPromptContext';
describe('assistantPromptContext', () => {
it('enriches prompt with active post context', () => {
const result = buildAssistantStartPrompt({
userPrompt: 'Find weak tags',
context: {
tabType: 'post',
id: 'post-1',
title: 'Launch Notes',
},
});
expect(result).toContain('User request: Find weak tags');
expect(result).toContain('Current editor context type: post');
expect(result).toContain('Current editor context id: post-1');
expect(result).toContain('Current editor context title: Launch Notes');
});
it('enriches prompt with active media context', () => {
const result = buildAssistantStartPrompt({
userPrompt: 'Suggest alt text variants',
context: {
tabType: 'media',
id: 'media-4',
title: 'cover.jpg',
},
});
expect(result).toContain('User request: Suggest alt text variants');
expect(result).toContain('Current editor context type: media');
expect(result).toContain('Current editor context id: media-4');
expect(result).toContain('Current editor context title: cover.jpg');
});
it('falls back to none when no active editor context is available', () => {
const result = buildAssistantStartPrompt({
userPrompt: 'Summarize current project health',
context: null,
});
expect(result).toContain('User request: Summarize current project health');
expect(result).toContain('Current editor context type: none');
expect(result).not.toContain('Current editor context id:');
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const root = path.resolve(__dirname, '../../..');
async function read(relativePath: string): Promise<string> {
return readFile(path.join(root, relativePath), 'utf8');
}
describe('assistant sidebar guard rails', () => {
it('keeps assistant sidebar self-contained and avoids opening chat tabs directly', async () => {
const sidebar = await read('src/renderer/components/AssistantSidebar/AssistantSidebar.tsx');
expect(sidebar).not.toContain('openChatTab(');
expect(sidebar).not.toContain("type: 'chat'");
});
it('renders extended widget branches for assistant panel', async () => {
const controls = await read('src/renderer/components/AssistantPanelControls/AssistantPanelControls.tsx');
const sidebar = await read('src/renderer/components/AssistantSidebar/AssistantSidebar.tsx');
expect(controls).toContain("element.type === 'chart'");
expect(controls).toContain("element.type === 'form'");
expect(controls).toContain("element.type === 'datePicker'");
expect(controls).toContain("element.type === 'card'");
expect(controls).toContain("element.type === 'image'");
expect(controls).toContain("element.type === 'tabs'");
expect(controls).toContain("element.type === 'input'");
expect(sidebar).toContain('<AssistantPanelControls');
});
it('persists assistant action feedback events to chat history', async () => {
const sidebar = await read('src/renderer/components/AssistantSidebar/AssistantSidebar.tsx');
expect(sidebar).toContain('chat.addSystemEvent');
});
});

View File

@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from 'vitest';
import {
ensureConversationId,
sendConversationMessage,
type ChatService,
} from '../../../src/renderer/navigation/chatSession';
describe('chatSession', () => {
it('reuses existing conversation id when available', async () => {
const chatService: Pick<ChatService, 'createConversation'> = {
createConversation: vi.fn(),
};
const conversationId = await ensureConversationId({
currentConversationId: 'conv-existing',
createTitle: 'Ignored',
chatService,
});
expect(conversationId).toBe('conv-existing');
expect(chatService.createConversation).not.toHaveBeenCalled();
});
it('creates conversation when no id exists', async () => {
const chatService: Pick<ChatService, 'createConversation'> = {
createConversation: vi.fn().mockResolvedValue({ id: 'conv-created' }),
};
const conversationId = await ensureConversationId({
currentConversationId: null,
createTitle: 'Assistant Session',
chatService,
});
expect(conversationId).toBe('conv-created');
expect(chatService.createConversation).toHaveBeenCalledWith('Assistant Session');
});
it('throws when conversation creation returns no id', async () => {
const chatService: Pick<ChatService, 'createConversation'> = {
createConversation: vi.fn().mockResolvedValue(null),
};
await expect(
ensureConversationId({
currentConversationId: null,
createTitle: 'Assistant Session',
chatService,
}),
).rejects.toThrow('No conversation id returned');
});
it('normalizes successful send response', async () => {
const chatService: Pick<ChatService, 'sendMessage'> = {
sendMessage: vi.fn().mockResolvedValue({ success: true, message: 'Response text' }),
};
const result = await sendConversationMessage({
conversationId: 'conv-1',
message: 'Hello',
chatService,
});
expect(result.success).toBe(true);
expect(result.message).toBe('Response text');
expect(chatService.sendMessage).toHaveBeenCalledWith('conv-1', 'Hello');
});
it('normalizes error send response', async () => {
const chatService: Pick<ChatService, 'sendMessage'> = {
sendMessage: vi.fn().mockResolvedValue({ success: false, error: 'Failed' }),
};
const result = await sendConversationMessage({
conversationId: 'conv-1',
message: 'Hello',
chatService,
});
expect(result.success).toBe(false);
expect(result.error).toBe('Failed');
});
it('forwards send metadata such as UI surface', async () => {
const chatService: Pick<ChatService, 'sendMessage'> = {
sendMessage: vi.fn().mockResolvedValue({ success: true, message: 'ok' }),
};
await sendConversationMessage({
conversationId: 'conv-1',
message: 'Hello',
metadata: { surface: 'sidebar' },
chatService,
});
expect(chatService.sendMessage).toHaveBeenCalledWith('conv-1', 'Hello', { surface: 'sidebar' });
});
});

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import {
getChatSurfaceMode,
type ChatSurfaceModeId,
} from '../../../src/renderer/navigation/chatSurfaceMode';
describe('chatSurfaceMode', () => {
it('returns mode flags for tab and sidebar surfaces', () => {
const tabMode = getChatSurfaceMode('tab');
const sidebarMode = getChatSurfaceMode('sidebar');
expect(tabMode.showModelSelector).toBe(true);
expect(tabMode.showWelcomeTips).toBe(true);
expect(tabMode.showToolMarkers).toBe(true);
expect(sidebarMode.showModelSelector).toBe(false);
expect(sidebarMode.showWelcomeTips).toBe(false);
expect(sidebarMode.showToolMarkers).toBe(true);
});
it('covers all declared mode ids', () => {
const modeIds: ChatSurfaceModeId[] = ['tab', 'sidebar'];
expect(() => modeIds.forEach((modeId) => getChatSurfaceMode(modeId))).not.toThrow();
});
});

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const root = path.resolve(__dirname, '../../..');
async function read(relativePath: string): Promise<string> {
return readFile(path.join(root, relativePath), 'utf8');
}
describe('chat surface mode usage guards', () => {
it('uses shared mode config in both chat surfaces', async () => {
const chatPanel = await read('src/renderer/components/ChatPanel/ChatPanel.tsx');
const assistantSidebar = await read('src/renderer/components/AssistantSidebar/AssistantSidebar.tsx');
expect(chatPanel).toContain('getChatSurfaceMode(');
expect(assistantSidebar).toContain('getChatSurfaceMode(');
expect(chatPanel).toContain('showModelSelector');
expect(chatPanel).toContain('showWelcomeTips');
expect(assistantSidebar).toContain('showWelcomeTips');
expect(assistantSidebar).toContain('showToolMarkers');
});
});

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const root = path.resolve(__dirname, '../../..');
async function read(relativePath: string): Promise<string> {
return readFile(path.join(root, relativePath), 'utf8');
}
describe('chat surface shared usage guards', () => {
it('uses shared chat surface state hook and transcript renderer in both surfaces', async () => {
const chatPanel = await read('src/renderer/components/ChatPanel/ChatPanel.tsx');
const assistantSidebar = await read('src/renderer/components/AssistantSidebar/AssistantSidebar.tsx');
expect(chatPanel).toContain('useChatSurfaceState(');
expect(chatPanel).toContain('<ChatTranscript');
expect(chatPanel).toContain('<AssistantPanelControls');
expect(chatPanel).toContain('extractAssistantResponseContent(');
expect(assistantSidebar).toContain('useChatSurfaceState(');
expect(assistantSidebar).toContain('<ChatTranscript');
expect(assistantSidebar).toContain('<AssistantPanelControls');
});
});

View File

@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { useChatMessageSender } from '../../../src/renderer/navigation/useChatMessageSender';
describe('useChatMessageSender', () => {
it('sends message and clears error on success', async () => {
const chatService = {
sendMessage: vi.fn().mockResolvedValue({ success: true, message: 'ok' }),
};
const { result } = renderHook(() => useChatMessageSender({ chatService }));
let response: Awaited<ReturnType<typeof result.current.sendMessage>> | null = null;
await act(async () => {
response = await result.current.sendMessage({
conversationId: 'conv-1',
message: 'hello',
});
});
expect(response?.success).toBe(true);
expect(response?.message).toBe('ok');
expect(result.current.lastError).toBeNull();
});
it('stores normalized error when send fails', async () => {
const chatService = {
sendMessage: vi.fn().mockResolvedValue({ success: false, error: 'boom' }),
};
const { result } = renderHook(() => useChatMessageSender({ chatService }));
await act(async () => {
await result.current.sendMessage({
conversationId: 'conv-1',
message: 'hello',
});
});
expect(result.current.lastError).toBe('boom');
});
it('returns default error when service is unavailable', async () => {
const { result } = renderHook(() => useChatMessageSender({ chatService: null }));
let response: Awaited<ReturnType<typeof result.current.sendMessage>> | null = null;
await act(async () => {
response = await result.current.sendMessage({
conversationId: 'conv-1',
message: 'hello',
});
});
expect(response?.success).toBe(false);
expect(response?.error).toContain('Chat service unavailable');
expect(result.current.lastError).toContain('Chat service unavailable');
});
});

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { useChatSurfaceState } from '../../../src/renderer/navigation/useChatSurfaceState';
describe('useChatSurfaceState', () => {
it('tracks a full user-assistant turn including streaming and tool calls', () => {
const { result } = renderHook(() => useChatSurfaceState());
act(() => {
result.current.beginUserTurn('conv-1', 'hello');
result.current.appendStreamDelta('A');
result.current.appendStreamDelta('B');
result.current.recordToolCall('list_posts', { query: 'hello' });
result.current.recordToolResult('list_posts');
result.current.finalizeAssistantTurn('conv-1', 'AB');
});
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0].role).toBe('user');
expect(result.current.messages[1].role).toBe('assistant');
expect(result.current.messages[1].content).toBe('AB');
expect(result.current.messages[1].toolCalls).toContain('list_posts');
expect(result.current.isStreaming).toBe(false);
expect(result.current.streamingContent).toBe('');
});
it('aborts a stream into a partial assistant message', () => {
const { result } = renderHook(() => useChatSurfaceState());
act(() => {
result.current.beginUserTurn('conv-2', 'hello');
result.current.appendStreamDelta('partial content');
result.current.abortStreaming('conv-2', 'Cancelled');
});
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[1].content).toContain('partial content');
expect(result.current.messages[1].content).toContain('Cancelled');
});
});

View File

@@ -38,6 +38,7 @@ describe('AppStore', () => {
posts: [],
selectedPostId: null,
dirtyPosts: new Set(),
assistantSidebarVisible: false,
});
});
@@ -167,6 +168,16 @@ describe('AppStore', () => {
expect(getStore().preferredEditorMode).toBe('markdown');
});
it('should toggle assistant sidebar visibility', () => {
expect(getStore().assistantSidebarVisible).toBe(false);
getStore().toggleAssistantSidebar();
expect(getStore().assistantSidebarVisible).toBe(true);
getStore().toggleAssistantSidebar();
expect(getStore().assistantSidebarVisible).toBe(false);
});
it('should set active panel tab', () => {
getStore().setPanelActiveTab('output');