From dfd69ff63972971245ad8008806c84d7c951ce19 Mon Sep 17 00:00:00 2001 From: filipdzivy Date: Wed, 14 Oct 2026 12:15:31 +0200 Subject: [PATCH 1/3] feat(markdown): add offline /insert-toc slash command --- README.MD | 1 + backend/routers/markdown.py | 23 ++++++++ frontend/src/components/AIPanel.tsx | 15 +++++- .../src/hooks/editor/useEditorCommands.ts | 1 + .../src/hooks/editor/useEditorFormatting.ts | 8 +++ .../src/hooks/editor/useSlashCommandWiring.ts | 19 +++++++ frontend/src/hooks/useSlashCommands.ts | 6 ++- frontend/src/lib/api.ts | 8 +++ tests/test_insert_toc.py | 53 +++++++++++++++++++ 9 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 tests/test_insert_toc.py diff --git a/README.MD b/README.MD index f2a517d7..72747ffa 100644 --- a/README.MD +++ b/README.MD @@ -211,6 +211,7 @@ Type `/` at the start of a line (or after whitespace) in the editor to open an i | `/toc` | Generates a table of contents via AI | | `/fix-code` | Fixes code block fences via AI | | `/insert-table` | Inserts a Markdown table (no AI call) | +| `/insert-toc` | Inserts a table of contents generated locally from the document headings (no AI call) | Use the arrow keys to navigate, Enter to run a command, or Escape to dismiss the menu. diff --git a/backend/routers/markdown.py b/backend/routers/markdown.py index 733ddacf..e48bdebc 100644 --- a/backend/routers/markdown.py +++ b/backend/routers/markdown.py @@ -302,3 +302,26 @@ def get_outline(payload: OutlinePayload): """ outline = _extract_outline(payload.content) return {"outline": outline, "heading_count": len(outline)} + + +@router.post("/toc") +def generate_toc(payload: OutlinePayload): + """Generate a Markdown table of contents from the document's headings. + + This is the offline, deterministic counterpart to the AI-powered ``/toc`` + automation. It reuses the same heading and anchor logic + (``backend.ai_logic._generate_markdown_toc``) so the generated links resolve + against the rendered preview exactly like the AI version, but it never + contacts an AI provider, requires an API key, or needs a network connection. + + The import is deferred to keep this lightweight router cheap to import during + packaged app startup, mirroring the conversion endpoints above. + + :param payload: The Markdown document text to build the table of contents from. + + :return: A mapping ``{"toc": }``. ``toc`` is an empty string when + the document contains no headings. + """ + from backend.ai_logic import _generate_markdown_toc + + return {"toc": _generate_markdown_toc(payload.content)} diff --git a/frontend/src/components/AIPanel.tsx b/frontend/src/components/AIPanel.tsx index 310eb026..d91ac9ca 100644 --- a/frontend/src/components/AIPanel.tsx +++ b/frontend/src/components/AIPanel.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useCallback, type ReactNode } from "react"; import { useAIChat, type TranslationPair, type TranslationProgress } from "@/hooks/useAIChat"; -import { AI, getDefaultAISettings, type AISettings, Knowledge, type KnowledgeStatus } from "@/lib/api"; +import { AI, getDefaultAISettings, type AISettings, Knowledge, type KnowledgeStatus, Markdown } from "@/lib/api"; import { tokenizeInlineMarkdown } from "@/lib/inline-markdown.mjs"; export type AIPanelTab = "chat" | "translate" | "settings" | "work"; @@ -559,6 +559,17 @@ export default function AIPanel({ onApplyAction?.("replace_selection", INSERT_TABLE_MARKDOWN); return; } + if (slashCommand === "/insert-toc") { + // Offline table of contents generated from the document headings. + try { + const { toc } = await Markdown.toc(documentText); + if (toc.trim()) onApplyAction?.("replace_selection", toc); + } catch { + // Network/backend errors are surfaced by the editor slash command path; + // here we simply skip inserting rather than interrupting the chat. + } + return; + } const sendOptions = { useKnowledgeBase: knowledgeEnabled && Boolean(knowledgeStatus?.exists), }; @@ -759,7 +770,7 @@ export default function AIPanel({

{knowledgeStatus?.exists && knowledgeEnabled ? "Ask anything about your notes, or try /summarize, /format, /toc, /fix-code." - : "Try /summarize, /translate, /format, /toc, /fix-code, or /insert-table."} + : "Try /summarize, /translate, /format, /toc, /fix-code, /insert-table, or /insert-toc."}

)} {messages.map((msg) => ( diff --git a/frontend/src/hooks/editor/useEditorCommands.ts b/frontend/src/hooks/editor/useEditorCommands.ts index c7ff993a..2865a028 100644 --- a/frontend/src/hooks/editor/useEditorCommands.ts +++ b/frontend/src/hooks/editor/useEditorCommands.ts @@ -69,6 +69,7 @@ export function useEditorCommands({ monacoReady, slash, insertTable: formatting.insertTable, + insertTableOfContents: formatting.insertTableOfContents, executeAIPrompt, documentText: editor.activeTab.content, syncSelectedText, diff --git a/frontend/src/hooks/editor/useEditorFormatting.ts b/frontend/src/hooks/editor/useEditorFormatting.ts index 6facdc5c..43254c0a 100644 --- a/frontend/src/hooks/editor/useEditorFormatting.ts +++ b/frontend/src/hooks/editor/useEditorFormatting.ts @@ -71,6 +71,13 @@ export function useEditorFormatting( })); }, [replaceSelection]); + const insertTableOfContents = useCallback( + (toc: string) => { + replaceSelection("insert-toc", () => ({ text: toc })); + }, + [replaceSelection] + ); + const insertCitation = useCallback( (citationKey: string) => { replaceSelection("insert-citation", () => ({ @@ -87,6 +94,7 @@ export function useEditorFormatting( wrapSelection, applyHeading, insertTable, + insertTableOfContents, insertCitation, }; } diff --git a/frontend/src/hooks/editor/useSlashCommandWiring.ts b/frontend/src/hooks/editor/useSlashCommandWiring.ts index 8e320224..98700b28 100644 --- a/frontend/src/hooks/editor/useSlashCommandWiring.ts +++ b/frontend/src/hooks/editor/useSlashCommandWiring.ts @@ -9,12 +9,14 @@ import { type SlashCommand, type useSlashCommands, } from "@/hooks/useSlashCommands"; +import { Markdown } from "@/lib/api"; export type UseSlashCommandWiringOptions = { monacoRef: MutableRefObject; monacoReady: boolean; slash: ReturnType; insertTable: () => void; + insertTableOfContents: (toc: string) => void; executeAIPrompt: (prompt: string, documentText: string) => Promise; documentText: string; syncSelectedText: () => void; @@ -27,6 +29,7 @@ export function useSlashCommandWiring({ monacoReady, slash, insertTable, + insertTableOfContents, executeAIPrompt, documentText, syncSelectedText, @@ -63,6 +66,21 @@ export function useSlashCommandWiring({ insertTable(); return; } + if (command.kind === "insert-toc") { + // Build the table of contents offline from the current buffer and + // insert it at the cursor. No AI provider or network access is used. + try { + const { toc } = await Markdown.toc(text); + if (!toc.trim()) { + alert("No headings found to build a table of contents."); + return; + } + insertTableOfContents(toc); + } catch (err) { + alert(`Failed to generate table of contents: ${err instanceof Error ? err.message : String(err)}`); + } + return; + } if (command.kind === "open-translate-tab") { setAiPanelInitialTab("translate"); setShowAIPanel(true); @@ -83,6 +101,7 @@ export function useSlashCommandWiring({ documentText, executeAIPrompt, insertTable, + insertTableOfContents, monacoRef, setAiPanelInitialTab, setShowAIPanel, diff --git a/frontend/src/hooks/useSlashCommands.ts b/frontend/src/hooks/useSlashCommands.ts index 1c4e44b6..6d08686b 100644 --- a/frontend/src/hooks/useSlashCommands.ts +++ b/frontend/src/hooks/useSlashCommands.ts @@ -10,13 +10,14 @@ export type SlashCommandId = | "format" | "toc" | "fix-code" - | "insert-table"; + | "insert-table" + | "insert-toc"; export type SlashCommand = { id: SlashCommandId; label: string; description: string; - kind: "ai" | "open-translate-tab" | "insert-table"; + kind: "ai" | "open-translate-tab" | "insert-table" | "insert-toc"; prompt?: string; }; @@ -27,6 +28,7 @@ const STATIC_COMMANDS: Omit[] = [ { id: "toc", label: "/toc", description: "Generate a table of contents with AI", kind: "ai" }, { id: "fix-code", label: "/fix-code", description: "Fix code block fences with AI", kind: "ai" }, { id: "insert-table", label: "/insert-table", description: "Insert a Markdown table", kind: "insert-table" }, + { id: "insert-toc", label: "/insert-toc", description: "Insert a table of contents (offline, no AI)", kind: "insert-toc" }, ]; const TEMPLATE_ID_BY_COMMAND: Record = { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3f23b76e..215427a2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -319,6 +319,14 @@ export const Markdown = { body: JSON.stringify({ content }), }), + // Offline, deterministic table of contents built from the document headings. + // Returns an empty `toc` string when the document has no headings. + toc: (content: string) => + apiFetch<{ toc: string }>("/api/markdown/toc", { + method: "POST", + body: JSON.stringify({ content }), + }), + openPreviewInBrowser: (payload: RenderPayload) => apiFetch("/api/markdown/open-preview", { method: "POST", diff --git a/tests/test_insert_toc.py b/tests/test_insert_toc.py new file mode 100644 index 00000000..f171e6e0 --- /dev/null +++ b/tests/test_insert_toc.py @@ -0,0 +1,53 @@ +"""Endpoint tests for the offline ``/api/markdown/toc`` table-of-contents route. + +The deterministic generator itself is covered in ``test_markdown_toc.py``; these +tests cover the new endpoint wiring and the offline contract for issue #420: + +- a document with headings returns a nested Markdown table of contents +- the generated anchors match the shared slug rules (duplicates get ``-1`` …) +- a document without headings returns an empty string rather than an error +""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from backend.main import app + + +def _toc(content: str) -> str: + with TestClient(app) as client: + response = client.post("/api/markdown/toc", json={"content": content}) + assert response.status_code == 200, response.text + return response.json()["toc"] + + +def test_toc_endpoint_lists_headings_with_anchors(): + toc = _toc("# Overview\n\nSome text.\n\n## Details") + assert toc.startswith("## Table of Contents") + assert "- [Overview](#overview)" in toc + assert " - [Details](#details)" in toc + + +def test_toc_endpoint_preserves_heading_hierarchy(): + toc = _toc("# Overview\n\n## Details\n\n### Sub") + assert "- [Overview](#overview)" in toc + assert " - [Details](#details)" in toc + assert " - [Sub](#sub)" in toc + + +def test_toc_endpoint_disambiguates_duplicate_headings(): + toc = _toc("# Answers\n\n# Answers") + assert "- [Answers](#answers)" in toc + assert "- [Answers](#answers-1)" in toc + + +def test_toc_endpoint_ignores_headings_inside_code_fences(): + toc = _toc("```\n# Not a heading\n```\n\n# Real") + assert "[Not a heading]" not in toc + assert "- [Real](#real)" in toc + + +def test_toc_endpoint_empty_without_headings(): + assert _toc("Just a paragraph with no headings.") == "" + assert _toc("") == "" From 1ef972b7aa4b1c8c3df44bdaefca5cb64033d8de Mon Sep 17 00:00:00 2001 From: petertzy <97914108+petertzy@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:56:31 +0200 Subject: [PATCH 2/3] fix(editor): guard async TOC insertion --- frontend/src/components/AIPanel.tsx | 11 ++- .../src/hooks/editor/useSlashCommandWiring.ts | 8 ++ frontend/src/lib/toc-insertion-guard.mjs | 37 ++++++++ frontend/tests/toc-insertion-guard.test.mjs | 85 +++++++++++++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/toc-insertion-guard.mjs create mode 100644 frontend/tests/toc-insertion-guard.test.mjs diff --git a/frontend/src/components/AIPanel.tsx b/frontend/src/components/AIPanel.tsx index 55dce795..72c769d2 100644 --- a/frontend/src/components/AIPanel.tsx +++ b/frontend/src/components/AIPanel.tsx @@ -172,6 +172,8 @@ export default function AIPanel({ executeWork, isWorking, }: Props) { + const currentDocumentRef = useRef({ documentId, documentText }); + currentDocumentRef.current = { documentId, documentText }; const { messages, loading, error, lastOperation, sendMessage, translate, translateSentences, cancelTranslation, clearHistory } = useAIChat(documentId); const chatLoading = loading && lastOperation === "chat"; const chatError = lastOperation === "chat" ? error : null; @@ -600,9 +602,16 @@ export default function AIPanel({ } if (slashCommand === "/insert-toc") { // Offline table of contents generated from the document headings. + const requestDocument = { documentId, documentText }; try { const { toc } = await Markdown.toc(documentText); - if (toc.trim()) onApplyAction?.("replace_selection", toc); + if ( + toc.trim() && + currentDocumentRef.current.documentId === requestDocument.documentId && + currentDocumentRef.current.documentText === requestDocument.documentText + ) { + onApplyAction?.("replace_selection", toc); + } } catch { // Network/backend errors are surfaced by the editor slash command path; // here we simply skip inserting rather than interrupting the chat. diff --git a/frontend/src/hooks/editor/useSlashCommandWiring.ts b/frontend/src/hooks/editor/useSlashCommandWiring.ts index 98700b28..a700f83c 100644 --- a/frontend/src/hooks/editor/useSlashCommandWiring.ts +++ b/frontend/src/hooks/editor/useSlashCommandWiring.ts @@ -10,6 +10,10 @@ import { type useSlashCommands, } from "@/hooks/useSlashCommands"; import { Markdown } from "@/lib/api"; +import { + captureTocInsertionTarget, + isTocInsertionTargetCurrent, +} from "@/lib/toc-insertion-guard.mjs"; export type UseSlashCommandWiringOptions = { monacoRef: MutableRefObject; @@ -69,12 +73,16 @@ export function useSlashCommandWiring({ if (command.kind === "insert-toc") { // Build the table of contents offline from the current buffer and // insert it at the cursor. No AI provider or network access is used. + const insertionTarget = captureTocInsertionTarget(mono); try { const { toc } = await Markdown.toc(text); if (!toc.trim()) { alert("No headings found to build a table of contents."); return; } + if (!isTocInsertionTargetCurrent(insertionTarget, monacoRef.current)) { + return; + } insertTableOfContents(toc); } catch (err) { alert(`Failed to generate table of contents: ${err instanceof Error ? err.message : String(err)}`); diff --git a/frontend/src/lib/toc-insertion-guard.mjs b/frontend/src/lib/toc-insertion-guard.mjs new file mode 100644 index 00000000..4c9ca1b5 --- /dev/null +++ b/frontend/src/lib/toc-insertion-guard.mjs @@ -0,0 +1,37 @@ +/** + * Capture the editor state that owns an asynchronous table-of-contents request. + * A completed request may only write back to this exact, unchanged insertion + * target; otherwise a tab switch or user edit could receive stale content. + */ +export function captureTocInsertionTarget(editor) { + const model = editor?.getModel(); + const selection = editor?.getSelection(); + if (!editor || !model || !selection) return null; + + return { + editor, + model, + versionId: model.getVersionId(), + selection: { + startLineNumber: selection.startLineNumber, + startColumn: selection.startColumn, + endLineNumber: selection.endLineNumber, + endColumn: selection.endColumn, + }, + }; +} + +/** Return whether an asynchronous TOC result still has its original target. */ +export function isTocInsertionTargetCurrent(target, editor) { + if (!target || editor !== target.editor || editor.getModel() !== target.model) return false; + if (target.model.getVersionId() !== target.versionId) return false; + + const selection = editor.getSelection(); + return Boolean( + selection && + selection.startLineNumber === target.selection.startLineNumber && + selection.startColumn === target.selection.startColumn && + selection.endLineNumber === target.selection.endLineNumber && + selection.endColumn === target.selection.endColumn + ); +} diff --git a/frontend/tests/toc-insertion-guard.test.mjs b/frontend/tests/toc-insertion-guard.test.mjs new file mode 100644 index 00000000..7c356fbe --- /dev/null +++ b/frontend/tests/toc-insertion-guard.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + captureTocInsertionTarget, + isTocInsertionTargetCurrent, +} from "../src/lib/toc-insertion-guard.mjs"; + +function createEditor(name) { + const model = { + name, + version: 1, + getVersionId() { + return this.version; + }, + }; + return { + model, + selection: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, + getModel() { + return this.model; + }, + getSelection() { + return this.selection; + }, + }; +} + +function deferred() { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +test("a deferred TOC result is not inserted after switching tabs", async () => { + const editorA = createEditor("A"); + const editorB = createEditor("B"); + let activeEditor = editorA; + const target = captureTocInsertionTarget(editorA); + const request = deferred(); + const inserted = []; + + const completion = request.promise.then(() => { + if (isTocInsertionTargetCurrent(target, activeEditor)) inserted.push("TOC"); + }); + activeEditor = editorB; + request.resolve(); + await completion; + + assert.deepEqual(inserted, []); +}); + +test("a deferred TOC result is not inserted after an edit or cursor move", async () => { + const editor = createEditor("A"); + const target = captureTocInsertionTarget(editor); + const request = deferred(); + const inserted = []; + + const completion = request.promise.then(() => { + if (isTocInsertionTargetCurrent(target, editor)) inserted.push("TOC"); + }); + editor.model.version += 1; + editor.selection = { startLineNumber: 2, startColumn: 1, endLineNumber: 2, endColumn: 1 }; + request.resolve(); + await completion; + + assert.deepEqual(inserted, []); +}); + +test("a deferred TOC result inserts at an unchanged target", async () => { + const editor = createEditor("A"); + const target = captureTocInsertionTarget(editor); + const request = deferred(); + const inserted = []; + + const completion = request.promise.then(() => { + if (isTocInsertionTargetCurrent(target, editor)) inserted.push("TOC"); + }); + request.resolve(); + await completion; + + assert.deepEqual(inserted, ["TOC"]); +}); From ed6cca31850d40e59519d0e2bd62f5da3be4a640 Mon Sep 17 00:00:00 2001 From: petertzy <97914108+petertzy@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:01:46 +0200 Subject: [PATCH 3/3] style: format Python tests --- tests/test_features.py | 4 +++- tests/test_temperature_compatibility.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_features.py b/tests/test_features.py index a83811cf..be9670ad 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -348,7 +348,9 @@ def test_punctuation_not_counted(self): self.assertEqual(count, 2) def test_word_count_includes_heading_count(self): - payload = RenderPayload(content="# Title\n\nSome text.\n\n## Section\n\nMore text.") + payload = RenderPayload( + content="# Title\n\nSome text.\n\n## Section\n\nMore text." + ) result = word_count(payload) self.assertEqual(result["heading_count"], 2) self.assertGreater(result["words"], 0) diff --git a/tests/test_temperature_compatibility.py b/tests/test_temperature_compatibility.py index 5d98ca4a..dc687753 100644 --- a/tests/test_temperature_compatibility.py +++ b/tests/test_temperature_compatibility.py @@ -7,7 +7,9 @@ class TestTemperatureCompatibility(unittest.TestCase): def test_supported_models_keep_default_temperature(self): self.assertEqual(logic._temperature_for_model("gpt-4o", 0.2), 0.2) self.assertEqual(logic._temperature_for_model("gpt-4o-mini", 0.4), 0.4) - self.assertEqual(logic._temperature_for_model("llama-3.3-70b-versatile", 0.1), 0.1) + self.assertEqual( + logic._temperature_for_model("llama-3.3-70b-versatile", 0.1), 0.1 + ) def test_reasoning_and_newer_models_omit_temperature(self): self.assertIsNone(logic._temperature_for_model("o1-preview", 0.2))