From 8efbfef63e9d1ed06f78d12ec8033915f64ffc71 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Tue, 23 Dec 2025 13:39:52 +0000 Subject: [PATCH] feat: add active file context to chat requests - Include user's current active file path in chat requests - Move conversation save after attachments are added - Extract formattedRequest in suggest handler - Add WorkSpaceService.getActiveFile() method - Remove premature chatArea layout update - Update unit tests --- Components/ChatInput.svelte | 4 +- Components/ChatWindow.svelte | 1 - Services/ChatService.ts | 12 +++- Services/WorkSpaceService.ts | 4 ++ __tests__/AIClasses/Claude.test.ts | 24 ++++---- __tests__/AIClasses/Gemini.test.ts | 18 +++--- __tests__/AIClasses/OpenAI.test.ts | 62 ++++++++++++++++---- __tests__/Services/ChatService.test.ts | 81 ++++++++++++++++++++++++++ 8 files changed, 170 insertions(+), 36 deletions(-) diff --git a/Components/ChatInput.svelte b/Components/ChatInput.svelte index 3ce4d73..a6a49dd 100644 --- a/Components/ChatInput.svelte +++ b/Components/ChatInput.svelte @@ -94,8 +94,8 @@ if (userRequest.trim() === "" || !diffOpen) { return; } - const suggestion = requestFromInput().formattedRequest; - diffService.onSuggest(suggestion); + const suggestion = requestFromInput(); + diffService.onSuggest(suggestion.formattedRequest); } function requestFromInput() { diff --git a/Components/ChatWindow.svelte b/Components/ChatWindow.svelte index 519bccf..faac814 100644 --- a/Components/ChatWindow.svelte +++ b/Components/ChatWindow.svelte @@ -101,7 +101,6 @@ await chatService.submit(conversation, editModeActive, currentRequest, formattedRequest, attachments, { onSubmit: () => { - chatArea.updateChatAreaLayout("smooth"); isSubmitting = true; attachments = []; }, diff --git a/Services/ChatService.ts b/Services/ChatService.ts index 9bf7c1b..fcf84ac 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -17,6 +17,7 @@ import { Exception } from "Helpers/Exception"; import { Copy } from "Enums/Copy"; import type { Attachment } from "Conversations/Attachment"; import { Reference } from "Conversations/Reference"; +import type { WorkSpaceService } from "./WorkSpaceService"; export interface IChatServiceCallbacks { onSubmit: () => void; @@ -31,6 +32,7 @@ export class ChatService { private conversationService: ConversationFileSystemService; private aiFunctionService: AIFunctionService; private namingService: ConversationNamingService; + private workSpaceService: WorkSpaceService; private eventService: EventService; private abortService: AbortService; @@ -41,6 +43,7 @@ export class ChatService { this.conversationService = Resolve(Services.ConversationFileSystemService); this.aiFunctionService = Resolve(Services.AIFunctionService); this.namingService = Resolve(Services.ConversationNamingService); + this.workSpaceService = Resolve(Services.WorkSpaceService); this.eventService = Resolve(Services.EventService); this.abortService = Resolve(Services.AbortService); this.semaphore = new Semaphore(1, false); @@ -71,11 +74,10 @@ export class ChatService { const conversationContent = new ConversationContent({ role: Role.User, - content: formattedRequest, + content: this.requestWithContext(formattedRequest), displayContent: userRequest }); conversation.contents.push(conversationContent); - await this.saveConversation(conversation); if (attachments.length > 0) { // Add any attachments that came from paste / drop @@ -88,6 +90,7 @@ export class ChatService { conversationContent.references = attachments.map(attachment => new Reference(attachment.fileName, attachment.approximateFileSizeMB())); } + await this.saveConversation(conversation); callbacks.onSubmit(); callbacks.onStreamingUpdate(null); @@ -282,4 +285,9 @@ export class ChatService { return sanitized; } + + private requestWithContext(request: string) { + const activeFile = this.workSpaceService.getActiveFile(); + return activeFile ? `${request}\nUser current active file: "${activeFile.path}"` : request; + } } diff --git a/Services/WorkSpaceService.ts b/Services/WorkSpaceService.ts index 65c3274..4e2f726 100644 --- a/Services/WorkSpaceService.ts +++ b/Services/WorkSpaceService.ts @@ -14,4 +14,8 @@ export class WorkSpaceService { await leaf.openFile(file); } } + + public getActiveFile(): TFile | null { + return this.plugin.app.workspace.getActiveFile(); + } } \ No newline at end of file diff --git a/__tests__/AIClasses/Claude.test.ts b/__tests__/AIClasses/Claude.test.ts index 21a2759..a7b46b8 100644 --- a/__tests__/AIClasses/Claude.test.ts +++ b/__tests__/AIClasses/Claude.test.ts @@ -737,7 +737,7 @@ describe('Claude', () => { expect(result[0].content.length).toBeGreaterThan(1); // Should have filename text block from formatBinaryFiles - const filenameBlock = result[0].content.find((block: any) => block.type === 'text' && block.text === 'test-image.png'); + const filenameBlock = result[0].content.find((block: any) => block.type === 'text' && block.text === 'Binary data for test-image.png follows in next message'); expect(filenameBlock).toBeDefined(); // Should have image content blocks from formatBinaryFiles @@ -769,7 +769,7 @@ describe('Claude', () => { expect(result[0].content.length).toBeGreaterThan(1); // Should have filename text block from formatBinaryFiles - const filenameBlock = result[0].content.find((block: any) => block.type === 'text' && block.text === 'document.pdf'); + const filenameBlock = result[0].content.find((block: any) => block.type === 'text' && block.text === 'Binary data for document.pdf follows in next message'); expect(filenameBlock).toBeDefined(); // Should have document content blocks from formatBinaryFiles @@ -903,7 +903,7 @@ describe('Claude', () => { expect(parsed).toHaveLength(2); expect(parsed[0]).toEqual({ type: 'text', - text: 'report.pdf' + text: 'Binary data for report.pdf follows in next message' }); expect(parsed[1]).toEqual({ type: 'document', @@ -930,7 +930,7 @@ describe('Claude', () => { expect(parsed).toHaveLength(2); expect(parsed[0]).toEqual({ type: 'text', - text: 'photo.jpg' + text: 'Binary data for photo.jpg follows in next message' }); expect(parsed[1]).toEqual({ type: 'image', @@ -1064,7 +1064,7 @@ describe('Claude', () => { expect(parsed).toHaveLength(6); // PDF file - expect(parsed[0]).toEqual({ type: 'text', text: 'doc.pdf' }); + expect(parsed[0]).toEqual({ type: 'text', text: 'Binary data for doc.pdf follows in next message' }); expect(parsed[1]).toEqual({ type: 'document', source: { @@ -1074,7 +1074,7 @@ describe('Claude', () => { }); // JPEG image - expect(parsed[2]).toEqual({ type: 'text', text: 'image.jpg' }); + expect(parsed[2]).toEqual({ type: 'text', text: 'Binary data for image.jpg follows in next message' }); expect(parsed[3]).toEqual({ type: 'image', source: { @@ -1084,7 +1084,7 @@ describe('Claude', () => { }); // PNG image - expect(parsed[4]).toEqual({ type: 'text', text: 'screenshot.png' }); + expect(parsed[4]).toEqual({ type: 'text', text: 'Binary data for screenshot.png follows in next message' }); expect(parsed[5]).toEqual({ type: 'image', source: { @@ -1128,14 +1128,14 @@ describe('Claude', () => { expect(parsed).toHaveLength(5); expect(parsed[0].type).toBe('text'); - expect(parsed[0].text).toBe('good.jpg'); + expect(parsed[0].text).toBe('Binary data for good.jpg follows in next message'); expect(parsed[1].type).toBe('image'); expect(parsed[2]).toEqual({ type: 'text', text: 'Unsupported mime type \'image/bmp\': bad.bmp' }); expect(parsed[3].type).toBe('text'); - expect(parsed[3].text).toBe('doc.pdf'); + expect(parsed[3].text).toBe('Binary data for doc.pdf follows in next message'); expect(parsed[4].type).toBe('document'); }); @@ -1180,9 +1180,9 @@ describe('Claude', () => { const parsed = JSON.parse(result); expect(parsed).toHaveLength(4); - expect(parsed[0].text).toBe('document.PDF'); + expect(parsed[0].text).toBe('Binary data for document.PDF follows in next message'); expect(parsed[1].type).toBe('document'); - expect(parsed[2].text).toBe('photo.JPG'); + expect(parsed[2].text).toBe('Binary data for photo.JPG follows in next message'); expect(parsed[3].type).toBe('image'); }); @@ -1208,7 +1208,7 @@ describe('Claude', () => { const result = claude.formatBinaryFiles([attachment as any]); const parsed = JSON.parse(result); - expect(parsed[0].text).toBe('report (final) v2.pdf'); + expect(parsed[0].text).toBe('Binary data for report (final) v2.pdf follows in next message'); }); it('should handle JPEG files with .jpeg extension', () => { diff --git a/__tests__/AIClasses/Gemini.test.ts b/__tests__/AIClasses/Gemini.test.ts index 85b0fb2..bc0800c 100644 --- a/__tests__/AIClasses/Gemini.test.ts +++ b/__tests__/AIClasses/Gemini.test.ts @@ -1089,7 +1089,7 @@ describe('Gemini', () => { expect(parsed).toHaveLength(2); expect(parsed[0]).toEqual({ - text: 'report.pdf' + text: 'Binary data for report.pdf follows in next message' }); expect(parsed[1]).toEqual({ fileData: { @@ -1114,7 +1114,7 @@ describe('Gemini', () => { expect(parsed).toHaveLength(2); expect(parsed[0]).toEqual({ - text: 'photo.jpg' + text: 'Binary data for photo.jpg follows in next message' }); expect(parsed[1]).toEqual({ fileData: { @@ -1218,7 +1218,7 @@ describe('Gemini', () => { expect(parsed).toHaveLength(6); // PDF file - expect(parsed[0]).toEqual({ text: 'doc.pdf' }); + expect(parsed[0]).toEqual({ text: 'Binary data for doc.pdf follows in next message' }); expect(parsed[1]).toEqual({ fileData: { mimeType: 'application/pdf', @@ -1227,7 +1227,7 @@ describe('Gemini', () => { }); // JPEG image - expect(parsed[2]).toEqual({ text: 'image.jpg' }); + expect(parsed[2]).toEqual({ text: 'Binary data for image.jpg follows in next message' }); expect(parsed[3]).toEqual({ fileData: { mimeType: 'image/jpeg', @@ -1236,7 +1236,7 @@ describe('Gemini', () => { }); // PNG image - expect(parsed[4]).toEqual({ text: 'screenshot.png' }); + expect(parsed[4]).toEqual({ text: 'Binary data for screenshot.png follows in next message' }); expect(parsed[5]).toEqual({ fileData: { mimeType: 'image/png', @@ -1278,12 +1278,12 @@ describe('Gemini', () => { expect(parsed).toHaveLength(5); - expect(parsed[0]).toEqual({ text: 'good.jpg' }); + expect(parsed[0]).toEqual({ text: 'Binary data for good.jpg follows in next message' }); expect(parsed[1]).toHaveProperty('fileData'); expect(parsed[2]).toEqual({ text: 'Unsupported mime type \'image/bmp\': bad.bmp' }); - expect(parsed[3]).toEqual({ text: 'doc.pdf' }); + expect(parsed[3]).toEqual({ text: 'Binary data for doc.pdf follows in next message' }); expect(parsed[4]).toHaveProperty('fileData'); }); @@ -1311,7 +1311,7 @@ describe('Gemini', () => { const parsed = JSON.parse(result); expect(parsed).toHaveLength(2); // Only successful upload - expect(parsed[0]).toEqual({ text: 'success.pdf' }); + expect(parsed[0]).toEqual({ text: 'Binary data for success.pdf follows in next message' }); expect(parsed[1]).toEqual({ fileData: { mimeType: 'application/pdf', @@ -1340,7 +1340,7 @@ describe('Gemini', () => { const result = gemini.formatBinaryFiles([attachment as any]); const parsed = JSON.parse(result); - expect(parsed[0].text).toBe('report (final) v2.pdf'); + expect(parsed[0].text).toBe('Binary data for report (final) v2.pdf follows in next message'); }); it('should handle JPEG files with .jpeg extension', () => { diff --git a/__tests__/AIClasses/OpenAI.test.ts b/__tests__/AIClasses/OpenAI.test.ts index dad9e44..fc4b16f 100644 --- a/__tests__/AIClasses/OpenAI.test.ts +++ b/__tests__/AIClasses/OpenAI.test.ts @@ -975,8 +975,12 @@ describe('OpenAI', () => { expect(parsed).toHaveLength(1); expect(parsed[0].role).toBe('user'); - expect(parsed[0].content).toHaveLength(1); + expect(parsed[0].content).toHaveLength(2); expect(parsed[0].content[0]).toEqual({ + type: 'input_text', + text: 'Binary data for report.pdf follows in next message' + }); + expect(parsed[0].content[1]).toEqual({ type: 'input_file', file_id: 'file-123' }); @@ -997,8 +1001,12 @@ describe('OpenAI', () => { expect(parsed).toHaveLength(1); expect(parsed[0].role).toBe('user'); - expect(parsed[0].content).toHaveLength(1); + expect(parsed[0].content).toHaveLength(2); expect(parsed[0].content[0]).toEqual({ + type: 'input_text', + text: 'Binary data for photo.jpg follows in next message' + }); + expect(parsed[0].content[1]).toEqual({ type: 'input_image', file_id: 'file-456' }); @@ -1018,7 +1026,12 @@ describe('OpenAI', () => { const parsed = JSON.parse(result); expect(parsed).toHaveLength(1); + expect(parsed[0].content).toHaveLength(2); expect(parsed[0].content[0]).toEqual({ + type: 'input_text', + text: 'Binary data for diagram.png follows in next message' + }); + expect(parsed[0].content[1]).toEqual({ type: 'input_image', file_id: 'file-789' }); @@ -1038,7 +1051,12 @@ describe('OpenAI', () => { const parsed = JSON.parse(result); expect(parsed).toHaveLength(1); + expect(parsed[0].content).toHaveLength(2); expect(parsed[0].content[0]).toEqual({ + type: 'input_text', + text: 'Binary data for modern.webp follows in next message' + }); + expect(parsed[0].content[1]).toEqual({ type: 'input_image', file_id: 'file-webp' }); @@ -1116,19 +1134,31 @@ describe('OpenAI', () => { expect(parsed).toHaveLength(1); expect(parsed[0].role).toBe('user'); - expect(parsed[0].content).toHaveLength(3); + expect(parsed[0].content).toHaveLength(6); expect(parsed[0].content[0]).toEqual({ + type: 'input_text', + text: 'Binary data for doc.pdf follows in next message' + }); + expect(parsed[0].content[1]).toEqual({ type: 'input_file', file_id: 'file-pdf' }); - expect(parsed[0].content[1]).toEqual({ + expect(parsed[0].content[2]).toEqual({ + type: 'input_text', + text: 'Binary data for image.jpg follows in next message' + }); + expect(parsed[0].content[3]).toEqual({ type: 'input_image', file_id: 'file-jpg' }); - expect(parsed[0].content[2]).toEqual({ + expect(parsed[0].content[4]).toEqual({ + type: 'input_text', + text: 'Binary data for screenshot.png follows in next message' + }); + expect(parsed[0].content[5]).toEqual({ type: 'input_image', file_id: 'file-png' }); @@ -1166,14 +1196,22 @@ describe('OpenAI', () => { const parsed = JSON.parse(result); expect(parsed).toHaveLength(1); - expect(parsed[0].content).toHaveLength(3); + expect(parsed[0].content).toHaveLength(5); - expect(parsed[0].content[0].type).toBe('input_image'); - expect(parsed[0].content[1]).toEqual({ + expect(parsed[0].content[0]).toEqual({ + type: 'input_text', + text: 'Binary data for good.jpg follows in next message' + }); + expect(parsed[0].content[1].type).toBe('input_image'); + expect(parsed[0].content[2]).toEqual({ type: 'input_text', text: 'Unsupported mime type \'image/bmp\': bad.bmp' }); - expect(parsed[0].content[2].type).toBe('input_file'); + expect(parsed[0].content[3]).toEqual({ + type: 'input_text', + text: 'Binary data for doc.pdf follows in next message' + }); + expect(parsed[0].content[4].type).toBe('input_file'); }); it('should handle mixed successful and failed uploads', () => { @@ -1200,8 +1238,12 @@ describe('OpenAI', () => { const parsed = JSON.parse(result); expect(parsed).toHaveLength(1); - expect(parsed[0].content).toHaveLength(1); // Only successful upload + expect(parsed[0].content).toHaveLength(2); // Text + file reference expect(parsed[0].content[0]).toEqual({ + type: 'input_text', + text: 'Binary data for success.pdf follows in next message' + }); + expect(parsed[0].content[1]).toEqual({ type: 'input_file', file_id: 'file-success' }); diff --git a/__tests__/Services/ChatService.test.ts b/__tests__/Services/ChatService.test.ts index 241370a..f061e1c 100644 --- a/__tests__/Services/ChatService.test.ts +++ b/__tests__/Services/ChatService.test.ts @@ -27,6 +27,7 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { let mockPrompt: any; let mockStatusBarService: any; let mockEventService: any; + let mockWorkSpaceService: any; let abortService: AbortService; beforeEach(() => { @@ -59,6 +60,12 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { off: vi.fn() }; + // Mock WorkSpaceService + mockWorkSpaceService = { + openNote: vi.fn(), + getActiveFile: vi.fn().mockReturnValue(null) + }; + // Create real AbortService instance abortService = new AbortService(); @@ -68,6 +75,7 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { RegisterSingleton(Services.ConversationNamingService, mockNamingService); RegisterSingleton(Services.IPrompt, mockPrompt); RegisterSingleton(Services.EventService, mockEventService); + RegisterSingleton(Services.WorkSpaceService, mockWorkSpaceService); RegisterSingleton(Services.AbortService, abortService); // Create service @@ -542,4 +550,77 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { expect(() => fromString('')).toThrow('Unknown function name: '); }); }); + + describe('requestWithContext (private method tests via reflection)', () => { + // Access private method for testing + const getRequestWithContextMethod = (service: ChatService) => { + return (service as any).requestWithContext.bind(service); + }; + + it('should return request unchanged when no active file exists', () => { + mockWorkSpaceService.getActiveFile.mockReturnValue(null); + const requestWithContext = getRequestWithContextMethod(service); + const request = 'Please help me with this task'; + + const result = requestWithContext(request); + + expect(result).toBe(request); + expect(mockWorkSpaceService.getActiveFile).toHaveBeenCalled(); + }); + + it('should append active file path when active file exists', () => { + const mockFile = { + path: 'notes/my-note.md', + name: 'my-note.md', + basename: 'my-note' + }; + mockWorkSpaceService.getActiveFile.mockReturnValue(mockFile); + const requestWithContext = getRequestWithContextMethod(service); + const request = 'Please help me with this task'; + + const result = requestWithContext(request); + + expect(result).toBe('Please help me with this task\nUser current active file: "notes/my-note.md"'); + expect(mockWorkSpaceService.getActiveFile).toHaveBeenCalled(); + }); + + it('should handle empty request with active file', () => { + const mockFile = { + path: 'test.md' + }; + mockWorkSpaceService.getActiveFile.mockReturnValue(mockFile); + const requestWithContext = getRequestWithContextMethod(service); + const request = ''; + + const result = requestWithContext(request); + + expect(result).toBe('\nUser current active file: "test.md"'); + }); + + it('should handle request with special characters in file path', () => { + const mockFile = { + path: 'folder/my file (v2).md' + }; + mockWorkSpaceService.getActiveFile.mockReturnValue(mockFile); + const requestWithContext = getRequestWithContextMethod(service); + const request = 'Update this file'; + + const result = requestWithContext(request); + + expect(result).toBe('Update this file\nUser current active file: "folder/my file (v2).md"'); + }); + + it('should handle multiline requests with active file', () => { + const mockFile = { + path: 'docs/readme.md' + }; + mockWorkSpaceService.getActiveFile.mockReturnValue(mockFile); + const requestWithContext = getRequestWithContextMethod(service); + const request = 'Please help me:\n1. First task\n2. Second task'; + + const result = requestWithContext(request); + + expect(result).toBe('Please help me:\n1. First task\n2. Second task\nUser current active file: "docs/readme.md"'); + }); + }); });