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