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
This commit is contained in:
Andrew Beal 2025-12-23 13:39:52 +00:00
parent ea9cd211fc
commit 8efbfef63e
8 changed files with 170 additions and 36 deletions

View file

@ -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() {

View file

@ -101,7 +101,6 @@
await chatService.submit(conversation, editModeActive, currentRequest, formattedRequest, attachments, {
onSubmit: () => {
chatArea.updateChatAreaLayout("smooth");
isSubmitting = true;
attachments = [];
},

View file

@ -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<ConversationFileSystemService>(Services.ConversationFileSystemService);
this.aiFunctionService = Resolve<AIFunctionService>(Services.AIFunctionService);
this.namingService = Resolve<ConversationNamingService>(Services.ConversationNamingService);
this.workSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
this.eventService = Resolve<EventService>(Services.EventService);
this.abortService = Resolve<AbortService>(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;
}
}

View file

@ -14,4 +14,8 @@ export class WorkSpaceService {
await leaf.openFile(file);
}
}
public getActiveFile(): TFile | null {
return this.plugin.app.workspace.getActiveFile();
}
}

View file

@ -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', () => {

View file

@ -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', () => {

View file

@ -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'
});

View file

@ -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"');
});
});
});