diff --git a/docs/superpowers/plans/2026-09-05-help-assistant-ai-merger.md b/docs/superpowers/plans/2026-09-05-help-assistant-ai-merger.md new file mode 100644 index 0000000000..8a11d35605 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-help-assistant-ai-merger.md @@ -0,0 +1,1967 @@ +# Help-first assistant merger — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** One "?" assistant dialog that opens on the wiki-grounded Help panel and offers, one click away, the BYOK map assistant with a `write_note` tool that edits notes through the notes editor, model settings behind a gear. + +**Architecture:** The upstream help widget (`help-assistant.ts`) becomes a two-panel shell; the AI chat dialog's logic moves into `help-assistant-map.ts` as the second panel; a new `help-assistant-notes.ts` provides the note context text, the `write_note` tool and undo; the agent session gains generic tool dispatch; the notes editor exposes a four-function bridge that is implemented over TinyMCE now and over Quill when the Quill branch merges. + +**Tech Stack:** TypeScript, Vite, vitest (jsdom), Playwright (system chromium on NixOS), jQuery UI dialogs, Biome. + +**Spec:** `docs/superpowers/specs/2026-09-05-help-assistant-ai-merger-design.md` + +## Global Constraints + +- Fork-only test build in worktree `.claude/worktrees/help-assistant-ai`, branch `worktree-help-assistant-ai`. Local commits only. **Never push.** +- Commits: explicit `git add ` (never `-a`/`add -A`), `git commit --no-verify`, no attribution lines. Any commit touching `public/` or `src/index.html` asset references runs `npm run stamp-assets` first. +- Formatting: Biome (`npx biome check `), double quotes, no trailing commas, 120 columns. Never Prettier. +- Dialog id stays `helpAssistant`, title stays "Azgaar's Assistant". Bubble opens Help; Tools button and notes editor robot button open This map. +- Storage keys unchanged: `fmg-ai-chat-model`, `fmg-ai-kl-`, `fmg-ai-local-url`, `fmg-ai-local-model`, `fmg-ai-chat-conversations`. +- `run` stays read-only by prompt; the only mutation tool is `write_note`. +- The dev server on port 5173 belongs to the user. Own servers run on 5199. Verify ports with `ss -ltnp`, never `curl`. +- Services (`src/services/agent/*`) import nothing from `src/controllers`. + +--- + +### Task 1: Merge the upstream help-assistant branch + +**Files:** +- Merge: `upstream/help-assistant` (26 commits, base 386e0f44 which fork main already contains) +- Conflicts expected: `src/index.html` (two `?v=` hash hunks), `src/utils/markdown.test.ts` (add/add; upstream = fork + one test) + +**Interfaces:** +- Produces: `src/controllers/help-assistant.ts` (exports `HelpAssistant = { open }`, `noticeFor`, `shouldAutoRetry`, `limitsLabel`, `normalizeQuestion`, `buildFeedbackControl`), `src/services/help/{api,auth,conversation}.ts`, `Controllers.HelpAssistant` registry entry, `#helpAssistantBubble` in `src/index.html`, `toggleAssistant()` + fragment stash in `public/main.js`, help CSS in `public/index.css`. + +- [ ] **Step 1: Confirm the worktree is clean and on the right branch** + +Run: `git status --short && git branch --show-current && git log --oneline -1` +Expected: no tracked changes, `worktree-help-assistant-ai`, `b6aa342e docs: design spec…` + +- [ ] **Step 2: Merge** + +Run: `git merge --no-commit upstream/help-assistant` +Expected: "Automatic merge failed; fix conflicts" naming exactly `src/index.html` and `src/utils/markdown.test.ts`. If any other file conflicts, stop and inspect it block by block — never resolve a whole file with `--theirs`/`--ours`. + +- [ ] **Step 3: Resolve `src/utils/markdown.test.ts`** + +Both sides are the same renderer's tests; upstream adds one `it("cannot break out of the href attribute…")`. Take upstream's file (it is a superset): + +Run: `git checkout upstream/help-assistant -- src/utils/markdown.test.ts` + +Then confirm the fork's test names are all still present: `grep -c "^ it(" src/utils/markdown.test.ts` should be one more than `git show cbe08801:src/utils/markdown.test.ts | grep -c "^ it("`. + +- [ ] **Step 4: Resolve `src/index.html`** + +The two hunks are cache-buster hashes only (`index.css?v=`, `options.js?v=`, `main.js?v=`). Open the file, delete the conflict markers keeping the upstream lines (`07b79718`, `d2008f94`, `b64d6429`), then regenerate them from content so they are right regardless: + +Run: `npm run stamp-assets` +Expected: exit 0; `grep -c "<<<<<<<\|>>>>>>>" src/index.html` prints `0`. + +- [ ] **Step 5: Type-check and test** + +Run: `npx tsc --noEmit && npx vitest run --reporter=dot 2>&1 | tail -5` +Expected: tsc silent; all test files pass (baseline was 92 files / 970 tests; expect more from the help services and controller tests). + +- [ ] **Step 6: Smoke the registry wiring** + +Run: `grep -n "HelpAssistant" src/controllers/index.ts public/main.js | head` and `grep -n "helpAssistantBubble" src/index.html` +Expected: registry entry present, three `main.js` listeners, the bubble div. + +- [ ] **Step 7: Commit the merge** + +```bash +git add src/index.html src/utils/markdown.test.ts +git commit --no-verify -m "Merge upstream/help-assistant into the assistant-merger worktree" +``` + +- [ ] **Step 8: Tick the spec checklist line "upstream/help-assistant merged; unit tests green; tsc clean"** in `docs/superpowers/specs/2026-09-05-help-assistant-ai-merger-design.md` and commit it with `git commit --no-verify -m "docs: tick merge step"`. + +--- + +### Task 2: Generic tool dispatch in the agent session + +**Files:** +- Modify: `src/services/agent/providers.ts` (`ToolUseBlock.input` typing) +- Modify: `src/services/agent/session.ts` +- Modify: `src/services/agent/providers-openai.ts` if `parseArguments` returns `{ code?: string }` (widen to `Record`) +- Test: `src/services/agent/session-tools.test.ts` (new; keeps mocks away from the pure `trimHistory` tests) + +**Interfaces:** +- Produces: + ```ts + export type ToolInput = Record; // providers.ts + export interface ToolOutcome { content: string; isError?: boolean } // session.ts + export interface AgentTool { definition: ToolDefinition; handle: (input: ToolInput) => Promise } + export interface SessionConfig { key: string; model: string; context?: string } + export interface SessionHandlers { …existing…; onTool?: (name: string, input: ToolInput) => void } + export function createSession(getConfig: () => SessionConfig, tools?: AgentTool[]): { ask, cancel } + ``` +- Consumes: `buildSystemPrompt(context?: string)` from Task 3 — until Task 3 lands, `buildSystemPrompt()` ignores the argument; TypeScript accepts the extra argument only after Task 3, so Task 2 calls `buildSystemPrompt()` and Task 3 threads `context` through. + +- [ ] **Step 1: Write the failing tests** + +Create `src/services/agent/session-tools.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Conversation } from "./conversations"; +import type { Completion, Message } from "./providers"; + +const { complete } = vi.hoisted(() => ({ complete: vi.fn() })); +vi.mock("./providers", () => ({ complete })); +vi.mock("./snapshot", () => ({ capture: () => {} })); +vi.mock("./context", () => ({ buildSystemPrompt: () => [] })); +vi.mock("./runtime", () => ({ + runScript: async (code: string) => ({ ok: true, value: `ran:${code}`, logs: [], ms: 1 }) +})); + +import { type AgentTool, createSession, type SessionHandlers } from "./session"; + +const usage = { input: 1, output: 1, cached: 0 }; +const text = (value: string): Completion => ({ content: [{ type: "text", text: value }], stopReason: "end_turn", usage }); +const toolUse = (name: string, input: Record): Completion => ({ + content: [{ type: "tool_use", id: `id-${name}`, name, input }], + stopReason: "tool_use", + usage +}); + +const conversation = (): Conversation => ({ + id: "c1", + title: "t", + mapId: 0, + updated: 0, + entries: [], + messages: [], + usage: { input: 0, output: 0, cached: 0 } +}); + +const handlers = (): SessionHandlers & { texts: string[]; tools: string[] } => { + const texts: string[] = []; + const tools: string[] = []; + return { + texts, + tools, + onText: value => texts.push(value), + onScript: () => {}, + onScriptResult: () => {}, + onStatus: () => {}, + onUsage: () => {}, + onTool: name => tools.push(name) + }; +}; + +const toolResults = (messages: Message[]) => + messages.flatMap(message => message.content.filter(block => block.type === "tool_result")); + +beforeEach(() => complete.mockReset()); + +describe("createSession tool dispatch", () => { + it("routes a registered tool and feeds its content back to the model", async () => { + const handle = vi.fn(async (input: Record) => ({ content: `wrote ${input.html}` })); + const tool: AgentTool = { + definition: { name: "write_note", description: "d", input_schema: { type: "object" } }, + handle + }; + complete.mockResolvedValueOnce(toolUse("write_note", { html: "

x

" })).mockResolvedValueOnce(text("done")); + + const session = createSession(() => ({ key: "k", model: "claude-sonnet-5" }), [tool]); + const chat = conversation(); + const h = handlers(); + await session.ask(chat, "edit it", h); + + expect(handle).toHaveBeenCalledWith({ html: "

x

" }); + expect(h.tools).toEqual(["write_note"]); + expect(toolResults(chat.messages)).toEqual([ + { type: "tool_result", tool_use_id: "id-write_note", content: "wrote

x

", is_error: false } + ]); + expect(h.texts).toEqual(["done"]); + // both tools are offered to the model + expect(complete.mock.calls[0][0].tools.map((t: { name: string }) => t.name)).toEqual(["run", "write_note"]); + }); + + it("marks a tool's error outcome as an error result", async () => { + const tool: AgentTool = { + definition: { name: "write_note", description: "d", input_schema: { type: "object" } }, + handle: async () => ({ content: "no note open", isError: true }) + }; + complete.mockResolvedValueOnce(toolUse("write_note", {})).mockResolvedValueOnce(text("sorry")); + const chat = conversation(); + await createSession(() => ({ key: "k", model: "m" }), [tool]).ask(chat, "q", handlers()); + expect(toolResults(chat.messages)[0]).toMatchObject({ content: "no note open", is_error: true }); + }); + + it("reports an unknown tool name back to the model as an error", async () => { + complete.mockResolvedValueOnce(toolUse("delete_everything", {})).mockResolvedValueOnce(text("ok")); + const chat = conversation(); + await createSession(() => ({ key: "k", model: "m" })).ask(chat, "q", handlers()); + const [result] = toolResults(chat.messages); + expect(result.is_error).toBe(true); + expect(result.content).toContain('Unknown tool "delete_everything"'); + expect(result.content).toContain("run"); + }); + + it("still runs scripts through the built-in run tool", async () => { + complete.mockResolvedValueOnce(toolUse("run", { code: "return 1" })).mockResolvedValueOnce(text("one")); + const chat = conversation(); + await createSession(() => ({ key: "k", model: "m" })).ask(chat, "q", handlers()); + expect(toolResults(chat.messages)[0].content).toContain("ran:return 1"); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run src/services/agent/session-tools.test.ts` +Expected: FAIL — `createSession` accepts no second argument / `onTool` not called / unknown tool executed as a script. + +- [ ] **Step 3: Widen the tool input type** + +In `src/services/agent/providers.ts` replace the `ToolUseBlock` interface: + +```ts +export type ToolInput = Record; + +export interface ToolUseBlock { + type: "tool_use"; + id: string; + name: string; + input: ToolInput; +} +``` + +In `src/services/agent/providers-openai.ts`, make `parseArguments` return `ToolInput` (import the type); it already parses JSON into an object, so only the annotation changes. Run `npx tsc --noEmit` and fix any site that read `.input.code` as a string without a type guard (the session is the only one; it changes in the next step). + +- [ ] **Step 4: Implement dispatch in `session.ts`** + +Replace the handler and config interfaces and the tool loop: + +```ts +import { type Completion, complete, type Message, type ToolDefinition, type ToolInput, type ToolResultBlock } from "./providers"; + +export interface ToolOutcome { + content: string; + isError?: boolean; +} + +export interface AgentTool { + definition: ToolDefinition; + handle: (input: ToolInput) => Promise; +} + +export interface SessionHandlers { + onText: (text: string) => void; + onScript: (code: string) => void; + onScriptResult: (result: RunResult) => void; + onStatus: (status: string) => void; + onUsage: () => void; + onTool?: (name: string, input: ToolInput) => void; +} + +export interface SessionConfig { + key: string; + model: string; + context?: string; // extra per-turn system text (e.g. the open note) — threaded through in Task 3 +} + +export function createSession(getConfig: () => SessionConfig, tools: AgentTool[] = []) { + let controller: AbortController | null = null; + const definitions = [RUN_TOOL, ...tools.map(tool => tool.definition)]; + const byName = new Map(tools.map(tool => [tool.definition.name, tool])); + + async function runTool(toolUse: { name: string; input: ToolInput }, handlers: SessionHandlers): Promise { + if (toolUse.name === "run") { + const code = typeof toolUse.input.code === "string" ? toolUse.input.code : ""; + handlers.onScript(code); + handlers.onStatus("Running script"); + capture(); + const result = await runScript(code); + handlers.onScriptResult(result); + return { content: formatResult(result), isError: !result.ok }; + } + const tool = byName.get(toolUse.name); + if (!tool) { + const known = definitions.map(definition => definition.name).join(", "); + return { content: `Unknown tool "${toolUse.name}". Available tools: ${known}.`, isError: true }; + } + handlers.onTool?.(toolUse.name, toolUse.input); + handlers.onStatus(`Using ${toolUse.name}`); + try { + return await tool.handle(toolUse.input); + } catch (error) { + return { content: `${toolUse.name} failed: ${error instanceof Error ? error.message : String(error)}`, isError: true }; + } + } + + async function ask(conversation: Conversation, question: string, handlers: SessionHandlers): Promise { + // …unchanged up to the completion call; pass `tools: definitions` instead of `[RUN_TOOL]` … + const results: ToolResultBlock[] = []; + for (const toolUse of toolUses) { + const outcome = await runTool(toolUse, handlers); + results.push({ + type: "tool_result", + tool_use_id: toolUse.id, + content: outcome.content, + is_error: outcome.isError ?? false + }); + } + messages.push({ role: "user", content: results }); + // …rest unchanged… + } + + return { ask, cancel: (): void => controller?.abort() }; +} +``` + +Keep `trimHistory`, `emitText`, `formatResult` as they are. Note `is_error` is now always present (`false` for success) — the test asserts that; the providers accept it. + +- [ ] **Step 5: Run the tests** + +Run: `npx vitest run src/services/agent/ && npx tsc --noEmit` +Expected: all agent tests pass including the four new ones; tsc silent. + +- [ ] **Step 6: Commit** + +```bash +git add src/services/agent/providers.ts src/services/agent/providers-openai.ts src/services/agent/session.ts src/services/agent/session-tools.test.ts +git commit --no-verify -m "feat(agent): generic tool dispatch in the session loop" +``` + +--- + +### Task 3: Conversation edit entries and the notes prompt sections + +**Files:** +- Modify: `src/services/agent/conversations.ts` (Entry union) +- Modify: `src/services/agent/context.ts` (RULES, `# Notes` section, `buildSystemPrompt(context)`) +- Modify: `src/services/agent/session.ts` (thread `context` into `buildSystemPrompt`) +- Test: `src/services/agent/context.test.ts` (add cases) + +**Interfaces:** +- Produces: + ```ts + export interface NoteState { legend: string; name: string } // conversations.ts + export type Entry = … | { kind: "edit"; id: string; name: string; chars: number; previous: NoteState | null; undone?: boolean }; + export function buildSystemPrompt(context = ""): SystemBlock[] // context.ts + ``` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/services/agent/context.test.ts`: + +```ts +import { buildSystemPrompt } from "./context"; + +test("the static prompt allows notes to change only through write_note", () => { + const [staticBlock] = buildSystemPrompt(); + expect(staticBlock.text).toContain("write_note"); + expect(staticBlock.text).toContain("# Notes"); + expect(staticBlock.cache_control).toEqual({ type: "ephemeral" }); +}); + +test("per-turn context is appended to the dynamic block, never the cached one", () => { + const blocks = buildSystemPrompt("# Notes editor\n\nopen on burg1"); + expect(blocks).toHaveLength(2); + expect(blocks[0].text).not.toContain("open on burg1"); + expect(blocks[1].text).toContain("# Current map"); + expect(blocks[1].text).toContain("open on burg1"); + expect(buildSystemPrompt()[1].text).not.toContain("# Notes editor"); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run src/services/agent/context.test.ts` +Expected: FAIL — "write_note" not in the prompt; extra argument ignored. + +- [ ] **Step 3: Edit `context.ts`** + +Replace the first RULES bullet with: + +``` +- **Read-only, except notes.** Do not assign to \`pack\`, \`grid\`, \`options\`, \`style\` or \`notes\`, do not + call generator methods that regenerate data, and do not call \`draw*\` or \`toggle*\` functions. Notes + change ONLY through the \`write_note\` tool, never by assigning to \`notes\` in a script. If the user + asks to change anything else on the map, explain that editing is not supported yet in this build. +``` + +Add a `NOTES` constant after `GOTCHAS` and include it in `staticPrompt` right after `RULES`: + +```ts +const NOTES = `# Notes + +Every map element can carry one note: \`{ id, name, legend }\` in the global \`notes\` array, where \`legend\` +is an HTML string shown in the notes box and in hover tooltips. Ids follow the element: \`burg\` for a +burg with index \`i\` (so \`pack.burgs[12]\` → \`burg12\`), \`marker\` for markers, \`state\`, \`route\`, +\`river\` and so on — a note may exist for an element or not. When the user names a place rather than a +note, find the element in a script first and derive the id from it. + +Write notes with \`write_note({ id?, name?, html })\`. \`html\` is the WHOLE legend. Omit \`id\` to target the +note open in the notes editor (see the "Notes editor" section of the current-map block when it is open). +The notes editor holds a limited HTML subset: \`p\`, \`br\`, \`strong\`, \`em\`, \`u\`, \`s\`, \`a\`, \`img\`, +\`ul\`/\`ol\`/\`li\`, \`blockquote\`, \`h1\`–\`h6\`, \`sub\`, \`sup\`, \`span\`/\`div\` and simple tables. Inline +styles are fine; classes, scripts, iframes and Markdown are not. Keep the user's existing text and +formatting unless they asked to change it, and tell them in one line what you changed.`; +``` + +Change the builder signature: + +```ts +export function buildSystemPrompt(context = ""): SystemBlock[] { + const dynamic = context ? `${describeCurrentMap()}\n\n${context}` : describeCurrentMap(); + return [ + { type: "text", text: staticPrompt, cache_control: { type: "ephemeral" } }, + { type: "text", text: dynamic } + ]; +} +``` + +- [ ] **Step 4: Thread the context through the session** + +In `session.ts` `ask()`: `const { key, model, context } = getConfig();` and `system: buildSystemPrompt(context)`. + +- [ ] **Step 5: Add the edit entry to `conversations.ts`** + +```ts +export interface NoteState { + legend: string; + name: string; +} + +export type Entry = + | { kind: "message"; role: MessageRole; text: string } + | { kind: "script"; code: string; result?: RunResult } + | { kind: "edit"; id: string; name: string; chars: number; previous: NoteState | null; undone?: boolean }; +``` + +`touch()` finds the first `message` entry for the title, so an `edit` entry needs no change there. The ai-chat renderer's `renderEntry` switch does not handle `edit` yet — that is Task 7; until then tsc is satisfied because `renderEntry` returns the `details` element for any non-message kind (verify with tsc; if it narrows and complains, add a temporary `if (entry.kind === "edit") return document.createElement("div");` in `ai-chat.ts`, removed when the file is deleted in Task 8). + +- [ ] **Step 6: Run tests and tsc** + +Run: `npx vitest run src/services/agent/ && npx tsc --noEmit` +Expected: pass; silent. + +- [ ] **Step 7: Commit** + +```bash +git add src/services/agent/context.ts src/services/agent/context.test.ts src/services/agent/conversations.ts src/services/agent/session.ts +git commit --no-verify -m "feat(agent): notes prompt section, per-turn context block, edit entries" +``` + +(Add `src/controllers/ai-chat.ts` to the `git add` if the temporary branch from Step 5 was needed.) + +--- + +### Task 4: Notes editor bridge (TinyMCE-era) and the rich-text check stub + +**Files:** +- Modify: `src/controllers/notes-editor.ts` +- Create: `src/controllers/notes-rich-text.ts` (stub: `canEditAsRichText` only; replaced wholesale by the Quill agent's module in Task 10) +- Test: `src/controllers/notes-editor.test.ts`, `src/controllers/notes-rich-text.test.ts` + +**Interfaces:** +- Produces: + ```ts + export interface Note { id: string; name: string; legend: string } // notes-editor.ts + export const NotesEditor = { open, current, write, remove, getSelectionHtml }; + function current(): Note | null + function write(id: string, legend: string, name?: string): Note + function remove(id: string): void + function getSelectionHtml(): string | null // always null until Quill + export function canEditAsRichText(html: string): boolean // notes-rich-text.ts + ``` + +- [ ] **Step 1: Write the failing tests for the rich-text check** + +Create `src/controllers/notes-rich-text.test.ts`: + +```ts +// @vitest-environment jsdom +import { describe, expect, it } from "vitest"; +import { canEditAsRichText } from "./notes-rich-text"; + +describe("canEditAsRichText", () => { + it.each(["", "plain text", "

Hi there

", "
  • a
", "
x
", "

T

q
"])( + "accepts %j", + html => expect(canEditAsRichText(html)).toBe(true) + ); + + it.each(["", "

a


", "", ""])( + "rejects %j", + html => expect(canEditAsRichText(html)).toBe(false) + ); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run src/controllers/notes-rich-text.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Create the stub module** + +`src/controllers/notes-rich-text.ts`: + +```ts +// Pre-Quill stand-in for the Quill module of the same name (Azgaar #1803 branch). Only the +// representable check is needed by the assistant's write_note tool; the Quill branch replaces this +// file wholesale at merge time. + +const RICH_TEXT_TAGS = new Set([ + "p", "div", "br", "span", "strong", "b", "em", "i", "u", "s", "strike", "a", "img", "ol", "ul", "li", + "blockquote", "pre", "h1", "h2", "h3", "h4", "h5", "h6", "sub", "sup", "table", "tbody", "tr", "td", "th" +]); + +export function canEditAsRichText(html: string): boolean { + if (!html.trim()) return true; + const body = new DOMParser().parseFromString(html, "text/html").body; + return [...body.querySelectorAll("*")].every(element => RICH_TEXT_TAGS.has(element.tagName.toLowerCase())); +} +``` + +Run: `npx vitest run src/controllers/notes-rich-text.test.ts` → PASS. + +- [ ] **Step 4: Write the failing bridge tests** + +Create `src/controllers/notes-editor.test.ts`: + +```ts +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/renderers/overlays/highlight", () => ({ highlightElement: () => {} })); +vi.mock("@/components/tooltips", () => ({ tip: () => {} })); + +import { NotesEditor } from "./notes-editor"; + +const w = globalThis as unknown as Record; + +beforeEach(() => { + document.body.innerHTML = `
+ `; + w.notes = [{ id: "burg1", name: "Kelmora", legend: "

old

" }]; + w.options = { pinNotes: false }; + w.svgWidth = 1000; + w.svgHeight = 600; + w.tinymce = { remove: () => {}, init: () => {}, _setBaseUrl: () => {}, activeEditor: null }; + window.$ = vi.fn(() => ({ dialog: vi.fn() })) as unknown as typeof window.$; +}); + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("NotesEditor bridge", () => { + it("has no current note while the editor is closed", () => { + expect(NotesEditor.current()).toBeNull(); + expect(NotesEditor.getSelectionHtml()).toBeNull(); + }); + + it("writes to the data only while the editor is closed", () => { + const note = NotesEditor.write("burg1", "

new

"); + expect(note).toEqual({ id: "burg1", name: "Kelmora", legend: "

new

" }); + expect((w.notes as { legend: string }[])[0].legend).toBe("

new

"); + expect(document.getElementById("notesEditor")).toBeNull(); + }); + + it("creates a missing note with the given name, defaulting to the id", () => { + NotesEditor.write("marker3", "

x

", "Old Well"); + NotesEditor.write("marker4", "

y

"); + const list = w.notes as { id: string; name: string }[]; + expect(list.map(n => `${n.id}:${n.name}`)).toEqual(["burg1:Kelmora", "marker3:Old Well", "marker4:marker4"]); + }); + + it("reports and refreshes the note shown in the open editor", () => { + NotesEditor.open("burg1"); + expect(NotesEditor.current()?.id).toBe("burg1"); + + NotesEditor.write("burg1", "

rewritten

", "Kelmora the Grim"); + expect(document.getElementById("notesLegend")?.innerHTML).toBe("

rewritten

"); + expect(document.getElementById("notesBody")?.innerHTML).toBe("

rewritten

"); + expect((document.getElementById("notesName") as HTMLInputElement).value).toBe("Kelmora the Grim"); + }); + + it("adds a note created while another is open to the element list", () => { + NotesEditor.open("burg1"); + NotesEditor.write("burg2", "

b

", "Varr"); + const options = [...(document.getElementById("notesSelect") as HTMLSelectElement).options].map(o => o.value); + expect(options).toEqual(["burg1", "burg2"]); + expect(NotesEditor.current()?.id).toBe("burg1"); + }); + + it("removes a note and moves the open editor to the next one", () => { + w.notes = [ + { id: "burg1", name: "Kelmora", legend: "

a

" }, + { id: "burg2", name: "Varr", legend: "

b

" } + ]; + NotesEditor.open("burg2"); + NotesEditor.remove("burg2"); + expect((w.notes as { id: string }[]).map(n => n.id)).toEqual(["burg1"]); + expect(NotesEditor.current()?.id).toBe("burg1"); + }); +}); +``` + +- [ ] **Step 5: Run to verify it fails** + +Run: `npx vitest run src/controllers/notes-editor.test.ts` +Expected: FAIL — `NotesEditor.current is not a function`. + +- [ ] **Step 6: Implement the bridge in `notes-editor.ts`** + +Export the `Note` interface. Add a shared `showNote(note)` and use it from `open()`, `changeElement`, and the generator apply: + +```ts +export interface Note { + id: string; + name: string; + legend: string; +} + +const isOpen = (): boolean => document.getElementById("notesEditor") !== null; + +// Fill the editor's fields with a note; the TinyMCE-era equivalent of the Quill controller's loadNote +function showNote(note: Note): void { + ensureEl("notesName").value = note.name; + ensureEl("notesLegend").innerHTML = note.legend; + window.tinymce?.activeEditor?.setContent(note.legend); + updateNotesBox(note); +} + +function current(): Note | null { + if (!isOpen()) return null; + const id = ensureEl("notesSelect").value; + return (notes as Note[]).find(note => note.id === id) ?? null; +} + +function write(id: string, legend: string, name?: string): Note { + const list = notes as Note[]; + let note = list.find(note => note.id === id); + if (note) { + note.legend = legend; + if (name !== undefined) note.name = name; + } else { + note = { id, name: name ?? id, legend }; + list.push(note); + if (isOpen()) ensureEl("notesSelect").options.add(new Option(id, id)); + } + if (current()?.id === id) showNote(note); + return note; +} + +function remove(id: string): void { + const wasCurrent = current()?.id === id; + notes = (notes as Note[]).filter(note => note.id !== id); + if (!wasCurrent) return; + if (!notes.length) { + $("#notesEditor").dialog("close"); + return; + } + open((notes as Note[])[0].id, (notes as Note[])[0].name); +} + +// Selection is a Quill feature; the TinyMCE-era editor reports none +const getSelectionHtml = (): string | null => null; + +export const NotesEditor = { open, current, write, remove, getSelectionHtml }; +``` + +Refactor the existing functions to use these: `changeElement` becomes `showNote(note)` after the lookup; `removeSelectedNote` becomes `remove(ensureEl("notesSelect").value)`; `openAiGenerator`'s `onApply` becomes `if (note) write(note.id, result); else ensureEl("notesLegend").innerHTML = result;`. Remove the local `interface Note` duplicate. `open()` already re-renders the dialog, so `remove()` reopening on the first note matches the previous behaviour exactly. + +- [ ] **Step 7: Run the tests and tsc** + +Run: `npx vitest run src/controllers/notes-editor.test.ts src/controllers/notes-rich-text.test.ts && npx tsc --noEmit` +Expected: all pass; silent. If `window.tinymce?.activeEditor?.setContent` fails typing, check the `tinymce` declaration in `src/types/global.ts` and match it. + +- [ ] **Step 8: Commit** + +```bash +git add src/controllers/notes-editor.ts src/controllers/notes-editor.test.ts src/controllers/notes-rich-text.ts src/controllers/notes-rich-text.test.ts +git commit --no-verify -m "feat(notes): editor bridge (current/write/remove/selection) and rich-text check stub" +``` + +--- + +### Task 5: Note context and the `write_note` tool with undo + +**Files:** +- Create: `src/controllers/help-assistant-notes.ts` +- Test: `src/controllers/help-assistant-notes.test.ts` + +**Interfaces:** +- Consumes: `Controllers.NotesEditor.{current,write,remove,getSelectionHtml}` (global lazy registry — every call returns a Promise), `canEditAsRichText` (Task 4), `AgentTool`/`ToolOutcome` (Task 2), `Entry`/`NoteState` (Task 3). +- Produces: + ```ts + export type EditEntry = Extract; + export async function noteContext(): Promise; // dynamic prompt text or null + export async function noteChipLabel(): Promise; // "Kelmora" or null + export function writeNoteTool(onEdit: (entry: EditEntry) => void): AgentTool; + export async function writeNote(input: ToolInput, onEdit: (entry: EditEntry) => void): Promise; + export async function undoEdit(entry: EditEntry): Promise; + export const MAX_CONTEXT_CHARS = 6000; + ``` + +- [ ] **Step 1: Write the failing tests** + +Create `src/controllers/help-assistant-notes.test.ts`: + +```ts +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { type EditEntry, MAX_CONTEXT_CHARS, noteChipLabel, noteContext, undoEdit, writeNote } from "./help-assistant-notes"; + +const w = globalThis as unknown as Record; + +const editor = { + current: vi.fn(), + write: vi.fn(), + remove: vi.fn(), + getSelectionHtml: vi.fn() +}; + +beforeEach(() => { + editor.current.mockReset().mockResolvedValue(null); + editor.getSelectionHtml.mockReset().mockResolvedValue(null); + editor.remove.mockReset().mockResolvedValue(undefined); + editor.write.mockReset().mockImplementation(async (id: string, legend: string, name?: string) => { + const list = w.notes as { id: string; name: string; legend: string }[]; + const existing = list.find(note => note.id === id); + if (existing) { + existing.legend = legend; + if (name !== undefined) existing.name = name; + return existing; + } + const note = { id, name: name ?? id, legend }; + list.push(note); + return note; + }); + w.Controllers = { NotesEditor: editor }; + w.notes = [{ id: "burg1", name: "Kelmora", legend: "

old

" }]; +}); + +describe("noteContext", () => { + it("is null when the editor is closed", async () => { + expect(await noteContext()).toBeNull(); + expect(await noteChipLabel()).toBeNull(); + }); + + it("describes the open note and its selection", async () => { + editor.current.mockResolvedValue({ id: "burg1", name: "Kelmora", legend: "

old

" }); + editor.getSelectionHtml.mockResolvedValue("

old

"); + const text = await noteContext(); + expect(text).toContain("# Notes editor"); + expect(text).toContain("`burg1`"); + expect(text).toContain('"Kelmora"'); + expect(text).toContain("

old

"); + expect(text).toContain("selected"); + expect(await noteChipLabel()).toBe("Kelmora"); + }); + + it("truncates a long legend and points at the rest", async () => { + const legend = "x".repeat(MAX_CONTEXT_CHARS + 500); + editor.current.mockResolvedValue({ id: "burg1", name: "K", legend }); + const text = (await noteContext()) ?? ""; + expect(text).not.toContain("x".repeat(MAX_CONTEXT_CHARS + 1)); + expect(text).toContain("500 more characters"); + expect(text).toContain('n.id === "burg1"'); + }); +}); + +describe("writeNote", () => { + const collect = () => { + const entries: EditEntry[] = []; + return { entries, onEdit: (entry: EditEntry) => entries.push(entry) }; + }; + + it("updates the open note when no id is given and records the previous state", async () => { + editor.current.mockResolvedValue({ id: "burg1", name: "Kelmora", legend: "

old

" }); + const { entries, onEdit } = collect(); + const outcome = await writeNote({ html: "

new

" }, onEdit); + expect(outcome.isError).toBeFalsy(); + expect(outcome.content).toContain("updated note burg1"); + expect(editor.write).toHaveBeenCalledWith("burg1", "

new

", undefined); + expect(entries).toEqual([{ kind: "edit", id: "burg1", name: "Kelmora", chars: 10, previous: { legend: "

old

", name: "Kelmora" } }]); + }); + + it("creates a note by id when none exists", async () => { + const { entries, onEdit } = collect(); + const outcome = await writeNote({ id: "marker2", name: "Old Well", html: "

w

" }, onEdit); + expect(outcome.content).toContain("created note marker2"); + expect(entries[0]).toMatchObject({ id: "marker2", name: "Old Well", previous: null }); + }); + + it("refuses when there is neither an id nor an open note", async () => { + const { entries, onEdit } = collect(); + const outcome = await writeNote({ html: "

x

" }, onEdit); + expect(outcome.isError).toBe(true); + expect(outcome.content).toContain("burg"); + expect(entries).toEqual([]); + expect(editor.write).not.toHaveBeenCalled(); + }); + + it("refuses html the editor cannot hold", async () => { + const { onEdit } = collect(); + const outcome = await writeNote({ id: "burg1", html: "" }, onEdit); + expect(outcome.isError).toBe(true); + expect(outcome.content).toContain("cannot hold"); + expect(editor.write).not.toHaveBeenCalled(); + }); + + it("refuses a missing html field", async () => { + const outcome = await writeNote({ id: "burg1" }, collect().onEdit); + expect(outcome.isError).toBe(true); + }); +}); + +describe("undoEdit", () => { + it("restores the previous legend and name once", async () => { + const entry: EditEntry = { kind: "edit", id: "burg1", name: "K2", chars: 1, previous: { legend: "

old

", name: "Kelmora" } }; + await undoEdit(entry); + await undoEdit(entry); + expect(editor.write).toHaveBeenCalledTimes(1); + expect(editor.write).toHaveBeenCalledWith("burg1", "

old

", "Kelmora"); + expect(entry.undone).toBe(true); + }); + + it("removes a note the assistant created", async () => { + const entry: EditEntry = { kind: "edit", id: "marker2", name: "Old Well", chars: 1, previous: null }; + await undoEdit(entry); + expect(editor.remove).toHaveBeenCalledWith("marker2"); + expect(entry.undone).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run src/controllers/help-assistant-notes.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `help-assistant-notes.ts`** + +```ts +// Notes editing for the assistant's "This map" panel: the per-turn context describing the note open +// in the notes editor, the write_note tool, and its undo. Writes go through the notes editor bridge +// (Controllers.NotesEditor, lazy) so an open editor stays in sync. + +import type { Entry, NoteState } from "@/services/agent/conversations"; +import type { ToolInput } from "@/services/agent/providers"; +import type { AgentTool, ToolOutcome } from "@/services/agent/session"; +import type { Note } from "./notes-editor"; +import { canEditAsRichText } from "./notes-rich-text"; + +export type EditEntry = Extract; + +export const MAX_CONTEXT_CHARS = 6000; + +const ALLOWED_TAGS = + "p, br, strong, em, u, s, a, img, ul, ol, li, blockquote, h1-h6, sub, sup, span, div, table/tbody/tr/td/th"; + +const WRITE_NOTE = { + name: "write_note", + description: `Replace the HTML legend of one note, creating the note when it does not exist. Omit \`id\` to +target the note open in the notes editor. \`html\` is the whole legend, not a fragment; allowed tags: ${ALLOWED_TAGS}. +Inline styles are kept, classes and scripts are not. Notes cannot be changed any other way.`, + input_schema: { + type: "object", + properties: { + id: { type: "string", description: "Note id, e.g. burg12 or marker3. Defaults to the note open in the editor." }, + name: { type: "string", description: "New display name for the note. Omit to keep the current one." }, + html: { type: "string", description: "The complete legend as HTML." } + }, + required: ["html"] + } +}; + +const noteById = (id: string): Note | undefined => (notes as Note[]).find(note => note.id === id); + +export async function noteContext(): Promise { + const note = await Controllers.NotesEditor.current(); + if (!note) return null; + const selection = await Controllers.NotesEditor.getSelectionHtml(); + const legend = clipLegend(note); + const lines = [ + "# Notes editor", + "", + `The notes editor is open on note \`${note.id}\` ("${note.name}"). \`write_note\` without an id targets it.`, + "", + "Current legend HTML:", + "```html", + legend, + "```" + ]; + if (selection) lines.push("", "The user has this part selected:", "```html", selection, "```"); + return lines.join("\n"); +} + +function clipLegend(note: Note): string { + if (!note.legend) return "(empty)"; + if (note.legend.length <= MAX_CONTEXT_CHARS) return note.legend; + const rest = note.legend.length - MAX_CONTEXT_CHARS; + return `${note.legend.slice(0, MAX_CONTEXT_CHARS)}\n… ${rest} more characters — read notes.find(n => n.id === "${note.id}").legend in a script for the rest`; +} + +export async function noteChipLabel(): Promise { + const note = await Controllers.NotesEditor.current(); + return note ? note.name || note.id : null; +} + +export function writeNoteTool(onEdit: (entry: EditEntry) => void): AgentTool { + return { definition: WRITE_NOTE, handle: input => writeNote(input, onEdit) }; +} + +const failure = (content: string): ToolOutcome => ({ content, isError: true }); + +export async function writeNote(input: ToolInput, onEdit: (entry: EditEntry) => void): Promise { + const html = typeof input.html === "string" ? input.html : null; + if (html === null) return failure("write_note needs an `html` string with the whole legend."); + if (!canEditAsRichText(html)) { + return failure(`The notes editor cannot hold that HTML. Use only ${ALLOWED_TAGS}; no iframe, hr, script or media.`); + } + + const id = typeof input.id === "string" && input.id ? input.id : (await Controllers.NotesEditor.current())?.id; + if (!id) { + return failure( + "No note is open in the notes editor and no id was given. Find the element in a script first and pass its note id (burg for burgs, marker for markers)." + ); + } + const name = typeof input.name === "string" ? input.name : undefined; + + const existing = noteById(id); + const previous: NoteState | null = existing ? { legend: existing.legend, name: existing.name } : null; + const note = await Controllers.NotesEditor.write(id, html, name); + onEdit({ kind: "edit", id, name: note.name, chars: html.length, previous }); + return { content: `${previous ? "updated" : "created"} note ${id} "${note.name}" — ${html.length} chars of HTML` }; +} + +export async function undoEdit(entry: EditEntry): Promise { + if (entry.undone) return; + entry.undone = true; + if (entry.previous) await Controllers.NotesEditor.write(entry.id, entry.previous.legend, entry.previous.name); + else await Controllers.NotesEditor.remove(entry.id); +} +``` + +`Controllers` and `notes` are globals (declared in `src/controllers/index.ts` and `src/types/global.ts`); no import. If tsc complains that `Controllers.NotesEditor.current` is unknown, the registry type is derived from the module's export object — Task 4 already added the functions to `NotesEditor`, so re-run tsc after checking the import in `index.ts` is unchanged. + +- [ ] **Step 4: Run the tests and tsc** + +Run: `npx vitest run src/controllers/help-assistant-notes.test.ts && npx tsc --noEmit` +Expected: pass; silent. + +- [ ] **Step 5: Commit** + +```bash +git add src/controllers/help-assistant-notes.ts src/controllers/help-assistant-notes.test.ts +git commit --no-verify -m "feat(assistant): note context, write_note tool and undo" +``` + +--- + +### Task 6: Two-panel shell in `help-assistant.ts` + +**Files:** +- Modify: `src/controllers/help-assistant.ts` +- Modify: `public/index.css` (mode bar + panel rules; help log flex) +- Test: `src/controllers/help-assistant-shell.test.ts` (new; the upstream `help-assistant.test.ts` stays untouched) + +**Interfaces:** +- Consumes (Task 7, mocked here): `mountMapPanel(host: HTMLElement): void`, `refreshMapContext(): void`, `unmountMapPanel(): void` from `./help-assistant-map`. Task 6 lands before Task 7, so create `src/controllers/help-assistant-map.ts` in this task as a placeholder exporting those three functions as no-ops with the exact signatures; Task 7 fills it. +- Produces: + ```ts + export type AssistantMode = "help" | "map"; + export interface OpenOptions { mode?: AssistantMode } + export const HelpAssistant = { open }; // open(options?: OpenOptions): void + export function setMode(mode: AssistantMode): void + ``` + +- [ ] **Step 1: Write the failing tests** + +Create `src/controllers/help-assistant-shell.test.ts`: + +```ts +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const panel = vi.hoisted(() => ({ mountMapPanel: vi.fn(), refreshMapContext: vi.fn(), unmountMapPanel: vi.fn() })); +vi.mock("./help-assistant-map", () => panel); + +import { HelpAssistant } from "./help-assistant"; + +const limits = { tier: "anonymous", remaining: 5, resetsAt: "2026-09-06T00:00:00.000Z" }; + +beforeEach(() => { + document.body.innerHTML = `
`; + window.$ = vi.fn(() => ({ dialog: vi.fn() })) as unknown as typeof window.$; + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify(limits), { status: 200 }))); + panel.mountMapPanel.mockClear(); + panel.refreshMapContext.mockClear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + document.body.innerHTML = ""; +}); + +const visible = (id: string): boolean => !(document.getElementById(id) as HTMLElement).hidden; +const selectedMode = (): string | null => + document.querySelector('#helpAssistant .helpAssistantMode[aria-selected="true"]')?.getAttribute("data-mode") ?? null; + +describe("HelpAssistant shell", () => { + it("opens on the Help panel by default", () => { + HelpAssistant.open(); + expect(document.getElementById("helpAssistant")).not.toBeNull(); + expect(visible("helpAssistantHelp")).toBe(true); + expect(visible("helpAssistantMap")).toBe(false); + expect(selectedMode()).toBe("help"); + expect(panel.mountMapPanel).not.toHaveBeenCalled(); + }); + + it("opens on This map when asked and mounts the panel once", () => { + HelpAssistant.open({ mode: "map" }); + expect(visible("helpAssistantMap")).toBe(true); + expect(visible("helpAssistantHelp")).toBe(false); + expect(selectedMode()).toBe("map"); + expect(panel.mountMapPanel).toHaveBeenCalledTimes(1); + expect(panel.mountMapPanel.mock.calls[0][0]).toBe(document.getElementById("helpAssistantMap")); + + HelpAssistant.open({ mode: "map" }); + expect(panel.mountMapPanel).toHaveBeenCalledTimes(1); + expect(panel.refreshMapContext).toHaveBeenCalledTimes(2); + }); + + it("switches modes on a mounted dialog without rebuilding it", () => { + HelpAssistant.open(); + const question = document.getElementById("helpAssistantQuestion") as HTMLTextAreaElement; + question.value = "half typed"; + HelpAssistant.open({ mode: "map" }); + expect(visible("helpAssistantMap")).toBe(true); + expect((document.getElementById("helpAssistantQuestion") as HTMLTextAreaElement).value).toBe("half typed"); + expect(document.getElementById("helpAssistantQuestion")).toBe(question); + }); + + it("switches with the mode buttons", () => { + HelpAssistant.open(); + (document.querySelector('.helpAssistantMode[data-mode="map"]') as HTMLButtonElement).click(); + expect(selectedMode()).toBe("map"); + expect(visible("helpAssistantMap")).toBe(true); + (document.querySelector('.helpAssistantMode[data-mode="help"]') as HTMLButtonElement).click(); + expect(selectedMode()).toBe("help"); + expect(visible("helpAssistantHelp")).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Create the placeholder map panel module and run the tests** + +`src/controllers/help-assistant-map.ts` (placeholder, replaced in Task 7): + +```ts +// The "This map" panel of the assistant dialog — filled in by the next task. +export function mountMapPanel(_host: HTMLElement): void {} +export function refreshMapContext(): void {} +export function unmountMapPanel(): void {} +``` + +Run: `npx vitest run src/controllers/help-assistant-shell.test.ts` +Expected: FAIL — no `helpAssistantHelp` element / `open` ignores options. + +- [ ] **Step 3: Restructure `help-assistant.ts`** + +Add near the top: + +```ts +import { mountMapPanel, refreshMapContext, unmountMapPanel } from "./help-assistant-map"; + +export type AssistantMode = "help" | "map"; + +export interface OpenOptions { + mode?: AssistantMode; +} +``` + +Replace `open()`: + +```ts +function open(options: OpenOptions = {}): void { + const mode = options.mode ?? "help"; + if (isMounted()) { + setMode(mode); + return; + } + + renderDialog(); + + $("#helpAssistant").dialog({ + title: "Azgaar's Assistant", + width: Math.min(460, window.innerWidth - 20), + height: Math.min(600, window.innerHeight - 40), + minWidth: 360, + minHeight: 420, + position: { my: "right top", at: "right-10 top+10", of: "svg", collision: "fit" }, + resizable: true, + close: () => { + if (retryTimer) { + clearInterval(retryTimer); + retryTimer = null; + } + autoRetried = false; + unmountMapPanel(); + destroyDialog("helpAssistant"); + } + }); + + setMode(mode); + if (isOfficialOrigin()) void refreshLimits(); +} + +export function setMode(mode: AssistantMode): void { + for (const button of document.querySelectorAll("#helpAssistant .helpAssistantMode")) { + const active = button.dataset.mode === mode; + button.setAttribute("aria-selected", String(active)); + button.classList.toggle("selected", active); + } + ensureEl("helpAssistantHelp").hidden = mode !== "help"; + const mapHost = ensureEl("helpAssistantMap"); + mapHost.hidden = mode !== "map"; + if (mode === "map") { + if (!mapHost.dataset.mounted) { + mountMapPanel(mapHost); + mapHost.dataset.mounted = "1"; + } + refreshMapContext(); + } +} +``` + +In `renderDialog()`, wrap the existing markup: + +```ts + const modes = /* html */ ` +
+ + +
`; + + const html = /* html */ `
+ ${modes} +
+ ${isOfficialOrigin() ? form : unlisted} + ${links} +
+ +
`; + ensureEl("dialogs").insertAdjacentHTML("beforeend", html); + + for (const button of document.querySelectorAll("#helpAssistant .helpAssistantMode")) { + button.addEventListener("click", () => setMode(button.dataset.mode as AssistantMode)); + } + + if (!isOfficialOrigin()) return; + // …existing listeners unchanged… +``` + +Everything else in the file (submit, notices, feedback, auth, limits) stays as it is. `export const HelpAssistant = { open };` keeps its shape; `open` now takes an optional argument. + +- [ ] **Step 4: CSS** + +In `public/index.css`, after the existing `#helpAssistant > div { width: auto; }` block add: + +```css +#helpAssistant { + display: flex; + flex-direction: column; + gap: 0.4em; + height: 100%; + user-select: text; +} + +#helpAssistant .helpAssistantModes { + display: flex; + gap: 0.25em; + padding: 0.15em; + border-radius: 0.5em; + background: rgb(0 0 0 / 6%); + flex: 0 0 auto; +} + +#helpAssistant .helpAssistantMode { + flex: 1; + padding: 0.3em 0.6em; + border: none; + border-radius: 0.4em; + background: transparent; + cursor: pointer; + opacity: 0.7; +} + +#helpAssistant .helpAssistantMode::before { + margin-right: 0.35em; +} + +#helpAssistant .helpAssistantMode.selected { + background: #fff; + box-shadow: 0 1px 2px rgb(0 0 0 / 20%); + opacity: 1; + font-weight: bold; +} + +#helpAssistant .helpAssistantPanel { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; +} + +#helpAssistant .helpAssistantPanel[hidden] { + display: none; +} +``` + +And change the help log so it fills the panel instead of capping at 40vh: + +```css +#helpAssistant .helpAssistantLog { + flex: 1 1 auto; + min-height: 8em; + overflow-y: auto; + margin-bottom: 0.5em; +} +``` + +Then `npm run stamp-assets` (it rewrites the `index.css?v=` hash in `src/index.html`). + +- [ ] **Step 5: Run tests and tsc** + +Run: `npx vitest run src/controllers/help-assistant*.test.ts && npx tsc --noEmit` +Expected: the upstream `help-assistant.test.ts` still passes; the four shell tests pass; tsc silent. + +- [ ] **Step 6: Commit** + +```bash +git add src/controllers/help-assistant.ts src/controllers/help-assistant-map.ts src/controllers/help-assistant-shell.test.ts public/index.css src/index.html +git commit --no-verify -m "feat(assistant): two-panel shell — Help first, This map behind a click" +``` + +--- + +### Task 7: The "This map" panel + +**Files:** +- Replace: `src/controllers/help-assistant-map.ts` (port of `ai-chat.ts` into a panel) +- Modify: `public/index.css` (panel styles, moved from `ai-chat.ts`'s inline `");const y=r(e),u=c(e),v='")).toBe(false); + expect(canEditAsRichText("")).toBe(false); + expect(canEditAsRichText("

a

")).toBe(false); + expect(canEditAsRichText("
AB
")).toBe(false); + }); +}); + +describe("rich text editor", () => { + let quill: Quill; + const onChange = vi.fn(); + + beforeEach(() => { + onChange.mockReset(); + document.body.innerHTML = + '
'; + quill = createRichTextEditor(document.getElementById("host")!, document.getElementById("toolbar")!, onChange); + }); + + it("round-trips a note as self-contained HTML", () => { + setEditorHtml( + quill, + '

Centered bold legacy

' + + "
  • one
  • two
ab
" + ); + const html = getEditorHtml(quill); + expect(html).toContain('

'); + expect(html).toContain("bold"); + expect(html).toContain("font-size: 12pt;"); + expect(html).toContain("

  • one
  • two
"); + expect(html.match(/bold { + setEditorHtml(quill, "


"); + expect(getEditorHtml(quill)).toBe(""); + }); + + it("reports user edits only", () => { + setEditorHtml(quill, "

loaded

"); + expect(onChange).not.toHaveBeenCalled(); + quill.insertText(0, "x", "user"); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("inserts a 3x3 table at the cursor", () => { + setEditorHtml(quill, "

x

"); + quill.setSelection(0, 0, "silent"); + runTableAction(quill, "insert"); + expect(getEditorHtml(quill).match(/ { + setEditorHtml(quill, "

first

"); + quill.insertText(0, "x", "user"); + setEditorHtml(quill, "

second

"); + expect(quill.history.stack.undo).toHaveLength(0); + }); + + it("ignores an unknown table action", () => { + setEditorHtml(quill, "

x

"); + runTableAction(quill, "nonsense"); + expect(getEditorHtml(quill)).toBe("

x

"); + }); +}); diff --git a/src/controllers/notes-rich-text.ts b/src/controllers/notes-rich-text.ts new file mode 100644 index 0000000000..a416078825 --- /dev/null +++ b/src/controllers/notes-rich-text.ts @@ -0,0 +1,113 @@ +import Quill from "quill"; +import type Table from "quill/modules/table"; +import "quill/dist/quill.snow.css"; + +// Quill's own align, size and font formats are ql-* classes that only render inside the editor. Inline +// styles render wherever a note is shown, and no whitelist keeps the sizes and fonts that may have been written +const { StyleAttributor, Scope } = Quill.import("parchment"); +Quill.register("formats/align", Quill.import("attributors/style/align"), true); +Quill.register("formats/size", new StyleAttributor("size", "font-size", { scope: Scope.INLINE }), true); +Quill.register("formats/font", new StyleAttributor("font", "font-family", { scope: Scope.INLINE }), true); + +// tags Quill can hold; a note with anything else (iframe, hr, script) is edited as raw HTML instead +const RICH_TEXT_TAGS = new Set( + "p div br span strong b em i u s strike a img ol ul li blockquote pre h1 h2 h3 h4 h5 h6 sub sup table tbody tr td".split( + " " + ) +); + +const TABLE_ACTIONS: Record void> = { + insert: table => table.insertTable(3, 3), + "row-above": table => table.insertRowAbove(), + "row-below": table => table.insertRowBelow(), + "column-left": table => table.insertColumnLeft(), + "column-right": table => table.insertColumnRight(), + "delete-row": table => table.deleteRow(), + "delete-column": table => table.deleteColumn(), + "delete-table": table => table.deleteTable() +}; + +export const TOOLBAR_HTML = /* html */ `
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
`; + +// a