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
66 changes: 60 additions & 6 deletions frontend/src/components/AIConversationPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -478,6 +484,12 @@ export default function AIConversationPanel({
const [attachNotice, setAttachNotice] = useState('')
const [frozenContext, setFrozenContext] = useState<PlanContext | null>(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<string | null>(null)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -978,10 +1006,16 @@ export default function AIConversationPanel({
const fileAttachments: FileAttachment[] = []
const annotationIds = new Set<string>()

// 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<ProcessedFile, {kind:'image'}> => 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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}
/>
)}
Expand Down Expand Up @@ -2277,6 +2312,25 @@ export default function AIConversationPanel({
</div>
)}

{/* Current text-selection pill */}
{liveSelection && !selectionDismissed && (
<div className="flex flex-wrap gap-1 px-2 pb-1">
<span
className="flex items-center gap-1 text-xs bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 rounded px-1.5 py-0.5"
title={liveSelection}
>
Using selection: “{liveSelection.length > 40 ? `${liveSelection.slice(0, 38)}…` : liveSelection}”
<button
onClick={() => setSelectionDismissed(true)}
className="hover:text-red-500 transition-colors"
aria-label="Stop using the current selection"
>
<X className="w-3 h-3" />
</button>
</span>
</div>
)}

{/* Pending file pills */}
{pendingFiles.length > 0 && (
<div className="flex flex-wrap gap-1 px-2 pb-1">
Expand Down
1 change: 1 addition & 0 deletions frontend/src/services/aiPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/utils/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -139,6 +144,30 @@ export function rewriteImageUrls(blocks: unknown[], mapping: Record<string, stri
return blocks
}

/**
* The user's current text selection in the live editor, rendered as Markdown so it
* reads consistently with the rest of the note body sent to the AI. Multi-block
* selections go through blocksToMarkdownLossy (same call used for full notes);
* an inline/single-block selection falls back to the plain highlighted substring.
*
* Note: BlockNote's selection lives in the editor's own state, not the DOM, so this
* still returns the right thing after focus moves to a sibling panel (e.g. the AI
* chat input) — it's only cleared once the user clicks/types back inside the editor.
*/
export function getEditorSelectionMarkdown(editor: MarkdownEditor | null | undefined): string {
if (!editor) return ''
try {
const selection = editor.getSelection?.()
if (selection?.blocks?.length) {
const fromBlocks = editor.blocksToMarkdownLossy(selection.blocks)
if (fromBlocks.trim()) return fromBlocks.trim()
}
const direct = editor.getSelectedText?.()
if (typeof direct === 'string' && direct.trim()) return direct.trim()
} catch { /* fall through */ }
return ''
}

export function extractLinkedFileUrls(blocks: unknown[]): string[] {
const urls: string[] = []
function walk(block: Record<string, unknown>) {
Expand Down
9 changes: 0 additions & 9 deletions frontend/src/utils/recipeVariables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down