Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions packages/ai/src/ai-actions/__tests__/editor/editor-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,98 @@ describe('EditorAdapter', () => {
});
});

describe('insertFormattedContent', () => {
it('calls editor.commands.insertContent with contentType for html', () => {
updateEditorState(defaultSegments, { from: 1, to: 5 });

mockAdapter.insertFormattedContent('<p>Hello <a href="https://example.com">link</a></p>', {
contentType: 'html',
position: 'replace',
});

expect(mockEditor.commands.setTextSelection).toHaveBeenCalledWith({ from: 1, to: 5 });
expect(mockEditor.commands.insertContent).toHaveBeenCalledWith(
'<p>Hello <a href="https://example.com">link</a></p>',
{ contentType: 'html' },
);
});

it('calls editor.commands.insertContent with contentType for markdown', () => {
updateEditorState(defaultSegments, { from: 1, to: 5 });

mockAdapter.insertFormattedContent('# Heading\n\n[link](https://example.com)', {
contentType: 'markdown',
position: 'replace',
});

expect(mockEditor.commands.insertContent).toHaveBeenCalledWith('# Heading\n\n[link](https://example.com)', {
contentType: 'markdown',
});
});

it('falls back to insertText for contentType: text', () => {
updateEditorState(defaultSegments, { from: 1, to: 5 });

const insertSpy = vi.spyOn(mockAdapter, 'insertText');
mockAdapter.insertFormattedContent('plain text', { contentType: 'text', position: 'replace' });

expect(insertSpy).toHaveBeenCalledWith('plain text', { contentType: 'text', position: 'replace' });
expect(mockEditor.commands.insertContent).not.toHaveBeenCalledWith(expect.anything(), {
contentType: 'text',
});

insertSpy.mockRestore();
});

it('falls back to insertText when no contentType is provided', () => {
updateEditorState(defaultSegments, { from: 1, to: 5 });

const insertSpy = vi.spyOn(mockAdapter, 'insertText');
mockAdapter.insertFormattedContent('just text', { position: 'replace' });

expect(insertSpy).toHaveBeenCalledWith('just text', { position: 'replace' });

insertSpy.mockRestore();
});

it('sets selection to collapsed range at from for position: before', () => {
updateEditorState(defaultSegments, { from: 3, to: 8 });

mockAdapter.insertFormattedContent('<p>Before</p>', {
contentType: 'html',
position: 'before',
});

// 'before' collapses to from: to = from
expect(mockEditor.commands.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 3 });
expect(mockEditor.commands.insertContent).toHaveBeenCalledWith('<p>Before</p>', { contentType: 'html' });
});

it('sets selection to collapsed range at to for position: after', () => {
updateEditorState(defaultSegments, { from: 3, to: 8 });

mockAdapter.insertFormattedContent('<p>After</p>', {
contentType: 'html',
position: 'after',
});

// 'after' collapses to to: from = to
expect(mockEditor.commands.setTextSelection).toHaveBeenCalledWith({ from: 8, to: 8 });
expect(mockEditor.commands.insertContent).toHaveBeenCalledWith('<p>After</p>', { contentType: 'html' });
});

it('uses full selection range for position: replace (default)', () => {
updateEditorState(defaultSegments, { from: 3, to: 8 });

mockAdapter.insertFormattedContent('<p>Replace</p>', {
contentType: 'html',
});

expect(mockEditor.commands.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 8 });
expect(mockEditor.commands.insertContent).toHaveBeenCalledWith('<p>Replace</p>', { contentType: 'html' });
});
});

describe('replaceText with appended content', () => {
it('handles appended text correctly (prefix equals original, suffix is 0)', () => {
const boldMark = schema.marks.bold.create();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,22 @@ describe('AIActionsService', () => {
expect(result.success).toBe(true);
expect(result.results).toHaveLength(1);
});

it('should throw when trackChanges and contentType: html are both set', async () => {
const actions = new AIActionsService(mockProvider, mockEditor, () => mockEditor.state.doc.textContent, false);

await expect(
actions.literalReplace('A', '<strong>B</strong>', { trackChanges: true, contentType: 'html' }),
).rejects.toThrow('trackChanges and contentType');
});

it('should throw when trackChanges and contentType: markdown are both set', async () => {
const actions = new AIActionsService(mockProvider, mockEditor, () => mockEditor.state.doc.textContent, false);

await expect(
actions.literalReplace('A', '**B**', { trackChanges: true, contentType: 'markdown' }),
).rejects.toThrow('trackChanges and contentType');
});
});

describe('insertTrackedChange', () => {
Expand Down Expand Up @@ -805,6 +821,200 @@ describe('AIActionsService', () => {
});
});

describe('insertContent with contentType', () => {
it('should disable streaming when contentType is html', async () => {
const response = JSON.stringify({
success: true,
results: [{ suggestedText: '<p>Hello <a href="https://example.com">link</a></p>' }],
});

const streamSpy = vi.fn().mockImplementation(async function* () {
yield response;
});
const completionSpy = vi.fn().mockResolvedValue(response);

mockProvider.streamCompletion = streamSpy as typeof mockProvider.streamCompletion;
mockProvider.getCompletion = completionSpy;

const insertFormattedSpy = vi
.spyOn(EditorAdapter.prototype, 'insertFormattedContent')
.mockImplementation(() => {});

const actions = new AIActionsService(
mockProvider,
mockEditor,
() => mockEditor.state.doc.textContent,
false,
undefined,
true, // streaming preference enabled
);

const result = await actions.insertContent('generate html', { contentType: 'html' });

expect(result.success).toBe(true);
// Streaming must be disabled for HTML content
expect(streamSpy).not.toHaveBeenCalled();
expect(completionSpy).toHaveBeenCalled();
// Should use insertFormattedContent, not insertText
expect(insertFormattedSpy).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ contentType: 'html' }),
);

insertFormattedSpy.mockRestore();
});

it('should disable streaming when contentType is markdown', async () => {
const response = JSON.stringify({
success: true,
results: [{ suggestedText: '# Title\n\n[link](https://example.com)' }],
});

const streamSpy = vi.fn().mockImplementation(async function* () {
yield response;
});
const completionSpy = vi.fn().mockResolvedValue(response);

mockProvider.streamCompletion = streamSpy as typeof mockProvider.streamCompletion;
mockProvider.getCompletion = completionSpy;

const insertFormattedSpy = vi
.spyOn(EditorAdapter.prototype, 'insertFormattedContent')
.mockImplementation(() => {});

const actions = new AIActionsService(
mockProvider,
mockEditor,
() => mockEditor.state.doc.textContent,
false,
undefined,
true,
);

const result = await actions.insertContent('generate markdown', { contentType: 'markdown' });

expect(result.success).toBe(true);
expect(streamSpy).not.toHaveBeenCalled();
expect(completionSpy).toHaveBeenCalled();
expect(insertFormattedSpy).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ contentType: 'markdown' }),
);

insertFormattedSpy.mockRestore();
});

it('should route through insertFormattedContent with position: before for html', async () => {
const response = JSON.stringify({
success: true,
results: [{ suggestedText: '<p>Before content</p>' }],
});

mockProvider.getCompletion = vi.fn().mockResolvedValue(response);

const insertFormattedSpy = vi
.spyOn(EditorAdapter.prototype, 'insertFormattedContent')
.mockImplementation(() => {});

const actions = new AIActionsService(mockProvider, mockEditor, () => mockEditor.state.doc.textContent, false);
const result = await actions.insertContent('add content', {
position: 'before',
contentType: 'html',
});

expect(result.success).toBe(true);
expect(insertFormattedSpy).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ position: 'before', contentType: 'html' }),
);

insertFormattedSpy.mockRestore();
});

it('should route through insertFormattedContent with position: after for html', async () => {
const response = JSON.stringify({
success: true,
results: [{ suggestedText: '<p>After content</p>' }],
});

mockProvider.getCompletion = vi.fn().mockResolvedValue(response);

const insertFormattedSpy = vi
.spyOn(EditorAdapter.prototype, 'insertFormattedContent')
.mockImplementation(() => {});

const actions = new AIActionsService(mockProvider, mockEditor, () => mockEditor.state.doc.textContent, false);
const result = await actions.insertContent('add content', {
position: 'after',
contentType: 'html',
});

expect(result.success).toBe(true);
expect(insertFormattedSpy).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ position: 'after', contentType: 'html' }),
);

insertFormattedSpy.mockRestore();
});

it('should use plain insertText path when contentType is text', async () => {
const response = JSON.stringify({
success: true,
results: [{ suggestedText: 'Plain text content' }],
});

mockProvider.getCompletion = vi.fn().mockResolvedValue(response);

const insertTextSpy = vi.spyOn(EditorAdapter.prototype, 'insertText').mockImplementation(() => {});
const insertFormattedSpy = vi
.spyOn(EditorAdapter.prototype, 'insertFormattedContent')
.mockImplementation(() => {});

const actions = new AIActionsService(mockProvider, mockEditor, () => mockEditor.state.doc.textContent, false);
const result = await actions.insertContent('add content', { contentType: 'text' });

expect(result.success).toBe(true);
expect(insertFormattedSpy).not.toHaveBeenCalled();
expect(insertTextSpy).toHaveBeenCalled();

insertTextSpy.mockRestore();
insertFormattedSpy.mockRestore();
});
});

describe('literalReplace with contentType', () => {
let literalSpy: ReturnType<typeof vi.spyOn<typeof EditorAdapter.prototype, 'findLiteralMatches'>>;
let insertFormattedSpy: ReturnType<typeof vi.spyOn<typeof EditorAdapter.prototype, 'insertFormattedContent'>>;

beforeEach(() => {
literalSpy = vi.spyOn(EditorAdapter.prototype, 'findLiteralMatches');
insertFormattedSpy = vi.spyOn(EditorAdapter.prototype, 'insertFormattedContent').mockImplementation(() => {});
});

afterEach(() => {
literalSpy.mockRestore();
insertFormattedSpy.mockRestore();
});

it('should route replacement through insertFormattedContent when contentType is html', async () => {
const match = { from: 0, to: 5, text: 'Hello' };
literalSpy.mockReturnValueOnce([match]).mockReturnValue([]);

const actions = new AIActionsService(mockProvider, mockEditor, () => mockEditor.state.doc.textContent, false);
const result = await actions.literalReplace('Hello', '<p><strong>Hi</strong></p>', {
contentType: 'html',
});

expect(result.success).toBe(true);
expect(mockEditor.commands.setTextSelection).toHaveBeenCalled();
expect(insertFormattedSpy).toHaveBeenCalledWith(
'<p><strong>Hi</strong></p>',
expect.objectContaining({ contentType: 'html', position: 'replace' }),
);
});
});

describe('error handling', () => {
it('should respect enableLogging flag', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {
Expand Down
45 changes: 45 additions & 0 deletions packages/ai/src/ai-actions/editor/editor-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,4 +744,49 @@ export class EditorAdapter {

this.applyPatch(from, to, suggestedText);
}

/**
* Inserts content with optional format parsing (HTML, markdown).
* When contentType is 'html' or 'markdown', delegates to the editor's
* insertContent command which parses the content through ProseMirror's DOMParser,
* creating proper marks (e.g., link marks for <a> tags).
* When contentType is 'text' or omitted, falls back to plain-text insertText.
*
* @param content - The content to insert
* @param options
*/
insertFormattedContent(
content: string,
options?: { position?: 'before' | 'after' | 'replace'; contentType?: 'html' | 'markdown' | 'text' },
): void {
const contentType = options?.contentType;

if (contentType && contentType !== 'text') {
const position = this.getSelectionRange();
if (!position) return;

const mode = options?.position ?? 'replace';
let from = position.from;
let to = position.to;

if (mode === 'before') {
to = from;
} else if (mode === 'after') {
from = to;
}

// Set selection to the target range before inserting
this.editor.commands?.setTextSelection?.({ from, to });
const commands = this.editor.commands as
| {
insertContent?: (value: string, config?: { contentType?: 'html' | 'markdown' | 'text' }) => unknown;
}
| undefined;
commands?.insertContent?.(content, { contentType });
return;
}

// Fall back to plain-text path
this.insertText(content, options);
}
}
7 changes: 5 additions & 2 deletions packages/ai/src/ai-actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export class AIActions {
literalReplace: async (
findText: string,
replaceText: string,
options?: { caseSensitive?: boolean; trackChanges?: boolean },
options?: { caseSensitive?: boolean; trackChanges?: boolean; contentType?: 'html' | 'markdown' | 'text' },
) => {
return this.executeActionWithCallbacks(() => this.commands.literalReplace(findText, replaceText, options));
},
Expand All @@ -97,7 +97,10 @@ export class AIActions {
summarize: async (instruction: string) => {
return this.executeActionWithCallbacks(() => this.commands.summarize(instruction));
},
insertContent: async (instruction: string, options?: { position?: 'before' | 'after' | 'replace' }) => {
insertContent: async (
instruction: string,
options?: { position?: 'before' | 'after' | 'replace'; contentType?: 'html' | 'markdown' | 'text' },
) => {
return this.executeActionWithCallbacks(() => this.commands.insertContent(instruction, options));
},
};
Expand Down
Loading
Loading