diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e63406b..76c0ceb 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "Bash(npm run build:*)", - "Bash(npx tsc:*)" + "Bash(npx tsc:*)", + "Bash(node ./node_modules/typescript/bin/tsc:*)" ] } } diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index 2e0ec6a..dd45043 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -268,6 +268,7 @@ export class OpenCodeManager { const toolCallsCollected: Array<{ name: string; args: unknown }> = []; try { + console.log('[OpenCodeManager] Sending to provider:', provider, 'model:', modelId); if (provider === 'anthropic') { const result = await this.sendAnthropicMessage( modelId, @@ -288,7 +289,9 @@ export class OpenCodeManager { ); fullResponse = result.content; } + console.log('[OpenCodeManager] fullResponse length:', fullResponse.length); } catch (error) { + console.error('[OpenCodeManager] Request error:', (error as Error).message); const isAborted = abortController.signal.aborted || (error as Error).message === 'Request cancelled'; if (!isAborted) { throw error; @@ -467,7 +470,7 @@ export class OpenCodeManager { callbacks: { onDelta?: (delta: string) => void } ): Promise<{ content: string }> { // Build OpenAI-format messages - const messages = [ + const messages: Array> = [ { role: 'system', content: systemPrompt }, ...dbMessages .filter(m => m.role === 'user' || m.role === 'assistant') @@ -488,93 +491,90 @@ export class OpenCodeManager { }, })); - const body: Record = { - model: modelId, - max_tokens: 4096, - messages, - tools: openaiTools, - }; + let accumulatedText = ''; + const MAX_TOOL_ROUNDS = 10; + let round = 0; - const response = await this.httpRequest(ZEN_OPENAI_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, - }, - body: JSON.stringify(body), - signal, - }); + while (round < MAX_TOOL_ROUNDS) { + round++; - if (response.statusCode >= 400) { - const errorMsg = this.parseErrorResponse(response); - throw new Error(errorMsg); - } - - const data = JSON.parse(response.body); - const choice = data.choices?.[0]; - - if (!choice?.message) { - throw new Error('API response missing expected message content'); - } - - // Handle tool calls in OpenAI format - if (choice.message.tool_calls && choice.message.tool_calls.length > 0) { - // Execute tools and do follow-up call - const toolMessages = [ - ...messages, - choice.message, - ]; - - for (const toolCall of choice.message.tool_calls) { - const toolName = toolCall.function.name; - const toolArgs = JSON.parse(toolCall.function.arguments || '{}'); - const result = await this.executeTool(toolName, toolArgs); - - toolMessages.push({ - role: 'tool', - content: JSON.stringify(result), - tool_call_id: toolCall.id, - } as Record as typeof messages[0]); - } - - // Make follow-up call with tool results - const followUpBody: Record = { + const body: Record = { model: modelId, max_tokens: 4096, - messages: toolMessages, + messages, tools: openaiTools, }; - const followUpResponse = await this.httpRequest(ZEN_OPENAI_URL, { + const response = await this.httpRequest(ZEN_OPENAI_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}`, }, - body: JSON.stringify(followUpBody), + body: JSON.stringify(body), signal, }); - if (followUpResponse.statusCode >= 400) { - throw new Error(this.parseErrorResponse(followUpResponse)); + if (response.statusCode >= 400) { + const errorMsg = this.parseErrorResponse(response); + throw new Error(errorMsg); } - const followUpData = JSON.parse(followUpResponse.body); - const content = followUpData.choices?.[0]?.message?.content || ''; + const data = JSON.parse(response.body); + const choice = data.choices?.[0]; - if (callbacks.onDelta) { - callbacks.onDelta(content); + console.log('[OpenCodeManager:OpenAI] Round', round, 'status:', response.statusCode, 'content type:', typeof choice?.message?.content, 'content length:', choice?.message?.content?.length, 'tool_calls:', choice?.message?.tool_calls?.length); + + if (!choice?.message) { + throw new Error('API response missing expected message content'); } - return { content }; + // Handle content that might be a string or an array of content parts + let textContent = ''; + const content = choice.message.content; + if (typeof content === 'string') { + textContent = content; + } else if (Array.isArray(content)) { + // Handle array of content parts (some models return this format) + textContent = content + .filter((part: { type?: string; text?: string }) => part.type === 'text' && part.text) + .map((part: { text: string }) => part.text) + .join(''); + } + + if (textContent) { + accumulatedText += textContent; + if (callbacks.onDelta) { + callbacks.onDelta(textContent); + } + } + + // If no tool calls, we're done + if (!choice.message.tool_calls || choice.message.tool_calls.length === 0) { + console.log('[OpenCodeManager:OpenAI] Done. Accumulated text length:', accumulatedText.length); + return { content: accumulatedText }; + } + + // Add assistant message (with tool_calls) to conversation + messages.push(choice.message); + + // Execute tool calls and add results + for (const toolCall of choice.message.tool_calls) { + const toolName = toolCall.function.name; + const toolArgs = JSON.parse(toolCall.function.arguments || '{}'); + const result = await this.executeTool(toolName, toolArgs); + + messages.push({ + role: 'tool', + content: JSON.stringify(result), + tool_call_id: toolCall.id, + }); + } } - const content = choice.message.content || ''; - if (callbacks.onDelta) { - callbacks.onDelta(content); - } - - return { content }; + // Hit max rounds + const fallbackText = accumulatedText || 'I reached the maximum number of tool calls. Please try again.'; + return { content: fallbackText }; } /** diff --git a/src/renderer/components/ChatPanel/ChatPanel.tsx b/src/renderer/components/ChatPanel/ChatPanel.tsx index 12b8fde..436f1eb 100644 --- a/src/renderer/components/ChatPanel/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel/ChatPanel.tsx @@ -135,7 +135,8 @@ export const ChatPanel: React.FC = ({ conversationId }) => { const result = await window.electronAPI?.chat.sendMessage(conversationId, message); // Use the streamed content we accumulated via onStreamDelta - const assistantContent = streamingRef.current; + // Fall back to the backend result message if streaming didn't capture the content + const assistantContent = streamingRef.current || (result?.success ? result.message : ''); if (assistantContent) { const assistantMessage: ChatMessage = { @@ -156,6 +157,17 @@ export const ChatPanel: React.FC = ({ conversationId }) => { createdAt: new Date().toISOString() }; setMessages(prev => [...prev, errorMessage]); + } 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: 'The model returned an empty response. Try a different model or rephrase your question.', + createdAt: new Date().toISOString() + }; + setMessages(prev => [...prev, noContentMessage]); } } catch (error) { console.error('Failed to send message:', error); diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 82bf3f7..a3d5a02 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -750,7 +750,7 @@ const SettingsNav: React.FC = () => { // Chat conversations list const ChatList: React.FC = () => { - const { openTab } = useAppStore(); + const { openTab, closeTab } = useAppStore(); const [conversations, setConversations] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isReady, setIsReady] = useState(false); @@ -819,6 +819,8 @@ const ChatList: React.FC = () => { try { await window.electronAPI?.chat.deleteConversation(conversationId); setConversations(prev => prev.filter(c => c.id !== conversationId)); + // Close the tab for the deleted chat + closeTab(conversationId); } catch (error) { console.error('Failed to delete conversation:', error); showToast.error('Failed to delete chat'); diff --git a/src/renderer/components/TabBar/TabBar.tsx b/src/renderer/components/TabBar/TabBar.tsx index 87e0fd5..bf3d5ed 100644 --- a/src/renderer/components/TabBar/TabBar.tsx +++ b/src/renderer/components/TabBar/TabBar.tsx @@ -2,7 +2,14 @@ import React, { useRef, useState, useEffect, useCallback } from 'react'; import { useAppStore, Tab } from '../../store'; import './TabBar.css'; -const getTabTitle = (tab: Tab, posts: { id: string; title: string }[], media: { id: string; originalName: string }[]): string => { +const MAX_CHAT_TITLE_LENGTH = 25; + +const getTabTitle = ( + tab: Tab, + posts: { id: string; title: string }[], + media: { id: string; originalName: string }[], + chatTitles: Map +): string => { if (tab.type === 'settings') { return 'Settings'; } @@ -21,6 +28,17 @@ const getTabTitle = (tab: Tab, posts: { id: string; title: string }[], media: { return mediaItem?.originalName || 'Media'; } + if (tab.type === 'chat') { + const title = chatTitles.get(tab.id); + if (title && title !== 'New Chat') { + // Truncate long titles for display + return title.length > MAX_CHAT_TITLE_LENGTH + ? title.substring(0, MAX_CHAT_TITLE_LENGTH) + '…' + : title; + } + return 'New Chat'; + } + return 'Unknown'; }; @@ -50,6 +68,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => { ); + case 'chat': + return ( + + + + ); default: return ( @@ -94,6 +118,52 @@ export const TabBar: React.FC = () => { const tabsContainerRef = useRef(null); const [showLeftArrow, setShowLeftArrow] = useState(false); const [showRightArrow, setShowRightArrow] = useState(false); + const [chatTitles, setChatTitles] = useState>(new Map()); + + // Fetch chat titles for chat tabs + useEffect(() => { + const chatTabs = tabs.filter(t => t.type === 'chat'); + if (chatTabs.length === 0) return; + + // Fetch titles for chat tabs that don't have a title yet + const fetchTitles = async () => { + const newTitles = new Map(chatTitles); + + for (const tab of chatTabs) { + if (!chatTitles.has(tab.id)) { + try { + const conversation = await window.electronAPI?.chat.getConversation(tab.id); + if (conversation) { + newTitles.set(tab.id, conversation.title); + } + } catch (error) { + console.error('Failed to fetch chat title:', error); + } + } + } + + if (newTitles.size !== chatTitles.size) { + setChatTitles(newTitles); + } + }; + + fetchTitles(); + }, [tabs]); // Note: intentionally not including chatTitles to avoid infinite loops + + // Listen for chat title updates + useEffect(() => { + const unsub = window.electronAPI?.chat.onTitleUpdated((data) => { + setChatTitles(prev => { + const newTitles = new Map(prev); + newTitles.set(data.conversationId, data.title); + return newTitles; + }); + }); + + return () => { + unsub?.(); + }; + }, []); // Check if arrows are needed based on scroll position const updateArrowVisibility = useCallback(() => { @@ -229,7 +299,7 @@ export const TabBar: React.FC = () => { {tabs.map((tab) => { const isActive = tab.id === activeTabId; const isDirty = tab.type === 'post' && dirtyPosts.has(tab.id); - const title = getTabTitle(tab, posts, media); + const title = getTabTitle(tab, posts, media, chatTitles); const icon = getTabIcon(tab); return ( diff --git a/tests/engine/MetaEngine.test.ts b/tests/engine/MetaEngine.test.ts index 6231675..f6423e3 100644 --- a/tests/engine/MetaEngine.test.ts +++ b/tests/engine/MetaEngine.test.ts @@ -17,24 +17,31 @@ const mockDirs = new Set(); let mockPosts: any[] = []; let mockProject: any = null; +// Helper to normalize paths (handle both Windows and Unix separators) +const normalizePath = (p: string): string => p.replace(/\\/g, '/'); + // Mock fs/promises vi.mock('fs/promises', () => ({ readFile: vi.fn(async (filePath: string) => { - if (mockFiles.has(filePath)) { - return mockFiles.get(filePath); + const normalizedPath = filePath.replace(/\\/g, '/'); + if (mockFiles.has(normalizedPath)) { + return mockFiles.get(normalizedPath); } const err = new Error(`ENOENT: no such file or directory, open '${filePath}'`) as NodeJS.ErrnoException; err.code = 'ENOENT'; throw err; }), writeFile: vi.fn(async (filePath: string, content: string) => { - mockFiles.set(filePath, content); + const normalizedPath = filePath.replace(/\\/g, '/'); + mockFiles.set(normalizedPath, content); }), mkdir: vi.fn(async (dirPath: string) => { - mockDirs.add(dirPath); + const normalizedPath = dirPath.replace(/\\/g, '/'); + mockDirs.add(normalizedPath); }), access: vi.fn(async (filePath: string) => { - if (!mockFiles.has(filePath) && !mockDirs.has(filePath)) { + const normalizedPath = filePath.replace(/\\/g, '/'); + if (!mockFiles.has(normalizedPath) && !mockDirs.has(normalizedPath)) { const err = new Error(`ENOENT: no such file or directory, access '${filePath}'`) as NodeJS.ErrnoException; err.code = 'ENOENT'; throw err; @@ -165,13 +172,13 @@ describe('MetaEngine', () => { await metaEngine.saveTags(); const metaDir = metaEngine.getMetaDir(); - const tagsPath = `${metaDir}\\tags.json`; - expect(mockFiles.has(tagsPath) || mockFiles.has(tagsPath.replace(/\\/g, '/'))).toBe(true); + const tagsPath = normalizePath(`${metaDir}/tags.json`); + expect(mockFiles.has(tagsPath)).toBe(true); }); it('should load tags from filesystem', async () => { const metaDir = metaEngine.getMetaDir(); - const tagsPath = `${metaDir}\\tags.json`; + const tagsPath = normalizePath(`${metaDir}/tags.json`); mockFiles.set(tagsPath, JSON.stringify(['saved-tag-1', 'saved-tag-2'])); await metaEngine.loadTags(); @@ -214,13 +221,13 @@ describe('MetaEngine', () => { await metaEngine.saveCategories(); const metaDir = metaEngine.getMetaDir(); - const catPath = `${metaDir}\\categories.json`; - expect(mockFiles.has(catPath) || mockFiles.has(catPath.replace(/\\/g, '/'))).toBe(true); + const catPath = normalizePath(`${metaDir}/categories.json`); + expect(mockFiles.has(catPath)).toBe(true); }); it('should load categories from filesystem', async () => { const metaDir = metaEngine.getMetaDir(); - const catPath = `${metaDir}\\categories.json`; + const catPath = normalizePath(`${metaDir}/categories.json`); mockFiles.set(catPath, JSON.stringify(['cat-1', 'cat-2'])); await metaEngine.loadCategories(); @@ -263,7 +270,7 @@ describe('MetaEngine', () => { it('should merge file tags with database tags', async () => { // File has some tags const metaDir = metaEngine.getMetaDir(); - mockFiles.set(`${metaDir}\\tags.json`, JSON.stringify(['file-tag'])); + mockFiles.set(normalizePath(`${metaDir}/tags.json`), JSON.stringify(['file-tag'])); // Posts have different tags mockPosts = [ @@ -279,7 +286,7 @@ describe('MetaEngine', () => { it('should merge file categories with database categories', async () => { const metaDir = metaEngine.getMetaDir(); - mockFiles.set(`${metaDir}\\categories.json`, JSON.stringify(['file-cat'])); + mockFiles.set(normalizePath(`${metaDir}/categories.json`), JSON.stringify(['file-cat'])); mockPosts = [ { categories: JSON.stringify(['db-cat']) }, @@ -302,7 +309,7 @@ describe('MetaEngine', () => { it('should save merged results back to file', async () => { const metaDir = metaEngine.getMetaDir(); - mockFiles.set(`${metaDir}\\tags.json`, JSON.stringify(['existing'])); + mockFiles.set(normalizePath(`${metaDir}/tags.json`), JSON.stringify(['existing'])); mockPosts = [{ tags: JSON.stringify(['new-from-db']), categories: JSON.stringify([]) }]; await metaEngine.syncOnStartup(); @@ -428,11 +435,11 @@ describe('MetaEngine', () => { }); const metaDir = metaEngine.getMetaDir(); - const projectPath = `${metaDir}\\project.json`; - expect(mockFiles.has(projectPath) || mockFiles.has(projectPath.replace(/\\/g, '/'))).toBe(true); + const projectPath = normalizePath(`${metaDir}/project.json`); + expect(mockFiles.has(projectPath)).toBe(true); // Verify content - const content = mockFiles.get(projectPath) || mockFiles.get(projectPath.replace(/\\/g, '/')); + const content = mockFiles.get(projectPath); const parsed = JSON.parse(content!); expect(parsed.name).toBe('Test Project'); expect(parsed.description).toBe('Test description'); @@ -440,7 +447,7 @@ describe('MetaEngine', () => { it('should load project metadata from filesystem', async () => { const metaDir = metaEngine.getMetaDir(); - const projectPath = `${metaDir}\\project.json`; + const projectPath = normalizePath(`${metaDir}/project.json`); mockFiles.set(projectPath, JSON.stringify({ name: 'Loaded Project', description: 'Loaded description', @@ -489,7 +496,7 @@ describe('MetaEngine', () => { it('should load project metadata during syncOnStartup if file exists', async () => { const metaDir = metaEngine.getMetaDir(); - mockFiles.set(`${metaDir}\\project.json`, JSON.stringify({ + mockFiles.set(normalizePath(`${metaDir}/project.json`), JSON.stringify({ name: 'Synced Project', description: 'Synced description', })); @@ -503,7 +510,7 @@ describe('MetaEngine', () => { it('should create project.json with data from database during syncOnStartup if file does not exist', async () => { const metaDir = metaEngine.getMetaDir(); - const projectPath = `${metaDir}\\project.json`; + const projectPath = normalizePath(`${metaDir}/project.json`); // Setup mock project in database mockProject = { @@ -522,7 +529,7 @@ describe('MetaEngine', () => { await metaEngine.syncOnStartup(); // File should be created - expect(mockFiles.has(projectPath) || mockFiles.has(projectPath.replace(/\\/g, '/'))).toBe(true); + expect(mockFiles.has(projectPath)).toBe(true); // Should have metadata from database const metadata = await metaEngine.getProjectMetadata(); @@ -540,7 +547,7 @@ describe('MetaEngine', () => { it('should create categories.json with defaults for new project with no posts', async () => { const metaDir = metaEngine.getMetaDir(); - const catPath = `${metaDir}\\categories.json`; + const catPath = normalizePath(`${metaDir}/categories.json`); // Setup mock project in database mockProject = { @@ -559,7 +566,7 @@ describe('MetaEngine', () => { await metaEngine.syncOnStartup(); // File should be created with default categories - expect(mockFiles.has(catPath) || mockFiles.has(catPath.replace(/\\/g, '/'))).toBe(true); + expect(mockFiles.has(catPath)).toBe(true); const categories = await metaEngine.getCategories(); expect(categories).toContain('article');