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
7 changes: 6 additions & 1 deletion frontend/src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,12 @@
"insertWithCitationsEmpty": "Insert with citations",
"insertTextOnly": "Insert text only",
"generateError": "Could not generate text.",
"threadTitle": "Drafting assistant"
"threadTitle": "Drafting assistant",
"stop": "Stop",
"stopAria": "Stop generation",
"targetStale": "The original selection is no longer in the document. Reselect the text.",
"pendingTargetHint": "Will insert at the highlighted selection",
"submitHint": "Ctrl+Enter to generate"
},
"aiDraftDisclaimer": "AI-generated text — review before using.",
"aiDraftAttribution": "— AI-generated. Review before using."
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/i18n/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,12 @@
"insertWithCitationsEmpty": "Insertar con citas",
"insertTextOnly": "Insertar solo texto",
"generateError": "No se pudo generar el texto.",
"threadTitle": "Asistente de redacción"
"threadTitle": "Asistente de redacción",
"stop": "Detener",
"stopAria": "Detener generación",
"targetStale": "La selección original ya no está en el documento. Vuelve a seleccionar el texto.",
"pendingTargetHint": "Insertará en la selección marcada",
"submitHint": "Ctrl+Enter para generar"
},
"aiDraftDisclaimer": "Texto generado por IA — revísalo antes de usarlo.",
"aiDraftAttribution": "— Generado por IA. Revisa antes de usar."
Expand Down
84 changes: 84 additions & 0 deletions frontend/src/lib/ai-draft-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from 'vitest';
import { useAiDraftStore } from './ai-draft-store';
import type { ChatMessage } from '@/lib/types';

function resetStore(): void {
useAiDraftStore.setState({ docs: {} });
}

describe('useAiDraftStore', () => {
it('isolates draft state per document', () => {
resetStore();
const controllerA = useAiDraftStore.getState().startGenerate('doc-a', null);
useAiDraftStore.getState().applyChunk('doc-a', { type: 'text', delta: 'parcial A' }, controllerA);

const controllerB = useAiDraftStore.getState().startGenerate('doc-b', null);
useAiDraftStore.getState().applyChunk('doc-b', { type: 'text', delta: 'parcial B' }, controllerB);

const stateA = useAiDraftStore.getState().getDocState('doc-a');
const stateB = useAiDraftStore.getState().getDocState('doc-b');
expect(stateA.stream?.role).toBe('assistant');
expect(stateB.stream?.role).toBe('assistant');
if (stateA.stream?.role === 'assistant') {
expect(stateA.stream.content).toEqual(['parcial A']);
}
if (stateB.stream?.role === 'assistant') {
expect(stateB.stream.content).toEqual(['parcial B']);
}
});

it('stopGenerate aborts the controller and keeps the partial stream', () => {
resetStore();
const controller = useAiDraftStore.getState().startGenerate('doc-1', {
from: 1,
to: 4,
text: 'abc',
});
useAiDraftStore.getState().applyChunk('doc-1', { type: 'text', delta: 'parcial' }, controller);
const aborted = vi.fn();
controller.signal.addEventListener('abort', aborted);

useAiDraftStore.getState().stopGenerate('doc-1');

expect(aborted).toHaveBeenCalled();
const state = useAiDraftStore.getState().getDocState('doc-1');
expect(state.busy).toBe(false);
expect(state.abortController).toBeNull();
expect(state.insertTarget).toEqual({ from: 1, to: 4, text: 'abc' });
expect(state.stream?.role).toBe('assistant');
if (state.stream?.role === 'assistant') {
expect(state.stream.content).toEqual(['parcial']);
}
});

it('keeps threadId across clearDraft so follow-ups reuse the same thread', () => {
resetStore();
useAiDraftStore.getState().setThreadId('doc-1', 'thread-x');
useAiDraftStore.getState().setPrompt('doc-1', 'Redacta');
const controller = useAiDraftStore.getState().startGenerate('doc-1', null);
useAiDraftStore.getState().applyChunk('doc-1', { type: 'text', delta: 'hola' }, controller);
useAiDraftStore.getState().finishGenerate('doc-1', controller);

useAiDraftStore.getState().clearDraft('doc-1');

const state = useAiDraftStore.getState().getDocState('doc-1');
expect(state.threadId).toBe('thread-x');
expect(state.stream).toBeNull();
expect(state.prompt).toBe('');
expect(state.busy).toBe(false);
});

it('ignores chunks from a superseded generate', () => {
resetStore();
const first = useAiDraftStore.getState().startGenerate('doc-1', null);
const second = useAiDraftStore.getState().startGenerate('doc-1', null);
useAiDraftStore.getState().applyChunk('doc-1', { type: 'text', delta: 'viejo' }, first);
useAiDraftStore.getState().applyChunk('doc-1', { type: 'text', delta: 'nuevo' }, second);

const stream = useAiDraftStore.getState().getDocState('doc-1').stream as ChatMessage | null;
expect(stream?.role).toBe('assistant');
if (stream?.role === 'assistant') {
expect(stream.content).toEqual(['nuevo']);
}
});
});
150 changes: 150 additions & 0 deletions frontend/src/lib/ai-draft-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* Per-document ephemeral AI-draft state for the editor assistant (#73).
*
* Session-scoped (no persist): closing the panel unmounts the UI but the last
* stream, prompt, and thread id stay here so a reopen does not lose work or
* mint a duplicate chat thread. Mirrors `useChatStream` abort plumbing.
*
* Invariants:
* - State is keyed by editor `docId`; documents never share a slice.
* - `threadId` survives `clearDraft` so follow-up prompts stay in one thread.
* - `stopGenerate` keeps accumulated `stream` (partial draft after Detener).
*/
import { create } from 'zustand';
import { applyChunk as mergeChatChunk } from '@/lib/api.mock';
import type { ChatChunk, ChatMessage } from '@/lib/types';

/** Captured editor range a preset-targeted draft should replace. */
export interface AiInsertTarget {
from: number;
to: number;
text: string;
}

export interface DocAiDraftState {
prompt: string;
stream: ChatMessage | null;
busy: boolean;
error: string | null;
threadId: string | null;
insertTarget: AiInsertTarget | null;
abortController: AbortController | null;
}

export const EMPTY_DOC_AI_DRAFT: DocAiDraftState = {
prompt: '',
stream: null,
busy: false,
error: null,
threadId: null,
insertTarget: null,
abortController: null,
};

interface AiDraftStore {
docs: Record<string, DocAiDraftState>;
getDocState(docId: string): DocAiDraftState;
setPrompt(docId: string, prompt: string): void;
setError(docId: string, error: string | null): void;
setThreadId(docId: string, threadId: string): void;
startGenerate(docId: string, insertTarget: AiInsertTarget | null): AbortController;
applyChunk(docId: string, chunk: ChatChunk, controller: AbortController): void;
stopGenerate(docId: string): void;
finishGenerate(docId: string, controller: AbortController): void;
clearDraft(docId: string): void;
}

function withDoc(
docs: Record<string, DocAiDraftState>,
docId: string,
updater: (state: DocAiDraftState) => DocAiDraftState,
): Record<string, DocAiDraftState> {
const current = docs[docId] ?? EMPTY_DOC_AI_DRAFT;
return { ...docs, [docId]: updater(current) };
}

function isCurrentController(doc: DocAiDraftState, controller: AbortController): boolean {
return doc.abortController === controller;
}

export const useAiDraftStore = create<AiDraftStore>((set, get) => ({
docs: {},
getDocState(docId) {
return get().docs[docId] ?? EMPTY_DOC_AI_DRAFT;
},
setPrompt(docId, prompt) {
set((state) => ({
docs: withDoc(state.docs, docId, (doc) => ({ ...doc, prompt })),
}));
},
setError(docId, error) {
set((state) => ({
docs: withDoc(state.docs, docId, (doc) => ({ ...doc, error })),
}));
},
setThreadId(docId, threadId) {
set((state) => ({
docs: withDoc(state.docs, docId, (doc) => ({ ...doc, threadId })),
}));
},
startGenerate(docId, insertTarget) {
const abortController = new AbortController();
set((state) => ({
docs: withDoc(state.docs, docId, (doc) => ({
...doc,
stream: null,
busy: true,
error: null,
insertTarget,
abortController,
})),
}));
return abortController;
},
applyChunk(docId, chunk, controller) {
set((state) => {
const current = state.docs[docId] ?? EMPTY_DOC_AI_DRAFT;
if (!isCurrentController(current, controller)) return state;
return {
docs: withDoc(state.docs, docId, (doc) => ({
...doc,
stream: mergeChatChunk(doc.stream, chunk),
})),
};
});
},
stopGenerate(docId) {
const controller = get().docs[docId]?.abortController;
controller?.abort();
set((state) => ({
docs: withDoc(state.docs, docId, (doc) => ({
...doc,
busy: false,
abortController: null,
})),
}));
},
finishGenerate(docId, controller) {
set((state) => {
const current = state.docs[docId] ?? EMPTY_DOC_AI_DRAFT;
if (current.abortController !== null && !isCurrentController(current, controller)) {
return state;
}
return {
docs: withDoc(state.docs, docId, (doc) => ({
...doc,
busy: false,
abortController: null,
})),
};
});
},
clearDraft(docId) {
set((state) => ({
docs: withDoc(state.docs, docId, (doc) => ({
...EMPTY_DOC_AI_DRAFT,
threadId: doc.threadId,
})),
}));
},
}));
1 change: 1 addition & 0 deletions frontend/src/pages/EditorPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ vi.mock('@/pages/editor/ExportMenu', () => ({ ExportMenu: () => null }));
vi.mock('@/pages/editor/extensions/LegalCitation', () => ({ LegalCitation: {} }));
vi.mock('@/pages/editor/extensions/CommentMark', () => ({ CommentMark: {} }));
vi.mock('@/pages/editor/extensions/AiGeneratedMark', () => ({ AiGeneratedMark: {} }));
vi.mock('@/pages/editor/extensions/PendingInsertHighlight', () => ({ PendingInsertHighlight: {} }));

/** Navigates when `docId` prop changes so doc-switch effects run inside one router. */
function DocIdNavigator({ docId }: { docId: string }) {
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/pages/EditorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { ExportMenu } from '@/pages/editor/ExportMenu';
import { LegalCitation } from '@/pages/editor/extensions/LegalCitation';
import { CommentMark } from '@/pages/editor/extensions/CommentMark';
import { AiGeneratedMark } from '@/pages/editor/extensions/AiGeneratedMark';
import { PendingInsertHighlight } from '@/pages/editor/extensions/PendingInsertHighlight';
import { useCommentStore } from '@/lib/comment-store';
import { cn } from '@/lib/utils';
import { DocumentList, DocumentPicker } from '@/pages/editor/DocumentList';
Expand Down Expand Up @@ -187,6 +188,7 @@ function EditorDocumentSurface({ docId }: { docId: string }) {
// Inline comment anchors (#602). The note text lives in comment-store.
CommentMark,
AiGeneratedMark,
PendingInsertHighlight,
],
content: initialDoc.content,
editable: !isReadOnly,
Expand Down Expand Up @@ -403,7 +405,12 @@ function EditorDocumentSurface({ docId }: { docId: string }) {
{editor &&
aiPanelOpen &&
createPortal(
<AiDraftPanel editor={editor} onClose={() => setAiPanelOpen(false)} />,
<AiDraftPanel
editor={editor}
docId={docId}
docTitle={title}
onClose={() => setAiPanelOpen(false)}
/>,
document.body,
)}

Expand Down Expand Up @@ -460,6 +467,9 @@ function EditorDocumentSurface({ docId }: { docId: string }) {
'[&_.ProseMirror_.lex-comment--resolved]:decoration-amber-400/70',
'[&_.ProseMirror_.lex-ai-generated]:rounded-sm [&_.ProseMirror_.lex-ai-generated]:bg-[hsl(var(--indigo-500)/0.18)]',
'[&_.ProseMirror_.lex-ai-generated]:box-decoration-clone [&_.ProseMirror_.lex-ai-generated]:px-0.5',
'[&_.ProseMirror_.lex-pending-insert]:rounded-sm [&_.ProseMirror_.lex-pending-insert]:box-decoration-clone',
'[&_.ProseMirror_.lex-pending-insert]:outline [&_.ProseMirror_.lex-pending-insert]:outline-dashed',
'[&_.ProseMirror_.lex-pending-insert]:outline-1 [&_.ProseMirror_.lex-pending-insert]:outline-indigo-400/80',
isReadOnly && 'cursor-default',
)}
>
Expand Down
Loading
Loading