diff --git a/frontend/src/components/AIConversationPanel.tsx b/frontend/src/components/AIConversationPanel.tsx index b08f7ef..5fec066 100644 --- a/frontend/src/components/AIConversationPanel.tsx +++ b/frontend/src/components/AIConversationPanel.tsx @@ -30,9 +30,9 @@ import { errorMessage } from '@/utils/aiErrors' import { isActive, isAwaitingApproval, isSettled } from '@/api/activity' import { useActivityStore } from '@/stores/activity' import type { Recipe } from '@/api/recipes' -import { renderRecipePrompt, getCurrentSelectionText } from '@/utils/recipeVariables' +import { renderRecipePrompt } from '@/utils/recipeVariables' import { matchRecipeVoiceCommand } from '@/utils/recipeVoiceCommand' -import { extractPlainText, extractLinkedFileUrls, extractBlockTexts, type MarkdownEditor } from '@/utils/blocks' +import { extractPlainText, extractLinkedFileUrls, extractBlockTexts, getEditorSelectionMarkdown, type MarkdownEditor } from '@/utils/blocks' import { describeDiagrams } from '@/utils/diagram' import type { FileAttachment, ConversationTurn, ConversationRequest } from '@/services/ai' import { @@ -210,6 +210,12 @@ function safeStringify(v: unknown): string { try { return JSON.stringify(v, null, 2) } catch { return String(v) } } +// Wraps the user's highlighted excerpt with a note telling the model what it means, +// so "this"/"the highlighted section" in the user's message resolves unambiguously. +function formatSelectionBlock(text: string): string { + return `**User's current text selection in this note:**\n\n> ${text.replace(/\n/g, '\n> ')}\n\nWhen the user refers to "this", "the highlighted section", "the selected text", or similar, they mean the excerpt above.` +} + // Full error dump for the collapsible "More details" panel: request line, HTTP // status, and the complete (pretty-printed) response body — so the cause of a // failure is visible in the chat without reopening the browser devtools. @@ -478,6 +484,12 @@ export default function AIConversationPanel({ const [attachNotice, setAttachNotice] = useState('') const [frozenContext, setFrozenContext] = useState(null) const [freezing, setFreezing] = useState(false) + // The user's live text selection in the open note (editor mode only), kept in sync + // via editor.onSelectionChange purely for display — buildScopeContext() re-reads the + // editor directly at send time, since the editor's own selection state (not the DOM) + // is what persists once focus moves to this panel's input. + const [liveSelection, setLiveSelection] = useState('') + const [selectionDismissed, setSelectionDismissed] = useState(false) // Live text of the in-flight streamed reply (null = not streaming). See planOnce. // The reply so far, polled off the turn's job row while it is planning. const [streamingText, setStreamingText] = useState(null) @@ -809,6 +821,22 @@ export default function AIConversationPanel({ setFrozenContext(null) }, [contextScope, useSummaries, includeLinkedFiles, attachedNotes]) + // Track the editor's live text selection, for the "using selection" chip. List mode + // has no open editor. A fresh non-empty selection clears any earlier dismissal. + useEffect(() => { + if (isList || !editor?.onSelectionChange) { + setLiveSelection('') + return + } + const readSelection = () => { + const text = getEditorSelectionMarkdown(editor) + setLiveSelection(text) + if (text) setSelectionDismissed(false) + } + readSelection() + return editor.onSelectionChange(readSelection) + }, [isList, editor]) + useEffect(() => { if (!isOpen) return // Scroll the message list itself rather than scrollIntoView on an anchor: @@ -978,10 +1006,16 @@ export default function AIConversationPanel({ const fileAttachments: FileAttachment[] = [] const annotationIds = new Set() + // Read fresh at send time rather than trusting `liveSelection` — the editor's + // selection may have changed (or been cleared by clicking back into it) since the + // last onSelectionChange fired. + const selectionText = (!isList && !selectionDismissed) ? getEditorSelectionMarkdown(editor) : '' + const selectionBlock = selectionText ? formatSelectionBlock(selectionText) : '' + if (!isList && contextScope === 'none' && attachedNotes.length === 0) { const processed = await Promise.all(pendingFiles.map(processFile)) const imgs = processed.filter((p): p is Extract => p.kind === 'image') - return { referenceContextText: '', currentNoteText: '', attachments: supportsImages ? imgs.map(p => p.attachment) : [], targetNotes: [], annotationIds } + return { referenceContextText: '', currentNoteText: selectionBlock, attachments: supportsImages ? imgs.map(p => p.attachment) : [], targetNotes: [], annotationIds } } // `id` is carried through so the model can target each note in plan actions. @@ -1095,6 +1129,7 @@ export default function AIConversationPanel({ ? 0 : notes.findIndex((n) => Boolean(n.id) && n.id === noteId) const currentNoteParts: string[] = currentIndex >= 0 ? [rendered[currentIndex]] : [] + if (selectionBlock) currentNoteParts.push(selectionBlock) const referenceParts = rendered.filter((_, i) => i !== currentIndex) // Assets marked "use as AI context" — the reference material kept alongside the note @@ -1413,7 +1448,7 @@ export default function AIConversationPanel({ // user is prompted to confirm" naturally falls out — a respond-only reply still runs // straight through either way. function handleRunRecipe(recipe: Recipe) { - const prompt = renderRecipePrompt(recipe.prompt, { title: noteTitle, selectedText: getCurrentSelectionText() }) + const prompt = renderRecipePrompt(recipe.prompt, { title: noteTitle, selectedText: getEditorSelectionMarkdown(editor) }) if (!prompt.trim()) return setPanelTab('chat') void handleSend(prompt, conversation) @@ -1592,7 +1627,7 @@ export default function AIConversationPanel({ // routing below, so recipes stay a one-shot voice command. const recipeMatch = matchRecipeVoiceCommand(text, recipes) if (recipeMatch) { - const prompt = renderRecipePrompt(recipeMatch.prompt, { title: noteTitle, selectedText: getCurrentSelectionText() }) + const prompt = renderRecipePrompt(recipeMatch.prompt, { title: noteTitle, selectedText: getEditorSelectionMarkdown(editor) }) await handleSend(prompt, conversationRef.current) return } @@ -1808,7 +1843,7 @@ export default function AIConversationPanel({ onUpdate={updateRecipe} onDelete={deleteRecipe} onRun={handleRunRecipe} - previewContext={{ title: noteTitle, selectedText: getCurrentSelectionText() }} + previewContext={{ title: noteTitle, selectedText: getEditorSelectionMarkdown(editor) }} disabled={!aiService} /> )} @@ -2277,6 +2312,25 @@ export default function AIConversationPanel({ )} + {/* Current text-selection pill */} + {liveSelection && !selectionDismissed && ( +
+ + Using selection: “{liveSelection.length > 40 ? `${liveSelection.slice(0, 38)}…` : liveSelection}” + + +
+ )} + {/* Pending file pills */} {pendingFiles.length > 0 && (
diff --git a/frontend/src/services/aiPlan.ts b/frontend/src/services/aiPlan.ts index 958a8a5..b64623f 100644 --- a/frontend/src/services/aiPlan.ts +++ b/frontend/src/services/aiPlan.ts @@ -144,6 +144,7 @@ Rules: - Deferred body generation (IMPORTANT for long or multiple bodies): For create_note, create_child_note, edit_note, edit_section and append_note you may EITHER write the body inline in "content", OR set "spec" to a precise description of what the body must contain and leave "content" empty (""). When a body would be long, or you are creating/rewriting MULTIPLE notes, PREFER "spec" and leave "content" empty — each spec'd body is written in a separate follow-up step that sees this same plan and context, which avoids truncation. Use inline "content" only for short, simple bodies. Never set both for the same action. - "noteId", "parentId", "folderId" and "categoryId" MUST be an id taken from the lists below, OR a "ref" label you assigned to an entity created earlier in THIS plan. NEVER invent an id. (Exception: find_notes's "folderId" may also be "current" or null — see "Finding notes" above.) - For the note the user currently has open ("this note", "this article", …), use its id from the list below, or the literal "current". Its live, up-to-date content is provided in the most recent user message (labelled "Current note — live"); other in-context notes appear in the "Context (note bodies)" section. Ids that appear only earlier in the conversation may be stale — do not reuse an id unless it is listed below. +- Text selection: if the most recent user message includes a "User's current text selection in this note" excerpt, the user has highlighted that exact text in the editor. Treat "this", "the highlighted section", "the selected text", "what does this mean" and similar as referring to that excerpt, not the whole note. For a pure question about it ("what does this mean?", "explain this"), just answer with a respond action. For a request to expand, rewrite or otherwise change it, use edit_section if the excerpt lines up with a section heading, otherwise edit_note "amend" targeting that part of the note — do not rewrite unrelated sections. When no such excerpt is present, nothing is selected and requests about "this note" refer to the whole note as usual. - Dates and the current folder: every note in "Notes in context" is annotated with its creation and last-modified timestamps in ISO-8601 UTC (e.g. "(created 2026-07-01T…Z, modified 2026-07-03T…Z)"). The reference block header also states the current date and the folder the user is currently viewing. Use these to satisfy date-based requests — e.g. "created more than 3 days ago", "modified this week", "the oldest notes", "sort by date" — computing any relative dates against the stated current date. When the user says "here", "this folder", or "in here", it means the folder currently being viewed: use its id as "parentFolderId" for create_folder and as "folderId" for move_note (a null current folder means the root). Newly created notes are automatically placed in the current folder, so create_note needs no folder id. - Note references: "referenceNoteId" and "referenceTitle" for add_reference actions must come from the notes listed below. If a note to reference is not in context, return a respond action explaining which note to add to the context. - Forward references: a create_note / create_child_note / create_folder action may set "ref" to a short label (e.g. "f1"); a later action may use that label anywhere an id is expected (e.g. move a note into "folderId":"f1"). This lets you, for example, create a folder and then move notes into it within one plan. diff --git a/frontend/src/utils/blocks.ts b/frontend/src/utils/blocks.ts index 70d7c30..fe48ca6 100644 --- a/frontend/src/utils/blocks.ts +++ b/frontend/src/utils/blocks.ts @@ -9,6 +9,11 @@ */ export interface MarkdownEditor { blocksToMarkdownLossy(blocks?: unknown[]): string + // Optional: only the live editor (not the parsed-JSON view of a fetched note) + // implements these. Used to read the user's current text selection. + getSelection?(): { blocks?: unknown[] } | undefined + getSelectedText?(): string + onSelectionChange?(callback: () => void): () => void } /** @@ -139,6 +144,30 @@ export function rewriteImageUrls(blocks: unknown[], mapping: Record) { diff --git a/frontend/src/utils/recipeVariables.ts b/frontend/src/utils/recipeVariables.ts index f831e7b..7b41875 100644 --- a/frontend/src/utils/recipeVariables.ts +++ b/frontend/src/utils/recipeVariables.ts @@ -40,15 +40,6 @@ export function renderRecipePrompt(template: string, ctx: RecipeVariableContext) }) } -// The current browser text selection, if any — used to fill {{selected text}}. -export function getCurrentSelectionText(): string { - try { - return window.getSelection()?.toString() ?? '' - } catch { - return '' - } -} - // Shown in the recipe editor so authors know which placeholders are available. export const RECIPE_VARIABLE_HELP: { token: string; description: string }[] = [ { token: '{{title}}', description: "The current note's title" },