From b6aa342e88ed687e14812659c6937ada16582a1c Mon Sep 17 00:00:00 2001 From: barrulus Date: Sat, 5 Sep 2026 14:08:06 +0100 Subject: [PATCH 01/26] docs: design spec for the help-first assistant merger with notes editing --- ...6-09-05-help-assistant-ai-merger-design.md | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-05-help-assistant-ai-merger-design.md diff --git a/docs/superpowers/specs/2026-09-05-help-assistant-ai-merger-design.md b/docs/superpowers/specs/2026-09-05-help-assistant-ai-merger-design.md new file mode 100644 index 0000000000..cd6689cd66 --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-help-assistant-ai-merger-design.md @@ -0,0 +1,261 @@ +# Assistant merger: help first, map AI behind a click, notes editing through Quill + +**Date:** 2026-09-05 +**Status:** design for an unattended fork-only test build. Decisions below were taken by the +agent under the user's brief ("help first; model choice accessible but not front and centre; +start with notes editing; free rein on look and feel"). Anything the user would want to revisit +is marked *(decision)*. +**Worktree:** `.claude/worktrees/help-assistant-ai`, branch `worktree-help-assistant-ai`, off fork +main `cbe08801` (1.151.2-fork.1). Local commits only; nothing is pushed. + +## Purpose + +Three lineages exist today and the brief is to make them one surface: + +| Lineage | Where it lives | What it is | +|---|---|---| +| Help assistant (Azgaar #1804, 1.151.2) | `upstream/master`, `upstream/help-assistant` — not yet on fork main | The "?" bubble and `#helpAssistant` dialog: one wiki-grounded question at a time to the fmg-bot gateway, Discord sign-in, thumbs feedback, server-side conversation id. Free, budgeted, official origin only. | +| AI chat (Azgaar `ai-interface` + fork PRs #39/#40 + model discovery) | fork main: `src/controllers/ai-chat.ts`, `src/services/agent/*` | BYOK agent with one `run(code)` tool over the live map, read-only by prompt, six providers, local models, persisted conversations per map, snapshot before every run. Opened from Tools → "AI Chat". | +| Notes editor on Quill 2 (Azgaar #1803) | being built by the Quill agent in `~/dev/fmg-quill`, branch `feat/notes-editor-quill` off upstream master; not yet created | TinyMCE replaced by a bundled Quill 2 view over the HTML in `note.legend`; module-scope Quill instance; `loadNote`/`updateLegend` paths; raw-HTML textarea mode for non-representable notes. | + +After this work the "?" dialog is the only assistant surface. It opens on **Help**. One click away +is **This map**, the BYOK assistant, which can now edit notes through the notes editor and whose +model and key settings sit behind a gear. The separate "AI Chat" dialog is gone. + +## Decisions from the brief + +1. **Help first.** The dialog always opens on the Help panel when opened from the bubble. Only + contextual entry points (Tools → Assistant, the notes editor's AI button) land on This map, + because there the user has already asked for the map assistant. +2. **Model choice is a click away, not a bar of controls.** The model, key and local-server fields + live in a settings drawer toggled by a gear. A status line names the current model in one short + phrase. The drawer opens itself only when sending is impossible without it (no key). +3. **Notes first, map later.** The only mutation the model gets is `write_note`. `run` stays + read-only by prompt exactly as today. The tool plumbing is generic so Stage-2 map editing (per + `docs/prd/ai-chat.md`) adds tools, not architecture. +4. **Fork-only test build.** Nothing here is shaped for an upstream PR. Where the upstream help + widget code is touched, the touch is minimal and additive so the next sync stays easy. + +## Approaches considered + +- **A. Two dialogs, cross-linked.** Keep `#helpAssistant` and `#aiChat` and add a link between + them. Rejected: the brief says one dialog, and two transcripts, two "new chat" controls and two + layouts would fight each other on screen next to the notes editor. +- **B. One dialog, two panels (chosen).** The help dialog gains a segmented mode control; the AI + chat UI moves in as the second panel, restyled to the help dialog's idiom. The gateway help path + and the BYOK agent path stay separate services with separate transcripts, so neither budget, + trust model nor contract changes. +- **C. One conversation, the model routes.** A single transcript where the user's own model answers + usage questions too and decides when to run scripts. Rejected: the help answers must stay + wiki-grounded and free (the gateway refuses client-supplied history by design), a BYOK model + answering "how do I export SVG" from its training data is worse than the gateway, and the + unlisted-origin case (every fork deployment) would leave Help with no provider at all. + +## Design + +### 1. Dialog shell + +- Element id `helpAssistant`, title "Azgaar's Assistant" (unchanged — the name is Azgaar's call). + Same lazy controller, same bubble, same `azgaarAssistant` option and Electron exclusion of the + bubble. +- **Mode control** at the top: two segments, `Help` (`icon-help-circled`) and `This map` + (`icon-robot`). Exactly one panel is visible. Switching never re-renders the other panel, so a + half-typed question or an in-flight request survives a switch. +- **Size and position** *(decision)*: resizable, default 460×600, minimum 360×420, opened at the + right edge of the map (`my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"`) so it + sits beside the centred notes editor instead of on top of it. The help dialog was 420 wide, + fixed and centred; This map needs the height, and one geometry for both panels avoids a jump on + switch. Each panel is a flex column filling the dialog; the transcript areas take the slack. +- **`open(options?)`**: `{ mode?: "help" | "map" }`. When the dialog is already mounted, `open` + switches mode and refreshes the map panel's context instead of rebuilding — the notes editor's + AI button can be clicked repeatedly without losing the transcript. +- **Entry points.** Bubble → `open()` (Help). Tools menu button `openAiChatButton`, relabelled + "Assistant" → `open({ mode: "map" })`. Notes editor's `notesGenerateWithAi` (robot) → + `open({ mode: "map" })`; the note context is implicit because the editor is open. The AI Text + Generator controller stays in the tree, registered and untouched, but nothing opens it any more + *(decision — the assistant with `write_note` covers its one use)*. +- **Electron**: the bubble stays hidden (help gateway is web-only), the Tools button still opens the + dialog on This map, and the Help panel shows the same unlisted-origin explanation a fork + deployment sees. The desktop CSP already allows `connect-src https:`. + +### 2. Help panel + +The upstream widget's markup and behaviour, moved under a panel wrapper, otherwise unchanged: +form (log, notice, question, footer with limits/auth/ask, disclosure) on the official origin or in +DEV, the unlisted-origin explanation elsewhere, the four community links under either. All exported +helpers (`noticeFor`, `shouldAutoRetry`, `limitsLabel`, `normalizeQuestion`, +`buildFeedbackControl`) and their tests stay as they are. The only edits are: the panel wrapper, +the id-based `isMounted()` check becoming panel-aware where needed, and the dialog options above. + +### 3. This map panel + +Derived from `src/controllers/ai-chat.ts` (which is deleted; `Controllers.AiChat` leaves the +registry; the agent context is regenerated). + +- **Header row**: conversation ``; + 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 ` ({ tip: vi.fn() })); +vi.mock("@/services/agent/providers-models", () => ({ + cachedModels: () => [], + listModels: vi.fn().mockRejectedValue(new Error("offline")), + mergeModels: (curated: string[]) => curated +})); +const notesApi = vi.hoisted(() => ({ label: null as string | null })); +vi.mock("./help-assistant-notes", () => ({ + noteChipLabel: async () => notesApi.label, + noteContext: async () => (notesApi.label ? `# Notes editor\n\n${notesApi.label}` : null), + writeNoteTool: () => ({ + definition: { name: "write_note", description: "", input_schema: {} }, + handle: async () => ({ content: "" }) + }), + undoEdit: vi.fn(async () => {}) +})); + +import { current } from "@/services/agent/conversations"; +import { mountMapPanel, NOTE_SUGGESTIONS, needsKey, refreshMapContext, unmountMapPanel } from "./help-assistant-map"; +import { undoEdit } from "./help-assistant-notes"; + +const w = globalThis as unknown as Record; +const el = (id: string): T => document.getElementById(id) as T; +const flush = (): Promise => new Promise(resolve => setTimeout(resolve, 0)); + +beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = `
`; + w.mapId = 1; + w.customization = 0; + window.$ = vi.fn(() => ({ dialog: vi.fn() })) as unknown as typeof window.$; + notesApi.label = null; +}); + +afterEach(() => { + unmountMapPanel(); + document.body.innerHTML = ""; +}); + +describe("needsKey", () => { + it("is true for a cloud model without a key and false for local models", () => { + expect(needsKey("claude-sonnet-5", "")).toBe(true); + expect(needsKey("claude-sonnet-5", " ")).toBe(true); + expect(needsKey("claude-sonnet-5", "sk-1")).toBe(false); + expect(needsKey("local", "")).toBe(false); + }); +}); + +describe("map panel", () => { + it("mounts with the drawer closed and the model named in the status line", () => { + mountMapPanel(el("host")); + expect(el("helpMapDrawer").hidden).toBe(true); + expect(el("helpMapStatusModel").textContent).toContain("claude-sonnet-5"); + expect(el("helpMapStatusKey").textContent).toContain("no key"); + expect(el("helpMapContext").hidden).toBe(true); + }); + + it("opens the drawer with a hint instead of sending when the key is missing", () => { + mountMapPanel(el("host")); + el("helpMapInput").value = "hello"; + el("helpMapInput").dispatchEvent(new Event("input")); + el("helpMapSend").click(); + expect(el("helpMapDrawer").hidden).toBe(false); + expect(el("helpMapHint").hidden).toBe(false); + expect(document.activeElement).toBe(el("helpMapKey")); + expect(el("helpMapLog").querySelector(".helpMapUser")).toBeNull(); + }); + + it("toggles the drawer from the gear and the status model button", () => { + mountMapPanel(el("host")); + el("helpMapSettings").click(); + expect(el("helpMapDrawer").hidden).toBe(false); + el("helpMapSettings").click(); + expect(el("helpMapDrawer").hidden).toBe(true); + el("helpMapStatusModel").click(); + expect(el("helpMapDrawer").hidden).toBe(false); + }); + + it("shows the note chip and note suggestions when the notes editor is open", async () => { + notesApi.label = "Kelmora"; + mountMapPanel(el("host")); + refreshMapContext(); + await flush(); + expect(el("helpMapContext").hidden).toBe(false); + expect(el("helpMapContext").textContent).toContain("Kelmora"); + const chips = [...el("helpMapLog").querySelectorAll("button")].map(button => button.textContent); + expect(chips).toEqual(NOTE_SUGGESTIONS); + }); + + it("renders an edit entry with a working undo", async () => { + mountMapPanel(el("host")); + // reach the renderer through the conversation store: push an entry and re-render by remounting + current().entries.push({ + kind: "edit", + id: "burg1", + name: "Kelmora", + chars: 1200, + previous: { legend: "

o

", name: "Kelmora" } + }); + unmountMapPanel(); + mountMapPanel(el("host")); + const entry = el("helpMapLog").querySelector(".helpMapEdit") as HTMLElement; + expect(entry.textContent).toContain("Updated note"); + expect(entry.textContent).toContain("Kelmora"); + (entry.querySelector("button") as HTMLButtonElement).click(); + await flush(); + expect(undoEdit).toHaveBeenCalled(); + expect((entry.querySelector("button") as HTMLButtonElement).disabled).toBe(true); + }); +}); diff --git a/src/controllers/help-assistant-map.ts b/src/controllers/help-assistant-map.ts index 78530f1e33..3508f5cfc2 100644 --- a/src/controllers/help-assistant-map.ts +++ b/src/controllers/help-assistant-map.ts @@ -1,4 +1,565 @@ -// 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 {} +// The assistant dialog's "This map" panel: the BYOK agent over the open map (formerly the AI Chat +// dialog), with model settings in a drawer and note editing through write_note. + +import { confirmationDialog } from "@/components/dialog/dialog-helpers"; +import { tip } from "@/components/tooltips"; +import { + type Conversation, + create, + type Entry, + forCurrentMap, + isEmpty, + list, + type MessageRole, + remove, + select, + touch +} from "@/services/agent/conversations"; +import { + DEFAULT_LOCAL_URL, + DEFAULT_MODEL, + keyStorageFor, + LOCAL_MODEL, + LOCAL_MODEL_STORAGE, + LOCAL_URL_STORAGE, + PROVIDERS, + providerOf, + registerModels +} from "@/services/agent/providers"; +import { cachedModels, listModels, mergeModels } from "@/services/agent/providers-models"; +import type { RunResult } from "@/services/agent/runtime"; +import { createSession } from "@/services/agent/session"; +import { openURL } from "@/utils"; +import { renderMarkdown } from "@/utils/markdown"; +import { ensureEl } from "../utils"; +import { type EditEntry, noteChipLabel, noteContext, undoEdit, writeNoteTool } from "./help-assistant-notes"; + +const MODEL_STORAGE = "fmg-ai-chat-model"; +const MAX_INPUT_HEIGHT = 120; + +export const MAP_SUGGESTIONS = [ + "Which states have no ports?", + "List the five largest burgs and their states", + "How is the land split between biomes?" +]; + +export const NOTE_SUGGESTIONS = [ + "Write a description for this note", + "Make it more ominous", + "Tighten the wording, keep the facts" +]; + +export const needsKey = (model: string, key: string): boolean => providerOf(model).id !== "local" && !key.trim(); + +let host: HTMLElement | null = null; +let conversation: Conversation; +let currentStep: HTMLDetailsElement | null = null; +let busy = false; +let turnContext = ""; +let noteLabel: string | null = null; + +const session = createSession( + () => ({ + key: ensureEl("helpMapKey").value, + model: ensureEl("helpMapModel").value, + context: turnContext + }), + [writeNoteTool(entry => addEntry(entry))] +); + +export function mountMapPanel(target: HTMLElement): void { + host = target; + conversation = forCurrentMap(); + target.innerHTML = panelHtml(); + bind(); + setInitialValues(); + renderConversations(); + renderTranscript(); + renderUsage(); +} + +// Called on every open/switch into the panel: the note chip and the suggestions follow the notes editor +export function refreshMapContext(): void { + void noteChipLabel().then(label => { + if (!host || !document.getElementById("helpMapContext")) return; + noteLabel = label; + const chip = ensureEl("helpMapContext"); + chip.hidden = label === null; + chip.textContent = label === null ? "" : `Note: ${label}`; + if (isEmpty(conversation)) renderTranscript(); + ensureEl("helpMapInput").focus(); + }); +} + +export function unmountMapPanel(): void { + session.cancel(); + busy = false; + currentStep = null; + if (host) host.innerHTML = ""; + host = null; +} + +function panelHtml(): string { + return /* html */ ` +
+ + + +
+
+ +
+ + + +
+ +
+ + + +
`; +} + +function bind(): void { + ensureEl("helpMapConversation").addEventListener("change", event => { + conversation = select((event.target as HTMLSelectElement).value); + renderTranscript(); + renderUsage(); + }); + ensureEl("helpMapNew").addEventListener("click", startNewConversation); + ensureEl("helpMapRemove").addEventListener("click", removeConversation); + ensureEl("helpMapKeyHelp").addEventListener("click", () => + openURL(providerOf(ensureEl("helpMapModel").value).keyLink) + ); + ensureEl("helpMapSend").addEventListener("click", () => { + if (busy) session.cancel(); + else void send(); + }); + ensureEl("helpMapSettings").addEventListener("click", () => toggleDrawer()); + ensureEl("helpMapStatusModel").addEventListener("click", () => toggleDrawer(true)); + ensureEl("helpMapKey").addEventListener("input", renderStatus); + + ensureEl("helpMapLog").addEventListener("click", event => { + const link = (event.target as HTMLElement)?.closest?.("a[href]"); + if (!link) return; + event.preventDefault(); + openURL(link.getAttribute("href") ?? ""); + }); + + const input = ensureEl("helpMapInput"); + input.addEventListener("input", () => { + input.style.height = "auto"; + input.style.height = `${Math.min(input.scrollHeight, MAX_INPUT_HEIGHT)}px`; + updateSendButton(); + }); + input.addEventListener("keydown", event => { + if (!(event instanceof KeyboardEvent) || event.key !== "Enter" || event.shiftKey) return; + event.preventDefault(); + void send(); + }); +} + +function toggleDrawer(open?: boolean): void { + const drawer = ensureEl("helpMapDrawer"); + drawer.hidden = open === undefined ? !drawer.hidden : !open; + ensureEl("helpMapSettings").setAttribute("aria-expanded", String(!drawer.hidden)); + if (drawer.hidden) ensureEl("helpMapHint").hidden = true; +} + +function renderStatus(): void { + const model = ensureEl("helpMapModel").value; + const provider = providerOf(model); + ensureEl("helpMapStatusModel").textContent = model === LOCAL_MODEL ? "local model" : `${model} · ${provider.label}`; + const key = ensureEl("helpMapKey").value; + ensureEl("helpMapStatusKey").textContent = provider.id === "local" ? "" : key ? "· key set" : "· no key"; +} + +function setInitialValues(): void { + // Models found by earlier discovery runs must route before any fetch happens this session + PROVIDERS.forEach(provider => { + registerModels(provider.id, cachedModels(provider.id)); + }); + buildModelSelect(); + + const select = ensureEl("helpMapModel"); + const stored = localStorage.getItem(MODEL_STORAGE) ?? ""; + select.value = isKnownModel(stored) ? stored : DEFAULT_MODEL; + + select.addEventListener("change", () => { + loadKeyForModel(); + void refreshModels(); + }); + loadKeyForModel(); + updateSendButton(); + void refreshModels(); +} + +function isKnownModel(model: string): boolean { + try { + providerOf(model); + return true; + } catch { + return false; + } +} + +function buildModelSelect(): void { + const select = ensureEl("helpMapModel"); + const previous = select.value; + select.replaceChildren(); // options.length = 0 would leave the old optgroup shells behind + PROVIDERS.forEach(provider => { + const group = document.createElement("optgroup"); + group.label = provider.label; + mergeModels(provider.models, cachedModels(provider.id)).forEach(model => { + group.append(new Option(model === LOCAL_MODEL ? "custom model…" : model, model)); + }); + select.append(group); + }); + if (previous && isKnownModel(previous)) select.value = previous; +} + +// Ask the selected provider what its key can actually use, so new models appear without a release +async function refreshModels(): Promise { + const provider = providerOf(ensureEl("helpMapModel").value); + const key = ensureEl("helpMapKey").value; + if (provider.id !== "local" && !key) return; + try { + await listModels(provider.id, key); + if (document.getElementById("helpMapModel")) buildModelSelect(); + } catch { + // unreachable server or bad key: the curated list stands + } +} + +// Each provider has its own key slot, so switching models swaps the key field with it +function loadKeyForModel(): void { + const model = ensureEl("helpMapModel").value; + const local = providerOf(model).id === "local"; + const key = ensureEl("helpMapKey"); + key.value = localStorage.getItem(keyStorageFor(model)) ?? ""; + key.placeholder = local ? "API key (optional)" : "API key"; + key.dataset.tip = local + ? "Optional API key — most local servers need none. Sent as a Bearer token when set" + : `${providerOf(model).label} API key. It's stored on your machine only (browser storage) and sent directly to the provider`; + + // Discovered local models already carry their name; only the sentinel needs the manual fields + ensureEl("helpMapLocal").hidden = model !== LOCAL_MODEL; + if (model === LOCAL_MODEL) { + ensureEl("helpMapLocalUrl").value = localStorage.getItem(LOCAL_URL_STORAGE) ?? ""; + ensureEl("helpMapLocalModel").value = localStorage.getItem(LOCAL_MODEL_STORAGE) ?? ""; + } + renderStatus(); + updateSendButton(); +} + +// The request outlives the panel when the dialog is closed mid-run, so every DOM touch below +// tolerates a missing element — the conversation keeps the content either way +function updateSendButton(): void { + const button = document.getElementById("helpMapSend") as HTMLButtonElement | null; + if (!button) return; + button.className = busy ? "icon-cancel" : "icon-right-open"; + button.dataset.tip = busy ? "Stop the current request" : "Send the message"; + button.disabled = !busy && !ensureEl("helpMapInput").value.trim(); +} + +async function send(text?: string): Promise { + if (busy) return; + if (customization) { + tip("Please exit the customization mode first", false, "error"); + return; + } + + const input = ensureEl("helpMapInput"); + const question = (text ?? input.value).trim(); + if (!question) return; + + const model = ensureEl("helpMapModel").value; + const key = ensureEl("helpMapKey").value; + if (needsKey(model, key)) { + toggleDrawer(true); + ensureEl("helpMapHint").hidden = false; + ensureEl("helpMapKey").focus(); + return; + } + if (model === LOCAL_MODEL) { + const localModel = ensureEl("helpMapLocalModel").value.trim(); + if (!localModel) { + toggleDrawer(true); + ensureEl("helpMapLocalModel").focus(); + tip("Please enter the local model name", true, "error", 4000); + return; + } + localStorage.setItem(LOCAL_URL_STORAGE, ensureEl("helpMapLocalUrl").value.trim()); + localStorage.setItem(LOCAL_MODEL_STORAGE, localModel); + } + localStorage.setItem(keyStorageFor(model), key); + localStorage.setItem(MODEL_STORAGE, model); + toggleDrawer(false); + + input.value = ""; + input.style.height = "auto"; + addEntry({ kind: "message", role: "user", text: question }); + renderConversations(); + + busy = true; + updateSendButton(); + showThinking("Thinking"); + turnContext = (await noteContext()) ?? ""; + + try { + await session.ask(conversation, question, { + onText: answer => addEntry({ kind: "message", role: "assistant", text: answer }), + onScript: code => addEntry({ kind: "script", code }), + onScriptResult: result => completeStep(result), + onStatus: status => (status ? showThinking(status) : hideThinking()), + onUsage: renderUsage, + onTool: () => showThinking("Editing the note") + }); + } catch (error) { + const aborted = error instanceof DOMException && error.name === "AbortError"; + const message = (error instanceof Error && error.message) || String(error); + addEntry({ kind: "message", role: aborted ? "system" : "error", text: aborted ? "Stopped." : message }); + } finally { + busy = false; + currentStep = null; + hideThinking(); + touch(conversation); + if (document.getElementById("helpMapInput")) { + updateSendButton(); + ensureEl("helpMapInput").focus(); + } + } +} + +function startNewConversation(): void { + if (isEmpty(conversation)) { + tip("This conversation is already empty", true, "warn", 3000); + return; + } + conversation = create(); + renderConversations(); + renderTranscript(); + renderUsage(); +} + +function removeConversation(): void { + const drop = (): void => { + remove(conversation.id); + conversation = forCurrentMap(); + renderConversations(); + renderTranscript(); + renderUsage(); + }; + + if (isEmpty(conversation)) { + drop(); + return; + } + confirmationDialog({ + title: "Delete conversation", + message: `Delete "${conversation.title}"?
The conversation cannot be restored`, + confirm: "Delete", + onConfirm: drop + }); +} + +// Rendering — the conversation is the source of truth, the log is rebuilt from it on every mount + +function renderConversations(): void { + const select = document.getElementById("helpMapConversation") as HTMLSelectElement | null; + if (!select) return; + + select.options.length = 0; + list().forEach(item => { + const label = item.mapId === mapId ? item.title : `${item.title} (other map)`; + select.options.add(new Option(label, item.id)); + }); + select.value = conversation.id; +} + +function renderUsage(): void { + const line = document.getElementById("helpMapUsage"); + if (!line) return; + + const { input, output, cached } = conversation.usage; + if (!input && !output) { + line.textContent = ""; + return; + } + const cheap = cached ? `, ${thousands(cached)} cached` : ""; + line.textContent = `· ${thousands(input)} sent${cheap}, ${thousands(output)} received`; + line.dataset.tip = "Tokens spent in this conversation. Cached tokens cost a tenth of the rest"; +} + +const thousands = (value: number): string => (value < 1000 ? String(value) : `${(value / 1000).toFixed(1)}k`); + +function renderTranscript(): void { + const log = document.getElementById("helpMapLog"); + if (!log) return; + + log.innerHTML = ""; + conversation.entries.forEach(entry => { + log.append(renderEntry(entry)); + }); + if (isEmpty(conversation)) log.append(emptyState()); + scrollToEnd(); +} + +function addEntry(entry: Entry): void { + conversation.entries.push(entry); + touch(conversation); + + const log = document.getElementById("helpMapLog"); + if (!log) return; + document.getElementById("helpMapEmpty")?.remove(); + const element = renderEntry(entry); + log.append(element); + if (entry.kind === "script") currentStep = element as HTMLDetailsElement; + + const thinking = document.getElementById("helpMapThinking"); + if (thinking) log.append(thinking); + scrollToEnd(); +} + +function renderEntry(entry: Entry): HTMLElement { + if (entry.kind === "message") { + const roles: Record = { + user: "helpMapUser", + assistant: "helpMapAssistant", + system: "helpMapSystem", + error: "helpMapError" + }; + const element = document.createElement("div"); + element.className = `helpMapMessage ${roles[entry.role]}`; + // only the model writes Markdown; everything else is shown exactly as typed + if (entry.role === "assistant") element.innerHTML = renderMarkdown(entry.text); + else element.textContent = entry.text; + return element; + } + if (entry.kind === "edit") return renderEdit(entry); + + const details = document.createElement("details"); + details.className = "helpMapStep"; + details.append(document.createElement("summary"), preElement(entry.code)); + setStepSummary(details, entry.result); + if (entry.result) details.append(preElement(resultText(entry.result))); + return details; +} + +function renderEdit(entry: EditEntry): HTMLElement { + const element = document.createElement("div"); + element.className = "helpMapEdit"; + const verb = entry.previous ? "Updated" : "Created"; + const label = (): string => + `${verb} note “${entry.name}” · ${thousands(entry.chars)} chars${entry.undone ? " · undone" : ""}`; + const text = document.createElement("span"); + text.textContent = label(); + const undo = document.createElement("button"); + undo.type = "button"; + undo.className = "icon-ccw"; + undo.textContent = " Undo"; + undo.dataset.tip = "Put the note back the way it was before this edit"; + undo.disabled = Boolean(entry.undone); + undo.addEventListener("click", () => { + undo.disabled = true; + void undoEdit(entry).then(() => { + text.textContent = label(); + touch(conversation); + }); + }); + element.append(text, undo); + return element; +} + +function completeStep(result: RunResult): void { + const entry = conversation.entries.at(-1); + if (entry?.kind === "script") entry.result = result; + touch(conversation); + if (!currentStep) return; + + setStepSummary(currentStep, result); + currentStep.append(preElement(resultText(result))); + currentStep = null; + scrollToEnd(); +} + +function setStepSummary(details: HTMLDetailsElement, result?: RunResult): void { + const summary = details.querySelector("summary"); + if (!summary) return; + if (!result) summary.textContent = "Running a script…"; + else summary.textContent = result.ok ? `Ran a script · ${result.ms} ms` : `Script failed · ${result.ms} ms`; +} + +function resultText(result: RunResult): string { + const logs = result.logs.length ? `${result.logs.join("\n")}\n\n` : ""; + return result.ok ? logs + result.value : `${logs}${result.error?.message}\n${result.error?.stack}`; +} + +function preElement(text: string): HTMLPreElement { + const element = document.createElement("pre"); + element.textContent = text; + return element; +} + +function emptyState(): HTMLElement { + const container = document.createElement("div"); + container.id = "helpMapEmpty"; + + const hint = document.createElement("div"); + hint.textContent = noteLabel + ? `I can read this map and rewrite the note “${noteLabel}” in the notes editor. Every edit has an Undo.` + : "I can read this map and answer questions about it, and edit notes when the notes editor is open."; + container.append(hint); + + (noteLabel ? NOTE_SUGGESTIONS : MAP_SUGGESTIONS).forEach(suggestion => { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = suggestion; + button.addEventListener("click", () => void send(suggestion)); + container.append(button); + }); + + return container; +} + +function showThinking(status: string): void { + const log = document.getElementById("helpMapLog"); + if (!log) return; + + let thinking = document.getElementById("helpMapThinking"); + if (!thinking) { + thinking = document.createElement("div"); + thinking.id = "helpMapThinking"; + thinking.append(document.createElement("span"), ...[0, 1, 2].map(() => document.createElement("i"))); + log.append(thinking); + } + + const label = thinking.querySelector("span"); + if (label) label.textContent = status; + log.append(thinking); + scrollToEnd(); +} + +function hideThinking(): void { + document.getElementById("helpMapThinking")?.remove(); +} + +// Follow new content only when the user is already at the bottom, so scrolling back stays put +function scrollToEnd(): void { + const log = document.getElementById("helpMapLog"); + if (!log) return; + const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60; + if (atBottom) log.scrollTop = log.scrollHeight; +} diff --git a/src/index.html b/src/index.html index ca6bf52a98..3675e9a8aa 100644 --- a/src/index.html +++ b/src/index.html @@ -129,7 +129,7 @@ { else if (buttonId === "overviewMarketsButton") void Controllers.MarketsOverview.open(); else if (buttonId === "overviewCellsButton") void Controllers.CellInfo.open(); else if (buttonId === "openMinimapButton") void Controllers.Minimap.open(); - else if (buttonId === "openAiChatButton") void Controllers.AiChat.open(); + else if (buttonId === "openAiChatButton") void Controllers.HelpAssistant.open({ mode: "map" }); else if (buttonId === "configRegenerateMarkers") void Controllers.MarkersSettings.open(); else if (buttonId === "addBurgTool") void Controllers.BurgCreator.toggle(); else if (buttonId === "addLabel") void Controllers.LabelCreator.toggle(); diff --git a/src/controllers/ai-chat.ts b/src/controllers/ai-chat.ts deleted file mode 100644 index 0b37f4dbea..0000000000 --- a/src/controllers/ai-chat.ts +++ /dev/null @@ -1,556 +0,0 @@ -import { confirmationDialog, destroyDialog } from "@/components/dialog/dialog-helpers"; -import { tip } from "@/components/tooltips"; -import { - type Conversation, - create, - type Entry, - forCurrentMap, - isEmpty, - list, - type MessageRole, - remove, - select, - touch -} from "@/services/agent/conversations"; -import { - DEFAULT_LOCAL_URL, - DEFAULT_MODEL, - keyStorageFor, - LOCAL_MODEL, - LOCAL_MODEL_STORAGE, - LOCAL_URL_STORAGE, - PROVIDERS, - providerOf, - registerModels -} from "@/services/agent/providers"; -import { cachedModels, listModels, mergeModels } from "@/services/agent/providers-models"; -import type { RunResult } from "@/services/agent/runtime"; -import { createSession } from "@/services/agent/session"; -import { openURL } from "@/utils"; -import { renderMarkdown } from "@/utils/markdown"; -import { ensureEl } from "../utils"; - -const DIALOG_ID = "aiChat"; -const MODEL_STORAGE = "fmg-ai-chat-model"; -const MAX_INPUT_HEIGHT = 120; - -const SUGGESTIONS = [ - "Which states have no ports?", - "List the five largest burgs and their states", - "How is the land split between biomes?" -]; - -const session = createSession(() => ({ - key: ensureEl("aiChatKey").value, - model: ensureEl("aiChatModel").value -})); - -let conversation: Conversation; -let currentStep: HTMLDetailsElement | null = null; -let busy = false; - -function open(): void { - if (customization) { - tip("Please exit the customization mode first", false, "error"); - return; - } - - conversation = forCurrentMap(); - renderDialog(); - setInitialValues(); - renderConversations(); - renderTranscript(); - renderUsage(); - - $(`#${DIALOG_ID}`).dialog({ - title: "AI Chat", - width: 480, - height: 560, - minWidth: 320, - minHeight: 320, - position: { my: "right top", at: "right-10 top+10", of: "svg", collision: "fit" }, - close: cleanup - }); - - ensureEl("aiChatInput").focus(); -} - -function renderDialog(): void { - destroyDialog(DIALOG_ID); - ensureEl("dialogs").insertAdjacentHTML("beforeend", dialogHtml()); - - ensureEl("aiChatConversation").addEventListener("change", event => { - conversation = select((event.target as HTMLSelectElement).value); - renderTranscript(); - renderUsage(); - }); - ensureEl("aiChatNew").addEventListener("click", startNewConversation); - ensureEl("aiChatRemove").addEventListener("click", removeConversation); - ensureEl("aiChatKeyHelp").addEventListener("click", () => - openURL(providerOf(ensureEl("aiChatModel").value).keyLink) - ); - ensureEl("aiChatSend").addEventListener("click", () => { - if (busy) session.cancel(); - else void send(); - }); - - ensureEl("aiChatLog").addEventListener("click", event => { - const link = (event.target as HTMLElement)?.closest?.("a[href]"); - if (!link) return; - event.preventDefault(); - openURL(link.getAttribute("href") ?? ""); - }); - - const input = ensureEl("aiChatInput"); - input.addEventListener("input", () => { - input.style.height = "auto"; - input.style.height = `${Math.min(input.scrollHeight, MAX_INPUT_HEIGHT)}px`; - updateSendButton(); - }); - input.addEventListener("keydown", event => { - if (!(event instanceof KeyboardEvent) || event.key !== "Enter" || event.shiftKey) return; - event.preventDefault(); - void send(); - }); -} - -function dialogHtml(): string { - return /* html */ `
- - -
- - - -
- -
- -
- - - - - -
- - - - -
-
`; -} - -function setInitialValues(): void { - // Models found by earlier discovery runs must route before any fetch happens this session - PROVIDERS.forEach(provider => { - registerModels(provider.id, cachedModels(provider.id)); - }); - buildModelSelect(); - - const select = ensureEl("aiChatModel"); - const stored = localStorage.getItem(MODEL_STORAGE) ?? ""; - select.value = isKnownModel(stored) ? stored : DEFAULT_MODEL; - - select.addEventListener("change", () => { - loadKeyForModel(); - void refreshModels(); - }); - loadKeyForModel(); - updateSendButton(); - void refreshModels(); -} - -function isKnownModel(model: string): boolean { - try { - providerOf(model); - return true; - } catch { - return false; - } -} - -function buildModelSelect(): void { - const select = ensureEl("aiChatModel"); - const previous = select.value; - select.replaceChildren(); // options.length = 0 would leave the old optgroup shells behind - PROVIDERS.forEach(provider => { - const group = document.createElement("optgroup"); - group.label = provider.label; - mergeModels(provider.models, cachedModels(provider.id)).forEach(model => { - group.append(new Option(model === LOCAL_MODEL ? "custom model…" : model, model)); - }); - select.append(group); - }); - if (previous && isKnownModel(previous)) select.value = previous; -} - -// Ask the selected provider what its key can actually use, so new models appear without a release -async function refreshModels(): Promise { - const provider = providerOf(ensureEl("aiChatModel").value); - const key = ensureEl("aiChatKey").value; - if (provider.id !== "local" && !key) return; - try { - await listModels(provider.id, key); - if (document.getElementById(DIALOG_ID)) buildModelSelect(); - } catch { - // unreachable server or bad key: the curated list stands - } -} - -// Each provider has its own key slot, so switching models swaps the key field with it -function loadKeyForModel(): void { - const model = ensureEl("aiChatModel").value; - const local = providerOf(model).id === "local"; - const key = ensureEl("aiChatKey"); - key.value = localStorage.getItem(keyStorageFor(model)) ?? ""; - key.placeholder = local ? "API key (optional)" : "API key"; - key.dataset.tip = local - ? "Optional API key — most local servers need none. Sent as a Bearer token when set" - : `${providerOf(model).label} API key. It's stored on your machine only (browser storage) and sent directly to the provider`; - - // Discovered local models already carry their name; only the sentinel needs the manual fields - ensureEl("aiChatLocal").style.display = model === LOCAL_MODEL ? "flex" : "none"; - if (model === LOCAL_MODEL) { - ensureEl("aiChatLocalUrl").value = localStorage.getItem(LOCAL_URL_STORAGE) ?? ""; - ensureEl("aiChatLocalModel").value = localStorage.getItem(LOCAL_MODEL_STORAGE) ?? ""; - } - updateSendButton(); -} - -// The request outlives the dialog when it is closed mid-run, so every DOM touch below tolerates a -// missing dialog — the conversation keeps the content either way -function updateSendButton(): void { - const button = document.getElementById("aiChatSend") as HTMLButtonElement | null; - if (!button) return; - button.className = busy ? "icon-cancel" : "icon-right-open"; - button.dataset.tip = busy ? "Stop the current request" : "Send the message"; - button.disabled = !busy && !ensureEl("aiChatInput").value.trim(); -} - -async function send(text?: string): Promise { - if (busy) return; - - const input = ensureEl("aiChatInput"); - const question = (text ?? input.value).trim(); - if (!question) return; - - const model = ensureEl("aiChatModel").value; - const local = providerOf(model).id === "local"; - const key = ensureEl("aiChatKey").value; - if (!key && !local) { - ensureEl("aiChatKey").focus(); - tip("Please enter an API key", true, "error", 4000); - return; - } - if (model === LOCAL_MODEL) { - const localModel = ensureEl("aiChatLocalModel").value.trim(); - if (!localModel) { - ensureEl("aiChatLocalModel").focus(); - tip("Please enter the local model name", true, "error", 4000); - return; - } - localStorage.setItem(LOCAL_URL_STORAGE, ensureEl("aiChatLocalUrl").value.trim()); - localStorage.setItem(LOCAL_MODEL_STORAGE, localModel); - } - localStorage.setItem(keyStorageFor(model), key); - localStorage.setItem(MODEL_STORAGE, model); - - input.value = ""; - input.style.height = "auto"; - addEntry({ kind: "message", role: "user", text: question }); - renderConversations(); - - busy = true; - updateSendButton(); - showThinking("Thinking"); - - try { - await session.ask(conversation, question, { - onText: answer => addEntry({ kind: "message", role: "assistant", text: answer }), - onScript: code => addEntry({ kind: "script", code }), - onScriptResult: result => completeStep(result), - onStatus: status => (status ? showThinking(status) : hideThinking()), - onUsage: renderUsage - }); - } catch (error) { - const aborted = error instanceof DOMException && error.name === "AbortError"; - const message = (error instanceof Error && error.message) || String(error); - addEntry({ kind: "message", role: aborted ? "system" : "error", text: aborted ? "Stopped." : message }); - } finally { - busy = false; - currentStep = null; - hideThinking(); - touch(conversation); - if (document.getElementById(DIALOG_ID)) { - updateSendButton(); - ensureEl("aiChatInput").focus(); - } - } -} - -function startNewConversation(): void { - if (isEmpty(conversation)) { - tip("This conversation is already empty", true, "warn", 3000); - return; - } - conversation = create(); - renderConversations(); - renderTranscript(); - renderUsage(); -} - -function removeConversation(): void { - const drop = (): void => { - remove(conversation.id); - conversation = forCurrentMap(); - renderConversations(); - renderTranscript(); - renderUsage(); - }; - - if (isEmpty(conversation)) { - drop(); - return; - } - confirmationDialog({ - title: "Delete conversation", - message: `Delete "${conversation.title}"?
The conversation cannot be restored`, - confirm: "Delete", - onConfirm: drop - }); -} - -// Rendering — the conversation is the source of truth, the log is rebuilt from it on every open - -function renderConversations(): void { - const select = document.getElementById("aiChatConversation") as HTMLSelectElement | null; - if (!select) return; - - select.options.length = 0; - list().forEach(item => { - const label = item.mapId === mapId ? item.title : `${item.title} (other map)`; - select.options.add(new Option(label, item.id)); - }); - select.value = conversation.id; -} - -function renderUsage(): void { - const line = document.getElementById("aiChatUsage"); - if (!line) return; - - const { input, output, cached } = conversation.usage; - if (!input && !output) { - line.textContent = ""; - return; - } - const cheap = cached ? `, ${thousands(cached)} cached` : ""; - line.textContent = `Tokens: ${thousands(input)} sent${cheap} · ${thousands(output)} received`; - line.dataset.tip = "Tokens spent in this conversation. Cached tokens cost a tenth of the rest"; -} - -const thousands = (value: number): string => (value < 1000 ? String(value) : `${(value / 1000).toFixed(1)}k`); - -function renderTranscript(): void { - const log = document.getElementById("aiChatLog"); - if (!log) return; - - log.innerHTML = ""; - conversation.entries.forEach(entry => { - log.append(renderEntry(entry)); - }); - if (isEmpty(conversation)) log.append(emptyState()); - scrollToEnd(); -} - -function addEntry(entry: Entry): void { - conversation.entries.push(entry); - touch(conversation); - - const log = document.getElementById("aiChatLog"); - if (!log) return; - document.getElementById("aiChatEmpty")?.remove(); - const element = renderEntry(entry); - log.append(element); - if (entry.kind === "script") currentStep = element as HTMLDetailsElement; - - const thinking = document.getElementById("aiChatThinking"); - if (thinking) log.append(thinking); - scrollToEnd(); -} - -function renderEntry(entry: Entry): HTMLElement { - if (entry.kind === "message") { - const roles: Record = { - user: "aiChatUser", - assistant: "aiChatAssistant", - system: "aiChatSystem", - error: "aiChatError" - }; - const element = document.createElement("div"); - element.className = `aiChatMessage ${roles[entry.role]}`; - // only the model writes Markdown; everything else is shown exactly as typed - if (entry.role === "assistant") element.innerHTML = renderMarkdown(entry.text); - else element.textContent = entry.text; - return element; - } - if (entry.kind === "edit") return document.createElement("div"); // rendered by the assistant panel; this dialog is retired - - const details = document.createElement("details"); - details.className = "aiChatStep"; - details.append(document.createElement("summary"), preElement(entry.code)); - setStepSummary(details, entry.result); - if (entry.result) details.append(preElement(resultText(entry.result))); - return details; -} - -function completeStep(result: RunResult): void { - const entry = conversation.entries.at(-1); - if (entry?.kind === "script") entry.result = result; - touch(conversation); - if (!currentStep) return; - - setStepSummary(currentStep, result); - currentStep.append(preElement(resultText(result))); - currentStep = null; - scrollToEnd(); -} - -function setStepSummary(details: HTMLDetailsElement, result?: RunResult): void { - const summary = details.querySelector("summary"); - if (!summary) return; - if (!result) summary.textContent = "Running a script…"; - else summary.textContent = result.ok ? `Ran a script · ${result.ms} ms` : `Script failed · ${result.ms} ms`; -} - -function resultText(result: RunResult): string { - const logs = result.logs.length ? `${result.logs.join("\n")}\n\n` : ""; - return result.ok ? logs + result.value : `${logs}${result.error?.message}\n${result.error?.stack}`; -} - -function preElement(text: string): HTMLPreElement { - const element = document.createElement("pre"); - element.textContent = text; - return element; -} - -function emptyState(): HTMLElement { - const container = document.createElement("div"); - container.id = "aiChatEmpty"; - - const hint = document.createElement("div"); - hint.textContent = "I can read the current map and answer questions about it. I cannot change it yet."; - container.append(hint); - - SUGGESTIONS.forEach(suggestion => { - const button = document.createElement("button"); - button.textContent = suggestion; - button.addEventListener("click", () => void send(suggestion)); - container.append(button); - }); - - return container; -} - -function showThinking(status: string): void { - const log = document.getElementById("aiChatLog"); - if (!log) return; - - let thinking = document.getElementById("aiChatThinking"); - if (!thinking) { - thinking = document.createElement("div"); - thinking.id = "aiChatThinking"; - thinking.append(document.createElement("span"), ...[0, 1, 2].map(() => document.createElement("i"))); - log.append(thinking); - } - - const label = thinking.querySelector("span"); - if (label) label.textContent = status; - log.append(thinking); - scrollToEnd(); -} - -function hideThinking(): void { - document.getElementById("aiChatThinking")?.remove(); -} - -// Follow new content only when the user is already at the bottom, so scrolling back stays put -function scrollToEnd(): void { - const log = document.getElementById("aiChatLog"); - if (!log) return; - const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60; - if (atBottom) log.scrollTop = log.scrollHeight; -} - -function cleanup(): void { - session.cancel(); - busy = false; - currentStep = null; - destroyDialog(DIALOG_ID); -} - -export const AiChat = { open }; diff --git a/src/controllers/index.ts b/src/controllers/index.ts index a95b06945a..256bcd39af 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1,7 +1,6 @@ import { createRegistry } from "@/utils/registry"; export const Controllers = createRegistry({ - AiChat: () => import("@/controllers/ai-chat").then(m => m.AiChat), AiGenerator: () => import("@/controllers/ai-generator").then(m => m.AiGenerator), BattleScreen: () => import("@/controllers/battle-screen").then(m => m.BattleScreen), BiomesEditor: () => import("@/controllers/biomes-editor").then(m => m.BiomesEditor), diff --git a/src/controllers/notes-editor.ts b/src/controllers/notes-editor.ts index 9f4c77d651..04973d5aff 100644 --- a/src/controllers/notes-editor.ts +++ b/src/controllers/notes-editor.ts @@ -74,7 +74,7 @@ function renderDialog(): void {
- + @@ -89,7 +89,7 @@ function renderDialog(): void { ensureEl("notesLegend").addEventListener("blur", updateLegend); ensureEl("notesPin").addEventListener("click", toggleNotesPin); ensureEl("notesFocus").addEventListener("click", validateHighlightElement); - ensureEl("notesGenerateWithAi").addEventListener("click", openAiGenerator); + ensureEl("notesGenerateWithAi").addEventListener("click", () => void Controllers.HelpAssistant.open({ mode: "map" })); ensureEl("notesDownload").addEventListener("click", downloadLegends); ensureEl("notesUpload").addEventListener("click", () => ensureEl("legendsToLoad").click()); ensureEl("legendsToLoad").addEventListener("change", function (this: HTMLInputElement) { @@ -207,21 +207,6 @@ function removeSelectedNote(): void { remove(ensureEl("notesSelect").value); } -function openAiGenerator(): void { - const note = current(); - - let prompt = `Respond with description. Use simple dry language. Invent facts, names and details. Split to paragraphs and format to HTML. Remove h tags, remove markdown.`; - if (note?.name) prompt += ` Name: ${note.name}.`; - if (note?.legend) prompt += ` Data: ${note.legend}`; - - const onApply = (result: string): void => { - if (note) write(note.id, result); - else ensureEl("notesLegend").innerHTML = result; - }; - - void Controllers.AiGenerator.open(prompt, onApply); -} - function downloadLegends(): void { const notesData = JSON.stringify(notes); const name = `${getFileName("Notes")}.txt`; diff --git a/src/index.html b/src/index.html index 3675e9a8aa..8bc690b530 100644 --- a/src/index.html +++ b/src/index.html @@ -1925,8 +1925,11 @@ -
Create
diff --git a/src/services/agent/context.generated.ts b/src/services/agent/context.generated.ts index 221ad1de88..9d91f1f4ed 100644 --- a/src/services/agent/context.generated.ts +++ b/src/services/agent/context.generated.ts @@ -5,7 +5,7 @@ export const GLOBAL_DECLARATIONS = "var MOBILE: boolean;\n\n /**\n * Migrated export const GENERATOR_GLOBALS = "var AddedLabels: AddedLabelsModule;\nvar Biomes: BiomesGenerator;\nvar Burgs: BurgModule;\nvar Coordinates: CoordinatesModule;\nvar Cultures: CulturesGenerator;\nvar Features: FeatureModule;\nvar GenerationPipeline: import(\"@/generators/pipeline\").Pipeline;\nvar Goods: GoodsModule;\nvar Grid: GridModule;\nvar HeightmapGenerator: HeightmapModule;\nvar Ice: IceModule;\nvar Labels: LabelsModule;\nvar Lakes: LakesModule;\nvar Markers: MarkersModule;\nvar Markets: MarketsModule;\nvar Measurers: MeasurersModule;\nvar Military: MilitaryModule;\nvar Names: NamesGenerator;\nvar Pack: PackModule;\nvar Precipitation: PrecipitationModule;\nvar Production: ProductionModule;\nvar Provinces: ProvinceModule;\nvar Relief: ReliefModule;\nvar Religions: ReligionsModule;\nvar Rivers: RiverModule;\nvar Routes: RoutesModule;\nvar States: StatesModule;\nvar Temperature: TemperatureModule;\nvar Transports: TransportsModule;\nvar Zones: ZonesModule;\nvar simplify: (points: Point[], tolerance: number, highestQuality?: boolean) => Point[];"; -export const REGISTRY_KEYS = "Controllers.AiChat\nControllers.AiGenerator\nControllers.BattleScreen\nControllers.BiomesEditor\nControllers.BurgCreator\nControllers.BurgEditor\nControllers.BurgGroupEditor\nControllers.BurgsOverview\nControllers.CellInfo\nControllers.ChartsOverview\nControllers.CoastlineEditor\nControllers.CoastlineVertexEditor\nControllers.ColorPicker\nControllers.ComparePrices\nControllers.CulturesEditor\nControllers.DiplomacyEditor\nControllers.DistributionEditor\nControllers.ElevationProfile\nControllers.EmblemsEditor\nControllers.GoodEditor\nControllers.GoodsEditor\nControllers.HeightmapEditor\nControllers.HeightmapSelection\nControllers.HelpAssistant\nControllers.IconSelector\nControllers.HierarchyTree\nControllers.IceEditor\nControllers.JourneyEditor\nControllers.JourneysOverview\nControllers.LabelsEditor\nControllers.LabelGroupsConfigurator\nControllers.LabelCreator\nControllers.LabelsOverview\nControllers.LakesEditor\nControllers.MarkersEditor\nControllers.MarkersSettings\nControllers.MarkerCreator\nControllers.MarkersInRadius\nControllers.MarkersOverview\nControllers.MarketDealsOverview\nControllers.MarketOverview\nControllers.MarketsOverview\nControllers.MeasurersEditor\nControllers.MilitaryOverview\nControllers.Minimap\nControllers.NamesbaseEditor\nControllers.NotesEditor\nControllers.PaintEditor\nControllers.ProductionChains\nControllers.ProductionOverview\nControllers.ProvincesEditor\nControllers.RegimentEditor\nControllers.RegimentsOverview\nControllers.ReliefEditor\nControllers.ReligionsEditor\nControllers.RiverCreator\nControllers.RiverAutoCreator\nControllers.RiverEditor\nControllers.RiversOverview\nControllers.RouteCreator\nControllers.RouteEditor\nControllers.RouteGroupsEditor\nControllers.RoutesOverview\nControllers.StatesEditor\nControllers.SubmapTool\nControllers.TemperatureGraph\nControllers.TradeAnimationEditor\nControllers.TradeDetails\nControllers.TransformTool\nControllers.TransportEditor\nControllers.UnitsEditor\nControllers.View3d\nControllers.WorldConfigurator\nControllers.ZonesEditor\nServices.AppOffer\nServices.Cloud\nServices.ExportJson\nServices.ExportMap\nServices.Load\nServices.Save\nServices.UiTour"; +export const REGISTRY_KEYS = "Controllers.AiGenerator\nControllers.BattleScreen\nControllers.BiomesEditor\nControllers.BurgCreator\nControllers.BurgEditor\nControllers.BurgGroupEditor\nControllers.BurgsOverview\nControllers.CellInfo\nControllers.ChartsOverview\nControllers.CoastlineEditor\nControllers.CoastlineVertexEditor\nControllers.ColorPicker\nControllers.ComparePrices\nControllers.CulturesEditor\nControllers.DiplomacyEditor\nControllers.DistributionEditor\nControllers.ElevationProfile\nControllers.EmblemsEditor\nControllers.GoodEditor\nControllers.GoodsEditor\nControllers.HeightmapEditor\nControllers.HeightmapSelection\nControllers.HelpAssistant\nControllers.IconSelector\nControllers.HierarchyTree\nControllers.IceEditor\nControllers.JourneyEditor\nControllers.JourneysOverview\nControllers.LabelsEditor\nControllers.LabelGroupsConfigurator\nControllers.LabelCreator\nControllers.LabelsOverview\nControllers.LakesEditor\nControllers.MarkersEditor\nControllers.MarkersSettings\nControllers.MarkerCreator\nControllers.MarkersInRadius\nControllers.MarkersOverview\nControllers.MarketDealsOverview\nControllers.MarketOverview\nControllers.MarketsOverview\nControllers.MeasurersEditor\nControllers.MilitaryOverview\nControllers.Minimap\nControllers.NamesbaseEditor\nControllers.NotesEditor\nControllers.PaintEditor\nControllers.ProductionChains\nControllers.ProductionOverview\nControllers.ProvincesEditor\nControllers.RegimentEditor\nControllers.RegimentsOverview\nControllers.ReliefEditor\nControllers.ReligionsEditor\nControllers.RiverCreator\nControllers.RiverAutoCreator\nControllers.RiverEditor\nControllers.RiversOverview\nControllers.RouteCreator\nControllers.RouteEditor\nControllers.RouteGroupsEditor\nControllers.RoutesOverview\nControllers.StatesEditor\nControllers.SubmapTool\nControllers.TemperatureGraph\nControllers.TradeAnimationEditor\nControllers.TradeDetails\nControllers.TransformTool\nControllers.TransportEditor\nControllers.UnitsEditor\nControllers.View3d\nControllers.WorldConfigurator\nControllers.ZonesEditor\nServices.AppOffer\nServices.Cloud\nServices.ExportJson\nServices.ExportMap\nServices.Load\nServices.Save\nServices.UiTour"; export const PACKED_GRAPH_TYPES = "export type TypedArray = Uint8Array | Uint16Array | Uint32Array | Int8Array | Int16Array | Float32Array | Float64Array;\n\nexport interface PackedGraph {\n cells: {\n i: number[]; // cell indices\n c: number[][]; // neighboring cells\n v: number[][]; // neighboring vertices\n p: [number, number][]; // cell polygon points\n b: boolean[]; // cell is on border\n h: TypedArray; // cell heights\n t: TypedArray; // cell terrain types\n r: TypedArray; // river id passing through cell\n f: TypedArray; // feature id occupying cell\n fl: TypedArray; // flux presence in cell\n s: TypedArray; // cell suitability\n pop: TypedArray; // cell population\n conf: TypedArray; // cell water confidence\n haven: TypedArray; // cell is a haven\n g: number[]; // cell ground type\n culture: TypedArray; // cell culture id\n biome: TypedArray; // cell biome id\n harbor: TypedArray; // cell harbour presence\n burg: TypedArray; // cell burg id\n religion: TypedArray; // cell religion id\n state: TypedArray; // cell state id\n area: TypedArray; // cell area\n province: TypedArray; // cell province id\n good: Uint16Array; // cell good id\n market: Uint16Array; // cell market id\n routes: Record>;\n };\n vertices: {\n i: number[]; // vertex indices\n c: [number, number, number][]; // neighboring cells\n v: number[][]; // neighboring vertices\n x: number[]; // x coordinates\n y: number[]; // y coordinates\n p: [number, number][]; // vertex points\n };\n rivers: River[];\n relief: ReliefIcon[];\n biomes: Biome[];\n features: Feature[];\n burgs: Burg[];\n states: State[];\n cultures: Culture[];\n routes: Route[];\n religions: Religion[];\n zones: Zone[];\n markers: Marker[];\n ice: Ice[];\n provinces: Province[];\n goods: Good[];\n markets: Market[];\n deals: Deal[];\n measurers: Measurer[];\n addedLabels: AddedLabel[];\n journeys: Journey[];\n}"; From bfe4fc4fb1fc16f3818f3b41c0f66f824c76e18f Mon Sep 17 00:00:00 2001 From: barrulus Date: Sat, 5 Sep 2026 14:32:19 +0100 Subject: [PATCH 11/26] =?UTF-8?q?test(e2e):=20assistant=20dialog=20?= =?UTF-8?q?=E2=80=94=20help=20first,=20note=20editing=20with=20undo,=20key?= =?UTF-8?q?=20drawer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-09-05-help-assistant-ai-merger-design.md | 12 +- playwright.local.config.ts | 14 ++ public/index.css | 5 +- src/index.html | 2 +- tests/e2e/help-assistant-ai.spec.ts | 153 ++++++++++++++++++ 5 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 playwright.local.config.ts create mode 100644 tests/e2e/help-assistant-ai.spec.ts diff --git a/docs/superpowers/specs/2026-09-05-help-assistant-ai-merger-design.md b/docs/superpowers/specs/2026-09-05-help-assistant-ai-merger-design.md index ad5d7a0128..f94bba7c12 100644 --- a/docs/superpowers/specs/2026-09-05-help-assistant-ai-merger-design.md +++ b/docs/superpowers/specs/2026-09-05-help-assistant-ai-merger-design.md @@ -251,11 +251,11 @@ origin is unlisted anyway). ## Verification checklist (filled in as the build proceeds) - [x] upstream/help-assistant merged; unit tests green (96 files / 1025 tests after regenerating `context.generated.ts` for the new registry entry); tsc clean -- [ ] mode control, open(options), entry points -- [ ] This map panel with drawer, status line, chip, empty state -- [ ] notes bridge (TinyMCE-era implementation) -- [ ] write_note tool + undo + prompt sections -- [ ] unit tests listed in §6 -- [ ] browser scenarios 1–5 on port 5199 +- [x] mode control, open(options), entry points (fe7e9377, af6969bb) +- [x] This map panel with drawer, status line, chip, empty state (27e78684) +- [x] notes bridge (TinyMCE-era implementation) (defa7a92) +- [x] write_note tool + undo + prompt sections (1e3f069e, 89a8bc4b) +- [x] unit tests listed in §6 — 102 files / 1067 tests green after Task 8 +- [x] browser scenarios 1–5 (scenario 4 is inside the third test) — 4/4 passed on port **5211** (5199 was held by the height-scale session's dev server); one CSS specificity bug found only in the browser: the drawer's `display: flex` outranked `[hidden]`, fixed with attribute-qualified selectors - [ ] Quill branch merged; bridge re-implemented over Quill; scenarios rerun - [ ] biome + stamp-assets clean diff --git a/playwright.local.config.ts b/playwright.local.config.ts new file mode 100644 index 0000000000..603bcdf017 --- /dev/null +++ b/playwright.local.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "@playwright/test"; +import base from "./playwright.config"; + +// Runs against this worktree's own dev server on 5211. Port 5173 belongs to the user's session. +export default defineConfig({ + ...base, + use: { ...base.use, baseURL: "http://localhost:5211" }, + webServer: { + command: "npx vite --port 5211 --strictPort", + url: "http://localhost:5211", + reuseExistingServer: true, + timeout: 120000 + } +}); diff --git a/public/index.css b/public/index.css index 365c1cf18e..9ac61a4616 100644 --- a/public/index.css +++ b/public/index.css @@ -2812,7 +2812,10 @@ body.tour-free-roam * { gap: 0.3em; } -#helpAssistantMap [hidden] { +/* the panel's own display rules would otherwise outrank the hidden attribute */ +#helpAssistantMap .helpMapDrawer[hidden], +#helpAssistantMap .helpMapContext[hidden], +#helpAssistantMap .helpMapHint[hidden] { display: none; } diff --git a/src/index.html b/src/index.html index 8bc690b530..47f30ca906 100644 --- a/src/index.html +++ b/src/index.html @@ -129,7 +129,7 @@ ({ + status, + headers: { ...CORS, "content-type": "application/json" }, + body: JSON.stringify(body) +}); + +async function stubGateway(page: Page): Promise { + await page.route("https://ask.azgaarsfmg.com/**", route => { + const request = route.request(); + if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers: CORS }); + if (request.url().endsWith("/v1/limits")) { + return route.fulfill(json({ tier: "anonymous", remaining: 5, resetsAt: "2099-01-01T00:00:00.000Z" })); + } + if (request.url().endsWith("/v1/ask")) { + return route.fulfill( + json({ + conversationId: "abcdefghijklmnopqrstuvwx", + requestId: 7, + answer: "Use **File → Export → SVG**.", + model: "stub", + usage: null + }) + ); + } + return route.fulfill({ status: 204, headers: CORS }); + }); +} + +// First completion asks to edit the note, second answers in text +async function stubAnthropic(page: Page, html: string): Promise { + let calls = 0; + await page.route("https://api.anthropic.com/v1/messages", route => { + if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers: CORS }); + calls += 1; + const usage = { input_tokens: 10, output_tokens: 5 }; + if (calls === 1) { + return route.fulfill( + json({ + content: [{ type: "tool_use", id: "toolu_1", name: "write_note", input: { html } }], + stop_reason: "tool_use", + usage + }) + ); + } + return route.fulfill( + json({ content: [{ type: "text", text: "Done — I rewrote the note." }], stop_reason: "end_turn", usage }) + ); + }); +} + +async function loadMap(page: Page): Promise { + await page.route("https://azgaar.github.io/**", route => route.abort()); // no remote TinyMCE: plain contenteditable + await page.goto("/?seed=assistant-e2e&width=1280&height=720"); + await page.waitForFunction(() => (window as any).mapId !== undefined, { timeout: 120000 }); + // `notes` is a top-level `let` in the classic main.js — a global binding, not a window property + await page.evaluate('notes.push({ id: "burg1", name: "Kelmora", legend: "

Old text.

" })'); +} + +const legendOf = (page: Page, id: string): Promise => + page.evaluate(`notes.find(n => n.id === ${JSON.stringify(id)}).legend`); + +test.describe("assistant dialog", () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem("fmg-ai-chat-model", "claude-sonnet-5"); + localStorage.setItem("fmg-ai-kl-anthropic", "sk-test"); + localStorage.removeItem("fmg-ai-chat-conversations"); + }); + await stubGateway(page); + await loadMap(page); + }); + + test("the bubble opens Help first and the mode control switches", async ({ page }) => { + await page.locator("#helpAssistantBubble").click(); + await expect(page.locator("#helpAssistant")).toBeVisible(); + await expect(page.locator("#helpAssistantHelp")).toBeVisible(); + await expect(page.locator("#helpAssistantMap")).toBeHidden(); + await page.locator("#helpAssistantQuestion").fill("half typed"); + await page.locator('.helpAssistantMode[data-mode="map"]').click(); + await expect(page.locator("#helpAssistantMap")).toBeVisible(); + await expect(page.locator("#helpMapDrawer")).toBeHidden(); + await page.locator('.helpAssistantMode[data-mode="help"]').click(); + await expect(page.locator("#helpAssistantQuestion")).toHaveValue("half typed"); + }); + + test("help questions round-trip through the gateway", async ({ page }) => { + await page.locator("#helpAssistantBubble").click(); + await page.locator("#helpAssistantQuestion").fill("How do I export SVG?"); + await page.locator("#helpAssistantAsk").click(); + const answer = page.locator("#helpAssistantLog .helpAssistantAnswer"); + await expect(answer).toContainText("Use"); + await expect(answer.locator("strong")).toHaveText("File → Export → SVG"); + await expect(answer.locator(".helpAssistantFeedback button")).toHaveCount(2); + await expect(page.locator("#helpAssistantLimits")).toContainText("5 questions left today"); + }); + + test("the notes editor's AI button opens This map with the note chip, and write_note edits with undo", async ({ + page + }) => { + await stubAnthropic(page, "

Kelmora broods beneath a sky of ash.

"); + await page.evaluate(() => (window as any).Controllers.NotesEditor.open("burg1")); + await expect(page.locator("#notesEditor")).toBeVisible(); + await page.locator("#notesGenerateWithAi").click(); + + await expect(page.locator("#helpAssistantMap")).toBeVisible(); + await expect(page.locator("#helpMapContext")).toHaveText("Note: Kelmora"); + await expect(page.locator("#helpMapLog button").first()).toHaveText("Write a description for this note"); + + await page.locator("#helpMapInput").fill("make it ominous"); + await page.locator("#helpMapInput").press("Enter"); + + const edit = page.locator("#helpMapLog .helpMapEdit"); + await expect(edit).toContainText("Updated note “Kelmora”"); + await expect(page.locator("#helpMapLog .helpMapAssistant")).toContainText("Done"); + expect(await legendOf(page, "burg1")).toBe("

Kelmora broods beneath a sky of ash.

"); + await expect(page.locator("#notesBody")).toHaveText("Kelmora broods beneath a sky of ash."); + await expect(page.locator("#notesLegend")).toHaveText("Kelmora broods beneath a sky of ash."); + + await edit.locator("button").click(); + await expect(edit).toContainText("undone"); + expect(await legendOf(page, "burg1")).toBe("

Old text.

"); + await expect(page.locator("#notesBody")).toHaveText("Old text."); + }); + + test("a missing key opens the settings drawer instead of sending", async ({ page }) => { + await page.evaluate(() => localStorage.removeItem("fmg-ai-kl-anthropic")); + await page.evaluate(() => (window as any).Controllers.HelpAssistant.open({ mode: "map" })); + await expect(page.locator("#helpMapStatusKey")).toContainText("no key"); + await page.locator("#helpMapInput").fill("hello"); + await page.locator("#helpMapInput").press("Enter"); + await expect(page.locator("#helpMapDrawer")).toBeVisible(); + await expect(page.locator("#helpMapHint")).toBeVisible(); + await expect(page.locator("#helpMapLog .helpMapUser")).toHaveCount(0); + + await stubAnthropic(page, "

unused

"); + await page.locator("#helpMapKey").fill("sk-test"); + await page.locator("#helpMapInput").press("Enter"); + await expect(page.locator("#helpMapLog .helpMapUser")).toHaveText("hello"); + await expect(page.locator("#helpMapDrawer")).toBeHidden(); + }); +}); From e4eb8cfe06391708777cc0081fbe75b347c7e22f Mon Sep 17 00:00:00 2001 From: barrulus Date: Sat, 5 Sep 2026 14:33:21 +0100 Subject: [PATCH 12/26] fix(assistant): bring the dialog to the front when re-opened from the notes editor --- src/controllers/help-assistant.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/controllers/help-assistant.ts b/src/controllers/help-assistant.ts index 2478e03d6a..89179700f6 100644 --- a/src/controllers/help-assistant.ts +++ b/src/controllers/help-assistant.ts @@ -77,6 +77,7 @@ function open(options: OpenOptions = {}): void { const mode = options.mode ?? "help"; if (isMounted()) { setMode(mode); + $("#helpAssistant").dialog("moveToTop"); // the notes editor is large and centred; a re-open must not hide behind it return; } From baf6a77aae4787603ab7720fa230be37b26be5c9 Mon Sep 17 00:00:00 2001 From: barrulus Date: Sat, 5 Sep 2026 14:34:26 +0100 Subject: [PATCH 13/26] docs: verification checklist --- .../specs/2026-09-05-help-assistant-ai-merger-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-09-05-help-assistant-ai-merger-design.md b/docs/superpowers/specs/2026-09-05-help-assistant-ai-merger-design.md index f94bba7c12..ed8665469a 100644 --- a/docs/superpowers/specs/2026-09-05-help-assistant-ai-merger-design.md +++ b/docs/superpowers/specs/2026-09-05-help-assistant-ai-merger-design.md @@ -257,5 +257,5 @@ origin is unlisted anyway). - [x] write_note tool + undo + prompt sections (1e3f069e, 89a8bc4b) - [x] unit tests listed in §6 — 102 files / 1067 tests green after Task 8 - [x] browser scenarios 1–5 (scenario 4 is inside the third test) — 4/4 passed on port **5211** (5199 was held by the height-scale session's dev server); one CSS specificity bug found only in the browser: the drawer's `display: flex` outranked `[hidden]`, fixed with attribute-qualified selectors -- [ ] Quill branch merged; bridge re-implemented over Quill; scenarios rerun -- [ ] biome + stamp-assets clean +- [ ] Quill branch merged; bridge re-implemented over Quill; scenarios rerun — PENDING: `feat/notes-editor-quill` did not exist yet when Tasks 1–9 finished (2026-09-05 14:35); plan Task 10 has the merge recipe +- [x] biome + stamp-assets clean (`npx biome check src public/main.js`: 0 errors; all 8 stamps current); tsc silent; 102 files / 1067 unit tests From 52c480f7afc2dae8b11e4418eb306fa1cc48e297 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sat, 5 Sep 2026 15:42:31 +0200 Subject: [PATCH 14/26] start --- docs/wiki/Changelog.md | 5 +- public/index.css | 112 +++++++++++++++++++++++++++--- public/main.js | 11 +-- src/controllers/help-assistant.ts | 27 ++++--- src/index.html | 13 ++-- src/services/help/api.ts | 10 --- src/services/versioning.ts | 12 +--- 7 files changed, 136 insertions(+), 54 deletions(-) diff --git a/docs/wiki/Changelog.md b/docs/wiki/Changelog.md index cafd777893..748b1130fb 100644 --- a/docs/wiki/Changelog.md +++ b/docs/wiki/Changelog.md @@ -12,11 +12,10 @@ Check out [Dev board](https://github.com/users/Azgaar/projects/3/views/1?sumFiel Current version of the Fantasy Map Generator is the latest `master` branch. You can download it here: https://github.com/Azgaar/Fantasy-Map-Generator/archive/refs/heads/master.zip. Also see [the wiki](https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Q&A#can-i-use-the-generator-offline). -- Help assistant: ask questions about the Generator in the app, answered from the wiki and Discord knowledge by _[barrulus](https://github.com/barrulus)_ [1.151.2] +- Help assistant: ask questions about FMG by _[barrulus](https://github.com/barrulus)_ [1.151.2] - Annex states and provinces by clicking on the map by _[barrulus](https://github.com/barrulus)_ [1.151.2] - Option to redraw labels, icons and relief only after a zoom by _[barrulus](https://github.com/barrulus)_ [1.151.2] -- Fix burg label tiers hidden at every zoom on maps migrated from pre-1.140 versions by _[barrulus](https://github.com/barrulus)_ [1.151.2] -- Brushes stamp by distance travelled: smooth, gap-free painting at any refresh rate by _[barrulus](https://github.com/barrulus)_ [1.151.1] +- Brushes stamp by distance travelled by _[barrulus](https://github.com/barrulus)_ [1.151.1] # Releases diff --git a/public/index.css b/public/index.css index 1c5bbe5647..c6ebc34e17 100644 --- a/public/index.css +++ b/public/index.css @@ -2499,8 +2499,8 @@ svg.button { #tourPromptButton { position: fixed; - bottom: 100px; - right: 36px; + bottom: 116px; /* stacked above the assistant bubble, sharing its column */ + right: 18px; width: 36px; height: 36px; border-radius: 50%; @@ -2632,23 +2632,117 @@ body.tour-free-roam * { margin: 0.8em 0 0.4em 0; } +/* Assistant call button: the flat, themed circle the OpenWidget launcher used to be — a + dark rounded badge with the glyph punched out of it, and the same press squish */ #helpAssistantBubble { position: fixed; right: 16px; bottom: 64px; /* clear of the scale bar in the corner */ - width: 44px; - height: 44px; - border-radius: 50%; - background: #35424d; - color: #fff; - font-size: 1.4em; + width: 40px; + height: 40px; + padding: 0; align-items: center; justify-content: center; + border: 0; + border-radius: 50%; + background: var(--light-solid); cursor: pointer; - box-shadow: 0 2px 6px rgb(0 0 0 / 35%); + user-select: none; + box-shadow: + 0 1px 1px -1px rgb(0 0 0 / 13%), + 0 2px 5px -2px rgb(0 0 0 / 18%); + animation: assistantBubbleIn 0.3s cubic-bezier(0.14, 0, 0, 1) backwards; z-index: 99; } +#helpAssistantBubble:focus-visible { + outline: 2px solid var(--dark-solid); + outline-offset: 3px; +} + +/* the badge alone squishes under the press, the circle stays put, as the launcher did */ +#helpAssistantBubble > .helpAssistantBubbleBadge { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 18px; + border-radius: 7px; + background: #0d0b0c; /* fallback for browsers without color-mix */ + background: color-mix(in srgb, var(--dark-solid) 40%, #000000); + color: var(--light-solid); /* the glyphs read as holes cut in the badge */ + font-size: 13px; + line-height: 1; + transition: transform 0.25s cubic-bezier(0.14, 0, 0, 1); +} + +#helpAssistantBubble:active > .helpAssistantBubbleBadge { + transform: scale(0.75); +} + +/* both glyphs sit on top of each other: the question mark morphs into the rounded square + under the pointer, and stays there while the dialog is open */ +#helpAssistantBubble .icon-help, +#helpAssistantBubble .helpAssistantBubbleMinimize { + position: absolute; + transition: + opacity 0.2s ease-out, + transform 0.25s cubic-bezier(0.14, 0, 0, 1); +} + +#helpAssistantBubble .icon-help { + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +#helpAssistantBubble .helpAssistantBubbleMinimize { + inset: 0; + width: 9px; + height: 8px; + margin: auto; + border: 2px solid currentcolor; + border-radius: 3px; + opacity: 0; + transform: scale(0.6); +} + +#helpAssistantBubble:hover .icon-help, +#helpAssistantBubble.open .icon-help { + opacity: 0; + transform: scale(0.6); +} + +#helpAssistantBubble:hover .helpAssistantBubbleMinimize, +#helpAssistantBubble.open .helpAssistantBubbleMinimize { + opacity: 1; + transform: none; +} + +@keyframes assistantBubbleIn { + from { + opacity: 0; + transform: scale(0.85); + } + + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + #helpAssistantBubble, + #helpAssistantBubble > .helpAssistantBubbleBadge, + #helpAssistantBubble .icon-help, + #helpAssistantBubble .helpAssistantBubbleMinimize { + animation: none; + transition: none; + } +} + /* the dialog is fixed-width; undo the global .dialog > div { width: max-content } so children wrap instead of overflowing */ #helpAssistant > div { diff --git a/public/main.js b/public/main.js index f42a43cc79..9dd29ac1ca 100644 --- a/public/main.js +++ b/public/main.js @@ -28,14 +28,9 @@ Layers.init(); // create the svg layer groups d3.select("#scaleBar") .on("mousemove", () => tip("Click to open Units Editor")) .on("click", () => window.Controllers.UnitsEditor.open()); -document.getElementById("helpAssistantBubble")?.addEventListener("click", () => window.Controllers.HelpAssistant.open()); -document.getElementById("helpAssistantBubble")?.addEventListener("mouseover", showDataTip); -document.getElementById("helpAssistantBubble")?.addEventListener("keydown", event => { - if (event.key === "Enter" || event.key === " ") { - if (event.key === " ") event.preventDefault(); - window.Controllers.HelpAssistant.open(); - } -}); +const helpAssistantBubble = document.getElementById("helpAssistantBubble"); +helpAssistantBubble?.addEventListener("click", () => window.Controllers.HelpAssistant.toggle()); +helpAssistantBubble?.addEventListener("mouseover", showDataTip); d3.select("#legend") .on("mousemove", () => tip("Drag to change the position. Click to hide the legend")) .on("click", () => clearLegend()); diff --git a/src/controllers/help-assistant.ts b/src/controllers/help-assistant.ts index 075d968fc0..6b9c91cba4 100644 --- a/src/controllers/help-assistant.ts +++ b/src/controllers/help-assistant.ts @@ -1,7 +1,3 @@ -// First-party help assistant: asks the fmg-bot gateway one question at a time and renders the -// answer as escaped markdown. Replaces the OpenWidget bubble. Client design spec: -// docs/superpowers/specs/2026-09-01-help-box-client-design.md (fork repo). - import { destroyDialog } from "@/components/dialog/dialog-helpers"; import type { Limits } from "@/services/help/api"; import { ask, getLimits, HelpApiError, OFFICIAL_ORIGIN, sendFeedback, signIn, signOut } from "@/services/help/api"; @@ -64,11 +60,25 @@ function isMounted(): boolean { return document.getElementById("helpAssistant") !== null; } +// The call button mirrors the dialog: while the panel is up it shows a close glyph, so a +// second click on it reads as "close" rather than "open again" +function markBubble(isOpen: boolean): void { + const bubble = document.getElementById("helpAssistantBubble"); + if (!bubble) return; + bubble.classList.toggle("open", isOpen); + bubble.setAttribute("aria-expanded", String(isOpen)); +} + +function toggle(): void { + if (isMounted()) $("#helpAssistant").dialog("close"); + else open(); +} + function open(): void { renderDialog(); $("#helpAssistant").dialog({ - title: "Azgaar's Assistant", + title: "Azgaar Assistant", position: { my: "center", at: "center", of: "svg" }, width: Math.min(420, window.innerWidth - 20), // fixed sane width — FMG dialogs otherwise grow with content resizable: false, @@ -78,10 +88,12 @@ function open(): void { retryTimer = null; } autoRetried = false; + markBubble(false); destroyDialog("helpAssistant"); } }); + markBubble(true); if (isOfficialOrigin()) void refreshLimits(); } @@ -316,9 +328,6 @@ function applyNotice(notice: WidgetNotice, error: HelpApiError, question: string }, 1000); } -// Sign-in is shown only on the exact official origin (or DEV, where the stub closes the -// loop) — NOT via isOfficialOrigin(): a staging build widens that gate, and sign-in from -// staging would land the user on production with their token (server redirect is fixed). const canSignIn = (): boolean => import.meta.env.DEV || location.origin === OFFICIAL_ORIGIN; function renderAuth(tier: string): void { @@ -367,4 +376,4 @@ async function refreshLimits(): Promise { } } -export const HelpAssistant = { open }; +export const HelpAssistant = { open, toggle }; diff --git a/src/index.html b/src/index.html index 0ef0d1f3d1..1bb731eee8 100644 --- a/src/index.html +++ b/src/index.html @@ -133,7 +133,7 @@ ? +