{"repo": "amousepad/lumiagent", "files": {"src/ui/drawer.ts": {"9a2b3600aa0d674a402e05f5ac414723cdc3eac5": {"t": 1785747195.882265, "n": 153716, "h": "60d371d4aa0e925a", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n CharacterStorageEntry,\n EditLogEntry,\n FrontendToBackend,\n MessageImage,\n MessageFile,\n WireImage,\n WireFile,\n RevertOutcomeWire,\n ScopeRef,\n SessionStatusWire,\n SessionSummaryWire,\n ChangeApprovalRequestWire,\n} from \"../types\";\nimport { characterScope, scopeKeyString } from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE, ICON_LUMIAGENT } from \"./icons\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\nimport { createChangeApprovalController } from \"./change-approval-modal\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveCustomIconUrl(): string | null {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return null;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n // Authoritative per-scope ledger snapshots, keyed by scopeKeyString. The\n // backend is the only writer of truth: every scope_edits_pushed replaces\n // one slot. The header badge and the workshop modal both read from here,\n // so there is no second cache to reconcile.\n scopeLedgers: Map;\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean; tpmLimit?: number | null; debugLogging?: boolean; requireChangeApproval?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n // Backend-authoritative session status. isGenerating / compacting are\n // derived from this on every push/load, never inferred locally. Null until\n // the first session_loaded / session_status arrives.\n sessionStatus: SessionStatusWire | null;\n // True when this client loaded into a generation it never tracked from\n // turn_started (a refresh mid-stream). The backend doesn't replay the\n // in-flight turn, so live events would build an incoherent partial bubble;\n // we suppress them and reload the authoritative session on the terminal\n // event instead.\n reattachedGeneration: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n // Set when the current session has a successful compaction boundary. Messages\n // with ts < compactedAt are pre-compaction (read-only, greyed).\n compactedAt: number | null;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n // Resized image attachments queued in the composer (base64 + mime), sent with\n // the next message. The workspace path is assigned at send time (needs sessionId).\n attachments: { id: string; data: string; mime_type: string }[];\n // Non-image file attachments queued in the composer. Uploaded (chunked) to the\n // workspace at ATTACH time so send is instant; only refs go on the message.\n fileAttachments: { id: string; file: File; name: string; size: number; mime: string; kind: \"text\" | \"binary\"; inlineContent?: string; path: string; status: \"uploading\" | \"ready\" | \"error\"; progress: number; error?: string }[];\n // Session id the queued attachments were uploaded under. For a not-yet-started\n // session this is generated eagerly so attach-time paths match the send.\n attachmentSessionId: string | null;\n // Wire attachments carried with pendingMessage across the start_session round-trip.\n pendingMessageImages: readonly WireImage[] | null;\n pendingMessageFiles: readonly WireFile[] | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n scopeStorage: readonly CharacterStorageEntry[];\n // View-only: which scope the workshop combo is focused on. Null = follow\n // the active session's scope. Never an authority for the header badge.\n workshopFocusScope: ScopeRef | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction showToast(text: string, durationMs = 4000): void {\n const t = document.createElement(\"div\");\n t.className = \"la-toast\";\n t.textContent = text;\n document.body.appendChild(t);\n requestAnimationFrame(() => t.classList.add(\"is-visible\"));\n setTimeout(() => {\n t.classList.remove(\"is-visible\");\n setTimeout(() => t.remove(), 300);\n }, durationMs);\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const customIconUrl = resolveCustomIconUrl();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n ...(customIconUrl ? { iconUrl: customIconUrl } : { iconSvg: ICON_LUMIAGENT }),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n const changeApprovals = createChangeApprovalController(ctx);\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n scopeLedgers: new Map(),\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n sessionStatus: null,\n reattachedGeneration: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n compactedAt: null,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n attachments: [],\n fileAttachments: [],\n attachmentSessionId: null,\n pendingMessageImages: null,\n pendingMessageFiles: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n scopeStorage: [],\n workshopFocusScope: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card to English\",\n send: \"Translate this card to English. Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings), UI surfaces (regex replace_string, trigger displays/values, bg-html, scriptstate_defaults), and card System/Post-History instructions. Skip anything already bilingual and internal identifiers. For greetings, `rewrite` the message and be careful of strings that have regex matches. Avoid using mechanical translation methods as sub-string matching will kill your translation, always manually edit surfaces -- unfortunately, there is no easy way to do this, you must inspect every surface and write to it. Ensure you read the full trigger scripts/lua, and find regex surfaces, and default variables before you touch anything, as those contain sensitive string semantics. Avoid touching any regex_find strings and their occurances in the rest of the card. Summarise findings and ask which surfaces to touch before writing. At each stage, please check in with the user to get them to test whether the card surfaces still work as expected, or if anything is broken.\"\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n void doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const attachBtn = el(\"button\", \"la-attach-btn\") as HTMLButtonElement;\n attachBtn.type = \"button\";\n attachBtn.setAttribute(\"aria-label\", \"Attach image\");\n attachBtn.innerHTML = '';\n const fileInput = document.createElement(\"input\");\n fileInput.type = \"file\";\n fileInput.multiple = true;\n fileInput.style.display = \"none\";\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, attachBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerAttachments = el(\"div\", \"la-attachments\");\n composerAttachments.style.display = \"none\";\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerAttachments, composerArea, composerStatus);\n composer.appendChild(composerInner);\n composer.appendChild(fileInput);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n void import(\"./image-cache\").then((m) => m.configureImageCache((path) => sendBackend({ type: \"ws_read_image\", path })));\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n // Resolve against the live connection list: if the saved id was deleted, send\n // the default the picker is actually showing (renderConnOptions falls back to\n // it silently, so state.connectionId can hold a stale dead id otherwise).\n const withConnection = (msg: T): T => {\n let id = state.connectionId;\n if (id && !state.connections.some((c) => c.id === id)) {\n id = state.connections.find((c) => c.is_default)?.id ?? null;\n }\n return id ? { ...msg, connectionId: id } : msg;\n };\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"All Characters\", sublabel: \"no focus, address any character by id\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.id === state.characterId ? `${c.name} (Active)` : c.name,\n sublabel: `${c.chat_count} chats · ${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n // Re-list responses can land mid-generation; reassert the busy lock so a\n // refresh never silently re-enables character switching.\n updateLocks();\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n updateLocks();\n };\n\n // The scope the active session points at. A no-character chat has no\n // active scope, so its header badge is always 0 (Lumiverse-scope edits\n // surface in the workshop, not the header).\n const activeScope = (): ScopeRef | null =>\n state.characterId !== null ? characterScope(state.characterId) : null;\n\n // Pure projection of one authoritative snapshot. Never reconciles.\n const scopeEntries = (scope: ScopeRef | null): readonly EditLogEntry[] =>\n scope ? state.scopeLedgers.get(scopeKeyString(scope)) ?? [] : [];\n\n // What the workshop modal shows: the combo-focused scope if the user\n // picked one, otherwise the active session's scope.\n const workshopScope = (): ScopeRef | null => state.workshopFocusScope ?? activeScope();\n\n // Combo options for the workshop. scopeStorage only lists scopes that\n // already have a ledger, so a freshly-selected diffless character would be\n // absent and the combo would fall through to the first character that does\n // have edits. Always surface the active character so opening the workshop\n // lands on it (empty diff is the correct, ephemeral view).\n const buildScopeOptions = (): ReadonlyArray<{ scope: ScopeRef; label: string; changeCount: number; chatCount?: number; msgCount?: number; attachedToPersona?: { id: string; name: string } }> => {\n const opts = state.scopeStorage.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n // Reverted commits are purged, so editCount is the live change count.\n changeCount: e.editCount,\n ...(e.chatCount !== undefined ? { chatCount: e.chatCount } : {}),\n ...(e.msgCount !== undefined ? { msgCount: e.msgCount } : {}),\n ...(e.attachedToPersona ? { attachedToPersona: e.attachedToPersona } : {}),\n }));\n const active = activeScope();\n if (active && !opts.some((o) => scopeKeyString(o.scope) === scopeKeyString(active))) {\n opts.unshift({ scope: active, label: state.characterName ?? \"Current character\", changeCount: 0 });\n }\n return opts;\n };\n\n const updateSessionBar = () => {\n const scope = activeScope();\n if (scope) {\n const source = scopeEntries(scope);\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n editsBadge.classList.toggle(\"has-edits\", source.length > 0);\n return;\n }\n // No-character session: there is no active scope, so the badge reflects\n // Lumiverse-scope changes (persona / preset / world_book / regex / chat),\n // summed from the scope storage list (non-character entries carry `scope`).\n let total = 0;\n for (const e of state.scopeStorage) if (e.scope && e.scope.kind !== \"character\") total += e.liveEditCount;\n editsCount.textContent = String(total);\n editsBadge.classList.toggle(\"has-edits\", total > 0);\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n // Refresh composer + input locks: compaction holds the session for several\n // seconds, during which Send must disable and the character / pin / connection\n // controls must lock, exactly as they do during a generation.\n updateComposer();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n // What a Send click does, derived from the backend-authoritative status\n // (with a local-message fallback only for the pre-first-status window of a\n // brand-new session). The click handler dispatches on this; it never\n // recomputes the decision.\n let sendMode: \"new\" | \"continue\" | \"recover\" | \"disabled\" = \"disabled\";\n\n // Lock the surfaces that mutate the session the agent is using. Anything\n // other than an idle session means a generation or compaction is live.\n const updateLocks = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n charCombo.setDisabled(busy);\n chatPinBtn.disabled = busy;\n // connCombo is also disabled when there are no connections; don't\n // re-enable it here in that case.\n if (state.connections.length > 0) connCombo.setDisabled(busy);\n };\n\n const setComposerStatus = (text: string) => {\n // Never stomp a sticky error (e.g. the start_session timeout notice).\n // doSend clears is-error before a fresh attempt.\n if (composerStatus.classList.contains(\"is-error\")) return;\n composerStatus.textContent = text;\n };\n\n const updateComposer = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n if (busy) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n // Can't abort before the backend has acknowledged the session.\n cancelBtn.disabled = state.startingSession;\n textarea.disabled = false;\n setComposerStatus(\n state.startingSession ? \"starting session...\"\n : state.compacting ? \"compacting context...\"\n : \"agent is working...\",\n );\n sendMode = \"disabled\";\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n\n const st = state.sessionStatus;\n const statusFresh = !!st && st.sessionId === state.sessionId;\n // Attachments alone are sendable (image/file-only message), so they count\n // as content for send-mode purposes.\n const hasText = textarea.value.trim().length > 0 || state.attachments.length > 0 || state.fileAttachments.length > 0;\n const recoverable = statusFresh && st!.phase === \"idle\"\n && st!.lastAssistantStatus === \"errored\" && st!.lastAssistantEmpty\n && st!.lastAssistantId !== null;\n // Authoritative last-role; fall back to the local mirror only before the\n // first status arrives (typing into a brand-new unsent session).\n const lastRole = statusFresh\n ? st!.lastMessageRole\n : (state.messages[state.messages.length - 1]?.role ?? null);\n\n if (hasText) {\n // Typed text always sends: new session, new user turn, or a follow-up\n // after a completed assistant message.\n sendMode = \"new\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n } else if (recoverable) {\n sendMode = \"recover\";\n setComposerStatus(\"Last response failed empty. Press Send to retry it.\");\n } else if (lastRole === \"user\" && !!state.sessionId) {\n sendMode = \"continue\";\n setComposerStatus(\"\");\n } else {\n // Assistant message last (completed/cancelled), or empty thread with\n // no text: nothing to send.\n sendMode = \"disabled\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n }\n // Block send until every queued file finishes uploading.\n const uploading = state.fileAttachments.some((a) => a.status === \"uploading\");\n sendBtn.disabled = sendMode === \"disabled\" || uploading;\n if (uploading) setComposerStatus(\"Uploading attachments…\");\n }\n updateLocks();\n updateCompactButton();\n };\n\n // The single point where backend-authoritative status becomes UI state.\n // Every session_loaded / session_status flows through here so a reload mid\n // generation reconstructs the button, locks, and ring correctly.\n const applyStatus = (st: SessionStatusWire): void => {\n if (st.sessionId !== state.sessionId) return;\n state.sessionStatus = st;\n state.isGenerating = st.phase === \"generating\";\n state.compacting = st.phase === \"compacting\";\n if (st.phase !== \"idle\") {\n state.startingSession = false;\n clearStartTimeout();\n }\n state.contextPromptTokens = st.promptTokens;\n if (st.contextTokens > 0) state.contextTokens = st.contextTokens;\n updateComposer();\n updateSessionBar();\n };\n\n // Optimistic prune so the UI doesn't flash the reverted row before the\n // backend's authoritative scope_edits_pushed arrives. The session mirror\n // and every scope slot drop the id; the push then re-syncs the one scope.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n for (const [key, entries] of state.scopeLedgers) {\n state.scopeLedgers.set(key, entries.filter((e) => !ids.has(e.id)));\n }\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire, scope: ScopeRef): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n // Reset the modal's revert button (stuck on \"Reverting…\"): only a clean\n // revert pushes scope_edits_pushed, so a failure leaves it disabled.\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed) {\n // Use the edit's own scope (persona / chat / preset / world_book /\n // regex_script), not state.characterId, so a force-revert of a\n // non-character edit hits the right ledger and works in no-character chats.\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, force: true, scope });\n } else if (state.diffModal) {\n // Cancelled the force-revert confirm: reset the modal's stuck button.\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => scopeEntries(workshopScope()),\n getScopes: () => buildScopeOptions(),\n getSelectedScope: () => workshopScope(),\n onSelectScope: (scope) => {\n state.workshopFocusScope = scope;\n sendBackend({ type: \"load_character_workshop\", characterId: scope.id, scope });\n },\n onRevert: async (editId) => {\n const scope = workshopScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertAll: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Revert all edits\",\n message: \"Revert every live edit in this scope? Cascade-aware. The ledger keeps history so reverts can be undone individually.\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_character_all\", characterId: scope.id, scope });\n },\n onForget: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget changes\",\n message: \"Permanently clear this scope's edit ledger? The underlying data is NOT touched, but you won't be able to revert any of these edits afterwards.\",\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (c.confirmed) sendBackend({ type: \"squash_character\", characterId: scope.id, scope });\n },\n onScopesNeeded: () => sendBackend({ type: \"list_characters_storage\" }),\n onClose: () => {\n state.diffModal = null;\n // Drop the combo focus so reopening follows the active session's\n // scope again. No refetch: the active scope's slot is already cached\n // and the badge reads it directly.\n state.workshopFocusScope = null;\n },\n filesPanel: state.workspacePanel.root,\n }, {\n ...(initialEditId !== undefined ? { initialEditId } : {}),\n // No character in this chat: open straight to the Lumiverse tab,\n // since the Characters tab would have nothing to show.\n ...(state.characterId === null ? { initialTab: \"lumiverse\" as const } : {}),\n });\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n // Per-message banners count edits this session attributed to a message,\n // across every scope, so the session mirror is the right source.\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n return state.edits.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n return state.edits.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n // Use the edit's OWN scope, not state.characterId: an edit can be filed\n // under persona/chat/preset/wb/rx, and in a no-character session\n // state.characterId is null. Without this the inline Revert hit the\n // wrong ledger (or was a dead no-op) — the modal path already does this.\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n // revert_edits_bulk addresses ONE ledger, but a card's edits can span\n // scopes; group by each edit's scope and emit one bulk call per scope.\n const byScope = new Map();\n for (const editId of editIds) {\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) continue;\n const key = scopeKeyString(scope);\n let g = byScope.get(key);\n if (!g) { g = { scope, ids: [] }; byScope.set(key, g); }\n g.ids.push(editId);\n }\n for (const { scope, ids } of byScope.values()) {\n sendBackend({ type: \"revert_edits_bulk\", characterId: scope.id, editIds: ids, scope, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n }\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n onForkMessage: (messageId: string) => {\n if (!state.sessionId) return;\n sendBackend({ type: \"fork_session\", sourceSessionId: state.sessionId, messageId });\n },\n isPreCompaction: (messageTs: number) => state.compactedAt !== null && messageTs < state.compactedAt,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n if (id === state.characterId) return;\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish before switching characters.\";\n composerStatus.classList.add(\"is-error\");\n renderCharOptions();\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId });\n if (state.sessionId !== null) {\n // Switch focus in place, keeping the thread. The backend owns the switch\n // (mutates the session, keeps the cached prompt prefix, learns the agent\n // via a context note) and pushes authoritative state back via focus_set,\n // which updates the picker / pin / per-character lists. The frontend does\n // not mutate focus state itself.\n sendBackend({ type: \"set_focus\", sessionId: state.sessionId, characterId: id });\n } else {\n // No active session yet: create one focused on the pick.\n state.characterId = id;\n // Queue the newest chat to auto-pin once chats_pushed lands (the fresh\n // session has no pin yet); without this the first pick leaves no pin.\n state.autoPinNeeded = !!id;\n renderCharOptions();\n startNewSession();\n if (id) {\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n }\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.attachments = [];\n state.fileAttachments = [];\n state.attachmentSessionId = null;\n state.pendingMessageImages = null;\n state.pendingMessageFiles = null;\n renderAttachments();\n // A fresh pending session gets no status / context_usage push from the\n // backend (nothing generates yet), so reset the context-derived UI here\n // or the compaction wheel keeps showing the previous session's usage.\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.reattachedGeneration = false;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", state.characterId ? \"No chats yet for this character.\" : \"No chats available.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n const memberNames = c.memberCharacterIds\n .map((id) => state.characters.find((character) => character.id === id)?.name ?? id)\n .join(\", \");\n const kind = c.isGroup ? `Group: ${memberNames} · ` : \"\";\n main.append(el(\"div\", \"la-session-item-meta\", `${kind}updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n const tpmRow = el(\"div\", \"la-settings-row\");\n tpmRow.append(el(\"label\", \"la-settings-row-label\", \"TPM limit (tk/min)\"));\n const tpmInput = document.createElement(\"input\");\n tpmInput.type = \"number\";\n tpmInput.className = \"la-slider-input\";\n tpmInput.min = \"1\";\n tpmInput.step = \"1\";\n tpmInput.placeholder = \"off\";\n tpmRow.appendChild(tpmInput);\n wrap.appendChild(tpmRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Pauses requests when prompt+completion tokens in the last 60s would exceed this. Set to your provider's tokens-per-minute quota (e.g. 250000 for Gemini free tier). Empty = no throttle.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Change approval\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Pause before each card, chat, lorebook, regex, external-provider, or workspace write so you can approve or reject it.\"));\n const changeApprovalRow = el(\"div\", \"la-settings-row\");\n changeApprovalRow.append(el(\"label\", \"la-settings-row-label\", \"Ask before changes\"));\n const changeApprovalInput = document.createElement(\"input\");\n changeApprovalInput.type = \"checkbox\";\n changeApprovalInput.className = \"la-checkbox\";\n changeApprovalRow.appendChild(changeApprovalInput);\n wrap.appendChild(changeApprovalRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n const debugLogRow = el(\"div\", \"la-settings-row\");\n debugLogRow.append(el(\"label\", \"la-settings-row-label\", \"Debug logging\"));\n const debugLogInput = document.createElement(\"input\");\n debugLogInput.type = \"checkbox\";\n debugLogInput.className = \"la-checkbox\";\n debugLogRow.appendChild(debugLogInput);\n wrap.appendChild(debugLogRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"OFF by default. When ON, verbose backend diagnostics (send_message, loop turns, llm stream, phone-line) are written to the host log. Warnings and errors always log.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n // handleUpdateSettings re-sends settings after every commit (blur / drag\n // release). That async echo must not overwrite a text field the user is\n // currently editing, or it silently discards characters typed during the\n // round-trip. Skip the rewrite while one of the form's text fields is focused.\n const active = document.activeElement;\n if (active === personaArea || active === promptArea || active === jbArea\n || active === wsCapInput || active === toolCapInput || active === tpmInput) return;\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n debugLogInput.checked = s.debugLogging ?? false;\n changeApprovalInput.checked = s.requireChangeApproval ?? false;\n tpmInput.value = s.tpmLimit ? String(s.tpmLimit) : \"\";\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n tpmLimit: parsePosInt(tpmInput.value),\n debugLogging: debugLogInput.checked,\n requireChangeApproval: changeApprovalInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput, tpmInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput, debugLogInput, changeApprovalInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconFrame = el(\"div\", \"la-icon-settings-frame\");\n const setIconPreview = (customUrl: string | null): void => {\n if (customUrl) {\n iconFrame.innerHTML = \"\";\n const img = document.createElement(\"img\");\n img.src = customUrl;\n img.alt = \"current icon\";\n iconFrame.appendChild(img);\n } else {\n iconFrame.innerHTML = ICON_LUMIAGENT;\n }\n };\n setIconPreview(resolveCustomIconUrl());\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconFrame);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n setIconPreview(dataUrl);\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n setIconPreview(null);\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const formatBytes = (n: number): string => n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / (1024 * 1024)).toFixed(1)} MB`;\n\n const renderAttachments = (): void => {\n composerAttachments.replaceChildren();\n if (state.attachments.length === 0 && state.fileAttachments.length === 0) {\n composerAttachments.style.display = \"none\"; updateComposer(); return;\n }\n composerAttachments.style.display = \"\";\n for (const att of state.attachments) {\n const chip = el(\"div\", \"la-attachment\");\n const img = document.createElement(\"img\");\n img.src = `data:${att.mime_type};base64,${att.data}`;\n img.alt = \"attachment\";\n const rm = el(\"button\", \"la-attachment-remove\") as HTMLButtonElement;\n rm.type = \"button\";\n rm.setAttribute(\"aria-label\", \"Remove attachment\");\n rm.textContent = \"×\";\n rm.addEventListener(\"click\", () => {\n state.attachments = state.attachments.filter((a) => a.id !== att.id);\n renderAttachments();\n });\n chip.append(img, rm);\n composerAttachments.appendChild(chip);\n }\n for (const att of state.fileAttachments) {\n const chip = el(\"div\", `la-file-attachment${att.status === \"uploading\" ? \" is-uploading\" : \"\"}${att.status === \"error\" ? \" is-error\" : \"\"}`);\n chip.setAttribute(\"data-att-id\", att.id);\n const sizeText = att.status === \"error\" ? `failed: ${att.error ?? \"upload error\"}` : formatBytes(att.size);\n chip.append(\n el(\"span\", \"la-file-attachment-name\", att.name),\n el(\"span\", \"la-file-attachment-size\", sizeText),\n );\n const progress = el(\"div\", \"la-file-progress\");\n const bar = el(\"div\", \"la-file-progress-bar\");\n bar.style.width = `${Math.round(att.progress * 100)}%`;\n progress.appendChild(bar);\n chip.appendChild(progress);\n const rm = el(\"button\", \"la-attachment-remove\") as HTMLButtonElement;\n rm.type = \"button\";\n rm.setAttribute(\"aria-label\", \"Remove file\");\n rm.textContent = \"×\";\n rm.addEventListener(\"click\", () => {\n state.fileAttachments = state.fileAttachments.filter((a) => a.id !== att.id);\n renderAttachments();\n });\n chip.appendChild(rm);\n composerAttachments.appendChild(chip);\n }\n updateComposer();\n };\n\n const addImageBlobs = async (blobs: readonly Blob[]): Promise => {\n const { resizeBlob, isSupportedImage, MAX_IMAGES } = await import(\"./image-resize\");\n for (const blob of blobs) {\n if (state.attachments.length >= MAX_IMAGES) {\n composerStatus.textContent = `Up to ${MAX_IMAGES} images per message.`;\n composerStatus.classList.add(\"is-error\");\n break;\n }\n if (!isSupportedImage(blob.type)) continue;\n try {\n const r = await resizeBlob(blob);\n state.attachments.push({ id: makeId(\"att\"), data: r.data, mime_type: r.mime_type });\n composerStatus.classList.remove(\"is-error\");\n renderAttachments();\n } catch (err) {\n composerStatus.textContent = `Couldn't attach image: ${(err as Error).message}`;\n composerStatus.classList.add(\"is-error\");\n }\n }\n };\n\n const extForMime = (mime: string): string => {\n switch (mime) {\n case \"image/png\": return \"png\";\n case \"image/jpeg\": return \"jpg\";\n case \"image/gif\": return \"gif\";\n case \"image/webp\": return \"webp\";\n default: return \"img\";\n }\n };\n\n // Assign a workspace path per attachment (sessionId is known by send time),\n // pre-seed the image cache so the sent bubble renders instantly, and return\n // the wire payload (path + bytes) plus the persisted refs.\n const buildImagePayload = async (sid: string): Promise<{ wire: WireImage[]; refs: MessageImage[] }> => {\n const { seedImage } = await import(\"./image-cache\");\n const wire: WireImage[] = [];\n const refs: MessageImage[] = [];\n for (const att of state.attachments) {\n const path = `attachments/${sid}/${att.id}.${extForMime(att.mime_type)}`;\n seedImage(path, `data:${att.mime_type};base64,${att.data}`);\n wire.push({ path, data: att.data, mime_type: att.mime_type });\n refs.push({ path, mime_type: att.mime_type });\n }\n return { wire, refs };\n };\n\n // Outstanding chunked uploads keyed by transferId; resolved by ws_upload_complete.\n const pendingUploads = new Map void; reject: (e: Error) => void }>();\n type FileAtt = (typeof state.fileAttachments)[number];\n\n // Session id the queued attachments upload under. For a not-yet-started\n // session, generated eagerly so attach-time paths match the eventual send.\n const composerSid = (): string => {\n if (state.sessionId) return state.sessionId;\n if (!state.attachmentSessionId) state.attachmentSessionId = makeId(\"sess\");\n return state.attachmentSessionId;\n };\n\n // Update one file chip's progress bar / status in place (no full re-render).\n const setChipProgress = (att: FileAtt): void => {\n const chip = composerAttachments.querySelector(`[data-att-id=\"${att.id}\"]`);\n if (!chip) return;\n chip.classList.toggle(\"is-uploading\", att.status === \"uploading\");\n chip.classList.toggle(\"is-error\", att.status === \"error\");\n const bar = chip.querySelector(\".la-file-progress-bar\") as HTMLElement | null;\n if (bar) bar.style.width = `${Math.round(att.progress * 100)}%`;\n };\n\n // Stream a file to its workspace path on attach. Chunk-sent progress caps at\n // 95%; the backend ack (ws_upload_complete) flips it to ready.\n const uploadAttachment = async (att: FileAtt): Promise => {\n const transferId = makeId(\"up\");\n const acked = new Promise((resolve, reject) => {\n const timer = setTimeout(() => { if (pendingUploads.delete(transferId)) reject(new Error(\"upload timed out\")); }, 5 * 60_000);\n pendingUploads.set(transferId, { resolve: () => { clearTimeout(timer); resolve(); }, reject: (e) => { clearTimeout(timer); reject(e); } });\n });\n try {\n const { streamUpload } = await import(\"./file-upload\");\n const bytes = new Uint8Array(await att.file.arrayBuffer());\n await streamUpload(sendBackend, att.path, bytes, transferId, (frac) => { att.progress = Math.min(0.95, frac * 0.95); setChipProgress(att); });\n await acked;\n att.status = \"ready\"; att.progress = 1;\n } catch (err) {\n att.status = \"error\"; att.error = (err as Error).message;\n } finally {\n pendingUploads.delete(transferId);\n setChipProgress(att);\n updateComposer();\n }\n };\n\n const addFiles = async (files: readonly File[]): Promise => {\n const { isTextFile, INLINE_TEXT_BYTES, MAX_FILES, safeAttachmentName } = await import(\"./file-upload\");\n for (const file of files) {\n if (state.fileAttachments.length >= MAX_FILES) {\n composerStatus.textContent = `Up to ${MAX_FILES} files per message.`;\n composerStatus.classList.add(\"is-error\");\n break;\n }\n const kind: \"text\" | \"binary\" = isTextFile(file) ? \"text\" : \"binary\";\n let inlineContent: string | undefined;\n if (kind === \"text\" && file.size <= INLINE_TEXT_BYTES) {\n try { inlineContent = await file.text(); } catch { /* fall back to ref-only */ }\n }\n const id = makeId(\"file\");\n const path = `attachments/${composerSid()}/${id}-${safeAttachmentName(file.name)}`;\n const att: FileAtt = { id, file, name: file.name, size: file.size, mime: file.type, kind, ...(inlineContent !== undefined ? { inlineContent } : {}), path, status: \"uploading\", progress: 0 };\n state.fileAttachments.push(att);\n composerStatus.classList.remove(\"is-error\");\n renderAttachments();\n void uploadAttachment(att);\n }\n updateComposer();\n };\n\n // Wire refs from the already-uploaded (ready) file attachments. Errored\n // uploads are dropped.\n const readyFileRefs = (): WireFile[] =>\n state.fileAttachments\n .filter((a) => a.status === \"ready\")\n .map((att) => ({ path: att.path, name: att.name, size: att.size, mime: att.mime, kind: att.kind, ...(att.inlineContent !== undefined ? { inlineContent: att.inlineContent } : {}) }));\n\n const appendUserMessage = (text: string, images?: readonly MessageImage[], files?: readonly MessageFile[]): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n ...(images && images.length > 0 ? { images } : {}),\n ...(files && files.length > 0 ? { files } : {}),\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string, images?: readonly WireImage[], files?: readonly WireFile[]): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length, images: images?.length ?? 0, files: files?.length ?? 0 });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n ...(images && images.length > 0 ? { images } : {}),\n ...(files && files.length > 0 ? { files } : {}),\n }));\n // This client is tracking the generation from its start, so the live\n // stream is coherent: never treat it as a reattach.\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n let sending = false;\n const doSend = async (): Promise => {\n const text = textarea.value.trim();\n if (sending || state.isGenerating || state.startingSession || state.compacting) return;\n\n const hasImages = state.attachments.length > 0;\n const hasFiles = state.fileAttachments.length > 0;\n\n // Empty send with no attachments: the action is whatever sendMode resolved\n // to in updateComposer (authoritative-status driven). Never recompute here.\n if (text.length === 0 && !hasImages && !hasFiles) {\n if (sendMode === \"recover\") {\n const targetId = state.sessionStatus?.lastAssistantId;\n if (!targetId || !state.sessionId) return;\n composerStatus.classList.remove(\"is-error\");\n // Drop the empty errored bubble locally so it doesn't linger; the\n // backend regenerate truncates from the preceding user message and\n // re-runs, replacing it.\n state.messages = state.messages.filter((m) => m.id !== targetId);\n rerenderThread();\n sendBackend(withConnection({\n type: \"regenerate_assistant_message\",\n sessionId: state.sessionId,\n assistantMessageId: targetId,\n editsAction: \"keep\",\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n if (sendMode === \"continue\" && state.sessionId) {\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId,\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n return; // disabled: nothing to send\n }\n\n // Files upload on attach; block send until they finish. The send button is\n // already disabled while uploading, so this is just a safety net.\n if (state.fileAttachments.some((a) => a.status === \"uploading\")) {\n composerStatus.textContent = \"Wait for attachments to finish uploading.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n const isNew = !state.sessionId;\n const sid = composerSid();\n const fileRefs = readyFileRefs();\n\n // Images are small (resized) and ride inline; build them now. Guard re-entry\n // across the brief async build.\n sending = true;\n updateComposer();\n let imageWire: WireImage[]; let imageRefs: MessageImage[];\n try {\n const img = await buildImagePayload(sid);\n imageWire = img.wire; imageRefs = img.refs;\n } catch (err) {\n composerStatus.textContent = `Attachment failed: ${(err as Error).message}`;\n composerStatus.classList.add(\"is-error\");\n sending = false;\n updateComposer();\n return;\n }\n sending = false;\n composerStatus.textContent = \"\";\n state.attachments = [];\n state.fileAttachments = [];\n state.attachmentSessionId = null;\n renderAttachments();\n\n if (!isNew) {\n const msg = appendUserMessage(text, imageRefs, fileRefs);\n dispatchSendForExisting(sid, msg.id, text, imageWire, fileRefs);\n return;\n }\n\n state.sessionId = sid;\n state.messages = [];\n state.edits = [];\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text, imageRefs, fileRefs);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n state.pendingMessageImages = imageWire.length > 0 ? imageWire : null;\n state.pendingMessageFiles = fileRefs.length > 0 ? fileRefs : null;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: sid,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.pendingMessageImages = null;\n state.pendingMessageFiles = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n // Route attachments: images to the vision path, everything else to file upload.\n const intake = (files: readonly File[]): void => {\n const imgs = files.filter((f) => f.type.startsWith(\"image/\"));\n const rest = files.filter((f) => !f.type.startsWith(\"image/\"));\n if (imgs.length > 0) void addImageBlobs(imgs);\n if (rest.length > 0) void addFiles(rest);\n };\n sendBtn.addEventListener(\"click\", () => void doSend());\n attachBtn.addEventListener(\"click\", () => fileInput.click());\n fileInput.addEventListener(\"change\", () => {\n if (fileInput.files && fileInput.files.length > 0) intake([...fileInput.files]);\n fileInput.value = \"\";\n });\n textarea.addEventListener(\"paste\", (ev) => {\n const items = ev.clipboardData?.items;\n if (!items) return;\n const blobs: File[] = [];\n for (const it of items) {\n if (it.kind === \"file\") {\n const f = it.getAsFile();\n if (f) blobs.push(f);\n }\n }\n if (blobs.length > 0) { ev.preventDefault(); intake(blobs); }\n });\n composer.addEventListener(\"dragover\", (ev) => {\n if (ev.dataTransfer && [...ev.dataTransfer.items].some((i) => i.kind === \"file\")) {\n ev.preventDefault();\n composer.classList.add(\"la-composer-dragover\");\n }\n });\n composer.addEventListener(\"dragleave\", (ev) => {\n if (ev.target === composer) composer.classList.remove(\"la-composer-dragover\");\n });\n composer.addEventListener(\"drop\", (ev) => {\n composer.classList.remove(\"la-composer-dragover\");\n const files = ev.dataTransfer?.files;\n if (!files || files.length === 0) return;\n ev.preventDefault();\n intake([...files]);\n });\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n void doSend();\n }\n });\n\n // Returns the id of the user message that's the backend's PRIMARY rolling\n // cache anchor: 1 user-turn behind the latest, matching LAG_USER_TURNS=1 in\n // cache-control.ts. (Was off by one — anchored 2 turns back — so the divider\n // and the edit pre-warn pointed at the wrong message.) Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker stamping.\n // Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes to them)\n // auto-cache the prompt prefix upstream regardless, so the cue still appears.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 1) return null;\n return userMsgs[userMsgs.length - 1 - 1]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n // Use the edit's own scope (see makeThreadDeps.onRevertEdit).\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n // A live handle proves a turn is in flight, so the message it backs is\n // the last assistant message regardless of its status field. Binding\n // only to status===\"streaming\" left token/tool events writing to the\n // DOM handle but not the message model when the status had already been\n // flipped, so the post-finalize static rerender drew an empty bubble.\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n if (!handle) return;\n // Recover the message pointer if it was cleared mid-stream. Bailing here\n // used to leave the turn half-finalized: the handle's DOM had the streamed\n // content but the message stayed status=\"streaming\" with whatever blocks\n // it had, and the next rerenderThread drew it from the (possibly empty)\n // model instead of the live DOM, so the whole response vanished until a\n // reload rebuilt it from the backend-persisted session.\n rebindCurrentAssistantMessage();\n const msg = state.currentAssistantMessage;\n handle.setStatus(status);\n if (msg) msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n const offBackendMessages = ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n // Fresh session: no compaction yet.\n state.compactedAt = null;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n const imgs = state.pendingMessageImages ?? undefined;\n const files = state.pendingMessageFiles ?? undefined;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.pendingMessageImages = null;\n state.pendingMessageFiles = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text, imgs, files);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n // Loading a session is a clean boundary. A sticky composer error from\n // a prior failed start would otherwise suppress setComposerStatus for\n // the freshly loaded session (the is-error guard never lifts on its\n // own across a session switch).\n composerStatus.classList.remove(\"is-error\");\n composerStatus.textContent = \"\";\n // Drop any live streaming pointers from a previously-loaded session so\n // a still-running background generation can't keep mutating this thread.\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n state.compactedAt = msg.compactedAt ?? null;\n // Drop composer attachments queued against the previous session.\n state.attachments = [];\n state.fileAttachments = [];\n state.attachmentSessionId = null;\n renderAttachments();\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n // Refresh picker labels so \"(Active)\" tracks the loaded session's char.\n renderCharOptions();\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n applyStatus(msg.status);\n // Loaded into a live generation we never tracked from turn_started.\n // Suppress its partial live stream; reload on the terminal event.\n state.reattachedGeneration = msg.status.phase === \"generating\";\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n } else {\n // No-character session: the header badge counts Lumiverse-scope\n // changes, sourced from the scope storage list. Fetch it so the\n // badge is populated without opening the workshop.\n sendBackend({ type: \"list_characters_storage\" });\n sendBackend({ type: \"list_chats\", characterId: null, sessionId: msg.sessionId });\n }\n break;\n case \"session_status\":\n applyStatus(msg.status);\n break;\n case \"session_forked\": {\n // The user stays in the current chat. The fork lives in a separate\n // session; surface a toast so they know it landed and can open it\n // via the Sessions menu. Critically we do NOT load the fork or touch\n // the current session's llmHistory/cache here.\n showToast(\"Forked into a new session. Open Sessions to switch to it.\");\n sendBackend({ type: \"list_sessions\" });\n break;\n }\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n // Deleting the ACTIVE session mid-generation: the backend aborts then\n // immediately sends session_deleted, and the trailing generation_cancelled\n // is then dropped (sessionId is now null). Reset the in-flight flags here\n // or the composer stays wedged in \"working...\" with a dead cancel button.\n state.isGenerating = false;\n state.startingSession = false;\n state.compacting = false;\n state.reattachedGeneration = false;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n clearStartTimeout();\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n case \"chat_event\":\n // Drop events for any session other than the one currently loaded.\n // Loading a different session while this one streams would otherwise\n // render its tokens / tool calls / edits into the wrong thread.\n if (msg.sessionId !== state.sessionId) break;\n // While reattached to a generation we didn't track from its start,\n // the live stream is missing everything before the refresh. Rendering\n // it would build an incoherent partial bubble and fight the\n // authoritative reload on the terminal event. Drop it.\n if (state.reattachedGeneration) break;\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n // A terminal event for a different (backgrounded) session must not\n // finalize the currently-loaded thread.\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n // No-character turn may have produced Lumiverse-scope edits; refresh\n // the scope storage so the header badge reflects them.\n if (state.characterId === null) sendBackend({ type: \"list_characters_storage\" });\n break;\n case \"generation_cancelled\":\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n clearStartTimeout();\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome, msg.scope);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"scope_edits_pushed\": {\n // Authoritative snapshot for exactly one scope. Replace its slot,\n // refresh the badge, and only re-render the modal if it is currently\n // showing this scope (a background push for another scope must not\n // yank the view the user is looking at).\n state.scopeLedgers.set(scopeKeyString(msg.scope), [...msg.entries]);\n updateSessionBar();\n const shown = workshopScope();\n if (state.diffModal && shown && scopeKeyString(shown) === scopeKeyString(msg.scope)) {\n state.diffModal.setEdits(msg.entries);\n }\n break;\n }\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n // The array was replaced with fresh objects. If a turn is in flight\n // (mid-turn squash emits this), rebind the streaming pointer to the new\n // element by id, else finalizeAssistantTurn would set \"complete\" on the\n // orphaned old object and the live message stays \"streaming\" (no actions).\n if (state.streamingAssistant && state.currentAssistantMessage) {\n const reboundId = state.currentAssistantMessage.id;\n const rebound = state.messages.find((m) => m.id === reboundId && m.role === \"assistant\");\n if (rebound && rebound.role === \"assistant\") state.currentAssistantMessage = rebound;\n }\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n }\n break;\n case \"focus_set\":\n // Authoritative focus state from the backend (it owns the switch). The\n // thread is preserved; only the focus marker, pin, and per-character\n // lists refresh.\n if (msg.sessionId === state.sessionId) {\n const changed = msg.characterId !== state.characterId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n renderCharOptions();\n updateComposer();\n updateSessionBar();\n // A real switch refreshes the chat list. The backend may preserve a\n // group-chat pin when the new focus is another member.\n if (changed) {\n state.pinnedChatId = msg.pinnedChatId;\n state.chatsForCharacter = [];\n state.autoPinNeeded = msg.characterId !== null && msg.pinnedChatId === null;\n setChatPinned(msg.pinnedChatId !== null);\n if (msg.characterId) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n } else {\n // No-character focus: refresh the Lumiverse-scope workshop view.\n sendBackend({ type: \"list_characters_storage\" });\n }\n }\n }\n break;\n case \"focus_rejected\":\n // The backend refused the switch (generation in flight, character gone).\n // Non-destructive: snap the combo back to the real focus and surface a\n // status line. Never touch the streaming bubble / composer-generating state.\n if (msg.sessionId === state.sessionId) {\n renderCharOptions();\n composerStatus.textContent = msg.reason;\n composerStatus.classList.add(\"is-error\");\n }\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n tpmLimit: msg.tpmLimit,\n debugLogging: msg.debugLogging,\n requireChangeApproval: msg.requireChangeApproval,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_image_ready\":\n void import(\"./image-cache\").then((m) => m.resolveImage(msg.path, `data:${msg.mimeType};base64,${msg.dataBase64}`));\n break;\n case \"ws_image_error\":\n void import(\"./image-cache\").then((m) => m.failImage(msg.path));\n break;\n case \"ws_upload_complete\": {\n const p = pendingUploads.get(msg.transferId);\n if (p) { pendingUploads.delete(msg.transferId); p.resolve(); }\n break;\n }\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n updateComposer();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\": {\n // Scope list for the workshop combo only. The badge is NOT touched\n // here: it is a pure projection of scopeLedgers, refreshed by the\n // authoritative scope_edits_pushed the backend sends alongside this.\n state.scopeStorage = msg.entries;\n state.diffModal?.setScopes(buildScopeOptions());\n // No-character badge is derived from scopeStorage, so refresh it when\n // the list lands. (With a character it reads scopeLedgers; harmless.)\n updateSessionBar();\n break;\n }\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else if (msg.op === \"image_resize\") {\n const { resizeBase64 } = await import(\"./image-resize\");\n const a = msg.args as { data: string; mime_type: string };\n result = await resizeBase64(a.data, a.mime_type);\n } else if (msg.op === \"approve_change\") {\n result = await changeApprovals.show(msg.rpcId, msg.args as ChangeApprovalRequestWire);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"frontend_rpc_cancel\":\n changeApprovals.cancel(msg.rpcId, msg.reason);\n break;\n case \"scope_squashed\": {\n // The scope's ledger was cleared. Drop its cached slot; if the combo\n // was focused on it, fall back to the active scope.\n const key = scopeKeyString(msg.scope);\n state.scopeLedgers.set(key, []);\n if (state.workshopFocusScope && scopeKeyString(state.workshopFocusScope) === key) {\n state.workshopFocusScope = null;\n }\n if (activeScope() && scopeKeyString(activeScope()!) === key) {\n // Only THIS scope's ledger was cleared. state.edits is the cross-scope\n // session mirror, so mark reverted only entries filed under this scope,\n // not every edit (which would falsely zero other scopes' live counts).\n state.edits = state.edits.map((e) => scopeKeyString(e.scope) === key ? { ...e, reverted: true } : e);\n rerenderThread();\n }\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n }\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offBackendMessages();\n offChatSwitched();\n changeApprovals.destroy();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "5a6f0406942a36c32d921b6521d52282c1329a2a": {"t": 1785771271.8820674, "n": 153716, "h": "60d371d4aa0e925a", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n CharacterStorageEntry,\n EditLogEntry,\n FrontendToBackend,\n MessageImage,\n MessageFile,\n WireImage,\n WireFile,\n RevertOutcomeWire,\n ScopeRef,\n SessionStatusWire,\n SessionSummaryWire,\n ChangeApprovalRequestWire,\n} from \"../types\";\nimport { characterScope, scopeKeyString } from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE, ICON_LUMIAGENT } from \"./icons\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\nimport { createChangeApprovalController } from \"./change-approval-modal\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveCustomIconUrl(): string | null {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return null;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n // Authoritative per-scope ledger snapshots, keyed by scopeKeyString. The\n // backend is the only writer of truth: every scope_edits_pushed replaces\n // one slot. The header badge and the workshop modal both read from here,\n // so there is no second cache to reconcile.\n scopeLedgers: Map;\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean; tpmLimit?: number | null; debugLogging?: boolean; requireChangeApproval?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n // Backend-authoritative session status. isGenerating / compacting are\n // derived from this on every push/load, never inferred locally. Null until\n // the first session_loaded / session_status arrives.\n sessionStatus: SessionStatusWire | null;\n // True when this client loaded into a generation it never tracked from\n // turn_started (a refresh mid-stream). The backend doesn't replay the\n // in-flight turn, so live events would build an incoherent partial bubble;\n // we suppress them and reload the authoritative session on the terminal\n // event instead.\n reattachedGeneration: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n // Set when the current session has a successful compaction boundary. Messages\n // with ts < compactedAt are pre-compaction (read-only, greyed).\n compactedAt: number | null;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n // Resized image attachments queued in the composer (base64 + mime), sent with\n // the next message. The workspace path is assigned at send time (needs sessionId).\n attachments: { id: string; data: string; mime_type: string }[];\n // Non-image file attachments queued in the composer. Uploaded (chunked) to the\n // workspace at ATTACH time so send is instant; only refs go on the message.\n fileAttachments: { id: string; file: File; name: string; size: number; mime: string; kind: \"text\" | \"binary\"; inlineContent?: string; path: string; status: \"uploading\" | \"ready\" | \"error\"; progress: number; error?: string }[];\n // Session id the queued attachments were uploaded under. For a not-yet-started\n // session this is generated eagerly so attach-time paths match the send.\n attachmentSessionId: string | null;\n // Wire attachments carried with pendingMessage across the start_session round-trip.\n pendingMessageImages: readonly WireImage[] | null;\n pendingMessageFiles: readonly WireFile[] | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n scopeStorage: readonly CharacterStorageEntry[];\n // View-only: which scope the workshop combo is focused on. Null = follow\n // the active session's scope. Never an authority for the header badge.\n workshopFocusScope: ScopeRef | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction showToast(text: string, durationMs = 4000): void {\n const t = document.createElement(\"div\");\n t.className = \"la-toast\";\n t.textContent = text;\n document.body.appendChild(t);\n requestAnimationFrame(() => t.classList.add(\"is-visible\"));\n setTimeout(() => {\n t.classList.remove(\"is-visible\");\n setTimeout(() => t.remove(), 300);\n }, durationMs);\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const customIconUrl = resolveCustomIconUrl();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n ...(customIconUrl ? { iconUrl: customIconUrl } : { iconSvg: ICON_LUMIAGENT }),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n const changeApprovals = createChangeApprovalController(ctx);\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n scopeLedgers: new Map(),\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n sessionStatus: null,\n reattachedGeneration: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n compactedAt: null,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n attachments: [],\n fileAttachments: [],\n attachmentSessionId: null,\n pendingMessageImages: null,\n pendingMessageFiles: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n scopeStorage: [],\n workshopFocusScope: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card to English\",\n send: \"Translate this card to English. Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings), UI surfaces (regex replace_string, trigger displays/values, bg-html, scriptstate_defaults), and card System/Post-History instructions. Skip anything already bilingual and internal identifiers. For greetings, `rewrite` the message and be careful of strings that have regex matches. Avoid using mechanical translation methods as sub-string matching will kill your translation, always manually edit surfaces -- unfortunately, there is no easy way to do this, you must inspect every surface and write to it. Ensure you read the full trigger scripts/lua, and find regex surfaces, and default variables before you touch anything, as those contain sensitive string semantics. Avoid touching any regex_find strings and their occurances in the rest of the card. Summarise findings and ask which surfaces to touch before writing. At each stage, please check in with the user to get them to test whether the card surfaces still work as expected, or if anything is broken.\"\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n void doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const attachBtn = el(\"button\", \"la-attach-btn\") as HTMLButtonElement;\n attachBtn.type = \"button\";\n attachBtn.setAttribute(\"aria-label\", \"Attach image\");\n attachBtn.innerHTML = '';\n const fileInput = document.createElement(\"input\");\n fileInput.type = \"file\";\n fileInput.multiple = true;\n fileInput.style.display = \"none\";\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, attachBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerAttachments = el(\"div\", \"la-attachments\");\n composerAttachments.style.display = \"none\";\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerAttachments, composerArea, composerStatus);\n composer.appendChild(composerInner);\n composer.appendChild(fileInput);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n void import(\"./image-cache\").then((m) => m.configureImageCache((path) => sendBackend({ type: \"ws_read_image\", path })));\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n // Resolve against the live connection list: if the saved id was deleted, send\n // the default the picker is actually showing (renderConnOptions falls back to\n // it silently, so state.connectionId can hold a stale dead id otherwise).\n const withConnection = (msg: T): T => {\n let id = state.connectionId;\n if (id && !state.connections.some((c) => c.id === id)) {\n id = state.connections.find((c) => c.is_default)?.id ?? null;\n }\n return id ? { ...msg, connectionId: id } : msg;\n };\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"All Characters\", sublabel: \"no focus, address any character by id\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.id === state.characterId ? `${c.name} (Active)` : c.name,\n sublabel: `${c.chat_count} chats · ${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n // Re-list responses can land mid-generation; reassert the busy lock so a\n // refresh never silently re-enables character switching.\n updateLocks();\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n updateLocks();\n };\n\n // The scope the active session points at. A no-character chat has no\n // active scope, so its header badge is always 0 (Lumiverse-scope edits\n // surface in the workshop, not the header).\n const activeScope = (): ScopeRef | null =>\n state.characterId !== null ? characterScope(state.characterId) : null;\n\n // Pure projection of one authoritative snapshot. Never reconciles.\n const scopeEntries = (scope: ScopeRef | null): readonly EditLogEntry[] =>\n scope ? state.scopeLedgers.get(scopeKeyString(scope)) ?? [] : [];\n\n // What the workshop modal shows: the combo-focused scope if the user\n // picked one, otherwise the active session's scope.\n const workshopScope = (): ScopeRef | null => state.workshopFocusScope ?? activeScope();\n\n // Combo options for the workshop. scopeStorage only lists scopes that\n // already have a ledger, so a freshly-selected diffless character would be\n // absent and the combo would fall through to the first character that does\n // have edits. Always surface the active character so opening the workshop\n // lands on it (empty diff is the correct, ephemeral view).\n const buildScopeOptions = (): ReadonlyArray<{ scope: ScopeRef; label: string; changeCount: number; chatCount?: number; msgCount?: number; attachedToPersona?: { id: string; name: string } }> => {\n const opts = state.scopeStorage.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n // Reverted commits are purged, so editCount is the live change count.\n changeCount: e.editCount,\n ...(e.chatCount !== undefined ? { chatCount: e.chatCount } : {}),\n ...(e.msgCount !== undefined ? { msgCount: e.msgCount } : {}),\n ...(e.attachedToPersona ? { attachedToPersona: e.attachedToPersona } : {}),\n }));\n const active = activeScope();\n if (active && !opts.some((o) => scopeKeyString(o.scope) === scopeKeyString(active))) {\n opts.unshift({ scope: active, label: state.characterName ?? \"Current character\", changeCount: 0 });\n }\n return opts;\n };\n\n const updateSessionBar = () => {\n const scope = activeScope();\n if (scope) {\n const source = scopeEntries(scope);\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n editsBadge.classList.toggle(\"has-edits\", source.length > 0);\n return;\n }\n // No-character session: there is no active scope, so the badge reflects\n // Lumiverse-scope changes (persona / preset / world_book / regex / chat),\n // summed from the scope storage list (non-character entries carry `scope`).\n let total = 0;\n for (const e of state.scopeStorage) if (e.scope && e.scope.kind !== \"character\") total += e.liveEditCount;\n editsCount.textContent = String(total);\n editsBadge.classList.toggle(\"has-edits\", total > 0);\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n // Refresh composer + input locks: compaction holds the session for several\n // seconds, during which Send must disable and the character / pin / connection\n // controls must lock, exactly as they do during a generation.\n updateComposer();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n // What a Send click does, derived from the backend-authoritative status\n // (with a local-message fallback only for the pre-first-status window of a\n // brand-new session). The click handler dispatches on this; it never\n // recomputes the decision.\n let sendMode: \"new\" | \"continue\" | \"recover\" | \"disabled\" = \"disabled\";\n\n // Lock the surfaces that mutate the session the agent is using. Anything\n // other than an idle session means a generation or compaction is live.\n const updateLocks = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n charCombo.setDisabled(busy);\n chatPinBtn.disabled = busy;\n // connCombo is also disabled when there are no connections; don't\n // re-enable it here in that case.\n if (state.connections.length > 0) connCombo.setDisabled(busy);\n };\n\n const setComposerStatus = (text: string) => {\n // Never stomp a sticky error (e.g. the start_session timeout notice).\n // doSend clears is-error before a fresh attempt.\n if (composerStatus.classList.contains(\"is-error\")) return;\n composerStatus.textContent = text;\n };\n\n const updateComposer = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n if (busy) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n // Can't abort before the backend has acknowledged the session.\n cancelBtn.disabled = state.startingSession;\n textarea.disabled = false;\n setComposerStatus(\n state.startingSession ? \"starting session...\"\n : state.compacting ? \"compacting context...\"\n : \"agent is working...\",\n );\n sendMode = \"disabled\";\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n\n const st = state.sessionStatus;\n const statusFresh = !!st && st.sessionId === state.sessionId;\n // Attachments alone are sendable (image/file-only message), so they count\n // as content for send-mode purposes.\n const hasText = textarea.value.trim().length > 0 || state.attachments.length > 0 || state.fileAttachments.length > 0;\n const recoverable = statusFresh && st!.phase === \"idle\"\n && st!.lastAssistantStatus === \"errored\" && st!.lastAssistantEmpty\n && st!.lastAssistantId !== null;\n // Authoritative last-role; fall back to the local mirror only before the\n // first status arrives (typing into a brand-new unsent session).\n const lastRole = statusFresh\n ? st!.lastMessageRole\n : (state.messages[state.messages.length - 1]?.role ?? null);\n\n if (hasText) {\n // Typed text always sends: new session, new user turn, or a follow-up\n // after a completed assistant message.\n sendMode = \"new\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n } else if (recoverable) {\n sendMode = \"recover\";\n setComposerStatus(\"Last response failed empty. Press Send to retry it.\");\n } else if (lastRole === \"user\" && !!state.sessionId) {\n sendMode = \"continue\";\n setComposerStatus(\"\");\n } else {\n // Assistant message last (completed/cancelled), or empty thread with\n // no text: nothing to send.\n sendMode = \"disabled\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n }\n // Block send until every queued file finishes uploading.\n const uploading = state.fileAttachments.some((a) => a.status === \"uploading\");\n sendBtn.disabled = sendMode === \"disabled\" || uploading;\n if (uploading) setComposerStatus(\"Uploading attachments…\");\n }\n updateLocks();\n updateCompactButton();\n };\n\n // The single point where backend-authoritative status becomes UI state.\n // Every session_loaded / session_status flows through here so a reload mid\n // generation reconstructs the button, locks, and ring correctly.\n const applyStatus = (st: SessionStatusWire): void => {\n if (st.sessionId !== state.sessionId) return;\n state.sessionStatus = st;\n state.isGenerating = st.phase === \"generating\";\n state.compacting = st.phase === \"compacting\";\n if (st.phase !== \"idle\") {\n state.startingSession = false;\n clearStartTimeout();\n }\n state.contextPromptTokens = st.promptTokens;\n if (st.contextTokens > 0) state.contextTokens = st.contextTokens;\n updateComposer();\n updateSessionBar();\n };\n\n // Optimistic prune so the UI doesn't flash the reverted row before the\n // backend's authoritative scope_edits_pushed arrives. The session mirror\n // and every scope slot drop the id; the push then re-syncs the one scope.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n for (const [key, entries] of state.scopeLedgers) {\n state.scopeLedgers.set(key, entries.filter((e) => !ids.has(e.id)));\n }\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire, scope: ScopeRef): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n // Reset the modal's revert button (stuck on \"Reverting…\"): only a clean\n // revert pushes scope_edits_pushed, so a failure leaves it disabled.\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed) {\n // Use the edit's own scope (persona / chat / preset / world_book /\n // regex_script), not state.characterId, so a force-revert of a\n // non-character edit hits the right ledger and works in no-character chats.\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, force: true, scope });\n } else if (state.diffModal) {\n // Cancelled the force-revert confirm: reset the modal's stuck button.\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => scopeEntries(workshopScope()),\n getScopes: () => buildScopeOptions(),\n getSelectedScope: () => workshopScope(),\n onSelectScope: (scope) => {\n state.workshopFocusScope = scope;\n sendBackend({ type: \"load_character_workshop\", characterId: scope.id, scope });\n },\n onRevert: async (editId) => {\n const scope = workshopScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertAll: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Revert all edits\",\n message: \"Revert every live edit in this scope? Cascade-aware. The ledger keeps history so reverts can be undone individually.\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_character_all\", characterId: scope.id, scope });\n },\n onForget: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget changes\",\n message: \"Permanently clear this scope's edit ledger? The underlying data is NOT touched, but you won't be able to revert any of these edits afterwards.\",\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (c.confirmed) sendBackend({ type: \"squash_character\", characterId: scope.id, scope });\n },\n onScopesNeeded: () => sendBackend({ type: \"list_characters_storage\" }),\n onClose: () => {\n state.diffModal = null;\n // Drop the combo focus so reopening follows the active session's\n // scope again. No refetch: the active scope's slot is already cached\n // and the badge reads it directly.\n state.workshopFocusScope = null;\n },\n filesPanel: state.workspacePanel.root,\n }, {\n ...(initialEditId !== undefined ? { initialEditId } : {}),\n // No character in this chat: open straight to the Lumiverse tab,\n // since the Characters tab would have nothing to show.\n ...(state.characterId === null ? { initialTab: \"lumiverse\" as const } : {}),\n });\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n // Per-message banners count edits this session attributed to a message,\n // across every scope, so the session mirror is the right source.\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n return state.edits.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n return state.edits.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n // Use the edit's OWN scope, not state.characterId: an edit can be filed\n // under persona/chat/preset/wb/rx, and in a no-character session\n // state.characterId is null. Without this the inline Revert hit the\n // wrong ledger (or was a dead no-op) — the modal path already does this.\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n // revert_edits_bulk addresses ONE ledger, but a card's edits can span\n // scopes; group by each edit's scope and emit one bulk call per scope.\n const byScope = new Map();\n for (const editId of editIds) {\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) continue;\n const key = scopeKeyString(scope);\n let g = byScope.get(key);\n if (!g) { g = { scope, ids: [] }; byScope.set(key, g); }\n g.ids.push(editId);\n }\n for (const { scope, ids } of byScope.values()) {\n sendBackend({ type: \"revert_edits_bulk\", characterId: scope.id, editIds: ids, scope, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n }\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n onForkMessage: (messageId: string) => {\n if (!state.sessionId) return;\n sendBackend({ type: \"fork_session\", sourceSessionId: state.sessionId, messageId });\n },\n isPreCompaction: (messageTs: number) => state.compactedAt !== null && messageTs < state.compactedAt,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n if (id === state.characterId) return;\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish before switching characters.\";\n composerStatus.classList.add(\"is-error\");\n renderCharOptions();\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId });\n if (state.sessionId !== null) {\n // Switch focus in place, keeping the thread. The backend owns the switch\n // (mutates the session, keeps the cached prompt prefix, learns the agent\n // via a context note) and pushes authoritative state back via focus_set,\n // which updates the picker / pin / per-character lists. The frontend does\n // not mutate focus state itself.\n sendBackend({ type: \"set_focus\", sessionId: state.sessionId, characterId: id });\n } else {\n // No active session yet: create one focused on the pick.\n state.characterId = id;\n // Queue the newest chat to auto-pin once chats_pushed lands (the fresh\n // session has no pin yet); without this the first pick leaves no pin.\n state.autoPinNeeded = !!id;\n renderCharOptions();\n startNewSession();\n if (id) {\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n }\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.attachments = [];\n state.fileAttachments = [];\n state.attachmentSessionId = null;\n state.pendingMessageImages = null;\n state.pendingMessageFiles = null;\n renderAttachments();\n // A fresh pending session gets no status / context_usage push from the\n // backend (nothing generates yet), so reset the context-derived UI here\n // or the compaction wheel keeps showing the previous session's usage.\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.reattachedGeneration = false;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", state.characterId ? \"No chats yet for this character.\" : \"No chats available.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n const memberNames = c.memberCharacterIds\n .map((id) => state.characters.find((character) => character.id === id)?.name ?? id)\n .join(\", \");\n const kind = c.isGroup ? `Group: ${memberNames} · ` : \"\";\n main.append(el(\"div\", \"la-session-item-meta\", `${kind}updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n const tpmRow = el(\"div\", \"la-settings-row\");\n tpmRow.append(el(\"label\", \"la-settings-row-label\", \"TPM limit (tk/min)\"));\n const tpmInput = document.createElement(\"input\");\n tpmInput.type = \"number\";\n tpmInput.className = \"la-slider-input\";\n tpmInput.min = \"1\";\n tpmInput.step = \"1\";\n tpmInput.placeholder = \"off\";\n tpmRow.appendChild(tpmInput);\n wrap.appendChild(tpmRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Pauses requests when prompt+completion tokens in the last 60s would exceed this. Set to your provider's tokens-per-minute quota (e.g. 250000 for Gemini free tier). Empty = no throttle.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Change approval\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Pause before each card, chat, lorebook, regex, external-provider, or workspace write so you can approve or reject it.\"));\n const changeApprovalRow = el(\"div\", \"la-settings-row\");\n changeApprovalRow.append(el(\"label\", \"la-settings-row-label\", \"Ask before changes\"));\n const changeApprovalInput = document.createElement(\"input\");\n changeApprovalInput.type = \"checkbox\";\n changeApprovalInput.className = \"la-checkbox\";\n changeApprovalRow.appendChild(changeApprovalInput);\n wrap.appendChild(changeApprovalRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n const debugLogRow = el(\"div\", \"la-settings-row\");\n debugLogRow.append(el(\"label\", \"la-settings-row-label\", \"Debug logging\"));\n const debugLogInput = document.createElement(\"input\");\n debugLogInput.type = \"checkbox\";\n debugLogInput.className = \"la-checkbox\";\n debugLogRow.appendChild(debugLogInput);\n wrap.appendChild(debugLogRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"OFF by default. When ON, verbose backend diagnostics (send_message, loop turns, llm stream, phone-line) are written to the host log. Warnings and errors always log.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n // handleUpdateSettings re-sends settings after every commit (blur / drag\n // release). That async echo must not overwrite a text field the user is\n // currently editing, or it silently discards characters typed during the\n // round-trip. Skip the rewrite while one of the form's text fields is focused.\n const active = document.activeElement;\n if (active === personaArea || active === promptArea || active === jbArea\n || active === wsCapInput || active === toolCapInput || active === tpmInput) return;\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n debugLogInput.checked = s.debugLogging ?? false;\n changeApprovalInput.checked = s.requireChangeApproval ?? false;\n tpmInput.value = s.tpmLimit ? String(s.tpmLimit) : \"\";\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n tpmLimit: parsePosInt(tpmInput.value),\n debugLogging: debugLogInput.checked,\n requireChangeApproval: changeApprovalInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput, tpmInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput, debugLogInput, changeApprovalInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconFrame = el(\"div\", \"la-icon-settings-frame\");\n const setIconPreview = (customUrl: string | null): void => {\n if (customUrl) {\n iconFrame.innerHTML = \"\";\n const img = document.createElement(\"img\");\n img.src = customUrl;\n img.alt = \"current icon\";\n iconFrame.appendChild(img);\n } else {\n iconFrame.innerHTML = ICON_LUMIAGENT;\n }\n };\n setIconPreview(resolveCustomIconUrl());\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconFrame);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n setIconPreview(dataUrl);\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n setIconPreview(null);\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const formatBytes = (n: number): string => n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / (1024 * 1024)).toFixed(1)} MB`;\n\n const renderAttachments = (): void => {\n composerAttachments.replaceChildren();\n if (state.attachments.length === 0 && state.fileAttachments.length === 0) {\n composerAttachments.style.display = \"none\"; updateComposer(); return;\n }\n composerAttachments.style.display = \"\";\n for (const att of state.attachments) {\n const chip = el(\"div\", \"la-attachment\");\n const img = document.createElement(\"img\");\n img.src = `data:${att.mime_type};base64,${att.data}`;\n img.alt = \"attachment\";\n const rm = el(\"button\", \"la-attachment-remove\") as HTMLButtonElement;\n rm.type = \"button\";\n rm.setAttribute(\"aria-label\", \"Remove attachment\");\n rm.textContent = \"×\";\n rm.addEventListener(\"click\", () => {\n state.attachments = state.attachments.filter((a) => a.id !== att.id);\n renderAttachments();\n });\n chip.append(img, rm);\n composerAttachments.appendChild(chip);\n }\n for (const att of state.fileAttachments) {\n const chip = el(\"div\", `la-file-attachment${att.status === \"uploading\" ? \" is-uploading\" : \"\"}${att.status === \"error\" ? \" is-error\" : \"\"}`);\n chip.setAttribute(\"data-att-id\", att.id);\n const sizeText = att.status === \"error\" ? `failed: ${att.error ?? \"upload error\"}` : formatBytes(att.size);\n chip.append(\n el(\"span\", \"la-file-attachment-name\", att.name),\n el(\"span\", \"la-file-attachment-size\", sizeText),\n );\n const progress = el(\"div\", \"la-file-progress\");\n const bar = el(\"div\", \"la-file-progress-bar\");\n bar.style.width = `${Math.round(att.progress * 100)}%`;\n progress.appendChild(bar);\n chip.appendChild(progress);\n const rm = el(\"button\", \"la-attachment-remove\") as HTMLButtonElement;\n rm.type = \"button\";\n rm.setAttribute(\"aria-label\", \"Remove file\");\n rm.textContent = \"×\";\n rm.addEventListener(\"click\", () => {\n state.fileAttachments = state.fileAttachments.filter((a) => a.id !== att.id);\n renderAttachments();\n });\n chip.appendChild(rm);\n composerAttachments.appendChild(chip);\n }\n updateComposer();\n };\n\n const addImageBlobs = async (blobs: readonly Blob[]): Promise => {\n const { resizeBlob, isSupportedImage, MAX_IMAGES } = await import(\"./image-resize\");\n for (const blob of blobs) {\n if (state.attachments.length >= MAX_IMAGES) {\n composerStatus.textContent = `Up to ${MAX_IMAGES} images per message.`;\n composerStatus.classList.add(\"is-error\");\n break;\n }\n if (!isSupportedImage(blob.type)) continue;\n try {\n const r = await resizeBlob(blob);\n state.attachments.push({ id: makeId(\"att\"), data: r.data, mime_type: r.mime_type });\n composerStatus.classList.remove(\"is-error\");\n renderAttachments();\n } catch (err) {\n composerStatus.textContent = `Couldn't attach image: ${(err as Error).message}`;\n composerStatus.classList.add(\"is-error\");\n }\n }\n };\n\n const extForMime = (mime: string): string => {\n switch (mime) {\n case \"image/png\": return \"png\";\n case \"image/jpeg\": return \"jpg\";\n case \"image/gif\": return \"gif\";\n case \"image/webp\": return \"webp\";\n default: return \"img\";\n }\n };\n\n // Assign a workspace path per attachment (sessionId is known by send time),\n // pre-seed the image cache so the sent bubble renders instantly, and return\n // the wire payload (path + bytes) plus the persisted refs.\n const buildImagePayload = async (sid: string): Promise<{ wire: WireImage[]; refs: MessageImage[] }> => {\n const { seedImage } = await import(\"./image-cache\");\n const wire: WireImage[] = [];\n const refs: MessageImage[] = [];\n for (const att of state.attachments) {\n const path = `attachments/${sid}/${att.id}.${extForMime(att.mime_type)}`;\n seedImage(path, `data:${att.mime_type};base64,${att.data}`);\n wire.push({ path, data: att.data, mime_type: att.mime_type });\n refs.push({ path, mime_type: att.mime_type });\n }\n return { wire, refs };\n };\n\n // Outstanding chunked uploads keyed by transferId; resolved by ws_upload_complete.\n const pendingUploads = new Map void; reject: (e: Error) => void }>();\n type FileAtt = (typeof state.fileAttachments)[number];\n\n // Session id the queued attachments upload under. For a not-yet-started\n // session, generated eagerly so attach-time paths match the eventual send.\n const composerSid = (): string => {\n if (state.sessionId) return state.sessionId;\n if (!state.attachmentSessionId) state.attachmentSessionId = makeId(\"sess\");\n return state.attachmentSessionId;\n };\n\n // Update one file chip's progress bar / status in place (no full re-render).\n const setChipProgress = (att: FileAtt): void => {\n const chip = composerAttachments.querySelector(`[data-att-id=\"${att.id}\"]`);\n if (!chip) return;\n chip.classList.toggle(\"is-uploading\", att.status === \"uploading\");\n chip.classList.toggle(\"is-error\", att.status === \"error\");\n const bar = chip.querySelector(\".la-file-progress-bar\") as HTMLElement | null;\n if (bar) bar.style.width = `${Math.round(att.progress * 100)}%`;\n };\n\n // Stream a file to its workspace path on attach. Chunk-sent progress caps at\n // 95%; the backend ack (ws_upload_complete) flips it to ready.\n const uploadAttachment = async (att: FileAtt): Promise => {\n const transferId = makeId(\"up\");\n const acked = new Promise((resolve, reject) => {\n const timer = setTimeout(() => { if (pendingUploads.delete(transferId)) reject(new Error(\"upload timed out\")); }, 5 * 60_000);\n pendingUploads.set(transferId, { resolve: () => { clearTimeout(timer); resolve(); }, reject: (e) => { clearTimeout(timer); reject(e); } });\n });\n try {\n const { streamUpload } = await import(\"./file-upload\");\n const bytes = new Uint8Array(await att.file.arrayBuffer());\n await streamUpload(sendBackend, att.path, bytes, transferId, (frac) => { att.progress = Math.min(0.95, frac * 0.95); setChipProgress(att); });\n await acked;\n att.status = \"ready\"; att.progress = 1;\n } catch (err) {\n att.status = \"error\"; att.error = (err as Error).message;\n } finally {\n pendingUploads.delete(transferId);\n setChipProgress(att);\n updateComposer();\n }\n };\n\n const addFiles = async (files: readonly File[]): Promise => {\n const { isTextFile, INLINE_TEXT_BYTES, MAX_FILES, safeAttachmentName } = await import(\"./file-upload\");\n for (const file of files) {\n if (state.fileAttachments.length >= MAX_FILES) {\n composerStatus.textContent = `Up to ${MAX_FILES} files per message.`;\n composerStatus.classList.add(\"is-error\");\n break;\n }\n const kind: \"text\" | \"binary\" = isTextFile(file) ? \"text\" : \"binary\";\n let inlineContent: string | undefined;\n if (kind === \"text\" && file.size <= INLINE_TEXT_BYTES) {\n try { inlineContent = await file.text(); } catch { /* fall back to ref-only */ }\n }\n const id = makeId(\"file\");\n const path = `attachments/${composerSid()}/${id}-${safeAttachmentName(file.name)}`;\n const att: FileAtt = { id, file, name: file.name, size: file.size, mime: file.type, kind, ...(inlineContent !== undefined ? { inlineContent } : {}), path, status: \"uploading\", progress: 0 };\n state.fileAttachments.push(att);\n composerStatus.classList.remove(\"is-error\");\n renderAttachments();\n void uploadAttachment(att);\n }\n updateComposer();\n };\n\n // Wire refs from the already-uploaded (ready) file attachments. Errored\n // uploads are dropped.\n const readyFileRefs = (): WireFile[] =>\n state.fileAttachments\n .filter((a) => a.status === \"ready\")\n .map((att) => ({ path: att.path, name: att.name, size: att.size, mime: att.mime, kind: att.kind, ...(att.inlineContent !== undefined ? { inlineContent: att.inlineContent } : {}) }));\n\n const appendUserMessage = (text: string, images?: readonly MessageImage[], files?: readonly MessageFile[]): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n ...(images && images.length > 0 ? { images } : {}),\n ...(files && files.length > 0 ? { files } : {}),\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string, images?: readonly WireImage[], files?: readonly WireFile[]): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length, images: images?.length ?? 0, files: files?.length ?? 0 });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n ...(images && images.length > 0 ? { images } : {}),\n ...(files && files.length > 0 ? { files } : {}),\n }));\n // This client is tracking the generation from its start, so the live\n // stream is coherent: never treat it as a reattach.\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n let sending = false;\n const doSend = async (): Promise => {\n const text = textarea.value.trim();\n if (sending || state.isGenerating || state.startingSession || state.compacting) return;\n\n const hasImages = state.attachments.length > 0;\n const hasFiles = state.fileAttachments.length > 0;\n\n // Empty send with no attachments: the action is whatever sendMode resolved\n // to in updateComposer (authoritative-status driven). Never recompute here.\n if (text.length === 0 && !hasImages && !hasFiles) {\n if (sendMode === \"recover\") {\n const targetId = state.sessionStatus?.lastAssistantId;\n if (!targetId || !state.sessionId) return;\n composerStatus.classList.remove(\"is-error\");\n // Drop the empty errored bubble locally so it doesn't linger; the\n // backend regenerate truncates from the preceding user message and\n // re-runs, replacing it.\n state.messages = state.messages.filter((m) => m.id !== targetId);\n rerenderThread();\n sendBackend(withConnection({\n type: \"regenerate_assistant_message\",\n sessionId: state.sessionId,\n assistantMessageId: targetId,\n editsAction: \"keep\",\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n if (sendMode === \"continue\" && state.sessionId) {\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId,\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n return; // disabled: nothing to send\n }\n\n // Files upload on attach; block send until they finish. The send button is\n // already disabled while uploading, so this is just a safety net.\n if (state.fileAttachments.some((a) => a.status === \"uploading\")) {\n composerStatus.textContent = \"Wait for attachments to finish uploading.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n const isNew = !state.sessionId;\n const sid = composerSid();\n const fileRefs = readyFileRefs();\n\n // Images are small (resized) and ride inline; build them now. Guard re-entry\n // across the brief async build.\n sending = true;\n updateComposer();\n let imageWire: WireImage[]; let imageRefs: MessageImage[];\n try {\n const img = await buildImagePayload(sid);\n imageWire = img.wire; imageRefs = img.refs;\n } catch (err) {\n composerStatus.textContent = `Attachment failed: ${(err as Error).message}`;\n composerStatus.classList.add(\"is-error\");\n sending = false;\n updateComposer();\n return;\n }\n sending = false;\n composerStatus.textContent = \"\";\n state.attachments = [];\n state.fileAttachments = [];\n state.attachmentSessionId = null;\n renderAttachments();\n\n if (!isNew) {\n const msg = appendUserMessage(text, imageRefs, fileRefs);\n dispatchSendForExisting(sid, msg.id, text, imageWire, fileRefs);\n return;\n }\n\n state.sessionId = sid;\n state.messages = [];\n state.edits = [];\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text, imageRefs, fileRefs);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n state.pendingMessageImages = imageWire.length > 0 ? imageWire : null;\n state.pendingMessageFiles = fileRefs.length > 0 ? fileRefs : null;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: sid,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.pendingMessageImages = null;\n state.pendingMessageFiles = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n // Route attachments: images to the vision path, everything else to file upload.\n const intake = (files: readonly File[]): void => {\n const imgs = files.filter((f) => f.type.startsWith(\"image/\"));\n const rest = files.filter((f) => !f.type.startsWith(\"image/\"));\n if (imgs.length > 0) void addImageBlobs(imgs);\n if (rest.length > 0) void addFiles(rest);\n };\n sendBtn.addEventListener(\"click\", () => void doSend());\n attachBtn.addEventListener(\"click\", () => fileInput.click());\n fileInput.addEventListener(\"change\", () => {\n if (fileInput.files && fileInput.files.length > 0) intake([...fileInput.files]);\n fileInput.value = \"\";\n });\n textarea.addEventListener(\"paste\", (ev) => {\n const items = ev.clipboardData?.items;\n if (!items) return;\n const blobs: File[] = [];\n for (const it of items) {\n if (it.kind === \"file\") {\n const f = it.getAsFile();\n if (f) blobs.push(f);\n }\n }\n if (blobs.length > 0) { ev.preventDefault(); intake(blobs); }\n });\n composer.addEventListener(\"dragover\", (ev) => {\n if (ev.dataTransfer && [...ev.dataTransfer.items].some((i) => i.kind === \"file\")) {\n ev.preventDefault();\n composer.classList.add(\"la-composer-dragover\");\n }\n });\n composer.addEventListener(\"dragleave\", (ev) => {\n if (ev.target === composer) composer.classList.remove(\"la-composer-dragover\");\n });\n composer.addEventListener(\"drop\", (ev) => {\n composer.classList.remove(\"la-composer-dragover\");\n const files = ev.dataTransfer?.files;\n if (!files || files.length === 0) return;\n ev.preventDefault();\n intake([...files]);\n });\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n void doSend();\n }\n });\n\n // Returns the id of the user message that's the backend's PRIMARY rolling\n // cache anchor: 1 user-turn behind the latest, matching LAG_USER_TURNS=1 in\n // cache-control.ts. (Was off by one — anchored 2 turns back — so the divider\n // and the edit pre-warn pointed at the wrong message.) Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker stamping.\n // Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes to them)\n // auto-cache the prompt prefix upstream regardless, so the cue still appears.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 1) return null;\n return userMsgs[userMsgs.length - 1 - 1]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n // Use the edit's own scope (see makeThreadDeps.onRevertEdit).\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n // A live handle proves a turn is in flight, so the message it backs is\n // the last assistant message regardless of its status field. Binding\n // only to status===\"streaming\" left token/tool events writing to the\n // DOM handle but not the message model when the status had already been\n // flipped, so the post-finalize static rerender drew an empty bubble.\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n if (!handle) return;\n // Recover the message pointer if it was cleared mid-stream. Bailing here\n // used to leave the turn half-finalized: the handle's DOM had the streamed\n // content but the message stayed status=\"streaming\" with whatever blocks\n // it had, and the next rerenderThread drew it from the (possibly empty)\n // model instead of the live DOM, so the whole response vanished until a\n // reload rebuilt it from the backend-persisted session.\n rebindCurrentAssistantMessage();\n const msg = state.currentAssistantMessage;\n handle.setStatus(status);\n if (msg) msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n const offBackendMessages = ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n // Fresh session: no compaction yet.\n state.compactedAt = null;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n const imgs = state.pendingMessageImages ?? undefined;\n const files = state.pendingMessageFiles ?? undefined;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.pendingMessageImages = null;\n state.pendingMessageFiles = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text, imgs, files);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n // Loading a session is a clean boundary. A sticky composer error from\n // a prior failed start would otherwise suppress setComposerStatus for\n // the freshly loaded session (the is-error guard never lifts on its\n // own across a session switch).\n composerStatus.classList.remove(\"is-error\");\n composerStatus.textContent = \"\";\n // Drop any live streaming pointers from a previously-loaded session so\n // a still-running background generation can't keep mutating this thread.\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n state.compactedAt = msg.compactedAt ?? null;\n // Drop composer attachments queued against the previous session.\n state.attachments = [];\n state.fileAttachments = [];\n state.attachmentSessionId = null;\n renderAttachments();\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n // Refresh picker labels so \"(Active)\" tracks the loaded session's char.\n renderCharOptions();\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n applyStatus(msg.status);\n // Loaded into a live generation we never tracked from turn_started.\n // Suppress its partial live stream; reload on the terminal event.\n state.reattachedGeneration = msg.status.phase === \"generating\";\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n } else {\n // No-character session: the header badge counts Lumiverse-scope\n // changes, sourced from the scope storage list. Fetch it so the\n // badge is populated without opening the workshop.\n sendBackend({ type: \"list_characters_storage\" });\n sendBackend({ type: \"list_chats\", characterId: null, sessionId: msg.sessionId });\n }\n break;\n case \"session_status\":\n applyStatus(msg.status);\n break;\n case \"session_forked\": {\n // The user stays in the current chat. The fork lives in a separate\n // session; surface a toast so they know it landed and can open it\n // via the Sessions menu. Critically we do NOT load the fork or touch\n // the current session's llmHistory/cache here.\n showToast(\"Forked into a new session. Open Sessions to switch to it.\");\n sendBackend({ type: \"list_sessions\" });\n break;\n }\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n // Deleting the ACTIVE session mid-generation: the backend aborts then\n // immediately sends session_deleted, and the trailing generation_cancelled\n // is then dropped (sessionId is now null). Reset the in-flight flags here\n // or the composer stays wedged in \"working...\" with a dead cancel button.\n state.isGenerating = false;\n state.startingSession = false;\n state.compacting = false;\n state.reattachedGeneration = false;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n clearStartTimeout();\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n case \"chat_event\":\n // Drop events for any session other than the one currently loaded.\n // Loading a different session while this one streams would otherwise\n // render its tokens / tool calls / edits into the wrong thread.\n if (msg.sessionId !== state.sessionId) break;\n // While reattached to a generation we didn't track from its start,\n // the live stream is missing everything before the refresh. Rendering\n // it would build an incoherent partial bubble and fight the\n // authoritative reload on the terminal event. Drop it.\n if (state.reattachedGeneration) break;\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n // A terminal event for a different (backgrounded) session must not\n // finalize the currently-loaded thread.\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n // No-character turn may have produced Lumiverse-scope edits; refresh\n // the scope storage so the header badge reflects them.\n if (state.characterId === null) sendBackend({ type: \"list_characters_storage\" });\n break;\n case \"generation_cancelled\":\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n clearStartTimeout();\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome, msg.scope);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"scope_edits_pushed\": {\n // Authoritative snapshot for exactly one scope. Replace its slot,\n // refresh the badge, and only re-render the modal if it is currently\n // showing this scope (a background push for another scope must not\n // yank the view the user is looking at).\n state.scopeLedgers.set(scopeKeyString(msg.scope), [...msg.entries]);\n updateSessionBar();\n const shown = workshopScope();\n if (state.diffModal && shown && scopeKeyString(shown) === scopeKeyString(msg.scope)) {\n state.diffModal.setEdits(msg.entries);\n }\n break;\n }\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n // The array was replaced with fresh objects. If a turn is in flight\n // (mid-turn squash emits this), rebind the streaming pointer to the new\n // element by id, else finalizeAssistantTurn would set \"complete\" on the\n // orphaned old object and the live message stays \"streaming\" (no actions).\n if (state.streamingAssistant && state.currentAssistantMessage) {\n const reboundId = state.currentAssistantMessage.id;\n const rebound = state.messages.find((m) => m.id === reboundId && m.role === \"assistant\");\n if (rebound && rebound.role === \"assistant\") state.currentAssistantMessage = rebound;\n }\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n }\n break;\n case \"focus_set\":\n // Authoritative focus state from the backend (it owns the switch). The\n // thread is preserved; only the focus marker, pin, and per-character\n // lists refresh.\n if (msg.sessionId === state.sessionId) {\n const changed = msg.characterId !== state.characterId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n renderCharOptions();\n updateComposer();\n updateSessionBar();\n // A real switch refreshes the chat list. The backend may preserve a\n // group-chat pin when the new focus is another member.\n if (changed) {\n state.pinnedChatId = msg.pinnedChatId;\n state.chatsForCharacter = [];\n state.autoPinNeeded = msg.characterId !== null && msg.pinnedChatId === null;\n setChatPinned(msg.pinnedChatId !== null);\n if (msg.characterId) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n } else {\n // No-character focus: refresh the Lumiverse-scope workshop view.\n sendBackend({ type: \"list_characters_storage\" });\n }\n }\n }\n break;\n case \"focus_rejected\":\n // The backend refused the switch (generation in flight, character gone).\n // Non-destructive: snap the combo back to the real focus and surface a\n // status line. Never touch the streaming bubble / composer-generating state.\n if (msg.sessionId === state.sessionId) {\n renderCharOptions();\n composerStatus.textContent = msg.reason;\n composerStatus.classList.add(\"is-error\");\n }\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n tpmLimit: msg.tpmLimit,\n debugLogging: msg.debugLogging,\n requireChangeApproval: msg.requireChangeApproval,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_image_ready\":\n void import(\"./image-cache\").then((m) => m.resolveImage(msg.path, `data:${msg.mimeType};base64,${msg.dataBase64}`));\n break;\n case \"ws_image_error\":\n void import(\"./image-cache\").then((m) => m.failImage(msg.path));\n break;\n case \"ws_upload_complete\": {\n const p = pendingUploads.get(msg.transferId);\n if (p) { pendingUploads.delete(msg.transferId); p.resolve(); }\n break;\n }\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n updateComposer();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\": {\n // Scope list for the workshop combo only. The badge is NOT touched\n // here: it is a pure projection of scopeLedgers, refreshed by the\n // authoritative scope_edits_pushed the backend sends alongside this.\n state.scopeStorage = msg.entries;\n state.diffModal?.setScopes(buildScopeOptions());\n // No-character badge is derived from scopeStorage, so refresh it when\n // the list lands. (With a character it reads scopeLedgers; harmless.)\n updateSessionBar();\n break;\n }\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else if (msg.op === \"image_resize\") {\n const { resizeBase64 } = await import(\"./image-resize\");\n const a = msg.args as { data: string; mime_type: string };\n result = await resizeBase64(a.data, a.mime_type);\n } else if (msg.op === \"approve_change\") {\n result = await changeApprovals.show(msg.rpcId, msg.args as ChangeApprovalRequestWire);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"frontend_rpc_cancel\":\n changeApprovals.cancel(msg.rpcId, msg.reason);\n break;\n case \"scope_squashed\": {\n // The scope's ledger was cleared. Drop its cached slot; if the combo\n // was focused on it, fall back to the active scope.\n const key = scopeKeyString(msg.scope);\n state.scopeLedgers.set(key, []);\n if (state.workshopFocusScope && scopeKeyString(state.workshopFocusScope) === key) {\n state.workshopFocusScope = null;\n }\n if (activeScope() && scopeKeyString(activeScope()!) === key) {\n // Only THIS scope's ledger was cleared. state.edits is the cross-scope\n // session mirror, so mark reverted only entries filed under this scope,\n // not every edit (which would falsely zero other scopes' live counts).\n state.edits = state.edits.map((e) => scopeKeyString(e.scope) === key ? { ...e, reverted: true } : e);\n rerenderThread();\n }\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n }\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offBackendMessages();\n offChatSwitched();\n changeApprovals.destroy();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "ed9b8afd8cb2b0257ecfcbbab4ed5ba75e4d308d": {"t": 1785771274.107922, "n": 152251, "h": "bee26f1806b56343", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n CharacterStorageEntry,\n EditLogEntry,\n FrontendToBackend,\n MessageImage,\n MessageFile,\n WireImage,\n WireFile,\n RevertOutcomeWire,\n ScopeRef,\n SessionStatusWire,\n SessionSummaryWire,\n} from \"../types\";\nimport { characterScope, scopeKeyString } from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE, ICON_LUMIAGENT } from \"./icons\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveCustomIconUrl(): string | null {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return null;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n // Authoritative per-scope ledger snapshots, keyed by scopeKeyString. The\n // backend is the only writer of truth: every scope_edits_pushed replaces\n // one slot. The header badge and the workshop modal both read from here,\n // so there is no second cache to reconcile.\n scopeLedgers: Map;\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean; tpmLimit?: number | null; debugLogging?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n // Backend-authoritative session status. isGenerating / compacting are\n // derived from this on every push/load, never inferred locally. Null until\n // the first session_loaded / session_status arrives.\n sessionStatus: SessionStatusWire | null;\n // True when this client loaded into a generation it never tracked from\n // turn_started (a refresh mid-stream). The backend doesn't replay the\n // in-flight turn, so live events would build an incoherent partial bubble;\n // we suppress them and reload the authoritative session on the terminal\n // event instead.\n reattachedGeneration: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n // Set when the current session has a successful compaction boundary. Messages\n // with ts < compactedAt are pre-compaction (read-only, greyed).\n compactedAt: number | null;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n // Resized image attachments queued in the composer (base64 + mime), sent with\n // the next message. The workspace path is assigned at send time (needs sessionId).\n attachments: { id: string; data: string; mime_type: string }[];\n // Non-image file attachments queued in the composer. Uploaded (chunked) to the\n // workspace at ATTACH time so send is instant; only refs go on the message.\n fileAttachments: { id: string; file: File; name: string; size: number; mime: string; kind: \"text\" | \"binary\"; inlineContent?: string; path: string; status: \"uploading\" | \"ready\" | \"error\"; progress: number; error?: string }[];\n // Session id the queued attachments were uploaded under. For a not-yet-started\n // session this is generated eagerly so attach-time paths match the send.\n attachmentSessionId: string | null;\n // Wire attachments carried with pendingMessage across the start_session round-trip.\n pendingMessageImages: readonly WireImage[] | null;\n pendingMessageFiles: readonly WireFile[] | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n scopeStorage: readonly CharacterStorageEntry[];\n // View-only: which scope the workshop combo is focused on. Null = follow\n // the active session's scope. Never an authority for the header badge.\n workshopFocusScope: ScopeRef | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction showToast(text: string, durationMs = 4000): void {\n const t = document.createElement(\"div\");\n t.className = \"la-toast\";\n t.textContent = text;\n document.body.appendChild(t);\n requestAnimationFrame(() => t.classList.add(\"is-visible\"));\n setTimeout(() => {\n t.classList.remove(\"is-visible\");\n setTimeout(() => t.remove(), 300);\n }, durationMs);\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const customIconUrl = resolveCustomIconUrl();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n ...(customIconUrl ? { iconUrl: customIconUrl } : { iconSvg: ICON_LUMIAGENT }),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n scopeLedgers: new Map(),\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n sessionStatus: null,\n reattachedGeneration: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n compactedAt: null,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n attachments: [],\n fileAttachments: [],\n attachmentSessionId: null,\n pendingMessageImages: null,\n pendingMessageFiles: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n scopeStorage: [],\n workshopFocusScope: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card to English\",\n send: \"Translate this card to English. Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings), UI surfaces (regex replace_string, trigger displays/values, bg-html, scriptstate_defaults), and card System/Post-History instructions. Skip anything already bilingual and internal identifiers. For greetings, `rewrite` the message and be careful of strings that have regex matches. Avoid using mechanical translation methods as sub-string matching will kill your translation, always manually edit surfaces -- unfortunately, there is no easy way to do this, you must inspect every surface and write to it. Ensure you read the full trigger scripts/lua, and find regex surfaces, and default variables before you touch anything, as those contain sensitive string semantics. Avoid touching any regex_find strings and their occurances in the rest of the card. Summarise findings and ask which surfaces to touch before writing. At each stage, please check in with the user to get them to test whether the card surfaces still work as expected, or if anything is broken.\"\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n void doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const attachBtn = el(\"button\", \"la-attach-btn\") as HTMLButtonElement;\n attachBtn.type = \"button\";\n attachBtn.setAttribute(\"aria-label\", \"Attach image\");\n attachBtn.innerHTML = '';\n const fileInput = document.createElement(\"input\");\n fileInput.type = \"file\";\n fileInput.multiple = true;\n fileInput.style.display = \"none\";\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, attachBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerAttachments = el(\"div\", \"la-attachments\");\n composerAttachments.style.display = \"none\";\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerAttachments, composerArea, composerStatus);\n composer.appendChild(composerInner);\n composer.appendChild(fileInput);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n void import(\"./image-cache\").then((m) => m.configureImageCache((path) => sendBackend({ type: \"ws_read_image\", path })));\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n // Resolve against the live connection list: if the saved id was deleted, send\n // the default the picker is actually showing (renderConnOptions falls back to\n // it silently, so state.connectionId can hold a stale dead id otherwise).\n const withConnection = (msg: T): T => {\n let id = state.connectionId;\n if (id && !state.connections.some((c) => c.id === id)) {\n id = state.connections.find((c) => c.is_default)?.id ?? null;\n }\n return id ? { ...msg, connectionId: id } : msg;\n };\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"All Characters\", sublabel: \"no focus, address any character by id\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.id === state.characterId ? `${c.name} (Active)` : c.name,\n sublabel: `${c.chat_count} chats · ${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n // Re-list responses can land mid-generation; reassert the busy lock so a\n // refresh never silently re-enables character switching.\n updateLocks();\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n updateLocks();\n };\n\n // The scope the active session points at. A no-character chat has no\n // active scope, so its header badge is always 0 (Lumiverse-scope edits\n // surface in the workshop, not the header).\n const activeScope = (): ScopeRef | null =>\n state.characterId !== null ? characterScope(state.characterId) : null;\n\n // Pure projection of one authoritative snapshot. Never reconciles.\n const scopeEntries = (scope: ScopeRef | null): readonly EditLogEntry[] =>\n scope ? state.scopeLedgers.get(scopeKeyString(scope)) ?? [] : [];\n\n // What the workshop modal shows: the combo-focused scope if the user\n // picked one, otherwise the active session's scope.\n const workshopScope = (): ScopeRef | null => state.workshopFocusScope ?? activeScope();\n\n // Combo options for the workshop. scopeStorage only lists scopes that\n // already have a ledger, so a freshly-selected diffless character would be\n // absent and the combo would fall through to the first character that does\n // have edits. Always surface the active character so opening the workshop\n // lands on it (empty diff is the correct, ephemeral view).\n const buildScopeOptions = (): ReadonlyArray<{ scope: ScopeRef; label: string; changeCount: number; chatCount?: number; msgCount?: number; attachedToPersona?: { id: string; name: string } }> => {\n const opts = state.scopeStorage.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n // Reverted commits are purged, so editCount is the live change count.\n changeCount: e.editCount,\n ...(e.chatCount !== undefined ? { chatCount: e.chatCount } : {}),\n ...(e.msgCount !== undefined ? { msgCount: e.msgCount } : {}),\n ...(e.attachedToPersona ? { attachedToPersona: e.attachedToPersona } : {}),\n }));\n const active = activeScope();\n if (active && !opts.some((o) => scopeKeyString(o.scope) === scopeKeyString(active))) {\n opts.unshift({ scope: active, label: state.characterName ?? \"Current character\", changeCount: 0 });\n }\n return opts;\n };\n\n const updateSessionBar = () => {\n const scope = activeScope();\n if (scope) {\n const source = scopeEntries(scope);\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n editsBadge.classList.toggle(\"has-edits\", source.length > 0);\n return;\n }\n // No-character session: there is no active scope, so the badge reflects\n // Lumiverse-scope changes (persona / preset / world_book / regex / chat),\n // summed from the scope storage list (non-character entries carry `scope`).\n let total = 0;\n for (const e of state.scopeStorage) if (e.scope && e.scope.kind !== \"character\") total += e.liveEditCount;\n editsCount.textContent = String(total);\n editsBadge.classList.toggle(\"has-edits\", total > 0);\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n // Refresh composer + input locks: compaction holds the session for several\n // seconds, during which Send must disable and the character / pin / connection\n // controls must lock, exactly as they do during a generation.\n updateComposer();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n // What a Send click does, derived from the backend-authoritative status\n // (with a local-message fallback only for the pre-first-status window of a\n // brand-new session). The click handler dispatches on this; it never\n // recomputes the decision.\n let sendMode: \"new\" | \"continue\" | \"recover\" | \"disabled\" = \"disabled\";\n\n // Lock the surfaces that mutate the session the agent is using. Anything\n // other than an idle session means a generation or compaction is live.\n const updateLocks = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n charCombo.setDisabled(busy);\n chatPinBtn.disabled = busy;\n // connCombo is also disabled when there are no connections; don't\n // re-enable it here in that case.\n if (state.connections.length > 0) connCombo.setDisabled(busy);\n };\n\n const setComposerStatus = (text: string) => {\n // Never stomp a sticky error (e.g. the start_session timeout notice).\n // doSend clears is-error before a fresh attempt.\n if (composerStatus.classList.contains(\"is-error\")) return;\n composerStatus.textContent = text;\n };\n\n const updateComposer = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n if (busy) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n // Can't abort before the backend has acknowledged the session.\n cancelBtn.disabled = state.startingSession;\n textarea.disabled = false;\n setComposerStatus(\n state.startingSession ? \"starting session...\"\n : state.compacting ? \"compacting context...\"\n : \"agent is working...\",\n );\n sendMode = \"disabled\";\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n\n const st = state.sessionStatus;\n const statusFresh = !!st && st.sessionId === state.sessionId;\n // Attachments alone are sendable (image/file-only message), so they count\n // as content for send-mode purposes.\n const hasText = textarea.value.trim().length > 0 || state.attachments.length > 0 || state.fileAttachments.length > 0;\n const recoverable = statusFresh && st!.phase === \"idle\"\n && st!.lastAssistantStatus === \"errored\" && st!.lastAssistantEmpty\n && st!.lastAssistantId !== null;\n // Authoritative last-role; fall back to the local mirror only before the\n // first status arrives (typing into a brand-new unsent session).\n const lastRole = statusFresh\n ? st!.lastMessageRole\n : (state.messages[state.messages.length - 1]?.role ?? null);\n\n if (hasText) {\n // Typed text always sends: new session, new user turn, or a follow-up\n // after a completed assistant message.\n sendMode = \"new\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n } else if (recoverable) {\n sendMode = \"recover\";\n setComposerStatus(\"Last response failed empty. Press Send to retry it.\");\n } else if (lastRole === \"user\" && !!state.sessionId) {\n sendMode = \"continue\";\n setComposerStatus(\"\");\n } else {\n // Assistant message last (completed/cancelled), or empty thread with\n // no text: nothing to send.\n sendMode = \"disabled\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n }\n // Block send until every queued file finishes uploading.\n const uploading = state.fileAttachments.some((a) => a.status === \"uploading\");\n sendBtn.disabled = sendMode === \"disabled\" || uploading;\n if (uploading) setComposerStatus(\"Uploading attachments…\");\n }\n updateLocks();\n updateCompactButton();\n };\n\n // The single point where backend-authoritative status becomes UI state.\n // Every session_loaded / session_status flows through here so a reload mid\n // generation reconstructs the button, locks, and ring correctly.\n const applyStatus = (st: SessionStatusWire): void => {\n if (st.sessionId !== state.sessionId) return;\n state.sessionStatus = st;\n state.isGenerating = st.phase === \"generating\";\n state.compacting = st.phase === \"compacting\";\n if (st.phase !== \"idle\") {\n state.startingSession = false;\n clearStartTimeout();\n }\n state.contextPromptTokens = st.promptTokens;\n if (st.contextTokens > 0) state.contextTokens = st.contextTokens;\n updateComposer();\n updateSessionBar();\n };\n\n // Optimistic prune so the UI doesn't flash the reverted row before the\n // backend's authoritative scope_edits_pushed arrives. The session mirror\n // and every scope slot drop the id; the push then re-syncs the one scope.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n for (const [key, entries] of state.scopeLedgers) {\n state.scopeLedgers.set(key, entries.filter((e) => !ids.has(e.id)));\n }\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire, scope: ScopeRef): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n // Reset the modal's revert button (stuck on \"Reverting…\"): only a clean\n // revert pushes scope_edits_pushed, so a failure leaves it disabled.\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed) {\n // Use the edit's own scope (persona / chat / preset / world_book /\n // regex_script), not state.characterId, so a force-revert of a\n // non-character edit hits the right ledger and works in no-character chats.\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, force: true, scope });\n } else if (state.diffModal) {\n // Cancelled the force-revert confirm: reset the modal's stuck button.\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => scopeEntries(workshopScope()),\n getScopes: () => buildScopeOptions(),\n getSelectedScope: () => workshopScope(),\n onSelectScope: (scope) => {\n state.workshopFocusScope = scope;\n sendBackend({ type: \"load_character_workshop\", characterId: scope.id, scope });\n },\n onRevert: async (editId) => {\n const scope = workshopScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertAll: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Revert all edits\",\n message: \"Revert every live edit in this scope? Cascade-aware. The ledger keeps history so reverts can be undone individually.\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_character_all\", characterId: scope.id, scope });\n },\n onForget: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget changes\",\n message: \"Permanently clear this scope's edit ledger? The underlying data is NOT touched, but you won't be able to revert any of these edits afterwards.\",\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (c.confirmed) sendBackend({ type: \"squash_character\", characterId: scope.id, scope });\n },\n onScopesNeeded: () => sendBackend({ type: \"list_characters_storage\" }),\n onClose: () => {\n state.diffModal = null;\n // Drop the combo focus so reopening follows the active session's\n // scope again. No refetch: the active scope's slot is already cached\n // and the badge reads it directly.\n state.workshopFocusScope = null;\n },\n filesPanel: state.workspacePanel.root,\n }, {\n ...(initialEditId !== undefined ? { initialEditId } : {}),\n // No character in this chat: open straight to the Lumiverse tab,\n // since the Characters tab would have nothing to show.\n ...(state.characterId === null ? { initialTab: \"lumiverse\" as const } : {}),\n });\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n // Per-message banners count edits this session attributed to a message,\n // across every scope, so the session mirror is the right source.\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n return state.edits.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n return state.edits.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n // Use the edit's OWN scope, not state.characterId: an edit can be filed\n // under persona/chat/preset/wb/rx, and in a no-character session\n // state.characterId is null. Without this the inline Revert hit the\n // wrong ledger (or was a dead no-op) — the modal path already does this.\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n // revert_edits_bulk addresses ONE ledger, but a card's edits can span\n // scopes; group by each edit's scope and emit one bulk call per scope.\n const byScope = new Map();\n for (const editId of editIds) {\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) continue;\n const key = scopeKeyString(scope);\n let g = byScope.get(key);\n if (!g) { g = { scope, ids: [] }; byScope.set(key, g); }\n g.ids.push(editId);\n }\n for (const { scope, ids } of byScope.values()) {\n sendBackend({ type: \"revert_edits_bulk\", characterId: scope.id, editIds: ids, scope, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n }\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n onForkMessage: (messageId: string) => {\n if (!state.sessionId) return;\n sendBackend({ type: \"fork_session\", sourceSessionId: state.sessionId, messageId });\n },\n isPreCompaction: (messageTs: number) => state.compactedAt !== null && messageTs < state.compactedAt,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n if (id === state.characterId) return;\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish before switching characters.\";\n composerStatus.classList.add(\"is-error\");\n renderCharOptions();\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId });\n if (state.sessionId !== null) {\n // Switch focus in place, keeping the thread. The backend owns the switch\n // (mutates the session, keeps the cached prompt prefix, learns the agent\n // via a context note) and pushes authoritative state back via focus_set,\n // which updates the picker / pin / per-character lists. The frontend does\n // not mutate focus state itself.\n sendBackend({ type: \"set_focus\", sessionId: state.sessionId, characterId: id });\n } else {\n // No active session yet: create one focused on the pick.\n state.characterId = id;\n // Queue the newest chat to auto-pin once chats_pushed lands (the fresh\n // session has no pin yet); without this the first pick leaves no pin.\n state.autoPinNeeded = !!id;\n renderCharOptions();\n startNewSession();\n if (id) {\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n }\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.attachments = [];\n state.fileAttachments = [];\n state.attachmentSessionId = null;\n state.pendingMessageImages = null;\n state.pendingMessageFiles = null;\n renderAttachments();\n // A fresh pending session gets no status / context_usage push from the\n // backend (nothing generates yet), so reset the context-derived UI here\n // or the compaction wheel keeps showing the previous session's usage.\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.reattachedGeneration = false;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", state.characterId ? \"No chats yet for this character.\" : \"No chats available.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n const memberNames = c.memberCharacterIds\n .map((id) => state.characters.find((character) => character.id === id)?.name ?? id)\n .join(\", \");\n const kind = c.isGroup ? `Group: ${memberNames} · ` : \"\";\n main.append(el(\"div\", \"la-session-item-meta\", `${kind}updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n const tpmRow = el(\"div\", \"la-settings-row\");\n tpmRow.append(el(\"label\", \"la-settings-row-label\", \"TPM limit (tk/min)\"));\n const tpmInput = document.createElement(\"input\");\n tpmInput.type = \"number\";\n tpmInput.className = \"la-slider-input\";\n tpmInput.min = \"1\";\n tpmInput.step = \"1\";\n tpmInput.placeholder = \"off\";\n tpmRow.appendChild(tpmInput);\n wrap.appendChild(tpmRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Pauses requests when prompt+completion tokens in the last 60s would exceed this. Set to your provider's tokens-per-minute quota (e.g. 250000 for Gemini free tier). Empty = no throttle.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n const debugLogRow = el(\"div\", \"la-settings-row\");\n debugLogRow.append(el(\"label\", \"la-settings-row-label\", \"Debug logging\"));\n const debugLogInput = document.createElement(\"input\");\n debugLogInput.type = \"checkbox\";\n debugLogInput.className = \"la-checkbox\";\n debugLogRow.appendChild(debugLogInput);\n wrap.appendChild(debugLogRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"OFF by default. When ON, verbose backend diagnostics (send_message, loop turns, llm stream, phone-line) are written to the host log. Warnings and errors always log.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n // handleUpdateSettings re-sends settings after every commit (blur / drag\n // release). That async echo must not overwrite a text field the user is\n // currently editing, or it silently discards characters typed during the\n // round-trip. Skip the rewrite while one of the form's text fields is focused.\n const active = document.activeElement;\n if (active === personaArea || active === promptArea || active === jbArea\n || active === wsCapInput || active === toolCapInput || active === tpmInput) return;\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n debugLogInput.checked = s.debugLogging ?? false;\n tpmInput.value = s.tpmLimit ? String(s.tpmLimit) : \"\";\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n tpmLimit: parsePosInt(tpmInput.value),\n debugLogging: debugLogInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput, tpmInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput, debugLogInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconFrame = el(\"div\", \"la-icon-settings-frame\");\n const setIconPreview = (customUrl: string | null): void => {\n if (customUrl) {\n iconFrame.innerHTML = \"\";\n const img = document.createElement(\"img\");\n img.src = customUrl;\n img.alt = \"current icon\";\n iconFrame.appendChild(img);\n } else {\n iconFrame.innerHTML = ICON_LUMIAGENT;\n }\n };\n setIconPreview(resolveCustomIconUrl());\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconFrame);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n setIconPreview(dataUrl);\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n setIconPreview(null);\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const formatBytes = (n: number): string => n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / (1024 * 1024)).toFixed(1)} MB`;\n\n const renderAttachments = (): void => {\n composerAttachments.replaceChildren();\n if (state.attachments.length === 0 && state.fileAttachments.length === 0) {\n composerAttachments.style.display = \"none\"; updateComposer(); return;\n }\n composerAttachments.style.display = \"\";\n for (const att of state.attachments) {\n const chip = el(\"div\", \"la-attachment\");\n const img = document.createElement(\"img\");\n img.src = `data:${att.mime_type};base64,${att.data}`;\n img.alt = \"attachment\";\n const rm = el(\"button\", \"la-attachment-remove\") as HTMLButtonElement;\n rm.type = \"button\";\n rm.setAttribute(\"aria-label\", \"Remove attachment\");\n rm.textContent = \"×\";\n rm.addEventListener(\"click\", () => {\n state.attachments = state.attachments.filter((a) => a.id !== att.id);\n renderAttachments();\n });\n chip.append(img, rm);\n composerAttachments.appendChild(chip);\n }\n for (const att of state.fileAttachments) {\n const chip = el(\"div\", `la-file-attachment${att.status === \"uploading\" ? \" is-uploading\" : \"\"}${att.status === \"error\" ? \" is-error\" : \"\"}`);\n chip.setAttribute(\"data-att-id\", att.id);\n const sizeText = att.status === \"error\" ? `failed: ${att.error ?? \"upload error\"}` : formatBytes(att.size);\n chip.append(\n el(\"span\", \"la-file-attachment-name\", att.name),\n el(\"span\", \"la-file-attachment-size\", sizeText),\n );\n const progress = el(\"div\", \"la-file-progress\");\n const bar = el(\"div\", \"la-file-progress-bar\");\n bar.style.width = `${Math.round(att.progress * 100)}%`;\n progress.appendChild(bar);\n chip.appendChild(progress);\n const rm = el(\"button\", \"la-attachment-remove\") as HTMLButtonElement;\n rm.type = \"button\";\n rm.setAttribute(\"aria-label\", \"Remove file\");\n rm.textContent = \"×\";\n rm.addEventListener(\"click\", () => {\n state.fileAttachments = state.fileAttachments.filter((a) => a.id !== att.id);\n renderAttachments();\n });\n chip.appendChild(rm);\n composerAttachments.appendChild(chip);\n }\n updateComposer();\n };\n\n const addImageBlobs = async (blobs: readonly Blob[]): Promise => {\n const { resizeBlob, isSupportedImage, MAX_IMAGES } = await import(\"./image-resize\");\n for (const blob of blobs) {\n if (state.attachments.length >= MAX_IMAGES) {\n composerStatus.textContent = `Up to ${MAX_IMAGES} images per message.`;\n composerStatus.classList.add(\"is-error\");\n break;\n }\n if (!isSupportedImage(blob.type)) continue;\n try {\n const r = await resizeBlob(blob);\n state.attachments.push({ id: makeId(\"att\"), data: r.data, mime_type: r.mime_type });\n composerStatus.classList.remove(\"is-error\");\n renderAttachments();\n } catch (err) {\n composerStatus.textContent = `Couldn't attach image: ${(err as Error).message}`;\n composerStatus.classList.add(\"is-error\");\n }\n }\n };\n\n const extForMime = (mime: string): string => {\n switch (mime) {\n case \"image/png\": return \"png\";\n case \"image/jpeg\": return \"jpg\";\n case \"image/gif\": return \"gif\";\n case \"image/webp\": return \"webp\";\n default: return \"img\";\n }\n };\n\n // Assign a workspace path per attachment (sessionId is known by send time),\n // pre-seed the image cache so the sent bubble renders instantly, and return\n // the wire payload (path + bytes) plus the persisted refs.\n const buildImagePayload = async (sid: string): Promise<{ wire: WireImage[]; refs: MessageImage[] }> => {\n const { seedImage } = await import(\"./image-cache\");\n const wire: WireImage[] = [];\n const refs: MessageImage[] = [];\n for (const att of state.attachments) {\n const path = `attachments/${sid}/${att.id}.${extForMime(att.mime_type)}`;\n seedImage(path, `data:${att.mime_type};base64,${att.data}`);\n wire.push({ path, data: att.data, mime_type: att.mime_type });\n refs.push({ path, mime_type: att.mime_type });\n }\n return { wire, refs };\n };\n\n // Outstanding chunked uploads keyed by transferId; resolved by ws_upload_complete.\n const pendingUploads = new Map void; reject: (e: Error) => void }>();\n type FileAtt = (typeof state.fileAttachments)[number];\n\n // Session id the queued attachments upload under. For a not-yet-started\n // session, generated eagerly so attach-time paths match the eventual send.\n const composerSid = (): string => {\n if (state.sessionId) return state.sessionId;\n if (!state.attachmentSessionId) state.attachmentSessionId = makeId(\"sess\");\n return state.attachmentSessionId;\n };\n\n // Update one file chip's progress bar / status in place (no full re-render).\n const setChipProgress = (att: FileAtt): void => {\n const chip = composerAttachments.querySelector(`[data-att-id=\"${att.id}\"]`);\n if (!chip) return;\n chip.classList.toggle(\"is-uploading\", att.status === \"uploading\");\n chip.classList.toggle(\"is-error\", att.status === \"error\");\n const bar = chip.querySelector(\".la-file-progress-bar\") as HTMLElement | null;\n if (bar) bar.style.width = `${Math.round(att.progress * 100)}%`;\n };\n\n // Stream a file to its workspace path on attach. Chunk-sent progress caps at\n // 95%; the backend ack (ws_upload_complete) flips it to ready.\n const uploadAttachment = async (att: FileAtt): Promise => {\n const transferId = makeId(\"up\");\n const acked = new Promise((resolve, reject) => {\n const timer = setTimeout(() => { if (pendingUploads.delete(transferId)) reject(new Error(\"upload timed out\")); }, 5 * 60_000);\n pendingUploads.set(transferId, { resolve: () => { clearTimeout(timer); resolve(); }, reject: (e) => { clearTimeout(timer); reject(e); } });\n });\n try {\n const { streamUpload } = await import(\"./file-upload\");\n const bytes = new Uint8Array(await att.file.arrayBuffer());\n await streamUpload(sendBackend, att.path, bytes, transferId, (frac) => { att.progress = Math.min(0.95, frac * 0.95); setChipProgress(att); });\n await acked;\n att.status = \"ready\"; att.progress = 1;\n } catch (err) {\n att.status = \"error\"; att.error = (err as Error).message;\n } finally {\n pendingUploads.delete(transferId);\n setChipProgress(att);\n updateComposer();\n }\n };\n\n const addFiles = async (files: readonly File[]): Promise => {\n const { isTextFile, INLINE_TEXT_BYTES, MAX_FILES, safeAttachmentName } = await import(\"./file-upload\");\n for (const file of files) {\n if (state.fileAttachments.length >= MAX_FILES) {\n composerStatus.textContent = `Up to ${MAX_FILES} files per message.`;\n composerStatus.classList.add(\"is-error\");\n break;\n }\n const kind: \"text\" | \"binary\" = isTextFile(file) ? \"text\" : \"binary\";\n let inlineContent: string | undefined;\n if (kind === \"text\" && file.size <= INLINE_TEXT_BYTES) {\n try { inlineContent = await file.text(); } catch { /* fall back to ref-only */ }\n }\n const id = makeId(\"file\");\n const path = `attachments/${composerSid()}/${id}-${safeAttachmentName(file.name)}`;\n const att: FileAtt = { id, file, name: file.name, size: file.size, mime: file.type, kind, ...(inlineContent !== undefined ? { inlineContent } : {}), path, status: \"uploading\", progress: 0 };\n state.fileAttachments.push(att);\n composerStatus.classList.remove(\"is-error\");\n renderAttachments();\n void uploadAttachment(att);\n }\n updateComposer();\n };\n\n // Wire refs from the already-uploaded (ready) file attachments. Errored\n // uploads are dropped.\n const readyFileRefs = (): WireFile[] =>\n state.fileAttachments\n .filter((a) => a.status === \"ready\")\n .map((att) => ({ path: att.path, name: att.name, size: att.size, mime: att.mime, kind: att.kind, ...(att.inlineContent !== undefined ? { inlineContent: att.inlineContent } : {}) }));\n\n const appendUserMessage = (text: string, images?: readonly MessageImage[], files?: readonly MessageFile[]): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n ...(images && images.length > 0 ? { images } : {}),\n ...(files && files.length > 0 ? { files } : {}),\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string, images?: readonly WireImage[], files?: readonly WireFile[]): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length, images: images?.length ?? 0, files: files?.length ?? 0 });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n ...(images && images.length > 0 ? { images } : {}),\n ...(files && files.length > 0 ? { files } : {}),\n }));\n // This client is tracking the generation from its start, so the live\n // stream is coherent: never treat it as a reattach.\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n let sending = false;\n const doSend = async (): Promise => {\n const text = textarea.value.trim();\n if (sending || state.isGenerating || state.startingSession || state.compacting) return;\n\n const hasImages = state.attachments.length > 0;\n const hasFiles = state.fileAttachments.length > 0;\n\n // Empty send with no attachments: the action is whatever sendMode resolved\n // to in updateComposer (authoritative-status driven). Never recompute here.\n if (text.length === 0 && !hasImages && !hasFiles) {\n if (sendMode === \"recover\") {\n const targetId = state.sessionStatus?.lastAssistantId;\n if (!targetId || !state.sessionId) return;\n composerStatus.classList.remove(\"is-error\");\n // Drop the empty errored bubble locally so it doesn't linger; the\n // backend regenerate truncates from the preceding user message and\n // re-runs, replacing it.\n state.messages = state.messages.filter((m) => m.id !== targetId);\n rerenderThread();\n sendBackend(withConnection({\n type: \"regenerate_assistant_message\",\n sessionId: state.sessionId,\n assistantMessageId: targetId,\n editsAction: \"keep\",\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n if (sendMode === \"continue\" && state.sessionId) {\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId,\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n return; // disabled: nothing to send\n }\n\n // Files upload on attach; block send until they finish. The send button is\n // already disabled while uploading, so this is just a safety net.\n if (state.fileAttachments.some((a) => a.status === \"uploading\")) {\n composerStatus.textContent = \"Wait for attachments to finish uploading.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n const isNew = !state.sessionId;\n const sid = composerSid();\n const fileRefs = readyFileRefs();\n\n // Images are small (resized) and ride inline; build them now. Guard re-entry\n // across the brief async build.\n sending = true;\n updateComposer();\n let imageWire: WireImage[]; let imageRefs: MessageImage[];\n try {\n const img = await buildImagePayload(sid);\n imageWire = img.wire; imageRefs = img.refs;\n } catch (err) {\n composerStatus.textContent = `Attachment failed: ${(err as Error).message}`;\n composerStatus.classList.add(\"is-error\");\n sending = false;\n updateComposer();\n return;\n }\n sending = false;\n composerStatus.textContent = \"\";\n state.attachments = [];\n state.fileAttachments = [];\n state.attachmentSessionId = null;\n renderAttachments();\n\n if (!isNew) {\n const msg = appendUserMessage(text, imageRefs, fileRefs);\n dispatchSendForExisting(sid, msg.id, text, imageWire, fileRefs);\n return;\n }\n\n state.sessionId = sid;\n state.messages = [];\n state.edits = [];\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text, imageRefs, fileRefs);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n state.pendingMessageImages = imageWire.length > 0 ? imageWire : null;\n state.pendingMessageFiles = fileRefs.length > 0 ? fileRefs : null;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: sid,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.pendingMessageImages = null;\n state.pendingMessageFiles = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n // Route attachments: images to the vision path, everything else to file upload.\n const intake = (files: readonly File[]): void => {\n const imgs = files.filter((f) => f.type.startsWith(\"image/\"));\n const rest = files.filter((f) => !f.type.startsWith(\"image/\"));\n if (imgs.length > 0) void addImageBlobs(imgs);\n if (rest.length > 0) void addFiles(rest);\n };\n sendBtn.addEventListener(\"click\", () => void doSend());\n attachBtn.addEventListener(\"click\", () => fileInput.click());\n fileInput.addEventListener(\"change\", () => {\n if (fileInput.files && fileInput.files.length > 0) intake([...fileInput.files]);\n fileInput.value = \"\";\n });\n textarea.addEventListener(\"paste\", (ev) => {\n const items = ev.clipboardData?.items;\n if (!items) return;\n const blobs: File[] = [];\n for (const it of items) {\n if (it.kind === \"file\") {\n const f = it.getAsFile();\n if (f) blobs.push(f);\n }\n }\n if (blobs.length > 0) { ev.preventDefault(); intake(blobs); }\n });\n composer.addEventListener(\"dragover\", (ev) => {\n if (ev.dataTransfer && [...ev.dataTransfer.items].some((i) => i.kind === \"file\")) {\n ev.preventDefault();\n composer.classList.add(\"la-composer-dragover\");\n }\n });\n composer.addEventListener(\"dragleave\", (ev) => {\n if (ev.target === composer) composer.classList.remove(\"la-composer-dragover\");\n });\n composer.addEventListener(\"drop\", (ev) => {\n composer.classList.remove(\"la-composer-dragover\");\n const files = ev.dataTransfer?.files;\n if (!files || files.length === 0) return;\n ev.preventDefault();\n intake([...files]);\n });\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n void doSend();\n }\n });\n\n // Returns the id of the user message that's the backend's PRIMARY rolling\n // cache anchor: 1 user-turn behind the latest, matching LAG_USER_TURNS=1 in\n // cache-control.ts. (Was off by one — anchored 2 turns back — so the divider\n // and the edit pre-warn pointed at the wrong message.) Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker stamping.\n // Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes to them)\n // auto-cache the prompt prefix upstream regardless, so the cue still appears.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 1) return null;\n return userMsgs[userMsgs.length - 1 - 1]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n // Use the edit's own scope (see makeThreadDeps.onRevertEdit).\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n // A live handle proves a turn is in flight, so the message it backs is\n // the last assistant message regardless of its status field. Binding\n // only to status===\"streaming\" left token/tool events writing to the\n // DOM handle but not the message model when the status had already been\n // flipped, so the post-finalize static rerender drew an empty bubble.\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n if (!handle) return;\n // Recover the message pointer if it was cleared mid-stream. Bailing here\n // used to leave the turn half-finalized: the handle's DOM had the streamed\n // content but the message stayed status=\"streaming\" with whatever blocks\n // it had, and the next rerenderThread drew it from the (possibly empty)\n // model instead of the live DOM, so the whole response vanished until a\n // reload rebuilt it from the backend-persisted session.\n rebindCurrentAssistantMessage();\n const msg = state.currentAssistantMessage;\n handle.setStatus(status);\n if (msg) msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n // Fresh session: no compaction yet.\n state.compactedAt = null;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n const imgs = state.pendingMessageImages ?? undefined;\n const files = state.pendingMessageFiles ?? undefined;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.pendingMessageImages = null;\n state.pendingMessageFiles = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text, imgs, files);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n // Loading a session is a clean boundary. A sticky composer error from\n // a prior failed start would otherwise suppress setComposerStatus for\n // the freshly loaded session (the is-error guard never lifts on its\n // own across a session switch).\n composerStatus.classList.remove(\"is-error\");\n composerStatus.textContent = \"\";\n // Drop any live streaming pointers from a previously-loaded session so\n // a still-running background generation can't keep mutating this thread.\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n state.compactedAt = msg.compactedAt ?? null;\n // Drop composer attachments queued against the previous session.\n state.attachments = [];\n state.fileAttachments = [];\n state.attachmentSessionId = null;\n renderAttachments();\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n // Refresh picker labels so \"(Active)\" tracks the loaded session's char.\n renderCharOptions();\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n applyStatus(msg.status);\n // Loaded into a live generation we never tracked from turn_started.\n // Suppress its partial live stream; reload on the terminal event.\n state.reattachedGeneration = msg.status.phase === \"generating\";\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n } else {\n // No-character session: the header badge counts Lumiverse-scope\n // changes, sourced from the scope storage list. Fetch it so the\n // badge is populated without opening the workshop.\n sendBackend({ type: \"list_characters_storage\" });\n sendBackend({ type: \"list_chats\", characterId: null, sessionId: msg.sessionId });\n }\n break;\n case \"session_status\":\n applyStatus(msg.status);\n break;\n case \"session_forked\": {\n // The user stays in the current chat. The fork lives in a separate\n // session; surface a toast so they know it landed and can open it\n // via the Sessions menu. Critically we do NOT load the fork or touch\n // the current session's llmHistory/cache here.\n showToast(\"Forked into a new session. Open Sessions to switch to it.\");\n sendBackend({ type: \"list_sessions\" });\n break;\n }\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n // Deleting the ACTIVE session mid-generation: the backend aborts then\n // immediately sends session_deleted, and the trailing generation_cancelled\n // is then dropped (sessionId is now null). Reset the in-flight flags here\n // or the composer stays wedged in \"working...\" with a dead cancel button.\n state.isGenerating = false;\n state.startingSession = false;\n state.compacting = false;\n state.reattachedGeneration = false;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n clearStartTimeout();\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n case \"chat_event\":\n // Drop events for any session other than the one currently loaded.\n // Loading a different session while this one streams would otherwise\n // render its tokens / tool calls / edits into the wrong thread.\n if (msg.sessionId !== state.sessionId) break;\n // While reattached to a generation we didn't track from its start,\n // the live stream is missing everything before the refresh. Rendering\n // it would build an incoherent partial bubble and fight the\n // authoritative reload on the terminal event. Drop it.\n if (state.reattachedGeneration) break;\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n // A terminal event for a different (backgrounded) session must not\n // finalize the currently-loaded thread.\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n // No-character turn may have produced Lumiverse-scope edits; refresh\n // the scope storage so the header badge reflects them.\n if (state.characterId === null) sendBackend({ type: \"list_characters_storage\" });\n break;\n case \"generation_cancelled\":\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n clearStartTimeout();\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome, msg.scope);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"scope_edits_pushed\": {\n // Authoritative snapshot for exactly one scope. Replace its slot,\n // refresh the badge, and only re-render the modal if it is currently\n // showing this scope (a background push for another scope must not\n // yank the view the user is looking at).\n state.scopeLedgers.set(scopeKeyString(msg.scope), [...msg.entries]);\n updateSessionBar();\n const shown = workshopScope();\n if (state.diffModal && shown && scopeKeyString(shown) === scopeKeyString(msg.scope)) {\n state.diffModal.setEdits(msg.entries);\n }\n break;\n }\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n // The array was replaced with fresh objects. If a turn is in flight\n // (mid-turn squash emits this), rebind the streaming pointer to the new\n // element by id, else finalizeAssistantTurn would set \"complete\" on the\n // orphaned old object and the live message stays \"streaming\" (no actions).\n if (state.streamingAssistant && state.currentAssistantMessage) {\n const reboundId = state.currentAssistantMessage.id;\n const rebound = state.messages.find((m) => m.id === reboundId && m.role === \"assistant\");\n if (rebound && rebound.role === \"assistant\") state.currentAssistantMessage = rebound;\n }\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n }\n break;\n case \"focus_set\":\n // Authoritative focus state from the backend (it owns the switch). The\n // thread is preserved; only the focus marker, pin, and per-character\n // lists refresh.\n if (msg.sessionId === state.sessionId) {\n const changed = msg.characterId !== state.characterId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n renderCharOptions();\n updateComposer();\n updateSessionBar();\n // A real switch refreshes the chat list. The backend may preserve a\n // group-chat pin when the new focus is another member.\n if (changed) {\n state.pinnedChatId = msg.pinnedChatId;\n state.chatsForCharacter = [];\n state.autoPinNeeded = msg.characterId !== null && msg.pinnedChatId === null;\n setChatPinned(msg.pinnedChatId !== null);\n if (msg.characterId) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n } else {\n // No-character focus: refresh the Lumiverse-scope workshop view.\n sendBackend({ type: \"list_characters_storage\" });\n }\n }\n }\n break;\n case \"focus_rejected\":\n // The backend refused the switch (generation in flight, character gone).\n // Non-destructive: snap the combo back to the real focus and surface a\n // status line. Never touch the streaming bubble / composer-generating state.\n if (msg.sessionId === state.sessionId) {\n renderCharOptions();\n composerStatus.textContent = msg.reason;\n composerStatus.classList.add(\"is-error\");\n }\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n tpmLimit: msg.tpmLimit,\n debugLogging: msg.debugLogging,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_image_ready\":\n void import(\"./image-cache\").then((m) => m.resolveImage(msg.path, `data:${msg.mimeType};base64,${msg.dataBase64}`));\n break;\n case \"ws_image_error\":\n void import(\"./image-cache\").then((m) => m.failImage(msg.path));\n break;\n case \"ws_upload_complete\": {\n const p = pendingUploads.get(msg.transferId);\n if (p) { pendingUploads.delete(msg.transferId); p.resolve(); }\n break;\n }\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n updateComposer();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\": {\n // Scope list for the workshop combo only. The badge is NOT touched\n // here: it is a pure projection of scopeLedgers, refreshed by the\n // authoritative scope_edits_pushed the backend sends alongside this.\n state.scopeStorage = msg.entries;\n state.diffModal?.setScopes(buildScopeOptions());\n // No-character badge is derived from scopeStorage, so refresh it when\n // the list lands. (With a character it reads scopeLedgers; harmless.)\n updateSessionBar();\n break;\n }\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else if (msg.op === \"image_resize\") {\n const { resizeBase64 } = await import(\"./image-resize\");\n const a = msg.args as { data: string; mime_type: string };\n result = await resizeBase64(a.data, a.mime_type);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"scope_squashed\": {\n // The scope's ledger was cleared. Drop its cached slot; if the combo\n // was focused on it, fall back to the active scope.\n const key = scopeKeyString(msg.scope);\n state.scopeLedgers.set(key, []);\n if (state.workshopFocusScope && scopeKeyString(state.workshopFocusScope) === key) {\n state.workshopFocusScope = null;\n }\n if (activeScope() && scopeKeyString(activeScope()!) === key) {\n // Only THIS scope's ledger was cleared. state.edits is the cross-scope\n // session mirror, so mark reverted only entries filed under this scope,\n // not every edit (which would falsely zero other scopes' live counts).\n state.edits = state.edits.map((e) => scopeKeyString(e.scope) === key ? { ...e, reverted: true } : e);\n rerenderThread();\n }\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n }\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "78d098d3f799cb988ce0fd9ec62216ebcc9e0a89": {"t": 1785771276.4801977, "n": 152174, "h": "e60dddc68534b127", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n CharacterStorageEntry,\n EditLogEntry,\n FrontendToBackend,\n MessageImage,\n MessageFile,\n WireImage,\n WireFile,\n RevertOutcomeWire,\n ScopeRef,\n SessionStatusWire,\n SessionSummaryWire,\n} from \"../types\";\nimport { characterScope, scopeKeyString } from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE, ICON_LUMIAGENT } from \"./icons\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveCustomIconUrl(): string | null {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return null;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n // Authoritative per-scope ledger snapshots, keyed by scopeKeyString. The\n // backend is the only writer of truth: every scope_edits_pushed replaces\n // one slot. The header badge and the workshop modal both read from here,\n // so there is no second cache to reconcile.\n scopeLedgers: Map;\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean; tpmLimit?: number | null; debugLogging?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n // Backend-authoritative session status. isGenerating / compacting are\n // derived from this on every push/load, never inferred locally. Null until\n // the first session_loaded / session_status arrives.\n sessionStatus: SessionStatusWire | null;\n // True when this client loaded into a generation it never tracked from\n // turn_started (a refresh mid-stream). The backend doesn't replay the\n // in-flight turn, so live events would build an incoherent partial bubble;\n // we suppress them and reload the authoritative session on the terminal\n // event instead.\n reattachedGeneration: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n // Set when the current session has a successful compaction boundary. Messages\n // with ts < compactedAt are pre-compaction (read-only, greyed).\n compactedAt: number | null;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n // Resized image attachments queued in the composer (base64 + mime), sent with\n // the next message. The workspace path is assigned at send time (needs sessionId).\n attachments: { id: string; data: string; mime_type: string }[];\n // Non-image file attachments queued in the composer. Uploaded (chunked) to the\n // workspace at ATTACH time so send is instant; only refs go on the message.\n fileAttachments: { id: string; file: File; name: string; size: number; mime: string; kind: \"text\" | \"binary\"; inlineContent?: string; path: string; status: \"uploading\" | \"ready\" | \"error\"; progress: number; error?: string }[];\n // Session id the queued attachments were uploaded under. For a not-yet-started\n // session this is generated eagerly so attach-time paths match the send.\n attachmentSessionId: string | null;\n // Wire attachments carried with pendingMessage across the start_session round-trip.\n pendingMessageImages: readonly WireImage[] | null;\n pendingMessageFiles: readonly WireFile[] | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n scopeStorage: readonly CharacterStorageEntry[];\n // View-only: which scope the workshop combo is focused on. Null = follow\n // the active session's scope. Never an authority for the header badge.\n workshopFocusScope: ScopeRef | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction showToast(text: string, durationMs = 4000): void {\n const t = document.createElement(\"div\");\n t.className = \"la-toast\";\n t.textContent = text;\n document.body.appendChild(t);\n requestAnimationFrame(() => t.classList.add(\"is-visible\"));\n setTimeout(() => {\n t.classList.remove(\"is-visible\");\n setTimeout(() => t.remove(), 300);\n }, durationMs);\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const customIconUrl = resolveCustomIconUrl();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n ...(customIconUrl ? { iconUrl: customIconUrl } : { iconSvg: ICON_LUMIAGENT }),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n scopeLedgers: new Map(),\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n sessionStatus: null,\n reattachedGeneration: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n compactedAt: null,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n attachments: [],\n fileAttachments: [],\n attachmentSessionId: null,\n pendingMessageImages: null,\n pendingMessageFiles: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n scopeStorage: [],\n workshopFocusScope: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card to English\",\n send: \"Translate this card to English. Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings), UI surfaces (regex replace_string, trigger displays/values, bg-html, scriptstate_defaults), and card System/Post-History instructions. Skip anything already bilingual and internal identifiers. For greetings, `rewrite` the message and be careful of strings that have regex matches. Avoid using mechanical translation methods as sub-string matching will kill your translation, always manually edit surfaces -- unfortunately, there is no easy way to do this, you must inspect every surface and write to it. Ensure you read the full trigger scripts/lua, and find regex surfaces, and default variables before you touch anything, as those contain sensitive string semantics. Avoid touching any regex_find strings and their occurances in the rest of the card. Summarise findings and ask which surfaces to touch before writing. At each stage, please check in with the user to get them to test whether the card surfaces still work as expected, or if anything is broken.\"\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n void doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const attachBtn = el(\"button\", \"la-attach-btn\") as HTMLButtonElement;\n attachBtn.type = \"button\";\n attachBtn.setAttribute(\"aria-label\", \"Attach image\");\n attachBtn.innerHTML = '';\n const fileInput = document.createElement(\"input\");\n fileInput.type = \"file\";\n fileInput.multiple = true;\n fileInput.style.display = \"none\";\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, attachBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerAttachments = el(\"div\", \"la-attachments\");\n composerAttachments.style.display = \"none\";\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerAttachments, composerArea, composerStatus);\n composer.appendChild(composerInner);\n composer.appendChild(fileInput);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n void import(\"./image-cache\").then((m) => m.configureImageCache((path) => sendBackend({ type: \"ws_read_image\", path })));\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n // Resolve against the live connection list: if the saved id was deleted, send\n // the default the picker is actually showing (renderConnOptions falls back to\n // it silently, so state.connectionId can hold a stale dead id otherwise).\n const withConnection = (msg: T): T => {\n let id = state.connectionId;\n if (id && !state.connections.some((c) => c.id === id)) {\n id = state.connections.find((c) => c.is_default)?.id ?? null;\n }\n return id ? { ...msg, connectionId: id } : msg;\n };\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"All Characters\", sublabel: \"no focus, address any character by id\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.id === state.characterId ? `${c.name} (Active)` : c.name,\n sublabel: `${c.chat_count} chats · ${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n // Re-list responses can land mid-generation; reassert the busy lock so a\n // refresh never silently re-enables character switching.\n updateLocks();\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n updateLocks();\n };\n\n // The scope the active session points at. A no-character chat has no\n // active scope, so its header badge is always 0 (Lumiverse-scope edits\n // surface in the workshop, not the header).\n const activeScope = (): ScopeRef | null =>\n state.characterId !== null ? characterScope(state.characterId) : null;\n\n // Pure projection of one authoritative snapshot. Never reconciles.\n const scopeEntries = (scope: ScopeRef | null): readonly EditLogEntry[] =>\n scope ? state.scopeLedgers.get(scopeKeyString(scope)) ?? [] : [];\n\n // What the workshop modal shows: the combo-focused scope if the user\n // picked one, otherwise the active session's scope.\n const workshopScope = (): ScopeRef | null => state.workshopFocusScope ?? activeScope();\n\n // Combo options for the workshop. scopeStorage only lists scopes that\n // already have a ledger, so a freshly-selected diffless character would be\n // absent and the combo would fall through to the first character that does\n // have edits. Always surface the active character so opening the workshop\n // lands on it (empty diff is the correct, ephemeral view).\n const buildScopeOptions = (): ReadonlyArray<{ scope: ScopeRef; label: string; changeCount: number; chatCount?: number; msgCount?: number; attachedToPersona?: { id: string; name: string } }> => {\n const opts = state.scopeStorage.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n // Reverted commits are purged, so editCount is the live change count.\n changeCount: e.editCount,\n ...(e.chatCount !== undefined ? { chatCount: e.chatCount } : {}),\n ...(e.msgCount !== undefined ? { msgCount: e.msgCount } : {}),\n ...(e.attachedToPersona ? { attachedToPersona: e.attachedToPersona } : {}),\n }));\n const active = activeScope();\n if (active && !opts.some((o) => scopeKeyString(o.scope) === scopeKeyString(active))) {\n opts.unshift({ scope: active, label: state.characterName ?? \"Current character\", changeCount: 0 });\n }\n return opts;\n };\n\n const updateSessionBar = () => {\n const scope = activeScope();\n if (scope) {\n const source = scopeEntries(scope);\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n editsBadge.classList.toggle(\"has-edits\", source.length > 0);\n return;\n }\n // No-character session: there is no active scope, so the badge reflects\n // Lumiverse-scope changes (persona / preset / world_book / regex / chat),\n // summed from the scope storage list (non-character entries carry `scope`).\n let total = 0;\n for (const e of state.scopeStorage) if (e.scope && e.scope.kind !== \"character\") total += e.liveEditCount;\n editsCount.textContent = String(total);\n editsBadge.classList.toggle(\"has-edits\", total > 0);\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n // Refresh composer + input locks: compaction holds the session for several\n // seconds, during which Send must disable and the character / pin / connection\n // controls must lock, exactly as they do during a generation.\n updateComposer();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n // What a Send click does, derived from the backend-authoritative status\n // (with a local-message fallback only for the pre-first-status window of a\n // brand-new session). The click handler dispatches on this; it never\n // recomputes the decision.\n let sendMode: \"new\" | \"continue\" | \"recover\" | \"disabled\" = \"disabled\";\n\n // Lock the surfaces that mutate the session the agent is using. Anything\n // other than an idle session means a generation or compaction is live.\n const updateLocks = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n charCombo.setDisabled(busy);\n chatPinBtn.disabled = busy;\n // connCombo is also disabled when there are no connections; don't\n // re-enable it here in that case.\n if (state.connections.length > 0) connCombo.setDisabled(busy);\n };\n\n const setComposerStatus = (text: string) => {\n // Never stomp a sticky error (e.g. the start_session timeout notice).\n // doSend clears is-error before a fresh attempt.\n if (composerStatus.classList.contains(\"is-error\")) return;\n composerStatus.textContent = text;\n };\n\n const updateComposer = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n if (busy) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n // Can't abort before the backend has acknowledged the session.\n cancelBtn.disabled = state.startingSession;\n textarea.disabled = false;\n setComposerStatus(\n state.startingSession ? \"starting session...\"\n : state.compacting ? \"compacting context...\"\n : \"agent is working...\",\n );\n sendMode = \"disabled\";\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n\n const st = state.sessionStatus;\n const statusFresh = !!st && st.sessionId === state.sessionId;\n // Attachments alone are sendable (image/file-only message), so they count\n // as content for send-mode purposes.\n const hasText = textarea.value.trim().length > 0 || state.attachments.length > 0 || state.fileAttachments.length > 0;\n const recoverable = statusFresh && st!.phase === \"idle\"\n && st!.lastAssistantStatus === \"errored\" && st!.lastAssistantEmpty\n && st!.lastAssistantId !== null;\n // Authoritative last-role; fall back to the local mirror only before the\n // first status arrives (typing into a brand-new unsent session).\n const lastRole = statusFresh\n ? st!.lastMessageRole\n : (state.messages[state.messages.length - 1]?.role ?? null);\n\n if (hasText) {\n // Typed text always sends: new session, new user turn, or a follow-up\n // after a completed assistant message.\n sendMode = \"new\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n } else if (recoverable) {\n sendMode = \"recover\";\n setComposerStatus(\"Last response failed empty. Press Send to retry it.\");\n } else if (lastRole === \"user\" && !!state.sessionId) {\n sendMode = \"continue\";\n setComposerStatus(\"\");\n } else {\n // Assistant message last (completed/cancelled), or empty thread with\n // no text: nothing to send.\n sendMode = \"disabled\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n }\n // Block send until every queued file finishes uploading.\n const uploading = state.fileAttachments.some((a) => a.status === \"uploading\");\n sendBtn.disabled = sendMode === \"disabled\" || uploading;\n if (uploading) setComposerStatus(\"Uploading attachments…\");\n }\n updateLocks();\n updateCompactButton();\n };\n\n // The single point where backend-authoritative status becomes UI state.\n // Every session_loaded / session_status flows through here so a reload mid\n // generation reconstructs the button, locks, and ring correctly.\n const applyStatus = (st: SessionStatusWire): void => {\n if (st.sessionId !== state.sessionId) return;\n state.sessionStatus = st;\n state.isGenerating = st.phase === \"generating\";\n state.compacting = st.phase === \"compacting\";\n if (st.phase !== \"idle\") {\n state.startingSession = false;\n clearStartTimeout();\n }\n state.contextPromptTokens = st.promptTokens;\n if (st.contextTokens > 0) state.contextTokens = st.contextTokens;\n updateComposer();\n updateSessionBar();\n };\n\n // Optimistic prune so the UI doesn't flash the reverted row before the\n // backend's authoritative scope_edits_pushed arrives. The session mirror\n // and every scope slot drop the id; the push then re-syncs the one scope.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n for (const [key, entries] of state.scopeLedgers) {\n state.scopeLedgers.set(key, entries.filter((e) => !ids.has(e.id)));\n }\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire, scope: ScopeRef): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n // Reset the modal's revert button (stuck on \"Reverting…\"): only a clean\n // revert pushes scope_edits_pushed, so a failure leaves it disabled.\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed) {\n // Use the edit's own scope (persona / chat / preset / world_book /\n // regex_script), not state.characterId, so a force-revert of a\n // non-character edit hits the right ledger and works in no-character chats.\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, force: true, scope });\n } else if (state.diffModal) {\n // Cancelled the force-revert confirm: reset the modal's stuck button.\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => scopeEntries(workshopScope()),\n getScopes: () => buildScopeOptions(),\n getSelectedScope: () => workshopScope(),\n onSelectScope: (scope) => {\n state.workshopFocusScope = scope;\n sendBackend({ type: \"load_character_workshop\", characterId: scope.id, scope });\n },\n onRevert: async (editId) => {\n const scope = workshopScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertAll: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Revert all edits\",\n message: \"Revert every live edit in this scope? Cascade-aware. The ledger keeps history so reverts can be undone individually.\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_character_all\", characterId: scope.id, scope });\n },\n onForget: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget changes\",\n message: \"Permanently clear this scope's edit ledger? The underlying data is NOT touched, but you won't be able to revert any of these edits afterwards.\",\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (c.confirmed) sendBackend({ type: \"squash_character\", characterId: scope.id, scope });\n },\n onScopesNeeded: () => sendBackend({ type: \"list_characters_storage\" }),\n onClose: () => {\n state.diffModal = null;\n // Drop the combo focus so reopening follows the active session's\n // scope again. No refetch: the active scope's slot is already cached\n // and the badge reads it directly.\n state.workshopFocusScope = null;\n },\n filesPanel: state.workspacePanel.root,\n }, {\n ...(initialEditId !== undefined ? { initialEditId } : {}),\n // No character in this chat: open straight to the Lumiverse tab,\n // since the Characters tab would have nothing to show.\n ...(state.characterId === null ? { initialTab: \"lumiverse\" as const } : {}),\n });\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n // Per-message banners count edits this session attributed to a message,\n // across every scope, so the session mirror is the right source.\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n return state.edits.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n return state.edits.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n // Use the edit's OWN scope, not state.characterId: an edit can be filed\n // under persona/chat/preset/wb/rx, and in a no-character session\n // state.characterId is null. Without this the inline Revert hit the\n // wrong ledger (or was a dead no-op) — the modal path already does this.\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n // revert_edits_bulk addresses ONE ledger, but a card's edits can span\n // scopes; group by each edit's scope and emit one bulk call per scope.\n const byScope = new Map();\n for (const editId of editIds) {\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) continue;\n const key = scopeKeyString(scope);\n let g = byScope.get(key);\n if (!g) { g = { scope, ids: [] }; byScope.set(key, g); }\n g.ids.push(editId);\n }\n for (const { scope, ids } of byScope.values()) {\n sendBackend({ type: \"revert_edits_bulk\", characterId: scope.id, editIds: ids, scope, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n }\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n onForkMessage: (messageId: string) => {\n if (!state.sessionId) return;\n sendBackend({ type: \"fork_session\", sourceSessionId: state.sessionId, messageId });\n },\n isPreCompaction: (messageTs: number) => state.compactedAt !== null && messageTs < state.compactedAt,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n if (id === state.characterId) return;\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish before switching characters.\";\n composerStatus.classList.add(\"is-error\");\n renderCharOptions();\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId });\n if (state.sessionId !== null) {\n // Switch focus in place, keeping the thread. The backend owns the switch\n // (mutates the session, keeps the cached prompt prefix, learns the agent\n // via a context note) and pushes authoritative state back via focus_set,\n // which updates the picker / pin / per-character lists. The frontend does\n // not mutate focus state itself.\n sendBackend({ type: \"set_focus\", sessionId: state.sessionId, characterId: id });\n } else {\n // No active session yet: create one focused on the pick.\n state.characterId = id;\n // Queue the newest chat to auto-pin once chats_pushed lands (the fresh\n // session has no pin yet); without this the first pick leaves no pin.\n state.autoPinNeeded = !!id;\n renderCharOptions();\n startNewSession();\n if (id) {\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n }\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.attachments = [];\n state.fileAttachments = [];\n state.attachmentSessionId = null;\n state.pendingMessageImages = null;\n state.pendingMessageFiles = null;\n renderAttachments();\n // A fresh pending session gets no status / context_usage push from the\n // backend (nothing generates yet), so reset the context-derived UI here\n // or the compaction wheel keeps showing the previous session's usage.\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.reattachedGeneration = false;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n const tpmRow = el(\"div\", \"la-settings-row\");\n tpmRow.append(el(\"label\", \"la-settings-row-label\", \"TPM limit (tk/min)\"));\n const tpmInput = document.createElement(\"input\");\n tpmInput.type = \"number\";\n tpmInput.className = \"la-slider-input\";\n tpmInput.min = \"1\";\n tpmInput.step = \"1\";\n tpmInput.placeholder = \"off\";\n tpmRow.appendChild(tpmInput);\n wrap.appendChild(tpmRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Pauses requests when prompt+completion tokens in the last 60s would exceed this. Set to your provider's tokens-per-minute quota (e.g. 250000 for Gemini free tier). Empty = no throttle.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n const debugLogRow = el(\"div\", \"la-settings-row\");\n debugLogRow.append(el(\"label\", \"la-settings-row-label\", \"Debug logging\"));\n const debugLogInput = document.createElement(\"input\");\n debugLogInput.type = \"checkbox\";\n debugLogInput.className = \"la-checkbox\";\n debugLogRow.appendChild(debugLogInput);\n wrap.appendChild(debugLogRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"OFF by default. When ON, verbose backend diagnostics (send_message, loop turns, llm stream, phone-line) are written to the host log. Warnings and errors always log.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n // handleUpdateSettings re-sends settings after every commit (blur / drag\n // release). That async echo must not overwrite a text field the user is\n // currently editing, or it silently discards characters typed during the\n // round-trip. Skip the rewrite while one of the form's text fields is focused.\n const active = document.activeElement;\n if (active === personaArea || active === promptArea || active === jbArea\n || active === wsCapInput || active === toolCapInput || active === tpmInput) return;\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n debugLogInput.checked = s.debugLogging ?? false;\n tpmInput.value = s.tpmLimit ? String(s.tpmLimit) : \"\";\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n tpmLimit: parsePosInt(tpmInput.value),\n debugLogging: debugLogInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput, tpmInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput, debugLogInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconFrame = el(\"div\", \"la-icon-settings-frame\");\n const setIconPreview = (customUrl: string | null): void => {\n if (customUrl) {\n iconFrame.innerHTML = \"\";\n const img = document.createElement(\"img\");\n img.src = customUrl;\n img.alt = \"current icon\";\n iconFrame.appendChild(img);\n } else {\n iconFrame.innerHTML = ICON_LUMIAGENT;\n }\n };\n setIconPreview(resolveCustomIconUrl());\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconFrame);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n setIconPreview(dataUrl);\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n setIconPreview(null);\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const formatBytes = (n: number): string => n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / (1024 * 1024)).toFixed(1)} MB`;\n\n const renderAttachments = (): void => {\n composerAttachments.replaceChildren();\n if (state.attachments.length === 0 && state.fileAttachments.length === 0) {\n composerAttachments.style.display = \"none\"; updateComposer(); return;\n }\n composerAttachments.style.display = \"\";\n for (const att of state.attachments) {\n const chip = el(\"div\", \"la-attachment\");\n const img = document.createElement(\"img\");\n img.src = `data:${att.mime_type};base64,${att.data}`;\n img.alt = \"attachment\";\n const rm = el(\"button\", \"la-attachment-remove\") as HTMLButtonElement;\n rm.type = \"button\";\n rm.setAttribute(\"aria-label\", \"Remove attachment\");\n rm.textContent = \"×\";\n rm.addEventListener(\"click\", () => {\n state.attachments = state.attachments.filter((a) => a.id !== att.id);\n renderAttachments();\n });\n chip.append(img, rm);\n composerAttachments.appendChild(chip);\n }\n for (const att of state.fileAttachments) {\n const chip = el(\"div\", `la-file-attachment${att.status === \"uploading\" ? \" is-uploading\" : \"\"}${att.status === \"error\" ? \" is-error\" : \"\"}`);\n chip.setAttribute(\"data-att-id\", att.id);\n const sizeText = att.status === \"error\" ? `failed: ${att.error ?? \"upload error\"}` : formatBytes(att.size);\n chip.append(\n el(\"span\", \"la-file-attachment-name\", att.name),\n el(\"span\", \"la-file-attachment-size\", sizeText),\n );\n const progress = el(\"div\", \"la-file-progress\");\n const bar = el(\"div\", \"la-file-progress-bar\");\n bar.style.width = `${Math.round(att.progress * 100)}%`;\n progress.appendChild(bar);\n chip.appendChild(progress);\n const rm = el(\"button\", \"la-attachment-remove\") as HTMLButtonElement;\n rm.type = \"button\";\n rm.setAttribute(\"aria-label\", \"Remove file\");\n rm.textContent = \"×\";\n rm.addEventListener(\"click\", () => {\n state.fileAttachments = state.fileAttachments.filter((a) => a.id !== att.id);\n renderAttachments();\n });\n chip.appendChild(rm);\n composerAttachments.appendChild(chip);\n }\n updateComposer();\n };\n\n const addImageBlobs = async (blobs: readonly Blob[]): Promise => {\n const { resizeBlob, isSupportedImage, MAX_IMAGES } = await import(\"./image-resize\");\n for (const blob of blobs) {\n if (state.attachments.length >= MAX_IMAGES) {\n composerStatus.textContent = `Up to ${MAX_IMAGES} images per message.`;\n composerStatus.classList.add(\"is-error\");\n break;\n }\n if (!isSupportedImage(blob.type)) continue;\n try {\n const r = await resizeBlob(blob);\n state.attachments.push({ id: makeId(\"att\"), data: r.data, mime_type: r.mime_type });\n composerStatus.classList.remove(\"is-error\");\n renderAttachments();\n } catch (err) {\n composerStatus.textContent = `Couldn't attach image: ${(err as Error).message}`;\n composerStatus.classList.add(\"is-error\");\n }\n }\n };\n\n const extForMime = (mime: string): string => {\n switch (mime) {\n case \"image/png\": return \"png\";\n case \"image/jpeg\": return \"jpg\";\n case \"image/gif\": return \"gif\";\n case \"image/webp\": return \"webp\";\n default: return \"img\";\n }\n };\n\n // Assign a workspace path per attachment (sessionId is known by send time),\n // pre-seed the image cache so the sent bubble renders instantly, and return\n // the wire payload (path + bytes) plus the persisted refs.\n const buildImagePayload = async (sid: string): Promise<{ wire: WireImage[]; refs: MessageImage[] }> => {\n const { seedImage } = await import(\"./image-cache\");\n const wire: WireImage[] = [];\n const refs: MessageImage[] = [];\n for (const att of state.attachments) {\n const path = `attachments/${sid}/${att.id}.${extForMime(att.mime_type)}`;\n seedImage(path, `data:${att.mime_type};base64,${att.data}`);\n wire.push({ path, data: att.data, mime_type: att.mime_type });\n refs.push({ path, mime_type: att.mime_type });\n }\n return { wire, refs };\n };\n\n // Outstanding chunked uploads keyed by transferId; resolved by ws_upload_complete.\n const pendingUploads = new Map void; reject: (e: Error) => void }>();\n type FileAtt = (typeof state.fileAttachments)[number];\n\n // Session id the queued attachments upload under. For a not-yet-started\n // session, generated eagerly so attach-time paths match the eventual send.\n const composerSid = (): string => {\n if (state.sessionId) return state.sessionId;\n if (!state.attachmentSessionId) state.attachmentSessionId = makeId(\"sess\");\n return state.attachmentSessionId;\n };\n\n // Update one file chip's progress bar / status in place (no full re-render).\n const setChipProgress = (att: FileAtt): void => {\n const chip = composerAttachments.querySelector(`[data-att-id=\"${att.id}\"]`);\n if (!chip) return;\n chip.classList.toggle(\"is-uploading\", att.status === \"uploading\");\n chip.classList.toggle(\"is-error\", att.status === \"error\");\n const bar = chip.querySelector(\".la-file-progress-bar\") as HTMLElement | null;\n if (bar) bar.style.width = `${Math.round(att.progress * 100)}%`;\n };\n\n // Stream a file to its workspace path on attach. Chunk-sent progress caps at\n // 95%; the backend ack (ws_upload_complete) flips it to ready.\n const uploadAttachment = async (att: FileAtt): Promise => {\n const transferId = makeId(\"up\");\n const acked = new Promise((resolve, reject) => {\n const timer = setTimeout(() => { if (pendingUploads.delete(transferId)) reject(new Error(\"upload timed out\")); }, 5 * 60_000);\n pendingUploads.set(transferId, { resolve: () => { clearTimeout(timer); resolve(); }, reject: (e) => { clearTimeout(timer); reject(e); } });\n });\n try {\n const { streamUpload } = await import(\"./file-upload\");\n const bytes = new Uint8Array(await att.file.arrayBuffer());\n await streamUpload(sendBackend, att.path, bytes, transferId, (frac) => { att.progress = Math.min(0.95, frac * 0.95); setChipProgress(att); });\n await acked;\n att.status = \"ready\"; att.progress = 1;\n } catch (err) {\n att.status = \"error\"; att.error = (err as Error).message;\n } finally {\n pendingUploads.delete(transferId);\n setChipProgress(att);\n updateComposer();\n }\n };\n\n const addFiles = async (files: readonly File[]): Promise => {\n const { isTextFile, INLINE_TEXT_BYTES, MAX_FILES, safeAttachmentName } = await import(\"./file-upload\");\n for (const file of files) {\n if (state.fileAttachments.length >= MAX_FILES) {\n composerStatus.textContent = `Up to ${MAX_FILES} files per message.`;\n composerStatus.classList.add(\"is-error\");\n break;\n }\n const kind: \"text\" | \"binary\" = isTextFile(file) ? \"text\" : \"binary\";\n let inlineContent: string | undefined;\n if (kind === \"text\" && file.size <= INLINE_TEXT_BYTES) {\n try { inlineContent = await file.text(); } catch { /* fall back to ref-only */ }\n }\n const id = makeId(\"file\");\n const path = `attachments/${composerSid()}/${id}-${safeAttachmentName(file.name)}`;\n const att: FileAtt = { id, file, name: file.name, size: file.size, mime: file.type, kind, ...(inlineContent !== undefined ? { inlineContent } : {}), path, status: \"uploading\", progress: 0 };\n state.fileAttachments.push(att);\n composerStatus.classList.remove(\"is-error\");\n renderAttachments();\n void uploadAttachment(att);\n }\n updateComposer();\n };\n\n // Wire refs from the already-uploaded (ready) file attachments. Errored\n // uploads are dropped.\n const readyFileRefs = (): WireFile[] =>\n state.fileAttachments\n .filter((a) => a.status === \"ready\")\n .map((att) => ({ path: att.path, name: att.name, size: att.size, mime: att.mime, kind: att.kind, ...(att.inlineContent !== undefined ? { inlineContent: att.inlineContent } : {}) }));\n\n const appendUserMessage = (text: string, images?: readonly MessageImage[], files?: readonly MessageFile[]): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n ...(images && images.length > 0 ? { images } : {}),\n ...(files && files.length > 0 ? { files } : {}),\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string, images?: readonly WireImage[], files?: readonly WireFile[]): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length, images: images?.length ?? 0, files: files?.length ?? 0 });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n ...(images && images.length > 0 ? { images } : {}),\n ...(files && files.length > 0 ? { files } : {}),\n }));\n // This client is tracking the generation from its start, so the live\n // stream is coherent: never treat it as a reattach.\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n let sending = false;\n const doSend = async (): Promise => {\n const text = textarea.value.trim();\n if (sending || state.isGenerating || state.startingSession || state.compacting) return;\n\n const hasImages = state.attachments.length > 0;\n const hasFiles = state.fileAttachments.length > 0;\n\n // Empty send with no attachments: the action is whatever sendMode resolved\n // to in updateComposer (authoritative-status driven). Never recompute here.\n if (text.length === 0 && !hasImages && !hasFiles) {\n if (sendMode === \"recover\") {\n const targetId = state.sessionStatus?.lastAssistantId;\n if (!targetId || !state.sessionId) return;\n composerStatus.classList.remove(\"is-error\");\n // Drop the empty errored bubble locally so it doesn't linger; the\n // backend regenerate truncates from the preceding user message and\n // re-runs, replacing it.\n state.messages = state.messages.filter((m) => m.id !== targetId);\n rerenderThread();\n sendBackend(withConnection({\n type: \"regenerate_assistant_message\",\n sessionId: state.sessionId,\n assistantMessageId: targetId,\n editsAction: \"keep\",\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n if (sendMode === \"continue\" && state.sessionId) {\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId,\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n return; // disabled: nothing to send\n }\n\n // Files upload on attach; block send until they finish. The send button is\n // already disabled while uploading, so this is just a safety net.\n if (state.fileAttachments.some((a) => a.status === \"uploading\")) {\n composerStatus.textContent = \"Wait for attachments to finish uploading.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n const isNew = !state.sessionId;\n const sid = composerSid();\n const fileRefs = readyFileRefs();\n\n // Images are small (resized) and ride inline; build them now. Guard re-entry\n // across the brief async build.\n sending = true;\n updateComposer();\n let imageWire: WireImage[]; let imageRefs: MessageImage[];\n try {\n const img = await buildImagePayload(sid);\n imageWire = img.wire; imageRefs = img.refs;\n } catch (err) {\n composerStatus.textContent = `Attachment failed: ${(err as Error).message}`;\n composerStatus.classList.add(\"is-error\");\n sending = false;\n updateComposer();\n return;\n }\n sending = false;\n composerStatus.textContent = \"\";\n state.attachments = [];\n state.fileAttachments = [];\n state.attachmentSessionId = null;\n renderAttachments();\n\n if (!isNew) {\n const msg = appendUserMessage(text, imageRefs, fileRefs);\n dispatchSendForExisting(sid, msg.id, text, imageWire, fileRefs);\n return;\n }\n\n state.sessionId = sid;\n state.messages = [];\n state.edits = [];\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text, imageRefs, fileRefs);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n state.pendingMessageImages = imageWire.length > 0 ? imageWire : null;\n state.pendingMessageFiles = fileRefs.length > 0 ? fileRefs : null;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: sid,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.pendingMessageImages = null;\n state.pendingMessageFiles = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n // Route attachments: images to the vision path, everything else to file upload.\n const intake = (files: readonly File[]): void => {\n const imgs = files.filter((f) => f.type.startsWith(\"image/\"));\n const rest = files.filter((f) => !f.type.startsWith(\"image/\"));\n if (imgs.length > 0) void addImageBlobs(imgs);\n if (rest.length > 0) void addFiles(rest);\n };\n sendBtn.addEventListener(\"click\", () => void doSend());\n attachBtn.addEventListener(\"click\", () => fileInput.click());\n fileInput.addEventListener(\"change\", () => {\n if (fileInput.files && fileInput.files.length > 0) intake([...fileInput.files]);\n fileInput.value = \"\";\n });\n textarea.addEventListener(\"paste\", (ev) => {\n const items = ev.clipboardData?.items;\n if (!items) return;\n const blobs: File[] = [];\n for (const it of items) {\n if (it.kind === \"file\") {\n const f = it.getAsFile();\n if (f) blobs.push(f);\n }\n }\n if (blobs.length > 0) { ev.preventDefault(); intake(blobs); }\n });\n composer.addEventListener(\"dragover\", (ev) => {\n if (ev.dataTransfer && [...ev.dataTransfer.items].some((i) => i.kind === \"file\")) {\n ev.preventDefault();\n composer.classList.add(\"la-composer-dragover\");\n }\n });\n composer.addEventListener(\"dragleave\", (ev) => {\n if (ev.target === composer) composer.classList.remove(\"la-composer-dragover\");\n });\n composer.addEventListener(\"drop\", (ev) => {\n composer.classList.remove(\"la-composer-dragover\");\n const files = ev.dataTransfer?.files;\n if (!files || files.length === 0) return;\n ev.preventDefault();\n intake([...files]);\n });\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n void doSend();\n }\n });\n\n // Returns the id of the user message that's the backend's PRIMARY rolling\n // cache anchor: 1 user-turn behind the latest, matching LAG_USER_TURNS=1 in\n // cache-control.ts. (Was off by one — anchored 2 turns back — so the divider\n // and the edit pre-warn pointed at the wrong message.) Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker stamping.\n // Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes to them)\n // auto-cache the prompt prefix upstream regardless, so the cue still appears.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 1) return null;\n return userMsgs[userMsgs.length - 1 - 1]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n // Use the edit's own scope (see makeThreadDeps.onRevertEdit).\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n // A live handle proves a turn is in flight, so the message it backs is\n // the last assistant message regardless of its status field. Binding\n // only to status===\"streaming\" left token/tool events writing to the\n // DOM handle but not the message model when the status had already been\n // flipped, so the post-finalize static rerender drew an empty bubble.\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n if (!handle) return;\n // Recover the message pointer if it was cleared mid-stream. Bailing here\n // used to leave the turn half-finalized: the handle's DOM had the streamed\n // content but the message stayed status=\"streaming\" with whatever blocks\n // it had, and the next rerenderThread drew it from the (possibly empty)\n // model instead of the live DOM, so the whole response vanished until a\n // reload rebuilt it from the backend-persisted session.\n rebindCurrentAssistantMessage();\n const msg = state.currentAssistantMessage;\n handle.setStatus(status);\n if (msg) msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n // Fresh session: no compaction yet.\n state.compactedAt = null;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n const imgs = state.pendingMessageImages ?? undefined;\n const files = state.pendingMessageFiles ?? undefined;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.pendingMessageImages = null;\n state.pendingMessageFiles = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text, imgs, files);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n // Loading a session is a clean boundary. A sticky composer error from\n // a prior failed start would otherwise suppress setComposerStatus for\n // the freshly loaded session (the is-error guard never lifts on its\n // own across a session switch).\n composerStatus.classList.remove(\"is-error\");\n composerStatus.textContent = \"\";\n // Drop any live streaming pointers from a previously-loaded session so\n // a still-running background generation can't keep mutating this thread.\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n state.compactedAt = msg.compactedAt ?? null;\n // Drop composer attachments queued against the previous session.\n state.attachments = [];\n state.fileAttachments = [];\n state.attachmentSessionId = null;\n renderAttachments();\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n // Refresh picker labels so \"(Active)\" tracks the loaded session's char.\n renderCharOptions();\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n applyStatus(msg.status);\n // Loaded into a live generation we never tracked from turn_started.\n // Suppress its partial live stream; reload on the terminal event.\n state.reattachedGeneration = msg.status.phase === \"generating\";\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n } else {\n // No-character session: the header badge counts Lumiverse-scope\n // changes, sourced from the scope storage list. Fetch it so the\n // badge is populated without opening the workshop.\n sendBackend({ type: \"list_characters_storage\" });\n }\n break;\n case \"session_status\":\n applyStatus(msg.status);\n break;\n case \"session_forked\": {\n // The user stays in the current chat. The fork lives in a separate\n // session; surface a toast so they know it landed and can open it\n // via the Sessions menu. Critically we do NOT load the fork or touch\n // the current session's llmHistory/cache here.\n showToast(\"Forked into a new session. Open Sessions to switch to it.\");\n sendBackend({ type: \"list_sessions\" });\n break;\n }\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n // Deleting the ACTIVE session mid-generation: the backend aborts then\n // immediately sends session_deleted, and the trailing generation_cancelled\n // is then dropped (sessionId is now null). Reset the in-flight flags here\n // or the composer stays wedged in \"working...\" with a dead cancel button.\n state.isGenerating = false;\n state.startingSession = false;\n state.compacting = false;\n state.reattachedGeneration = false;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n clearStartTimeout();\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n case \"chat_event\":\n // Drop events for any session other than the one currently loaded.\n // Loading a different session while this one streams would otherwise\n // render its tokens / tool calls / edits into the wrong thread.\n if (msg.sessionId !== state.sessionId) break;\n // While reattached to a generation we didn't track from its start,\n // the live stream is missing everything before the refresh. Rendering\n // it would build an incoherent partial bubble and fight the\n // authoritative reload on the terminal event. Drop it.\n if (state.reattachedGeneration) break;\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n // A terminal event for a different (backgrounded) session must not\n // finalize the currently-loaded thread.\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n // No-character turn may have produced Lumiverse-scope edits; refresh\n // the scope storage so the header badge reflects them.\n if (state.characterId === null) sendBackend({ type: \"list_characters_storage\" });\n break;\n case \"generation_cancelled\":\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n clearStartTimeout();\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome, msg.scope);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"scope_edits_pushed\": {\n // Authoritative snapshot for exactly one scope. Replace its slot,\n // refresh the badge, and only re-render the modal if it is currently\n // showing this scope (a background push for another scope must not\n // yank the view the user is looking at).\n state.scopeLedgers.set(scopeKeyString(msg.scope), [...msg.entries]);\n updateSessionBar();\n const shown = workshopScope();\n if (state.diffModal && shown && scopeKeyString(shown) === scopeKeyString(msg.scope)) {\n state.diffModal.setEdits(msg.entries);\n }\n break;\n }\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n // The array was replaced with fresh objects. If a turn is in flight\n // (mid-turn squash emits this), rebind the streaming pointer to the new\n // element by id, else finalizeAssistantTurn would set \"complete\" on the\n // orphaned old object and the live message stays \"streaming\" (no actions).\n if (state.streamingAssistant && state.currentAssistantMessage) {\n const reboundId = state.currentAssistantMessage.id;\n const rebound = state.messages.find((m) => m.id === reboundId && m.role === \"assistant\");\n if (rebound && rebound.role === \"assistant\") state.currentAssistantMessage = rebound;\n }\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"focus_set\":\n // Authoritative focus state from the backend (it owns the switch). The\n // thread is preserved; only the focus marker, pin, and per-character\n // lists refresh.\n if (msg.sessionId === state.sessionId) {\n const changed = msg.characterId !== state.characterId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n renderCharOptions();\n updateComposer();\n updateSessionBar();\n // A no-op echo (same character) must NOT wipe a pin the backend still\n // holds. Only a real switch clears the previous character's pin/chats.\n if (changed) {\n state.pinnedChatId = null;\n state.chatsForCharacter = [];\n state.autoPinNeeded = msg.characterId !== null;\n setChatPinned(false);\n if (msg.characterId) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n } else {\n // No-character focus: refresh the Lumiverse-scope workshop view.\n sendBackend({ type: \"list_characters_storage\" });\n }\n }\n }\n break;\n case \"focus_rejected\":\n // The backend refused the switch (generation in flight, character gone).\n // Non-destructive: snap the combo back to the real focus and surface a\n // status line. Never touch the streaming bubble / composer-generating state.\n if (msg.sessionId === state.sessionId) {\n renderCharOptions();\n composerStatus.textContent = msg.reason;\n composerStatus.classList.add(\"is-error\");\n }\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n tpmLimit: msg.tpmLimit,\n debugLogging: msg.debugLogging,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_image_ready\":\n void import(\"./image-cache\").then((m) => m.resolveImage(msg.path, `data:${msg.mimeType};base64,${msg.dataBase64}`));\n break;\n case \"ws_image_error\":\n void import(\"./image-cache\").then((m) => m.failImage(msg.path));\n break;\n case \"ws_upload_complete\": {\n const p = pendingUploads.get(msg.transferId);\n if (p) { pendingUploads.delete(msg.transferId); p.resolve(); }\n break;\n }\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n updateComposer();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\": {\n // Scope list for the workshop combo only. The badge is NOT touched\n // here: it is a pure projection of scopeLedgers, refreshed by the\n // authoritative scope_edits_pushed the backend sends alongside this.\n state.scopeStorage = msg.entries;\n state.diffModal?.setScopes(buildScopeOptions());\n // No-character badge is derived from scopeStorage, so refresh it when\n // the list lands. (With a character it reads scopeLedgers; harmless.)\n updateSessionBar();\n break;\n }\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else if (msg.op === \"image_resize\") {\n const { resizeBase64 } = await import(\"./image-resize\");\n const a = msg.args as { data: string; mime_type: string };\n result = await resizeBase64(a.data, a.mime_type);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"scope_squashed\": {\n // The scope's ledger was cleared. Drop its cached slot; if the combo\n // was focused on it, fall back to the active scope.\n const key = scopeKeyString(msg.scope);\n state.scopeLedgers.set(key, []);\n if (state.workshopFocusScope && scopeKeyString(state.workshopFocusScope) === key) {\n state.workshopFocusScope = null;\n }\n if (activeScope() && scopeKeyString(activeScope()!) === key) {\n // Only THIS scope's ledger was cleared. state.edits is the cross-scope\n // session mirror, so mark reverted only entries filed under this scope,\n // not every edit (which would falsely zero other scopes' live counts).\n state.edits = state.edits.map((e) => scopeKeyString(e.scope) === key ? { ...e, reverted: true } : e);\n rerenderThread();\n }\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n }\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "b6b8de1a695133db25dac21c43ac3d07d67b5eca": {"t": 1785771278.9108372, "n": 151878, "h": "86c6ae307379e48d", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n CharacterStorageEntry,\n EditLogEntry,\n FrontendToBackend,\n MessageImage,\n MessageFile,\n WireImage,\n WireFile,\n RevertOutcomeWire,\n ScopeRef,\n SessionStatusWire,\n SessionSummaryWire,\n} from \"../types\";\nimport { characterScope, scopeKeyString } from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n // Authoritative per-scope ledger snapshots, keyed by scopeKeyString. The\n // backend is the only writer of truth: every scope_edits_pushed replaces\n // one slot. The header badge and the workshop modal both read from here,\n // so there is no second cache to reconcile.\n scopeLedgers: Map;\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean; tpmLimit?: number | null; debugLogging?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n // Backend-authoritative session status. isGenerating / compacting are\n // derived from this on every push/load, never inferred locally. Null until\n // the first session_loaded / session_status arrives.\n sessionStatus: SessionStatusWire | null;\n // True when this client loaded into a generation it never tracked from\n // turn_started (a refresh mid-stream). The backend doesn't replay the\n // in-flight turn, so live events would build an incoherent partial bubble;\n // we suppress them and reload the authoritative session on the terminal\n // event instead.\n reattachedGeneration: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n // Set when the current session has a successful compaction boundary. Messages\n // with ts < compactedAt are pre-compaction (read-only, greyed).\n compactedAt: number | null;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n // Resized image attachments queued in the composer (base64 + mime), sent with\n // the next message. The workspace path is assigned at send time (needs sessionId).\n attachments: { id: string; data: string; mime_type: string }[];\n // Non-image file attachments queued in the composer. Uploaded (chunked) to the\n // workspace at ATTACH time so send is instant; only refs go on the message.\n fileAttachments: { id: string; file: File; name: string; size: number; mime: string; kind: \"text\" | \"binary\"; inlineContent?: string; path: string; status: \"uploading\" | \"ready\" | \"error\"; progress: number; error?: string }[];\n // Session id the queued attachments were uploaded under. For a not-yet-started\n // session this is generated eagerly so attach-time paths match the send.\n attachmentSessionId: string | null;\n // Wire attachments carried with pendingMessage across the start_session round-trip.\n pendingMessageImages: readonly WireImage[] | null;\n pendingMessageFiles: readonly WireFile[] | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n scopeStorage: readonly CharacterStorageEntry[];\n // View-only: which scope the workshop combo is focused on. Null = follow\n // the active session's scope. Never an authority for the header badge.\n workshopFocusScope: ScopeRef | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction showToast(text: string, durationMs = 4000): void {\n const t = document.createElement(\"div\");\n t.className = \"la-toast\";\n t.textContent = text;\n document.body.appendChild(t);\n requestAnimationFrame(() => t.classList.add(\"is-visible\"));\n setTimeout(() => {\n t.classList.remove(\"is-visible\");\n setTimeout(() => t.remove(), 300);\n }, durationMs);\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n scopeLedgers: new Map(),\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n sessionStatus: null,\n reattachedGeneration: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n compactedAt: null,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n attachments: [],\n fileAttachments: [],\n attachmentSessionId: null,\n pendingMessageImages: null,\n pendingMessageFiles: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n scopeStorage: [],\n workshopFocusScope: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card to English\",\n send: \"Translate this card to English. Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings), UI surfaces (regex replace_string, trigger displays/values, bg-html, scriptstate_defaults), and card System/Post-History instructions. Skip anything already bilingual and internal identifiers. For greetings, `rewrite` the message and be careful of strings that have regex matches. Avoid using mechanical translation methods as sub-string matching will kill your translation, always manually edit surfaces -- unfortunately, there is no easy way to do this, you must inspect every surface and write to it. Ensure you read the full trigger scripts/lua, and find regex surfaces, and default variables before you touch anything, as those contain sensitive string semantics. Avoid touching any regex_find strings and their occurances in the rest of the card. Summarise findings and ask which surfaces to touch before writing. At each stage, please check in with the user to get them to test whether the card surfaces still work as expected, or if anything is broken.\"\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n void doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const attachBtn = el(\"button\", \"la-attach-btn\") as HTMLButtonElement;\n attachBtn.type = \"button\";\n attachBtn.setAttribute(\"aria-label\", \"Attach image\");\n attachBtn.innerHTML = '';\n const fileInput = document.createElement(\"input\");\n fileInput.type = \"file\";\n fileInput.multiple = true;\n fileInput.style.display = \"none\";\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, attachBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerAttachments = el(\"div\", \"la-attachments\");\n composerAttachments.style.display = \"none\";\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerAttachments, composerArea, composerStatus);\n composer.appendChild(composerInner);\n composer.appendChild(fileInput);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n void import(\"./image-cache\").then((m) => m.configureImageCache((path) => sendBackend({ type: \"ws_read_image\", path })));\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n // Resolve against the live connection list: if the saved id was deleted, send\n // the default the picker is actually showing (renderConnOptions falls back to\n // it silently, so state.connectionId can hold a stale dead id otherwise).\n const withConnection = (msg: T): T => {\n let id = state.connectionId;\n if (id && !state.connections.some((c) => c.id === id)) {\n id = state.connections.find((c) => c.is_default)?.id ?? null;\n }\n return id ? { ...msg, connectionId: id } : msg;\n };\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"All Characters\", sublabel: \"no focus, address any character by id\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.id === state.characterId ? `${c.name} (Active)` : c.name,\n sublabel: `${c.chat_count} chats · ${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n // Re-list responses can land mid-generation; reassert the busy lock so a\n // refresh never silently re-enables character switching.\n updateLocks();\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n updateLocks();\n };\n\n // The scope the active session points at. A no-character chat has no\n // active scope, so its header badge is always 0 (Lumiverse-scope edits\n // surface in the workshop, not the header).\n const activeScope = (): ScopeRef | null =>\n state.characterId !== null ? characterScope(state.characterId) : null;\n\n // Pure projection of one authoritative snapshot. Never reconciles.\n const scopeEntries = (scope: ScopeRef | null): readonly EditLogEntry[] =>\n scope ? state.scopeLedgers.get(scopeKeyString(scope)) ?? [] : [];\n\n // What the workshop modal shows: the combo-focused scope if the user\n // picked one, otherwise the active session's scope.\n const workshopScope = (): ScopeRef | null => state.workshopFocusScope ?? activeScope();\n\n // Combo options for the workshop. scopeStorage only lists scopes that\n // already have a ledger, so a freshly-selected diffless character would be\n // absent and the combo would fall through to the first character that does\n // have edits. Always surface the active character so opening the workshop\n // lands on it (empty diff is the correct, ephemeral view).\n const buildScopeOptions = (): ReadonlyArray<{ scope: ScopeRef; label: string; changeCount: number; chatCount?: number; msgCount?: number; attachedToPersona?: { id: string; name: string } }> => {\n const opts = state.scopeStorage.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n // Reverted commits are purged, so editCount is the live change count.\n changeCount: e.editCount,\n ...(e.chatCount !== undefined ? { chatCount: e.chatCount } : {}),\n ...(e.msgCount !== undefined ? { msgCount: e.msgCount } : {}),\n ...(e.attachedToPersona ? { attachedToPersona: e.attachedToPersona } : {}),\n }));\n const active = activeScope();\n if (active && !opts.some((o) => scopeKeyString(o.scope) === scopeKeyString(active))) {\n opts.unshift({ scope: active, label: state.characterName ?? \"Current character\", changeCount: 0 });\n }\n return opts;\n };\n\n const updateSessionBar = () => {\n const scope = activeScope();\n if (scope) {\n const source = scopeEntries(scope);\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n editsBadge.classList.toggle(\"has-edits\", source.length > 0);\n return;\n }\n // No-character session: there is no active scope, so the badge reflects\n // Lumiverse-scope changes (persona / preset / world_book / regex / chat),\n // summed from the scope storage list (non-character entries carry `scope`).\n let total = 0;\n for (const e of state.scopeStorage) if (e.scope && e.scope.kind !== \"character\") total += e.liveEditCount;\n editsCount.textContent = String(total);\n editsBadge.classList.toggle(\"has-edits\", total > 0);\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n // Refresh composer + input locks: compaction holds the session for several\n // seconds, during which Send must disable and the character / pin / connection\n // controls must lock, exactly as they do during a generation.\n updateComposer();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n // What a Send click does, derived from the backend-authoritative status\n // (with a local-message fallback only for the pre-first-status window of a\n // brand-new session). The click handler dispatches on this; it never\n // recomputes the decision.\n let sendMode: \"new\" | \"continue\" | \"recover\" | \"disabled\" = \"disabled\";\n\n // Lock the surfaces that mutate the session the agent is using. Anything\n // other than an idle session means a generation or compaction is live.\n const updateLocks = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n charCombo.setDisabled(busy);\n chatPinBtn.disabled = busy;\n // connCombo is also disabled when there are no connections; don't\n // re-enable it here in that case.\n if (state.connections.length > 0) connCombo.setDisabled(busy);\n };\n\n const setComposerStatus = (text: string) => {\n // Never stomp a sticky error (e.g. the start_session timeout notice).\n // doSend clears is-error before a fresh attempt.\n if (composerStatus.classList.contains(\"is-error\")) return;\n composerStatus.textContent = text;\n };\n\n const updateComposer = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n if (busy) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n // Can't abort before the backend has acknowledged the session.\n cancelBtn.disabled = state.startingSession;\n textarea.disabled = false;\n setComposerStatus(\n state.startingSession ? \"starting session...\"\n : state.compacting ? \"compacting context...\"\n : \"agent is working...\",\n );\n sendMode = \"disabled\";\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n\n const st = state.sessionStatus;\n const statusFresh = !!st && st.sessionId === state.sessionId;\n // Attachments alone are sendable (image/file-only message), so they count\n // as content for send-mode purposes.\n const hasText = textarea.value.trim().length > 0 || state.attachments.length > 0 || state.fileAttachments.length > 0;\n const recoverable = statusFresh && st!.phase === \"idle\"\n && st!.lastAssistantStatus === \"errored\" && st!.lastAssistantEmpty\n && st!.lastAssistantId !== null;\n // Authoritative last-role; fall back to the local mirror only before the\n // first status arrives (typing into a brand-new unsent session).\n const lastRole = statusFresh\n ? st!.lastMessageRole\n : (state.messages[state.messages.length - 1]?.role ?? null);\n\n if (hasText) {\n // Typed text always sends: new session, new user turn, or a follow-up\n // after a completed assistant message.\n sendMode = \"new\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n } else if (recoverable) {\n sendMode = \"recover\";\n setComposerStatus(\"Last response failed empty. Press Send to retry it.\");\n } else if (lastRole === \"user\" && !!state.sessionId) {\n sendMode = \"continue\";\n setComposerStatus(\"\");\n } else {\n // Assistant message last (completed/cancelled), or empty thread with\n // no text: nothing to send.\n sendMode = \"disabled\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n }\n // Block send until every queued file finishes uploading.\n const uploading = state.fileAttachments.some((a) => a.status === \"uploading\");\n sendBtn.disabled = sendMode === \"disabled\" || uploading;\n if (uploading) setComposerStatus(\"Uploading attachments…\");\n }\n updateLocks();\n updateCompactButton();\n };\n\n // The single point where backend-authoritative status becomes UI state.\n // Every session_loaded / session_status flows through here so a reload mid\n // generation reconstructs the button, locks, and ring correctly.\n const applyStatus = (st: SessionStatusWire): void => {\n if (st.sessionId !== state.sessionId) return;\n state.sessionStatus = st;\n state.isGenerating = st.phase === \"generating\";\n state.compacting = st.phase === \"compacting\";\n if (st.phase !== \"idle\") {\n state.startingSession = false;\n clearStartTimeout();\n }\n state.contextPromptTokens = st.promptTokens;\n if (st.contextTokens > 0) state.contextTokens = st.contextTokens;\n updateComposer();\n updateSessionBar();\n };\n\n // Optimistic prune so the UI doesn't flash the reverted row before the\n // backend's authoritative scope_edits_pushed arrives. The session mirror\n // and every scope slot drop the id; the push then re-syncs the one scope.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n for (const [key, entries] of state.scopeLedgers) {\n state.scopeLedgers.set(key, entries.filter((e) => !ids.has(e.id)));\n }\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire, scope: ScopeRef): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n // Reset the modal's revert button (stuck on \"Reverting…\"): only a clean\n // revert pushes scope_edits_pushed, so a failure leaves it disabled.\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed) {\n // Use the edit's own scope (persona / chat / preset / world_book /\n // regex_script), not state.characterId, so a force-revert of a\n // non-character edit hits the right ledger and works in no-character chats.\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, force: true, scope });\n } else if (state.diffModal) {\n // Cancelled the force-revert confirm: reset the modal's stuck button.\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => scopeEntries(workshopScope()),\n getScopes: () => buildScopeOptions(),\n getSelectedScope: () => workshopScope(),\n onSelectScope: (scope) => {\n state.workshopFocusScope = scope;\n sendBackend({ type: \"load_character_workshop\", characterId: scope.id, scope });\n },\n onRevert: async (editId) => {\n const scope = workshopScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertAll: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Revert all edits\",\n message: \"Revert every live edit in this scope? Cascade-aware. The ledger keeps history so reverts can be undone individually.\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_character_all\", characterId: scope.id, scope });\n },\n onForget: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget changes\",\n message: \"Permanently clear this scope's edit ledger? The underlying data is NOT touched, but you won't be able to revert any of these edits afterwards.\",\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (c.confirmed) sendBackend({ type: \"squash_character\", characterId: scope.id, scope });\n },\n onScopesNeeded: () => sendBackend({ type: \"list_characters_storage\" }),\n onClose: () => {\n state.diffModal = null;\n // Drop the combo focus so reopening follows the active session's\n // scope again. No refetch: the active scope's slot is already cached\n // and the badge reads it directly.\n state.workshopFocusScope = null;\n },\n filesPanel: state.workspacePanel.root,\n }, {\n ...(initialEditId !== undefined ? { initialEditId } : {}),\n // No character in this chat: open straight to the Lumiverse tab,\n // since the Characters tab would have nothing to show.\n ...(state.characterId === null ? { initialTab: \"lumiverse\" as const } : {}),\n });\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n // Per-message banners count edits this session attributed to a message,\n // across every scope, so the session mirror is the right source.\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n return state.edits.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n return state.edits.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n // Use the edit's OWN scope, not state.characterId: an edit can be filed\n // under persona/chat/preset/wb/rx, and in a no-character session\n // state.characterId is null. Without this the inline Revert hit the\n // wrong ledger (or was a dead no-op) — the modal path already does this.\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n // revert_edits_bulk addresses ONE ledger, but a card's edits can span\n // scopes; group by each edit's scope and emit one bulk call per scope.\n const byScope = new Map();\n for (const editId of editIds) {\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) continue;\n const key = scopeKeyString(scope);\n let g = byScope.get(key);\n if (!g) { g = { scope, ids: [] }; byScope.set(key, g); }\n g.ids.push(editId);\n }\n for (const { scope, ids } of byScope.values()) {\n sendBackend({ type: \"revert_edits_bulk\", characterId: scope.id, editIds: ids, scope, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n }\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n onForkMessage: (messageId: string) => {\n if (!state.sessionId) return;\n sendBackend({ type: \"fork_session\", sourceSessionId: state.sessionId, messageId });\n },\n isPreCompaction: (messageTs: number) => state.compactedAt !== null && messageTs < state.compactedAt,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n if (id === state.characterId) return;\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish before switching characters.\";\n composerStatus.classList.add(\"is-error\");\n renderCharOptions();\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId });\n if (state.sessionId !== null) {\n // Switch focus in place, keeping the thread. The backend owns the switch\n // (mutates the session, keeps the cached prompt prefix, learns the agent\n // via a context note) and pushes authoritative state back via focus_set,\n // which updates the picker / pin / per-character lists. The frontend does\n // not mutate focus state itself.\n sendBackend({ type: \"set_focus\", sessionId: state.sessionId, characterId: id });\n } else {\n // No active session yet: create one focused on the pick.\n state.characterId = id;\n // Queue the newest chat to auto-pin once chats_pushed lands (the fresh\n // session has no pin yet); without this the first pick leaves no pin.\n state.autoPinNeeded = !!id;\n renderCharOptions();\n startNewSession();\n if (id) {\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n }\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.attachments = [];\n state.fileAttachments = [];\n state.attachmentSessionId = null;\n state.pendingMessageImages = null;\n state.pendingMessageFiles = null;\n renderAttachments();\n // A fresh pending session gets no status / context_usage push from the\n // backend (nothing generates yet), so reset the context-derived UI here\n // or the compaction wheel keeps showing the previous session's usage.\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.reattachedGeneration = false;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n const tpmRow = el(\"div\", \"la-settings-row\");\n tpmRow.append(el(\"label\", \"la-settings-row-label\", \"TPM limit (tk/min)\"));\n const tpmInput = document.createElement(\"input\");\n tpmInput.type = \"number\";\n tpmInput.className = \"la-slider-input\";\n tpmInput.min = \"1\";\n tpmInput.step = \"1\";\n tpmInput.placeholder = \"off\";\n tpmRow.appendChild(tpmInput);\n wrap.appendChild(tpmRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Pauses requests when prompt+completion tokens in the last 60s would exceed this. Set to your provider's tokens-per-minute quota (e.g. 250000 for Gemini free tier). Empty = no throttle.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n const debugLogRow = el(\"div\", \"la-settings-row\");\n debugLogRow.append(el(\"label\", \"la-settings-row-label\", \"Debug logging\"));\n const debugLogInput = document.createElement(\"input\");\n debugLogInput.type = \"checkbox\";\n debugLogInput.className = \"la-checkbox\";\n debugLogRow.appendChild(debugLogInput);\n wrap.appendChild(debugLogRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"OFF by default. When ON, verbose backend diagnostics (send_message, loop turns, llm stream, phone-line) are written to the host log. Warnings and errors always log.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n // handleUpdateSettings re-sends settings after every commit (blur / drag\n // release). That async echo must not overwrite a text field the user is\n // currently editing, or it silently discards characters typed during the\n // round-trip. Skip the rewrite while one of the form's text fields is focused.\n const active = document.activeElement;\n if (active === personaArea || active === promptArea || active === jbArea\n || active === wsCapInput || active === toolCapInput || active === tpmInput) return;\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n debugLogInput.checked = s.debugLogging ?? false;\n tpmInput.value = s.tpmLimit ? String(s.tpmLimit) : \"\";\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n tpmLimit: parsePosInt(tpmInput.value),\n debugLogging: debugLogInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput, tpmInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput, debugLogInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const formatBytes = (n: number): string => n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / (1024 * 1024)).toFixed(1)} MB`;\n\n const renderAttachments = (): void => {\n composerAttachments.replaceChildren();\n if (state.attachments.length === 0 && state.fileAttachments.length === 0) {\n composerAttachments.style.display = \"none\"; updateComposer(); return;\n }\n composerAttachments.style.display = \"\";\n for (const att of state.attachments) {\n const chip = el(\"div\", \"la-attachment\");\n const img = document.createElement(\"img\");\n img.src = `data:${att.mime_type};base64,${att.data}`;\n img.alt = \"attachment\";\n const rm = el(\"button\", \"la-attachment-remove\") as HTMLButtonElement;\n rm.type = \"button\";\n rm.setAttribute(\"aria-label\", \"Remove attachment\");\n rm.textContent = \"×\";\n rm.addEventListener(\"click\", () => {\n state.attachments = state.attachments.filter((a) => a.id !== att.id);\n renderAttachments();\n });\n chip.append(img, rm);\n composerAttachments.appendChild(chip);\n }\n for (const att of state.fileAttachments) {\n const chip = el(\"div\", `la-file-attachment${att.status === \"uploading\" ? \" is-uploading\" : \"\"}${att.status === \"error\" ? \" is-error\" : \"\"}`);\n chip.setAttribute(\"data-att-id\", att.id);\n const sizeText = att.status === \"error\" ? `failed: ${att.error ?? \"upload error\"}` : formatBytes(att.size);\n chip.append(\n el(\"span\", \"la-file-attachment-name\", att.name),\n el(\"span\", \"la-file-attachment-size\", sizeText),\n );\n const progress = el(\"div\", \"la-file-progress\");\n const bar = el(\"div\", \"la-file-progress-bar\");\n bar.style.width = `${Math.round(att.progress * 100)}%`;\n progress.appendChild(bar);\n chip.appendChild(progress);\n const rm = el(\"button\", \"la-attachment-remove\") as HTMLButtonElement;\n rm.type = \"button\";\n rm.setAttribute(\"aria-label\", \"Remove file\");\n rm.textContent = \"×\";\n rm.addEventListener(\"click\", () => {\n state.fileAttachments = state.fileAttachments.filter((a) => a.id !== att.id);\n renderAttachments();\n });\n chip.appendChild(rm);\n composerAttachments.appendChild(chip);\n }\n updateComposer();\n };\n\n const addImageBlobs = async (blobs: readonly Blob[]): Promise => {\n const { resizeBlob, isSupportedImage, MAX_IMAGES } = await import(\"./image-resize\");\n for (const blob of blobs) {\n if (state.attachments.length >= MAX_IMAGES) {\n composerStatus.textContent = `Up to ${MAX_IMAGES} images per message.`;\n composerStatus.classList.add(\"is-error\");\n break;\n }\n if (!isSupportedImage(blob.type)) continue;\n try {\n const r = await resizeBlob(blob);\n state.attachments.push({ id: makeId(\"att\"), data: r.data, mime_type: r.mime_type });\n composerStatus.classList.remove(\"is-error\");\n renderAttachments();\n } catch (err) {\n composerStatus.textContent = `Couldn't attach image: ${(err as Error).message}`;\n composerStatus.classList.add(\"is-error\");\n }\n }\n };\n\n const extForMime = (mime: string): string => {\n switch (mime) {\n case \"image/png\": return \"png\";\n case \"image/jpeg\": return \"jpg\";\n case \"image/gif\": return \"gif\";\n case \"image/webp\": return \"webp\";\n default: return \"img\";\n }\n };\n\n // Assign a workspace path per attachment (sessionId is known by send time),\n // pre-seed the image cache so the sent bubble renders instantly, and return\n // the wire payload (path + bytes) plus the persisted refs.\n const buildImagePayload = async (sid: string): Promise<{ wire: WireImage[]; refs: MessageImage[] }> => {\n const { seedImage } = await import(\"./image-cache\");\n const wire: WireImage[] = [];\n const refs: MessageImage[] = [];\n for (const att of state.attachments) {\n const path = `attachments/${sid}/${att.id}.${extForMime(att.mime_type)}`;\n seedImage(path, `data:${att.mime_type};base64,${att.data}`);\n wire.push({ path, data: att.data, mime_type: att.mime_type });\n refs.push({ path, mime_type: att.mime_type });\n }\n return { wire, refs };\n };\n\n // Outstanding chunked uploads keyed by transferId; resolved by ws_upload_complete.\n const pendingUploads = new Map void; reject: (e: Error) => void }>();\n type FileAtt = (typeof state.fileAttachments)[number];\n\n // Session id the queued attachments upload under. For a not-yet-started\n // session, generated eagerly so attach-time paths match the eventual send.\n const composerSid = (): string => {\n if (state.sessionId) return state.sessionId;\n if (!state.attachmentSessionId) state.attachmentSessionId = makeId(\"sess\");\n return state.attachmentSessionId;\n };\n\n // Update one file chip's progress bar / status in place (no full re-render).\n const setChipProgress = (att: FileAtt): void => {\n const chip = composerAttachments.querySelector(`[data-att-id=\"${att.id}\"]`);\n if (!chip) return;\n chip.classList.toggle(\"is-uploading\", att.status === \"uploading\");\n chip.classList.toggle(\"is-error\", att.status === \"error\");\n const bar = chip.querySelector(\".la-file-progress-bar\") as HTMLElement | null;\n if (bar) bar.style.width = `${Math.round(att.progress * 100)}%`;\n };\n\n // Stream a file to its workspace path on attach. Chunk-sent progress caps at\n // 95%; the backend ack (ws_upload_complete) flips it to ready.\n const uploadAttachment = async (att: FileAtt): Promise => {\n const transferId = makeId(\"up\");\n const acked = new Promise((resolve, reject) => {\n const timer = setTimeout(() => { if (pendingUploads.delete(transferId)) reject(new Error(\"upload timed out\")); }, 5 * 60_000);\n pendingUploads.set(transferId, { resolve: () => { clearTimeout(timer); resolve(); }, reject: (e) => { clearTimeout(timer); reject(e); } });\n });\n try {\n const { streamUpload } = await import(\"./file-upload\");\n const bytes = new Uint8Array(await att.file.arrayBuffer());\n await streamUpload(sendBackend, att.path, bytes, transferId, (frac) => { att.progress = Math.min(0.95, frac * 0.95); setChipProgress(att); });\n await acked;\n att.status = \"ready\"; att.progress = 1;\n } catch (err) {\n att.status = \"error\"; att.error = (err as Error).message;\n } finally {\n pendingUploads.delete(transferId);\n setChipProgress(att);\n updateComposer();\n }\n };\n\n const addFiles = async (files: readonly File[]): Promise => {\n const { isTextFile, INLINE_TEXT_BYTES, MAX_FILES, safeAttachmentName } = await import(\"./file-upload\");\n for (const file of files) {\n if (state.fileAttachments.length >= MAX_FILES) {\n composerStatus.textContent = `Up to ${MAX_FILES} files per message.`;\n composerStatus.classList.add(\"is-error\");\n break;\n }\n const kind: \"text\" | \"binary\" = isTextFile(file) ? \"text\" : \"binary\";\n let inlineContent: string | undefined;\n if (kind === \"text\" && file.size <= INLINE_TEXT_BYTES) {\n try { inlineContent = await file.text(); } catch { /* fall back to ref-only */ }\n }\n const id = makeId(\"file\");\n const path = `attachments/${composerSid()}/${id}-${safeAttachmentName(file.name)}`;\n const att: FileAtt = { id, file, name: file.name, size: file.size, mime: file.type, kind, ...(inlineContent !== undefined ? { inlineContent } : {}), path, status: \"uploading\", progress: 0 };\n state.fileAttachments.push(att);\n composerStatus.classList.remove(\"is-error\");\n renderAttachments();\n void uploadAttachment(att);\n }\n updateComposer();\n };\n\n // Wire refs from the already-uploaded (ready) file attachments. Errored\n // uploads are dropped.\n const readyFileRefs = (): WireFile[] =>\n state.fileAttachments\n .filter((a) => a.status === \"ready\")\n .map((att) => ({ path: att.path, name: att.name, size: att.size, mime: att.mime, kind: att.kind, ...(att.inlineContent !== undefined ? { inlineContent: att.inlineContent } : {}) }));\n\n const appendUserMessage = (text: string, images?: readonly MessageImage[], files?: readonly MessageFile[]): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n ...(images && images.length > 0 ? { images } : {}),\n ...(files && files.length > 0 ? { files } : {}),\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string, images?: readonly WireImage[], files?: readonly WireFile[]): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length, images: images?.length ?? 0, files: files?.length ?? 0 });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n ...(images && images.length > 0 ? { images } : {}),\n ...(files && files.length > 0 ? { files } : {}),\n }));\n // This client is tracking the generation from its start, so the live\n // stream is coherent: never treat it as a reattach.\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n let sending = false;\n const doSend = async (): Promise => {\n const text = textarea.value.trim();\n if (sending || state.isGenerating || state.startingSession || state.compacting) return;\n\n const hasImages = state.attachments.length > 0;\n const hasFiles = state.fileAttachments.length > 0;\n\n // Empty send with no attachments: the action is whatever sendMode resolved\n // to in updateComposer (authoritative-status driven). Never recompute here.\n if (text.length === 0 && !hasImages && !hasFiles) {\n if (sendMode === \"recover\") {\n const targetId = state.sessionStatus?.lastAssistantId;\n if (!targetId || !state.sessionId) return;\n composerStatus.classList.remove(\"is-error\");\n // Drop the empty errored bubble locally so it doesn't linger; the\n // backend regenerate truncates from the preceding user message and\n // re-runs, replacing it.\n state.messages = state.messages.filter((m) => m.id !== targetId);\n rerenderThread();\n sendBackend(withConnection({\n type: \"regenerate_assistant_message\",\n sessionId: state.sessionId,\n assistantMessageId: targetId,\n editsAction: \"keep\",\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n if (sendMode === \"continue\" && state.sessionId) {\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId,\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n return; // disabled: nothing to send\n }\n\n // Files upload on attach; block send until they finish. The send button is\n // already disabled while uploading, so this is just a safety net.\n if (state.fileAttachments.some((a) => a.status === \"uploading\")) {\n composerStatus.textContent = \"Wait for attachments to finish uploading.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n const isNew = !state.sessionId;\n const sid = composerSid();\n const fileRefs = readyFileRefs();\n\n // Images are small (resized) and ride inline; build them now. Guard re-entry\n // across the brief async build.\n sending = true;\n updateComposer();\n let imageWire: WireImage[]; let imageRefs: MessageImage[];\n try {\n const img = await buildImagePayload(sid);\n imageWire = img.wire; imageRefs = img.refs;\n } catch (err) {\n composerStatus.textContent = `Attachment failed: ${(err as Error).message}`;\n composerStatus.classList.add(\"is-error\");\n sending = false;\n updateComposer();\n return;\n }\n sending = false;\n composerStatus.textContent = \"\";\n state.attachments = [];\n state.fileAttachments = [];\n state.attachmentSessionId = null;\n renderAttachments();\n\n if (!isNew) {\n const msg = appendUserMessage(text, imageRefs, fileRefs);\n dispatchSendForExisting(sid, msg.id, text, imageWire, fileRefs);\n return;\n }\n\n state.sessionId = sid;\n state.messages = [];\n state.edits = [];\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text, imageRefs, fileRefs);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n state.pendingMessageImages = imageWire.length > 0 ? imageWire : null;\n state.pendingMessageFiles = fileRefs.length > 0 ? fileRefs : null;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: sid,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.pendingMessageImages = null;\n state.pendingMessageFiles = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n // Route attachments: images to the vision path, everything else to file upload.\n const intake = (files: readonly File[]): void => {\n const imgs = files.filter((f) => f.type.startsWith(\"image/\"));\n const rest = files.filter((f) => !f.type.startsWith(\"image/\"));\n if (imgs.length > 0) void addImageBlobs(imgs);\n if (rest.length > 0) void addFiles(rest);\n };\n sendBtn.addEventListener(\"click\", () => void doSend());\n attachBtn.addEventListener(\"click\", () => fileInput.click());\n fileInput.addEventListener(\"change\", () => {\n if (fileInput.files && fileInput.files.length > 0) intake([...fileInput.files]);\n fileInput.value = \"\";\n });\n textarea.addEventListener(\"paste\", (ev) => {\n const items = ev.clipboardData?.items;\n if (!items) return;\n const blobs: File[] = [];\n for (const it of items) {\n if (it.kind === \"file\") {\n const f = it.getAsFile();\n if (f) blobs.push(f);\n }\n }\n if (blobs.length > 0) { ev.preventDefault(); intake(blobs); }\n });\n composer.addEventListener(\"dragover\", (ev) => {\n if (ev.dataTransfer && [...ev.dataTransfer.items].some((i) => i.kind === \"file\")) {\n ev.preventDefault();\n composer.classList.add(\"la-composer-dragover\");\n }\n });\n composer.addEventListener(\"dragleave\", (ev) => {\n if (ev.target === composer) composer.classList.remove(\"la-composer-dragover\");\n });\n composer.addEventListener(\"drop\", (ev) => {\n composer.classList.remove(\"la-composer-dragover\");\n const files = ev.dataTransfer?.files;\n if (!files || files.length === 0) return;\n ev.preventDefault();\n intake([...files]);\n });\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n void doSend();\n }\n });\n\n // Returns the id of the user message that's the backend's PRIMARY rolling\n // cache anchor: 1 user-turn behind the latest, matching LAG_USER_TURNS=1 in\n // cache-control.ts. (Was off by one — anchored 2 turns back — so the divider\n // and the edit pre-warn pointed at the wrong message.) Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker stamping.\n // Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes to them)\n // auto-cache the prompt prefix upstream regardless, so the cue still appears.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 1) return null;\n return userMsgs[userMsgs.length - 1 - 1]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n // Use the edit's own scope (see makeThreadDeps.onRevertEdit).\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n // A live handle proves a turn is in flight, so the message it backs is\n // the last assistant message regardless of its status field. Binding\n // only to status===\"streaming\" left token/tool events writing to the\n // DOM handle but not the message model when the status had already been\n // flipped, so the post-finalize static rerender drew an empty bubble.\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n if (!handle) return;\n // Recover the message pointer if it was cleared mid-stream. Bailing here\n // used to leave the turn half-finalized: the handle's DOM had the streamed\n // content but the message stayed status=\"streaming\" with whatever blocks\n // it had, and the next rerenderThread drew it from the (possibly empty)\n // model instead of the live DOM, so the whole response vanished until a\n // reload rebuilt it from the backend-persisted session.\n rebindCurrentAssistantMessage();\n const msg = state.currentAssistantMessage;\n handle.setStatus(status);\n if (msg) msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n // Fresh session: no compaction yet.\n state.compactedAt = null;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n const imgs = state.pendingMessageImages ?? undefined;\n const files = state.pendingMessageFiles ?? undefined;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.pendingMessageImages = null;\n state.pendingMessageFiles = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text, imgs, files);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n // Loading a session is a clean boundary. A sticky composer error from\n // a prior failed start would otherwise suppress setComposerStatus for\n // the freshly loaded session (the is-error guard never lifts on its\n // own across a session switch).\n composerStatus.classList.remove(\"is-error\");\n composerStatus.textContent = \"\";\n // Drop any live streaming pointers from a previously-loaded session so\n // a still-running background generation can't keep mutating this thread.\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n state.compactedAt = msg.compactedAt ?? null;\n // Drop composer attachments queued against the previous session.\n state.attachments = [];\n state.fileAttachments = [];\n state.attachmentSessionId = null;\n renderAttachments();\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n // Refresh picker labels so \"(Active)\" tracks the loaded session's char.\n renderCharOptions();\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n applyStatus(msg.status);\n // Loaded into a live generation we never tracked from turn_started.\n // Suppress its partial live stream; reload on the terminal event.\n state.reattachedGeneration = msg.status.phase === \"generating\";\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n } else {\n // No-character session: the header badge counts Lumiverse-scope\n // changes, sourced from the scope storage list. Fetch it so the\n // badge is populated without opening the workshop.\n sendBackend({ type: \"list_characters_storage\" });\n }\n break;\n case \"session_status\":\n applyStatus(msg.status);\n break;\n case \"session_forked\": {\n // The user stays in the current chat. The fork lives in a separate\n // session; surface a toast so they know it landed and can open it\n // via the Sessions menu. Critically we do NOT load the fork or touch\n // the current session's llmHistory/cache here.\n showToast(\"Forked into a new session. Open Sessions to switch to it.\");\n sendBackend({ type: \"list_sessions\" });\n break;\n }\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n // Deleting the ACTIVE session mid-generation: the backend aborts then\n // immediately sends session_deleted, and the trailing generation_cancelled\n // is then dropped (sessionId is now null). Reset the in-flight flags here\n // or the composer stays wedged in \"working...\" with a dead cancel button.\n state.isGenerating = false;\n state.startingSession = false;\n state.compacting = false;\n state.reattachedGeneration = false;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n clearStartTimeout();\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n case \"chat_event\":\n // Drop events for any session other than the one currently loaded.\n // Loading a different session while this one streams would otherwise\n // render its tokens / tool calls / edits into the wrong thread.\n if (msg.sessionId !== state.sessionId) break;\n // While reattached to a generation we didn't track from its start,\n // the live stream is missing everything before the refresh. Rendering\n // it would build an incoherent partial bubble and fight the\n // authoritative reload on the terminal event. Drop it.\n if (state.reattachedGeneration) break;\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n // A terminal event for a different (backgrounded) session must not\n // finalize the currently-loaded thread.\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n // No-character turn may have produced Lumiverse-scope edits; refresh\n // the scope storage so the header badge reflects them.\n if (state.characterId === null) sendBackend({ type: \"list_characters_storage\" });\n break;\n case \"generation_cancelled\":\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n clearStartTimeout();\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome, msg.scope);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"scope_edits_pushed\": {\n // Authoritative snapshot for exactly one scope. Replace its slot,\n // refresh the badge, and only re-render the modal if it is currently\n // showing this scope (a background push for another scope must not\n // yank the view the user is looking at).\n state.scopeLedgers.set(scopeKeyString(msg.scope), [...msg.entries]);\n updateSessionBar();\n const shown = workshopScope();\n if (state.diffModal && shown && scopeKeyString(shown) === scopeKeyString(msg.scope)) {\n state.diffModal.setEdits(msg.entries);\n }\n break;\n }\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n // The array was replaced with fresh objects. If a turn is in flight\n // (mid-turn squash emits this), rebind the streaming pointer to the new\n // element by id, else finalizeAssistantTurn would set \"complete\" on the\n // orphaned old object and the live message stays \"streaming\" (no actions).\n if (state.streamingAssistant && state.currentAssistantMessage) {\n const reboundId = state.currentAssistantMessage.id;\n const rebound = state.messages.find((m) => m.id === reboundId && m.role === \"assistant\");\n if (rebound && rebound.role === \"assistant\") state.currentAssistantMessage = rebound;\n }\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"focus_set\":\n // Authoritative focus state from the backend (it owns the switch). The\n // thread is preserved; only the focus marker, pin, and per-character\n // lists refresh.\n if (msg.sessionId === state.sessionId) {\n const changed = msg.characterId !== state.characterId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n renderCharOptions();\n updateComposer();\n updateSessionBar();\n // A no-op echo (same character) must NOT wipe a pin the backend still\n // holds. Only a real switch clears the previous character's pin/chats.\n if (changed) {\n state.pinnedChatId = null;\n state.chatsForCharacter = [];\n state.autoPinNeeded = msg.characterId !== null;\n setChatPinned(false);\n if (msg.characterId) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n } else {\n // No-character focus: refresh the Lumiverse-scope workshop view.\n sendBackend({ type: \"list_characters_storage\" });\n }\n }\n }\n break;\n case \"focus_rejected\":\n // The backend refused the switch (generation in flight, character gone).\n // Non-destructive: snap the combo back to the real focus and surface a\n // status line. Never touch the streaming bubble / composer-generating state.\n if (msg.sessionId === state.sessionId) {\n renderCharOptions();\n composerStatus.textContent = msg.reason;\n composerStatus.classList.add(\"is-error\");\n }\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n tpmLimit: msg.tpmLimit,\n debugLogging: msg.debugLogging,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_image_ready\":\n void import(\"./image-cache\").then((m) => m.resolveImage(msg.path, `data:${msg.mimeType};base64,${msg.dataBase64}`));\n break;\n case \"ws_image_error\":\n void import(\"./image-cache\").then((m) => m.failImage(msg.path));\n break;\n case \"ws_upload_complete\": {\n const p = pendingUploads.get(msg.transferId);\n if (p) { pendingUploads.delete(msg.transferId); p.resolve(); }\n break;\n }\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n updateComposer();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\": {\n // Scope list for the workshop combo only. The badge is NOT touched\n // here: it is a pure projection of scopeLedgers, refreshed by the\n // authoritative scope_edits_pushed the backend sends alongside this.\n state.scopeStorage = msg.entries;\n state.diffModal?.setScopes(buildScopeOptions());\n // No-character badge is derived from scopeStorage, so refresh it when\n // the list lands. (With a character it reads scopeLedgers; harmless.)\n updateSessionBar();\n break;\n }\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else if (msg.op === \"image_resize\") {\n const { resizeBase64 } = await import(\"./image-resize\");\n const a = msg.args as { data: string; mime_type: string };\n result = await resizeBase64(a.data, a.mime_type);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"scope_squashed\": {\n // The scope's ledger was cleared. Drop its cached slot; if the combo\n // was focused on it, fall back to the active scope.\n const key = scopeKeyString(msg.scope);\n state.scopeLedgers.set(key, []);\n if (state.workshopFocusScope && scopeKeyString(state.workshopFocusScope) === key) {\n state.workshopFocusScope = null;\n }\n if (activeScope() && scopeKeyString(activeScope()!) === key) {\n // Only THIS scope's ledger was cleared. state.edits is the cross-scope\n // session mirror, so mark reverted only entries filed under this scope,\n // not every edit (which would falsely zero other scopes' live counts).\n state.edits = state.edits.map((e) => scopeKeyString(e.scope) === key ? { ...e, reverted: true } : e);\n rerenderThread();\n }\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n }\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "fc7e682244bc1db551200de758205a1152abaa5a": {"t": 1785771281.068454, "n": 136200, "h": "4b11e37f38e4b381", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n CharacterStorageEntry,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n ScopeRef,\n SessionStatusWire,\n SessionSummaryWire,\n} from \"../types\";\nimport { characterScope, scopeKeyString } from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n // Authoritative per-scope ledger snapshots, keyed by scopeKeyString. The\n // backend is the only writer of truth: every scope_edits_pushed replaces\n // one slot. The header badge and the workshop modal both read from here,\n // so there is no second cache to reconcile.\n scopeLedgers: Map;\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean; tpmLimit?: number | null; debugLogging?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n // Backend-authoritative session status. isGenerating / compacting are\n // derived from this on every push/load, never inferred locally. Null until\n // the first session_loaded / session_status arrives.\n sessionStatus: SessionStatusWire | null;\n // True when this client loaded into a generation it never tracked from\n // turn_started (a refresh mid-stream). The backend doesn't replay the\n // in-flight turn, so live events would build an incoherent partial bubble;\n // we suppress them and reload the authoritative session on the terminal\n // event instead.\n reattachedGeneration: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n // Set when the current session has a successful compaction boundary. Messages\n // with ts < compactedAt are pre-compaction (read-only, greyed).\n compactedAt: number | null;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n scopeStorage: readonly CharacterStorageEntry[];\n // View-only: which scope the workshop combo is focused on. Null = follow\n // the active session's scope. Never an authority for the header badge.\n workshopFocusScope: ScopeRef | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction showToast(text: string, durationMs = 4000): void {\n const t = document.createElement(\"div\");\n t.className = \"la-toast\";\n t.textContent = text;\n document.body.appendChild(t);\n requestAnimationFrame(() => t.classList.add(\"is-visible\"));\n setTimeout(() => {\n t.classList.remove(\"is-visible\");\n setTimeout(() => t.remove(), 300);\n }, durationMs);\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n scopeLedgers: new Map(),\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n sessionStatus: null,\n reattachedGeneration: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n compactedAt: null,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n scopeStorage: [],\n workshopFocusScope: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card to English\",\n send: \"Translate this card to English. Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings), UI surfaces (regex replace_string, trigger displays/values, bg-html, scriptstate_defaults), and card System/Post-History instructions. Skip anything already bilingual and internal identifiers. For greetings, `rewrite` the message and be careful of strings that have regex matches. Avoid using mechanical translation methods as sub-string matching will kill your translation, always manually edit surfaces -- unfortunately, there is no easy way to do this, you must inspect every surface and write to it. Ensure you read the full trigger scripts/lua, and find regex surfaces, and default variables before you touch anything, as those contain sensitive string semantics. Avoid touching any regex_find strings and their occurances in the rest of the card. Summarise findings and ask which surfaces to touch before writing. At each stage, please check in with the user to get them to test whether the card surfaces still work as expected, or if anything is broken.\"\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerArea, composerStatus);\n composer.appendChild(composerInner);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n // Resolve against the live connection list: if the saved id was deleted, send\n // the default the picker is actually showing (renderConnOptions falls back to\n // it silently, so state.connectionId can hold a stale dead id otherwise).\n const withConnection = (msg: T): T => {\n let id = state.connectionId;\n if (id && !state.connections.some((c) => c.id === id)) {\n id = state.connections.find((c) => c.is_default)?.id ?? null;\n }\n return id ? { ...msg, connectionId: id } : msg;\n };\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"All Characters\", sublabel: \"no focus, address any character by id\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.id === state.characterId ? `${c.name} (Active)` : c.name,\n sublabel: `${c.chat_count} chats · ${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n // Re-list responses can land mid-generation; reassert the busy lock so a\n // refresh never silently re-enables character switching.\n updateLocks();\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n updateLocks();\n };\n\n // The scope the active session points at. A no-character chat has no\n // active scope, so its header badge is always 0 (Lumiverse-scope edits\n // surface in the workshop, not the header).\n const activeScope = (): ScopeRef | null =>\n state.characterId !== null ? characterScope(state.characterId) : null;\n\n // Pure projection of one authoritative snapshot. Never reconciles.\n const scopeEntries = (scope: ScopeRef | null): readonly EditLogEntry[] =>\n scope ? state.scopeLedgers.get(scopeKeyString(scope)) ?? [] : [];\n\n // What the workshop modal shows: the combo-focused scope if the user\n // picked one, otherwise the active session's scope.\n const workshopScope = (): ScopeRef | null => state.workshopFocusScope ?? activeScope();\n\n // Combo options for the workshop. scopeStorage only lists scopes that\n // already have a ledger, so a freshly-selected diffless character would be\n // absent and the combo would fall through to the first character that does\n // have edits. Always surface the active character so opening the workshop\n // lands on it (empty diff is the correct, ephemeral view).\n const buildScopeOptions = (): ReadonlyArray<{ scope: ScopeRef; label: string; changeCount: number; chatCount?: number; msgCount?: number; attachedToPersona?: { id: string; name: string } }> => {\n const opts = state.scopeStorage.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n // Reverted commits are purged, so editCount is the live change count.\n changeCount: e.editCount,\n ...(e.chatCount !== undefined ? { chatCount: e.chatCount } : {}),\n ...(e.msgCount !== undefined ? { msgCount: e.msgCount } : {}),\n ...(e.attachedToPersona ? { attachedToPersona: e.attachedToPersona } : {}),\n }));\n const active = activeScope();\n if (active && !opts.some((o) => scopeKeyString(o.scope) === scopeKeyString(active))) {\n opts.unshift({ scope: active, label: state.characterName ?? \"Current character\", changeCount: 0 });\n }\n return opts;\n };\n\n const updateSessionBar = () => {\n const scope = activeScope();\n if (scope) {\n const source = scopeEntries(scope);\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n editsBadge.classList.toggle(\"has-edits\", source.length > 0);\n return;\n }\n // No-character session: there is no active scope, so the badge reflects\n // Lumiverse-scope changes (persona / preset / world_book / regex / chat),\n // summed from the scope storage list (non-character entries carry `scope`).\n let total = 0;\n for (const e of state.scopeStorage) if (e.scope && e.scope.kind !== \"character\") total += e.liveEditCount;\n editsCount.textContent = String(total);\n editsBadge.classList.toggle(\"has-edits\", total > 0);\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n // Refresh composer + input locks: compaction holds the session for several\n // seconds, during which Send must disable and the character / pin / connection\n // controls must lock, exactly as they do during a generation.\n updateComposer();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n // What a Send click does, derived from the backend-authoritative status\n // (with a local-message fallback only for the pre-first-status window of a\n // brand-new session). The click handler dispatches on this; it never\n // recomputes the decision.\n let sendMode: \"new\" | \"continue\" | \"recover\" | \"disabled\" = \"disabled\";\n\n // Lock the surfaces that mutate the session the agent is using. Anything\n // other than an idle session means a generation or compaction is live.\n const updateLocks = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n charCombo.setDisabled(busy);\n chatPinBtn.disabled = busy;\n // connCombo is also disabled when there are no connections; don't\n // re-enable it here in that case.\n if (state.connections.length > 0) connCombo.setDisabled(busy);\n };\n\n const setComposerStatus = (text: string) => {\n // Never stomp a sticky error (e.g. the start_session timeout notice).\n // doSend clears is-error before a fresh attempt.\n if (composerStatus.classList.contains(\"is-error\")) return;\n composerStatus.textContent = text;\n };\n\n const updateComposer = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n if (busy) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n // Can't abort before the backend has acknowledged the session.\n cancelBtn.disabled = state.startingSession;\n textarea.disabled = false;\n setComposerStatus(\n state.startingSession ? \"starting session...\"\n : state.compacting ? \"compacting context...\"\n : \"agent is working...\",\n );\n sendMode = \"disabled\";\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n\n const st = state.sessionStatus;\n const statusFresh = !!st && st.sessionId === state.sessionId;\n const hasText = textarea.value.trim().length > 0;\n const recoverable = statusFresh && st!.phase === \"idle\"\n && st!.lastAssistantStatus === \"errored\" && st!.lastAssistantEmpty\n && st!.lastAssistantId !== null;\n // Authoritative last-role; fall back to the local mirror only before the\n // first status arrives (typing into a brand-new unsent session).\n const lastRole = statusFresh\n ? st!.lastMessageRole\n : (state.messages[state.messages.length - 1]?.role ?? null);\n\n if (hasText) {\n // Typed text always sends: new session, new user turn, or a follow-up\n // after a completed assistant message.\n sendMode = \"new\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n } else if (recoverable) {\n sendMode = \"recover\";\n setComposerStatus(\"Last response failed empty. Press Send to retry it.\");\n } else if (lastRole === \"user\" && !!state.sessionId) {\n sendMode = \"continue\";\n setComposerStatus(\"\");\n } else {\n // Assistant message last (completed/cancelled), or empty thread with\n // no text: nothing to send.\n sendMode = \"disabled\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n }\n sendBtn.disabled = sendMode === \"disabled\";\n }\n updateLocks();\n updateCompactButton();\n };\n\n // The single point where backend-authoritative status becomes UI state.\n // Every session_loaded / session_status flows through here so a reload mid\n // generation reconstructs the button, locks, and ring correctly.\n const applyStatus = (st: SessionStatusWire): void => {\n if (st.sessionId !== state.sessionId) return;\n state.sessionStatus = st;\n state.isGenerating = st.phase === \"generating\";\n state.compacting = st.phase === \"compacting\";\n if (st.phase !== \"idle\") {\n state.startingSession = false;\n clearStartTimeout();\n }\n state.contextPromptTokens = st.promptTokens;\n if (st.contextTokens > 0) state.contextTokens = st.contextTokens;\n updateComposer();\n updateSessionBar();\n };\n\n // Optimistic prune so the UI doesn't flash the reverted row before the\n // backend's authoritative scope_edits_pushed arrives. The session mirror\n // and every scope slot drop the id; the push then re-syncs the one scope.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n for (const [key, entries] of state.scopeLedgers) {\n state.scopeLedgers.set(key, entries.filter((e) => !ids.has(e.id)));\n }\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire, scope: ScopeRef): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n // Reset the modal's revert button (stuck on \"Reverting…\"): only a clean\n // revert pushes scope_edits_pushed, so a failure leaves it disabled.\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed) {\n // Use the edit's own scope (persona / chat / preset / world_book /\n // regex_script), not state.characterId, so a force-revert of a\n // non-character edit hits the right ledger and works in no-character chats.\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, force: true, scope });\n } else if (state.diffModal) {\n // Cancelled the force-revert confirm: reset the modal's stuck button.\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => scopeEntries(workshopScope()),\n getScopes: () => buildScopeOptions(),\n getSelectedScope: () => workshopScope(),\n onSelectScope: (scope) => {\n state.workshopFocusScope = scope;\n sendBackend({ type: \"load_character_workshop\", characterId: scope.id, scope });\n },\n onRevert: async (editId) => {\n const scope = workshopScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertAll: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Revert all edits\",\n message: \"Revert every live edit in this scope? Cascade-aware. The ledger keeps history so reverts can be undone individually.\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_character_all\", characterId: scope.id, scope });\n },\n onForget: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget changes\",\n message: \"Permanently clear this scope's edit ledger? The underlying data is NOT touched, but you won't be able to revert any of these edits afterwards.\",\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (c.confirmed) sendBackend({ type: \"squash_character\", characterId: scope.id, scope });\n },\n onScopesNeeded: () => sendBackend({ type: \"list_characters_storage\" }),\n onClose: () => {\n state.diffModal = null;\n // Drop the combo focus so reopening follows the active session's\n // scope again. No refetch: the active scope's slot is already cached\n // and the badge reads it directly.\n state.workshopFocusScope = null;\n },\n filesPanel: state.workspacePanel.root,\n }, {\n ...(initialEditId !== undefined ? { initialEditId } : {}),\n // No character in this chat: open straight to the Lumiverse tab,\n // since the Characters tab would have nothing to show.\n ...(state.characterId === null ? { initialTab: \"lumiverse\" as const } : {}),\n });\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n // Per-message banners count edits this session attributed to a message,\n // across every scope, so the session mirror is the right source.\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n return state.edits.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n return state.edits.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n // Use the edit's OWN scope, not state.characterId: an edit can be filed\n // under persona/chat/preset/wb/rx, and in a no-character session\n // state.characterId is null. Without this the inline Revert hit the\n // wrong ledger (or was a dead no-op) — the modal path already does this.\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n // revert_edits_bulk addresses ONE ledger, but a card's edits can span\n // scopes; group by each edit's scope and emit one bulk call per scope.\n const byScope = new Map();\n for (const editId of editIds) {\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) continue;\n const key = scopeKeyString(scope);\n let g = byScope.get(key);\n if (!g) { g = { scope, ids: [] }; byScope.set(key, g); }\n g.ids.push(editId);\n }\n for (const { scope, ids } of byScope.values()) {\n sendBackend({ type: \"revert_edits_bulk\", characterId: scope.id, editIds: ids, scope, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n }\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n onForkMessage: (messageId: string) => {\n if (!state.sessionId) return;\n sendBackend({ type: \"fork_session\", sourceSessionId: state.sessionId, messageId });\n },\n isPreCompaction: (messageTs: number) => state.compactedAt !== null && messageTs < state.compactedAt,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n if (id === state.characterId) return;\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish before switching characters.\";\n composerStatus.classList.add(\"is-error\");\n renderCharOptions();\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId });\n if (state.sessionId !== null) {\n // Switch focus in place, keeping the thread. The backend owns the switch\n // (mutates the session, keeps the cached prompt prefix, learns the agent\n // via a context note) and pushes authoritative state back via focus_set,\n // which updates the picker / pin / per-character lists. The frontend does\n // not mutate focus state itself.\n sendBackend({ type: \"set_focus\", sessionId: state.sessionId, characterId: id });\n } else {\n // No active session yet: create one focused on the pick.\n state.characterId = id;\n // Queue the newest chat to auto-pin once chats_pushed lands (the fresh\n // session has no pin yet); without this the first pick leaves no pin.\n state.autoPinNeeded = !!id;\n renderCharOptions();\n startNewSession();\n if (id) {\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n }\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n // A fresh pending session gets no status / context_usage push from the\n // backend (nothing generates yet), so reset the context-derived UI here\n // or the compaction wheel keeps showing the previous session's usage.\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.reattachedGeneration = false;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n const tpmRow = el(\"div\", \"la-settings-row\");\n tpmRow.append(el(\"label\", \"la-settings-row-label\", \"TPM limit (tk/min)\"));\n const tpmInput = document.createElement(\"input\");\n tpmInput.type = \"number\";\n tpmInput.className = \"la-slider-input\";\n tpmInput.min = \"1\";\n tpmInput.step = \"1\";\n tpmInput.placeholder = \"off\";\n tpmRow.appendChild(tpmInput);\n wrap.appendChild(tpmRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Pauses requests when prompt+completion tokens in the last 60s would exceed this. Set to your provider's tokens-per-minute quota (e.g. 250000 for Gemini free tier). Empty = no throttle.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n const debugLogRow = el(\"div\", \"la-settings-row\");\n debugLogRow.append(el(\"label\", \"la-settings-row-label\", \"Debug logging\"));\n const debugLogInput = document.createElement(\"input\");\n debugLogInput.type = \"checkbox\";\n debugLogInput.className = \"la-checkbox\";\n debugLogRow.appendChild(debugLogInput);\n wrap.appendChild(debugLogRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"OFF by default. When ON, verbose backend diagnostics (send_message, loop turns, llm stream, phone-line) are written to the host log. Warnings and errors always log.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n // handleUpdateSettings re-sends settings after every commit (blur / drag\n // release). That async echo must not overwrite a text field the user is\n // currently editing, or it silently discards characters typed during the\n // round-trip. Skip the rewrite while one of the form's text fields is focused.\n const active = document.activeElement;\n if (active === personaArea || active === promptArea || active === jbArea\n || active === wsCapInput || active === toolCapInput || active === tpmInput) return;\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n debugLogInput.checked = s.debugLogging ?? false;\n tpmInput.value = s.tpmLimit ? String(s.tpmLimit) : \"\";\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n tpmLimit: parsePosInt(tpmInput.value),\n debugLogging: debugLogInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput, tpmInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput, debugLogInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const appendUserMessage = (text: string): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n }));\n // This client is tracking the generation from its start, so the live\n // stream is coherent: never treat it as a reattach.\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n const doSend = (): void => {\n const text = textarea.value.trim();\n if (state.isGenerating || state.startingSession || state.compacting) return;\n\n // Empty send: the action is whatever sendMode resolved to in\n // updateComposer (authoritative-status driven). Never recompute here.\n if (text.length === 0) {\n if (sendMode === \"recover\") {\n const targetId = state.sessionStatus?.lastAssistantId;\n if (!targetId || !state.sessionId) return;\n composerStatus.classList.remove(\"is-error\");\n // Drop the empty errored bubble locally so it doesn't linger; the\n // backend regenerate truncates from the preceding user message and\n // re-runs, replacing it.\n state.messages = state.messages.filter((m) => m.id !== targetId);\n rerenderThread();\n sendBackend(withConnection({\n type: \"regenerate_assistant_message\",\n sessionId: state.sessionId,\n assistantMessageId: targetId,\n editsAction: \"keep\",\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n if (sendMode === \"continue\" && state.sessionId) {\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId,\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n return; // disabled: nothing to send\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n if (state.sessionId) {\n const msg = appendUserMessage(text);\n dispatchSendForExisting(state.sessionId, msg.id, text);\n return;\n }\n\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n sendBtn.addEventListener(\"click\", doSend);\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n doSend();\n }\n });\n\n // Returns the id of the user message that's the backend's PRIMARY rolling\n // cache anchor: 1 user-turn behind the latest, matching LAG_USER_TURNS=1 in\n // cache-control.ts. (Was off by one — anchored 2 turns back — so the divider\n // and the edit pre-warn pointed at the wrong message.) Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker stamping.\n // Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes to them)\n // auto-cache the prompt prefix upstream regardless, so the cue still appears.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 1) return null;\n return userMsgs[userMsgs.length - 1 - 1]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n // Use the edit's own scope (see makeThreadDeps.onRevertEdit).\n const scope = state.edits.find((e) => e.id === editId)?.scope ?? activeScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n // A live handle proves a turn is in flight, so the message it backs is\n // the last assistant message regardless of its status field. Binding\n // only to status===\"streaming\" left token/tool events writing to the\n // DOM handle but not the message model when the status had already been\n // flipped, so the post-finalize static rerender drew an empty bubble.\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n if (!handle) return;\n // Recover the message pointer if it was cleared mid-stream. Bailing here\n // used to leave the turn half-finalized: the handle's DOM had the streamed\n // content but the message stayed status=\"streaming\" with whatever blocks\n // it had, and the next rerenderThread drew it from the (possibly empty)\n // model instead of the live DOM, so the whole response vanished until a\n // reload rebuilt it from the backend-persisted session.\n rebindCurrentAssistantMessage();\n const msg = state.currentAssistantMessage;\n handle.setStatus(status);\n if (msg) msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n // Fresh session: no compaction yet.\n state.compactedAt = null;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n // Loading a session is a clean boundary. A sticky composer error from\n // a prior failed start would otherwise suppress setComposerStatus for\n // the freshly loaded session (the is-error guard never lifts on its\n // own across a session switch).\n composerStatus.classList.remove(\"is-error\");\n composerStatus.textContent = \"\";\n // Drop any live streaming pointers from a previously-loaded session so\n // a still-running background generation can't keep mutating this thread.\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n state.compactedAt = msg.compactedAt ?? null;\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n // Refresh picker labels so \"(Active)\" tracks the loaded session's char.\n renderCharOptions();\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n applyStatus(msg.status);\n // Loaded into a live generation we never tracked from turn_started.\n // Suppress its partial live stream; reload on the terminal event.\n state.reattachedGeneration = msg.status.phase === \"generating\";\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n } else {\n // No-character session: the header badge counts Lumiverse-scope\n // changes, sourced from the scope storage list. Fetch it so the\n // badge is populated without opening the workshop.\n sendBackend({ type: \"list_characters_storage\" });\n }\n break;\n case \"session_status\":\n applyStatus(msg.status);\n break;\n case \"session_forked\": {\n // The user stays in the current chat. The fork lives in a separate\n // session; surface a toast so they know it landed and can open it\n // via the Sessions menu. Critically we do NOT load the fork or touch\n // the current session's llmHistory/cache here.\n showToast(\"Forked into a new session. Open Sessions to switch to it.\");\n sendBackend({ type: \"list_sessions\" });\n break;\n }\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n // Deleting the ACTIVE session mid-generation: the backend aborts then\n // immediately sends session_deleted, and the trailing generation_cancelled\n // is then dropped (sessionId is now null). Reset the in-flight flags here\n // or the composer stays wedged in \"working...\" with a dead cancel button.\n state.isGenerating = false;\n state.startingSession = false;\n state.compacting = false;\n state.reattachedGeneration = false;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n clearStartTimeout();\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n case \"chat_event\":\n // Drop events for any session other than the one currently loaded.\n // Loading a different session while this one streams would otherwise\n // render its tokens / tool calls / edits into the wrong thread.\n if (msg.sessionId !== state.sessionId) break;\n // While reattached to a generation we didn't track from its start,\n // the live stream is missing everything before the refresh. Rendering\n // it would build an incoherent partial bubble and fight the\n // authoritative reload on the terminal event. Drop it.\n if (state.reattachedGeneration) break;\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n // A terminal event for a different (backgrounded) session must not\n // finalize the currently-loaded thread.\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n // No-character turn may have produced Lumiverse-scope edits; refresh\n // the scope storage so the header badge reflects them.\n if (state.characterId === null) sendBackend({ type: \"list_characters_storage\" });\n break;\n case \"generation_cancelled\":\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n if (msg.sessionId !== state.sessionId) break;\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n clearStartTimeout();\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome, msg.scope);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"scope_edits_pushed\": {\n // Authoritative snapshot for exactly one scope. Replace its slot,\n // refresh the badge, and only re-render the modal if it is currently\n // showing this scope (a background push for another scope must not\n // yank the view the user is looking at).\n state.scopeLedgers.set(scopeKeyString(msg.scope), [...msg.entries]);\n updateSessionBar();\n const shown = workshopScope();\n if (state.diffModal && shown && scopeKeyString(shown) === scopeKeyString(msg.scope)) {\n state.diffModal.setEdits(msg.entries);\n }\n break;\n }\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n // The array was replaced with fresh objects. If a turn is in flight\n // (mid-turn squash emits this), rebind the streaming pointer to the new\n // element by id, else finalizeAssistantTurn would set \"complete\" on the\n // orphaned old object and the live message stays \"streaming\" (no actions).\n if (state.streamingAssistant && state.currentAssistantMessage) {\n const reboundId = state.currentAssistantMessage.id;\n const rebound = state.messages.find((m) => m.id === reboundId && m.role === \"assistant\");\n if (rebound && rebound.role === \"assistant\") state.currentAssistantMessage = rebound;\n }\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"focus_set\":\n // Authoritative focus state from the backend (it owns the switch). The\n // thread is preserved; only the focus marker, pin, and per-character\n // lists refresh.\n if (msg.sessionId === state.sessionId) {\n const changed = msg.characterId !== state.characterId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n renderCharOptions();\n updateComposer();\n updateSessionBar();\n // A no-op echo (same character) must NOT wipe a pin the backend still\n // holds. Only a real switch clears the previous character's pin/chats.\n if (changed) {\n state.pinnedChatId = null;\n state.chatsForCharacter = [];\n state.autoPinNeeded = msg.characterId !== null;\n setChatPinned(false);\n if (msg.characterId) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n } else {\n // No-character focus: refresh the Lumiverse-scope workshop view.\n sendBackend({ type: \"list_characters_storage\" });\n }\n }\n }\n break;\n case \"focus_rejected\":\n // The backend refused the switch (generation in flight, character gone).\n // Non-destructive: snap the combo back to the real focus and surface a\n // status line. Never touch the streaming bubble / composer-generating state.\n if (msg.sessionId === state.sessionId) {\n renderCharOptions();\n composerStatus.textContent = msg.reason;\n composerStatus.classList.add(\"is-error\");\n }\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n tpmLimit: msg.tpmLimit,\n debugLogging: msg.debugLogging,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n updateComposer();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\": {\n // Scope list for the workshop combo only. The badge is NOT touched\n // here: it is a pure projection of scopeLedgers, refreshed by the\n // authoritative scope_edits_pushed the backend sends alongside this.\n state.scopeStorage = msg.entries;\n state.diffModal?.setScopes(buildScopeOptions());\n // No-character badge is derived from scopeStorage, so refresh it when\n // the list lands. (With a character it reads scopeLedgers; harmless.)\n updateSessionBar();\n break;\n }\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"scope_squashed\": {\n // The scope's ledger was cleared. Drop its cached slot; if the combo\n // was focused on it, fall back to the active scope.\n const key = scopeKeyString(msg.scope);\n state.scopeLedgers.set(key, []);\n if (state.workshopFocusScope && scopeKeyString(state.workshopFocusScope) === key) {\n state.workshopFocusScope = null;\n }\n if (activeScope() && scopeKeyString(activeScope()!) === key) {\n // Only THIS scope's ledger was cleared. state.edits is the cross-scope\n // session mirror, so mark reverted only entries filed under this scope,\n // not every edit (which would falsely zero other scopes' live counts).\n state.edits = state.edits.map((e) => scopeKeyString(e.scope) === key ? { ...e, reverted: true } : e);\n rerenderThread();\n }\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n }\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "a23f6ee237e7033cdaf74bb7a867f657ae242916": {"t": 1785771283.2689574, "n": 129014, "h": "0adc22d76f2b4d0f", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n CharacterStorageEntry,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n ScopeRef,\n SessionStatusWire,\n SessionSummaryWire,\n} from \"../types\";\nimport { characterScope, scopeKeyString } from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n // Authoritative per-scope ledger snapshots, keyed by scopeKeyString. The\n // backend is the only writer of truth: every scope_edits_pushed replaces\n // one slot. The header badge and the workshop modal both read from here,\n // so there is no second cache to reconcile.\n scopeLedgers: Map;\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean; tpmLimit?: number | null; debugLogging?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n // Backend-authoritative session status. isGenerating / compacting are\n // derived from this on every push/load, never inferred locally. Null until\n // the first session_loaded / session_status arrives.\n sessionStatus: SessionStatusWire | null;\n // True when this client loaded into a generation it never tracked from\n // turn_started (a refresh mid-stream). The backend doesn't replay the\n // in-flight turn, so live events would build an incoherent partial bubble;\n // we suppress them and reload the authoritative session on the terminal\n // event instead.\n reattachedGeneration: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n // Set when the current session has a successful compaction boundary. Messages\n // with ts < compactedAt are pre-compaction (read-only, greyed).\n compactedAt: number | null;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n scopeStorage: readonly CharacterStorageEntry[];\n // View-only: which scope the workshop combo is focused on. Null = follow\n // the active session's scope. Never an authority for the header badge.\n workshopFocusScope: ScopeRef | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction showToast(text: string, durationMs = 4000): void {\n const t = document.createElement(\"div\");\n t.className = \"la-toast\";\n t.textContent = text;\n document.body.appendChild(t);\n requestAnimationFrame(() => t.classList.add(\"is-visible\"));\n setTimeout(() => {\n t.classList.remove(\"is-visible\");\n setTimeout(() => t.remove(), 300);\n }, durationMs);\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n scopeLedgers: new Map(),\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n sessionStatus: null,\n reattachedGeneration: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n compactedAt: null,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n scopeStorage: [],\n workshopFocusScope: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card to English\",\n send: \"Translate this card to English. Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings), UI surfaces (regex replace_string, trigger displays/values, bg-html, scriptstate_defaults), and card System/Post-History instructions. Skip anything already bilingual and internal identifiers. For greetings, `rewrite` the message and be careful of strings that have regex matches. Avoid using mechanical translation methods as sub-string matching will kill your translation, always manually edit surfaces -- unfortunately, there is no easy way to do this, you must inspect every surface and write to it. Ensure you read the full trigger scripts/lua, and find regex surfaces, and default variables before you touch anything, as those contain sensitive string semantics. Avoid touching any regex_find strings and their occurances in the rest of the card. Summarise findings and ask which surfaces to touch before writing. At each stage, please check in with the user to get them to test whether the card surfaces still work as expected, or if anything is broken.\"\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerArea, composerStatus);\n composer.appendChild(composerInner);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n const withConnection = (msg: T): T =>\n state.connectionId ? { ...msg, connectionId: state.connectionId } : msg;\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"All Characters\", sublabel: \"no focus, address any character by id\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.id === state.characterId ? `${c.name} (Active)` : c.name,\n sublabel: `${c.chat_count} chats · ${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n // Re-list responses can land mid-generation; reassert the busy lock so a\n // refresh never silently re-enables character switching.\n updateLocks();\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n updateLocks();\n };\n\n // The scope the active session points at. A no-character chat has no\n // active scope, so its header badge is always 0 (Lumiverse-scope edits\n // surface in the workshop, not the header).\n const activeScope = (): ScopeRef | null =>\n state.characterId !== null ? characterScope(state.characterId) : null;\n\n // Pure projection of one authoritative snapshot. Never reconciles.\n const scopeEntries = (scope: ScopeRef | null): readonly EditLogEntry[] =>\n scope ? state.scopeLedgers.get(scopeKeyString(scope)) ?? [] : [];\n\n // What the workshop modal shows: the combo-focused scope if the user\n // picked one, otherwise the active session's scope.\n const workshopScope = (): ScopeRef | null => state.workshopFocusScope ?? activeScope();\n\n // Combo options for the workshop. scopeStorage only lists scopes that\n // already have a ledger, so a freshly-selected diffless character would be\n // absent and the combo would fall through to the first character that does\n // have edits. Always surface the active character so opening the workshop\n // lands on it (empty diff is the correct, ephemeral view).\n const buildScopeOptions = (): ReadonlyArray<{ scope: ScopeRef; label: string; changeCount: number; chatCount?: number; msgCount?: number; attachedToPersona?: { id: string; name: string } }> => {\n const opts = state.scopeStorage.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n // Reverted commits are purged, so editCount is the live change count.\n changeCount: e.editCount,\n ...(e.chatCount !== undefined ? { chatCount: e.chatCount } : {}),\n ...(e.msgCount !== undefined ? { msgCount: e.msgCount } : {}),\n ...(e.attachedToPersona ? { attachedToPersona: e.attachedToPersona } : {}),\n }));\n const active = activeScope();\n if (active && !opts.some((o) => scopeKeyString(o.scope) === scopeKeyString(active))) {\n opts.unshift({ scope: active, label: state.characterName ?? \"Current character\", changeCount: 0 });\n }\n return opts;\n };\n\n const updateSessionBar = () => {\n const scope = activeScope();\n if (scope) {\n const source = scopeEntries(scope);\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n editsBadge.classList.toggle(\"has-edits\", source.length > 0);\n return;\n }\n // No-character session: there is no active scope, so the badge reflects\n // Lumiverse-scope changes (persona / preset / world_book / regex / chat),\n // summed from the scope storage list (non-character entries carry `scope`).\n let total = 0;\n for (const e of state.scopeStorage) if (e.scope && e.scope.kind !== \"character\") total += e.liveEditCount;\n editsCount.textContent = String(total);\n editsBadge.classList.toggle(\"has-edits\", total > 0);\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n // What a Send click does, derived from the backend-authoritative status\n // (with a local-message fallback only for the pre-first-status window of a\n // brand-new session). The click handler dispatches on this; it never\n // recomputes the decision.\n let sendMode: \"new\" | \"continue\" | \"recover\" | \"disabled\" = \"disabled\";\n\n // Lock the surfaces that mutate the session the agent is using. Anything\n // other than an idle session means a generation or compaction is live.\n const updateLocks = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n charCombo.setDisabled(busy);\n chatPinBtn.disabled = busy;\n // connCombo is also disabled when there are no connections; don't\n // re-enable it here in that case.\n if (state.connections.length > 0) connCombo.setDisabled(busy);\n };\n\n const setComposerStatus = (text: string) => {\n // Never stomp a sticky error (e.g. the start_session timeout notice).\n // doSend clears is-error before a fresh attempt.\n if (composerStatus.classList.contains(\"is-error\")) return;\n composerStatus.textContent = text;\n };\n\n const updateComposer = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n if (busy) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n // Can't abort before the backend has acknowledged the session.\n cancelBtn.disabled = state.startingSession;\n textarea.disabled = false;\n setComposerStatus(\n state.startingSession ? \"starting session...\"\n : state.compacting ? \"compacting context...\"\n : \"agent is working...\",\n );\n sendMode = \"disabled\";\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n\n const st = state.sessionStatus;\n const statusFresh = !!st && st.sessionId === state.sessionId;\n const hasText = textarea.value.trim().length > 0;\n const recoverable = statusFresh && st!.phase === \"idle\"\n && st!.lastAssistantStatus === \"errored\" && st!.lastAssistantEmpty\n && st!.lastAssistantId !== null;\n // Authoritative last-role; fall back to the local mirror only before the\n // first status arrives (typing into a brand-new unsent session).\n const lastRole = statusFresh\n ? st!.lastMessageRole\n : (state.messages[state.messages.length - 1]?.role ?? null);\n\n if (hasText) {\n // Typed text always sends: new session, new user turn, or a follow-up\n // after a completed assistant message.\n sendMode = \"new\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n } else if (recoverable) {\n sendMode = \"recover\";\n setComposerStatus(\"Last response failed empty. Press Send to retry it.\");\n } else if (lastRole === \"user\" && !!state.sessionId) {\n sendMode = \"continue\";\n setComposerStatus(\"\");\n } else {\n // Assistant message last (completed/cancelled), or empty thread with\n // no text: nothing to send.\n sendMode = \"disabled\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n }\n sendBtn.disabled = sendMode === \"disabled\";\n }\n updateLocks();\n updateCompactButton();\n };\n\n // The single point where backend-authoritative status becomes UI state.\n // Every session_loaded / session_status flows through here so a reload mid\n // generation reconstructs the button, locks, and ring correctly.\n const applyStatus = (st: SessionStatusWire): void => {\n if (st.sessionId !== state.sessionId) return;\n state.sessionStatus = st;\n state.isGenerating = st.phase === \"generating\";\n state.compacting = st.phase === \"compacting\";\n if (st.phase !== \"idle\") {\n state.startingSession = false;\n clearStartTimeout();\n }\n state.contextPromptTokens = st.promptTokens;\n if (st.contextTokens > 0) state.contextTokens = st.contextTokens;\n updateComposer();\n updateSessionBar();\n };\n\n // Optimistic prune so the UI doesn't flash the reverted row before the\n // backend's authoritative scope_edits_pushed arrives. The session mirror\n // and every scope slot drop the id; the push then re-syncs the one scope.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n for (const [key, entries] of state.scopeLedgers) {\n state.scopeLedgers.set(key, entries.filter((e) => !ids.has(e.id)));\n }\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed && state.characterId) {\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId, force: true });\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => scopeEntries(workshopScope()),\n getScopes: () => buildScopeOptions(),\n getSelectedScope: () => workshopScope(),\n onSelectScope: (scope) => {\n state.workshopFocusScope = scope;\n sendBackend({ type: \"load_character_workshop\", characterId: scope.id, scope });\n },\n onRevert: async (editId) => {\n const scope = workshopScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertAll: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Revert all edits\",\n message: \"Revert every live edit in this scope? Cascade-aware. The ledger keeps history so reverts can be undone individually.\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_character_all\", characterId: scope.id, scope });\n },\n onForget: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget changes\",\n message: \"Permanently clear this scope's edit ledger? The underlying data is NOT touched, but you won't be able to revert any of these edits afterwards.\",\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (c.confirmed) sendBackend({ type: \"squash_character\", characterId: scope.id, scope });\n },\n onScopesNeeded: () => sendBackend({ type: \"list_characters_storage\" }),\n onClose: () => {\n state.diffModal = null;\n // Drop the combo focus so reopening follows the active session's\n // scope again. No refetch: the active scope's slot is already cached\n // and the badge reads it directly.\n state.workshopFocusScope = null;\n },\n filesPanel: state.workspacePanel.root,\n }, {\n ...(initialEditId !== undefined ? { initialEditId } : {}),\n // No character in this chat: open straight to the Lumiverse tab,\n // since the Characters tab would have nothing to show.\n ...(state.characterId === null ? { initialTab: \"lumiverse\" as const } : {}),\n });\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n // Per-message banners count edits this session attributed to a message,\n // across every scope, so the session mirror is the right source.\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n return state.edits.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n return state.edits.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (!state.characterId || editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revert_edits_bulk\", characterId: state.characterId, editIds: [...editIds], ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n onForkMessage: (messageId: string) => {\n if (!state.sessionId) return;\n sendBackend({ type: \"fork_session\", sourceSessionId: state.sessionId, messageId });\n },\n isPreCompaction: (messageTs: number) => state.compactedAt !== null && messageTs < state.compactedAt,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n const switchingAway = state.sessionId !== null && id !== state.characterId;\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId, switchingAway });\n if (switchingAway) {\n // The active session belongs to a different character. Drop it from the\n // UI so state.characterId stays the source of truth. The session file is\n // safe on the backend, reloadable via Sessions modal.\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n persistUiPrefs();\n }\n state.characterId = id;\n state.chatsForCharacter = [];\n state.pinnedChatId = null;\n state.autoPinNeeded = !!id;\n // Refresh the picker so the \"(Active)\" marker tracks the new selection.\n renderCharOptions();\n setChatPinned(false);\n if (switchingAway) rerenderThread();\n updateComposer();\n updateSessionBar();\n if (id) {\n // Mirror the New chat button: spin up a fresh session for the picked\n // character so the queued autopin from chats_pushed actually lands.\n // Without this the user would see \"no pin\" until they manually click New.\n startNewSession();\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n } else {\n // No-character mode: still spin up a fresh session so the user can talk\n // to the agent. Ledger/chat data stay empty; tool filtering and the\n // one-sentence system-prompt directive handle the rest.\n startNewSession();\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n // A fresh pending session gets no status / context_usage push from the\n // backend (nothing generates yet), so reset the context-derived UI here\n // or the compaction wheel keeps showing the previous session's usage.\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.reattachedGeneration = false;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n const tpmRow = el(\"div\", \"la-settings-row\");\n tpmRow.append(el(\"label\", \"la-settings-row-label\", \"TPM limit (tk/min)\"));\n const tpmInput = document.createElement(\"input\");\n tpmInput.type = \"number\";\n tpmInput.className = \"la-slider-input\";\n tpmInput.min = \"1\";\n tpmInput.step = \"1\";\n tpmInput.placeholder = \"off\";\n tpmRow.appendChild(tpmInput);\n wrap.appendChild(tpmRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Pauses requests when prompt+completion tokens in the last 60s would exceed this. Set to your provider's tokens-per-minute quota (e.g. 250000 for Gemini free tier). Empty = no throttle.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n const debugLogRow = el(\"div\", \"la-settings-row\");\n debugLogRow.append(el(\"label\", \"la-settings-row-label\", \"Debug logging\"));\n const debugLogInput = document.createElement(\"input\");\n debugLogInput.type = \"checkbox\";\n debugLogInput.className = \"la-checkbox\";\n debugLogRow.appendChild(debugLogInput);\n wrap.appendChild(debugLogRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"OFF by default. When ON, verbose backend diagnostics (send_message, loop turns, llm stream, phone-line) are written to the host log. Warnings and errors always log.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n debugLogInput.checked = s.debugLogging ?? false;\n tpmInput.value = s.tpmLimit ? String(s.tpmLimit) : \"\";\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n tpmLimit: parsePosInt(tpmInput.value),\n debugLogging: debugLogInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput, tpmInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput, debugLogInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const appendUserMessage = (text: string): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n }));\n // This client is tracking the generation from its start, so the live\n // stream is coherent: never treat it as a reattach.\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n const doSend = (): void => {\n const text = textarea.value.trim();\n if (state.isGenerating || state.startingSession || state.compacting) return;\n\n // Empty send: the action is whatever sendMode resolved to in\n // updateComposer (authoritative-status driven). Never recompute here.\n if (text.length === 0) {\n if (sendMode === \"recover\") {\n const targetId = state.sessionStatus?.lastAssistantId;\n if (!targetId || !state.sessionId) return;\n composerStatus.classList.remove(\"is-error\");\n // Drop the empty errored bubble locally so it doesn't linger; the\n // backend regenerate truncates from the preceding user message and\n // re-runs, replacing it.\n state.messages = state.messages.filter((m) => m.id !== targetId);\n rerenderThread();\n sendBackend(withConnection({\n type: \"regenerate_assistant_message\",\n sessionId: state.sessionId,\n assistantMessageId: targetId,\n editsAction: \"keep\",\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n if (sendMode === \"continue\" && state.sessionId) {\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId,\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n return; // disabled: nothing to send\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n if (state.sessionId) {\n const msg = appendUserMessage(text);\n dispatchSendForExisting(state.sessionId, msg.id, text);\n return;\n }\n\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n sendBtn.addEventListener(\"click\", doSend);\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n doSend();\n }\n });\n\n // Returns the id of the user message that's currently the rolling cache\n // anchor (2 user-turns behind the latest). Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker\n // stamping. Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes\n // to them) auto-cache the prompt prefix upstream regardless, so the\n // visual cue should still appear.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 2) return null;\n return userMsgs[userMsgs.length - 1 - 2]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n // A live handle proves a turn is in flight, so the message it backs is\n // the last assistant message regardless of its status field. Binding\n // only to status===\"streaming\" left token/tool events writing to the\n // DOM handle but not the message model when the status had already been\n // flipped, so the post-finalize static rerender drew an empty bubble.\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n if (!handle) return;\n // Recover the message pointer if it was cleared mid-stream. Bailing here\n // used to leave the turn half-finalized: the handle's DOM had the streamed\n // content but the message stayed status=\"streaming\" with whatever blocks\n // it had, and the next rerenderThread drew it from the (possibly empty)\n // model instead of the live DOM, so the whole response vanished until a\n // reload rebuilt it from the backend-persisted session.\n rebindCurrentAssistantMessage();\n const msg = state.currentAssistantMessage;\n handle.setStatus(status);\n if (msg) msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n // Fresh session: no compaction yet.\n state.compactedAt = null;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n // Loading a session is a clean boundary. A sticky composer error from\n // a prior failed start would otherwise suppress setComposerStatus for\n // the freshly loaded session (the is-error guard never lifts on its\n // own across a session switch).\n composerStatus.classList.remove(\"is-error\");\n composerStatus.textContent = \"\";\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n state.compactedAt = msg.compactedAt ?? null;\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n // Refresh picker labels so \"(Active)\" tracks the loaded session's char.\n renderCharOptions();\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n applyStatus(msg.status);\n // Loaded into a live generation we never tracked from turn_started.\n // Suppress its partial live stream; reload on the terminal event.\n state.reattachedGeneration = msg.status.phase === \"generating\";\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n } else {\n // No-character session: the header badge counts Lumiverse-scope\n // changes, sourced from the scope storage list. Fetch it so the\n // badge is populated without opening the workshop.\n sendBackend({ type: \"list_characters_storage\" });\n }\n break;\n case \"session_status\":\n applyStatus(msg.status);\n break;\n case \"session_forked\": {\n // The user stays in the current chat. The fork lives in a separate\n // session; surface a toast so they know it landed and can open it\n // via the Sessions menu. Critically we do NOT load the fork or touch\n // the current session's llmHistory/cache here.\n showToast(\"Forked into a new session. Open Sessions to switch to it.\");\n sendBackend({ type: \"list_sessions\" });\n break;\n }\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.edits);\n break;\n case \"chat_event\":\n // While reattached to a generation we didn't track from its start,\n // the live stream is missing everything before the refresh. Rendering\n // it would build an incoherent partial bubble and fight the\n // authoritative reload on the terminal event. Drop it.\n if (state.reattachedGeneration) break;\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n // No-character turn may have produced Lumiverse-scope edits; refresh\n // the scope storage so the header badge reflects them.\n if (state.characterId === null) sendBackend({ type: \"list_characters_storage\" });\n break;\n case \"generation_cancelled\":\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n clearStartTimeout();\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"scope_edits_pushed\": {\n // Authoritative snapshot for exactly one scope. Replace its slot,\n // refresh the badge, and only re-render the modal if it is currently\n // showing this scope (a background push for another scope must not\n // yank the view the user is looking at).\n state.scopeLedgers.set(scopeKeyString(msg.scope), [...msg.entries]);\n updateSessionBar();\n const shown = workshopScope();\n if (state.diffModal && shown && scopeKeyString(shown) === scopeKeyString(msg.scope)) {\n state.diffModal.setEdits(msg.entries);\n }\n break;\n }\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n tpmLimit: msg.tpmLimit,\n debugLogging: msg.debugLogging,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\": {\n // Scope list for the workshop combo only. The badge is NOT touched\n // here: it is a pure projection of scopeLedgers, refreshed by the\n // authoritative scope_edits_pushed the backend sends alongside this.\n state.scopeStorage = msg.entries;\n state.diffModal?.setScopes(buildScopeOptions());\n // No-character badge is derived from scopeStorage, so refresh it when\n // the list lands. (With a character it reads scopeLedgers; harmless.)\n updateSessionBar();\n break;\n }\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"scope_squashed\": {\n // The scope's ledger was cleared. Drop its cached slot; if the combo\n // was focused on it, fall back to the active scope.\n const key = scopeKeyString(msg.scope);\n state.scopeLedgers.set(key, []);\n if (state.workshopFocusScope && scopeKeyString(state.workshopFocusScope) === key) {\n state.workshopFocusScope = null;\n }\n if (activeScope() && scopeKeyString(activeScope()!) === key) {\n state.edits = state.edits.map((e) => ({ ...e, reverted: true }));\n rerenderThread();\n }\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n }\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "ed2ddc0865917d1889d8925af411995365032071": {"t": 1785771285.2916193, "n": 129006, "h": "94bb733a22903b10", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n CharacterStorageEntry,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n ScopeRef,\n SessionStatusWire,\n SessionSummaryWire,\n} from \"../types\";\nimport { characterScope, scopeKeyString } from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n // Authoritative per-scope ledger snapshots, keyed by scopeKeyString. The\n // backend is the only writer of truth: every scope_edits_pushed replaces\n // one slot. The header badge and the workshop modal both read from here,\n // so there is no second cache to reconcile.\n scopeLedgers: Map;\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean; tpmLimit?: number | null; debugLogging?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n // Backend-authoritative session status. isGenerating / compacting are\n // derived from this on every push/load, never inferred locally. Null until\n // the first session_loaded / session_status arrives.\n sessionStatus: SessionStatusWire | null;\n // True when this client loaded into a generation it never tracked from\n // turn_started (a refresh mid-stream). The backend doesn't replay the\n // in-flight turn, so live events would build an incoherent partial bubble;\n // we suppress them and reload the authoritative session on the terminal\n // event instead.\n reattachedGeneration: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n // Set when the current session has a successful compaction boundary. Messages\n // with ts < compactedAt are pre-compaction (read-only, greyed).\n compactedAt: number | null;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n scopeStorage: readonly CharacterStorageEntry[];\n // View-only: which scope the workshop combo is focused on. Null = follow\n // the active session's scope. Never an authority for the header badge.\n workshopFocusScope: ScopeRef | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction showToast(text: string, durationMs = 4000): void {\n const t = document.createElement(\"div\");\n t.className = \"la-toast\";\n t.textContent = text;\n document.body.appendChild(t);\n requestAnimationFrame(() => t.classList.add(\"is-visible\"));\n setTimeout(() => {\n t.classList.remove(\"is-visible\");\n setTimeout(() => t.remove(), 300);\n }, durationMs);\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n scopeLedgers: new Map(),\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n sessionStatus: null,\n reattachedGeneration: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n compactedAt: null,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n scopeStorage: [],\n workshopFocusScope: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card to English\",\n send: \"Translate this card to English. Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings), UI surfaces (regex replace_string, trigger displays/values, bg-html, scriptstate_defaults), and card System/Post-History instructions. Skip anything already bilingual and internal identifiers. For greetings, `rewrite` the message and be careful of strings that have regex matches. Avoid using mechanical translation methods as sub-string matching will kill your translation, always manually edit surfaces -- unfortunately, there is no easy way to do this, you must inspect every surface and write to it. Ensure you read the full trigger scripts/lua, and find regex surfaces, and default variables before you touch anything, as those contain sensitive string semantics. Avoid touching any regex_find strings and their occurances in the rest of the card. Summarise findings and ask which surfaces to touch before writing. At each stage, please check in with the user to get them to test whether the card surfaces still work as expected, or if anything is broken.\"\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerArea, composerStatus);\n composer.appendChild(composerInner);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n const withConnection = (msg: T): T =>\n state.connectionId ? { ...msg, connectionId: state.connectionId } : msg;\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"(No character)\", sublabel: \"general chat + workspace only\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.id === state.characterId ? `${c.name} (Active)` : c.name,\n sublabel: `${c.chat_count} chats · ${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n // Re-list responses can land mid-generation; reassert the busy lock so a\n // refresh never silently re-enables character switching.\n updateLocks();\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n updateLocks();\n };\n\n // The scope the active session points at. A no-character chat has no\n // active scope, so its header badge is always 0 (Lumiverse-scope edits\n // surface in the workshop, not the header).\n const activeScope = (): ScopeRef | null =>\n state.characterId !== null ? characterScope(state.characterId) : null;\n\n // Pure projection of one authoritative snapshot. Never reconciles.\n const scopeEntries = (scope: ScopeRef | null): readonly EditLogEntry[] =>\n scope ? state.scopeLedgers.get(scopeKeyString(scope)) ?? [] : [];\n\n // What the workshop modal shows: the combo-focused scope if the user\n // picked one, otherwise the active session's scope.\n const workshopScope = (): ScopeRef | null => state.workshopFocusScope ?? activeScope();\n\n // Combo options for the workshop. scopeStorage only lists scopes that\n // already have a ledger, so a freshly-selected diffless character would be\n // absent and the combo would fall through to the first character that does\n // have edits. Always surface the active character so opening the workshop\n // lands on it (empty diff is the correct, ephemeral view).\n const buildScopeOptions = (): ReadonlyArray<{ scope: ScopeRef; label: string; changeCount: number; chatCount?: number; msgCount?: number; attachedToPersona?: { id: string; name: string } }> => {\n const opts = state.scopeStorage.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n // Reverted commits are purged, so editCount is the live change count.\n changeCount: e.editCount,\n ...(e.chatCount !== undefined ? { chatCount: e.chatCount } : {}),\n ...(e.msgCount !== undefined ? { msgCount: e.msgCount } : {}),\n ...(e.attachedToPersona ? { attachedToPersona: e.attachedToPersona } : {}),\n }));\n const active = activeScope();\n if (active && !opts.some((o) => scopeKeyString(o.scope) === scopeKeyString(active))) {\n opts.unshift({ scope: active, label: state.characterName ?? \"Current character\", changeCount: 0 });\n }\n return opts;\n };\n\n const updateSessionBar = () => {\n const scope = activeScope();\n if (scope) {\n const source = scopeEntries(scope);\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n editsBadge.classList.toggle(\"has-edits\", source.length > 0);\n return;\n }\n // No-character session: there is no active scope, so the badge reflects\n // Lumiverse-scope changes (persona / preset / world_book / regex / chat),\n // summed from the scope storage list (non-character entries carry `scope`).\n let total = 0;\n for (const e of state.scopeStorage) if (e.scope && e.scope.kind !== \"character\") total += e.liveEditCount;\n editsCount.textContent = String(total);\n editsBadge.classList.toggle(\"has-edits\", total > 0);\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n // What a Send click does, derived from the backend-authoritative status\n // (with a local-message fallback only for the pre-first-status window of a\n // brand-new session). The click handler dispatches on this; it never\n // recomputes the decision.\n let sendMode: \"new\" | \"continue\" | \"recover\" | \"disabled\" = \"disabled\";\n\n // Lock the surfaces that mutate the session the agent is using. Anything\n // other than an idle session means a generation or compaction is live.\n const updateLocks = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n charCombo.setDisabled(busy);\n chatPinBtn.disabled = busy;\n // connCombo is also disabled when there are no connections; don't\n // re-enable it here in that case.\n if (state.connections.length > 0) connCombo.setDisabled(busy);\n };\n\n const setComposerStatus = (text: string) => {\n // Never stomp a sticky error (e.g. the start_session timeout notice).\n // doSend clears is-error before a fresh attempt.\n if (composerStatus.classList.contains(\"is-error\")) return;\n composerStatus.textContent = text;\n };\n\n const updateComposer = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n if (busy) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n // Can't abort before the backend has acknowledged the session.\n cancelBtn.disabled = state.startingSession;\n textarea.disabled = false;\n setComposerStatus(\n state.startingSession ? \"starting session...\"\n : state.compacting ? \"compacting context...\"\n : \"agent is working...\",\n );\n sendMode = \"disabled\";\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n\n const st = state.sessionStatus;\n const statusFresh = !!st && st.sessionId === state.sessionId;\n const hasText = textarea.value.trim().length > 0;\n const recoverable = statusFresh && st!.phase === \"idle\"\n && st!.lastAssistantStatus === \"errored\" && st!.lastAssistantEmpty\n && st!.lastAssistantId !== null;\n // Authoritative last-role; fall back to the local mirror only before the\n // first status arrives (typing into a brand-new unsent session).\n const lastRole = statusFresh\n ? st!.lastMessageRole\n : (state.messages[state.messages.length - 1]?.role ?? null);\n\n if (hasText) {\n // Typed text always sends: new session, new user turn, or a follow-up\n // after a completed assistant message.\n sendMode = \"new\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n } else if (recoverable) {\n sendMode = \"recover\";\n setComposerStatus(\"Last response failed empty. Press Send to retry it.\");\n } else if (lastRole === \"user\" && !!state.sessionId) {\n sendMode = \"continue\";\n setComposerStatus(\"\");\n } else {\n // Assistant message last (completed/cancelled), or empty thread with\n // no text: nothing to send.\n sendMode = \"disabled\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n }\n sendBtn.disabled = sendMode === \"disabled\";\n }\n updateLocks();\n updateCompactButton();\n };\n\n // The single point where backend-authoritative status becomes UI state.\n // Every session_loaded / session_status flows through here so a reload mid\n // generation reconstructs the button, locks, and ring correctly.\n const applyStatus = (st: SessionStatusWire): void => {\n if (st.sessionId !== state.sessionId) return;\n state.sessionStatus = st;\n state.isGenerating = st.phase === \"generating\";\n state.compacting = st.phase === \"compacting\";\n if (st.phase !== \"idle\") {\n state.startingSession = false;\n clearStartTimeout();\n }\n state.contextPromptTokens = st.promptTokens;\n if (st.contextTokens > 0) state.contextTokens = st.contextTokens;\n updateComposer();\n updateSessionBar();\n };\n\n // Optimistic prune so the UI doesn't flash the reverted row before the\n // backend's authoritative scope_edits_pushed arrives. The session mirror\n // and every scope slot drop the id; the push then re-syncs the one scope.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n for (const [key, entries] of state.scopeLedgers) {\n state.scopeLedgers.set(key, entries.filter((e) => !ids.has(e.id)));\n }\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed && state.characterId) {\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId, force: true });\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => scopeEntries(workshopScope()),\n getScopes: () => buildScopeOptions(),\n getSelectedScope: () => workshopScope(),\n onSelectScope: (scope) => {\n state.workshopFocusScope = scope;\n sendBackend({ type: \"load_character_workshop\", characterId: scope.id, scope });\n },\n onRevert: async (editId) => {\n const scope = workshopScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertAll: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Revert all edits\",\n message: \"Revert every live edit in this scope? Cascade-aware. The ledger keeps history so reverts can be undone individually.\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_character_all\", characterId: scope.id, scope });\n },\n onForget: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget changes\",\n message: \"Permanently clear this scope's edit ledger? The underlying data is NOT touched, but you won't be able to revert any of these edits afterwards.\",\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (c.confirmed) sendBackend({ type: \"squash_character\", characterId: scope.id, scope });\n },\n onScopesNeeded: () => sendBackend({ type: \"list_characters_storage\" }),\n onClose: () => {\n state.diffModal = null;\n // Drop the combo focus so reopening follows the active session's\n // scope again. No refetch: the active scope's slot is already cached\n // and the badge reads it directly.\n state.workshopFocusScope = null;\n },\n filesPanel: state.workspacePanel.root,\n }, {\n ...(initialEditId !== undefined ? { initialEditId } : {}),\n // No character in this chat: open straight to the Lumiverse tab,\n // since the Characters tab would have nothing to show.\n ...(state.characterId === null ? { initialTab: \"lumiverse\" as const } : {}),\n });\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n // Per-message banners count edits this session attributed to a message,\n // across every scope, so the session mirror is the right source.\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n return state.edits.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n return state.edits.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (!state.characterId || editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revert_edits_bulk\", characterId: state.characterId, editIds: [...editIds], ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n onForkMessage: (messageId: string) => {\n if (!state.sessionId) return;\n sendBackend({ type: \"fork_session\", sourceSessionId: state.sessionId, messageId });\n },\n isPreCompaction: (messageTs: number) => state.compactedAt !== null && messageTs < state.compactedAt,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n const switchingAway = state.sessionId !== null && id !== state.characterId;\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId, switchingAway });\n if (switchingAway) {\n // The active session belongs to a different character. Drop it from the\n // UI so state.characterId stays the source of truth. The session file is\n // safe on the backend, reloadable via Sessions modal.\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n persistUiPrefs();\n }\n state.characterId = id;\n state.chatsForCharacter = [];\n state.pinnedChatId = null;\n state.autoPinNeeded = !!id;\n // Refresh the picker so the \"(Active)\" marker tracks the new selection.\n renderCharOptions();\n setChatPinned(false);\n if (switchingAway) rerenderThread();\n updateComposer();\n updateSessionBar();\n if (id) {\n // Mirror the New chat button: spin up a fresh session for the picked\n // character so the queued autopin from chats_pushed actually lands.\n // Without this the user would see \"no pin\" until they manually click New.\n startNewSession();\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n } else {\n // No-character mode: still spin up a fresh session so the user can talk\n // to the agent. Ledger/chat data stay empty; tool filtering and the\n // one-sentence system-prompt directive handle the rest.\n startNewSession();\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n // A fresh pending session gets no status / context_usage push from the\n // backend (nothing generates yet), so reset the context-derived UI here\n // or the compaction wheel keeps showing the previous session's usage.\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.reattachedGeneration = false;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n const tpmRow = el(\"div\", \"la-settings-row\");\n tpmRow.append(el(\"label\", \"la-settings-row-label\", \"TPM limit (tk/min)\"));\n const tpmInput = document.createElement(\"input\");\n tpmInput.type = \"number\";\n tpmInput.className = \"la-slider-input\";\n tpmInput.min = \"1\";\n tpmInput.step = \"1\";\n tpmInput.placeholder = \"off\";\n tpmRow.appendChild(tpmInput);\n wrap.appendChild(tpmRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Pauses requests when prompt+completion tokens in the last 60s would exceed this. Set to your provider's tokens-per-minute quota (e.g. 250000 for Gemini free tier). Empty = no throttle.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n const debugLogRow = el(\"div\", \"la-settings-row\");\n debugLogRow.append(el(\"label\", \"la-settings-row-label\", \"Debug logging\"));\n const debugLogInput = document.createElement(\"input\");\n debugLogInput.type = \"checkbox\";\n debugLogInput.className = \"la-checkbox\";\n debugLogRow.appendChild(debugLogInput);\n wrap.appendChild(debugLogRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"OFF by default. When ON, verbose backend diagnostics (send_message, loop turns, llm stream, phone-line) are written to the host log. Warnings and errors always log.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n debugLogInput.checked = s.debugLogging ?? false;\n tpmInput.value = s.tpmLimit ? String(s.tpmLimit) : \"\";\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n tpmLimit: parsePosInt(tpmInput.value),\n debugLogging: debugLogInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput, tpmInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput, debugLogInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const appendUserMessage = (text: string): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n }));\n // This client is tracking the generation from its start, so the live\n // stream is coherent: never treat it as a reattach.\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n const doSend = (): void => {\n const text = textarea.value.trim();\n if (state.isGenerating || state.startingSession || state.compacting) return;\n\n // Empty send: the action is whatever sendMode resolved to in\n // updateComposer (authoritative-status driven). Never recompute here.\n if (text.length === 0) {\n if (sendMode === \"recover\") {\n const targetId = state.sessionStatus?.lastAssistantId;\n if (!targetId || !state.sessionId) return;\n composerStatus.classList.remove(\"is-error\");\n // Drop the empty errored bubble locally so it doesn't linger; the\n // backend regenerate truncates from the preceding user message and\n // re-runs, replacing it.\n state.messages = state.messages.filter((m) => m.id !== targetId);\n rerenderThread();\n sendBackend(withConnection({\n type: \"regenerate_assistant_message\",\n sessionId: state.sessionId,\n assistantMessageId: targetId,\n editsAction: \"keep\",\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n if (sendMode === \"continue\" && state.sessionId) {\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId,\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n return; // disabled: nothing to send\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n if (state.sessionId) {\n const msg = appendUserMessage(text);\n dispatchSendForExisting(state.sessionId, msg.id, text);\n return;\n }\n\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n sendBtn.addEventListener(\"click\", doSend);\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n doSend();\n }\n });\n\n // Returns the id of the user message that's currently the rolling cache\n // anchor (2 user-turns behind the latest). Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker\n // stamping. Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes\n // to them) auto-cache the prompt prefix upstream regardless, so the\n // visual cue should still appear.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 2) return null;\n return userMsgs[userMsgs.length - 1 - 2]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n // A live handle proves a turn is in flight, so the message it backs is\n // the last assistant message regardless of its status field. Binding\n // only to status===\"streaming\" left token/tool events writing to the\n // DOM handle but not the message model when the status had already been\n // flipped, so the post-finalize static rerender drew an empty bubble.\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n if (!handle) return;\n // Recover the message pointer if it was cleared mid-stream. Bailing here\n // used to leave the turn half-finalized: the handle's DOM had the streamed\n // content but the message stayed status=\"streaming\" with whatever blocks\n // it had, and the next rerenderThread drew it from the (possibly empty)\n // model instead of the live DOM, so the whole response vanished until a\n // reload rebuilt it from the backend-persisted session.\n rebindCurrentAssistantMessage();\n const msg = state.currentAssistantMessage;\n handle.setStatus(status);\n if (msg) msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n // Fresh session: no compaction yet.\n state.compactedAt = null;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n // Loading a session is a clean boundary. A sticky composer error from\n // a prior failed start would otherwise suppress setComposerStatus for\n // the freshly loaded session (the is-error guard never lifts on its\n // own across a session switch).\n composerStatus.classList.remove(\"is-error\");\n composerStatus.textContent = \"\";\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n state.compactedAt = msg.compactedAt ?? null;\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n // Refresh picker labels so \"(Active)\" tracks the loaded session's char.\n renderCharOptions();\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n applyStatus(msg.status);\n // Loaded into a live generation we never tracked from turn_started.\n // Suppress its partial live stream; reload on the terminal event.\n state.reattachedGeneration = msg.status.phase === \"generating\";\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n } else {\n // No-character session: the header badge counts Lumiverse-scope\n // changes, sourced from the scope storage list. Fetch it so the\n // badge is populated without opening the workshop.\n sendBackend({ type: \"list_characters_storage\" });\n }\n break;\n case \"session_status\":\n applyStatus(msg.status);\n break;\n case \"session_forked\": {\n // The user stays in the current chat. The fork lives in a separate\n // session; surface a toast so they know it landed and can open it\n // via the Sessions menu. Critically we do NOT load the fork or touch\n // the current session's llmHistory/cache here.\n showToast(\"Forked into a new session. Open Sessions to switch to it.\");\n sendBackend({ type: \"list_sessions\" });\n break;\n }\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.edits);\n break;\n case \"chat_event\":\n // While reattached to a generation we didn't track from its start,\n // the live stream is missing everything before the refresh. Rendering\n // it would build an incoherent partial bubble and fight the\n // authoritative reload on the terminal event. Drop it.\n if (state.reattachedGeneration) break;\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n // No-character turn may have produced Lumiverse-scope edits; refresh\n // the scope storage so the header badge reflects them.\n if (state.characterId === null) sendBackend({ type: \"list_characters_storage\" });\n break;\n case \"generation_cancelled\":\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n clearStartTimeout();\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"scope_edits_pushed\": {\n // Authoritative snapshot for exactly one scope. Replace its slot,\n // refresh the badge, and only re-render the modal if it is currently\n // showing this scope (a background push for another scope must not\n // yank the view the user is looking at).\n state.scopeLedgers.set(scopeKeyString(msg.scope), [...msg.entries]);\n updateSessionBar();\n const shown = workshopScope();\n if (state.diffModal && shown && scopeKeyString(shown) === scopeKeyString(msg.scope)) {\n state.diffModal.setEdits(msg.entries);\n }\n break;\n }\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n tpmLimit: msg.tpmLimit,\n debugLogging: msg.debugLogging,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\": {\n // Scope list for the workshop combo only. The badge is NOT touched\n // here: it is a pure projection of scopeLedgers, refreshed by the\n // authoritative scope_edits_pushed the backend sends alongside this.\n state.scopeStorage = msg.entries;\n state.diffModal?.setScopes(buildScopeOptions());\n // No-character badge is derived from scopeStorage, so refresh it when\n // the list lands. (With a character it reads scopeLedgers; harmless.)\n updateSessionBar();\n break;\n }\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"scope_squashed\": {\n // The scope's ledger was cleared. Drop its cached slot; if the combo\n // was focused on it, fall back to the active scope.\n const key = scopeKeyString(msg.scope);\n state.scopeLedgers.set(key, []);\n if (state.workshopFocusScope && scopeKeyString(state.workshopFocusScope) === key) {\n state.workshopFocusScope = null;\n }\n if (activeScope() && scopeKeyString(activeScope()!) === key) {\n state.edits = state.edits.map((e) => ({ ...e, reverted: true }));\n rerenderThread();\n }\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n }\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "1c5ddfb6c460a5dc336327c11876d1e07b4e7760": {"t": 1785771287.4197443, "n": 127546, "h": "244e8f0a588b4a70", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n CharacterStorageEntry,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n ScopeRef,\n SessionStatusWire,\n SessionSummaryWire,\n} from \"../types\";\nimport { characterScope, scopeKeyString } from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n // Authoritative per-scope ledger snapshots, keyed by scopeKeyString. The\n // backend is the only writer of truth: every scope_edits_pushed replaces\n // one slot. The header badge and the workshop modal both read from here,\n // so there is no second cache to reconcile.\n scopeLedgers: Map;\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean; tpmLimit?: number | null; debugLogging?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n // Backend-authoritative session status. isGenerating / compacting are\n // derived from this on every push/load, never inferred locally. Null until\n // the first session_loaded / session_status arrives.\n sessionStatus: SessionStatusWire | null;\n // True when this client loaded into a generation it never tracked from\n // turn_started (a refresh mid-stream). The backend doesn't replay the\n // in-flight turn, so live events would build an incoherent partial bubble;\n // we suppress them and reload the authoritative session on the terminal\n // event instead.\n reattachedGeneration: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n scopeStorage: readonly CharacterStorageEntry[];\n // View-only: which scope the workshop combo is focused on. Null = follow\n // the active session's scope. Never an authority for the header badge.\n workshopFocusScope: ScopeRef | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n scopeLedgers: new Map(),\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n sessionStatus: null,\n reattachedGeneration: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n scopeStorage: [],\n workshopFocusScope: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card to English\",\n send: \"Translate this card to English. Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings), UI surfaces (regex replace_string, trigger displays/values, bg-html, scriptstate_defaults), and card System/Post-History instructions. Skip anything already bilingual and internal identifiers. For greetings, `rewrite` the message and be careful of strings that have regex matches. Avoid using mechanical translation methods as sub-string matching will kill your translation, always manually edit surfaces -- unfortunately, there is no easy way to do this, you must inspect every surface and write to it. Ensure you read the full trigger scripts/lua, and find regex surfaces, and default variables before you touch anything, as those contain sensitive string semantics. Avoid touching any regex_find strings and their occurances in the rest of the card. Summarise findings and ask which surfaces to touch before writing. At each stage, please check in with the user to get them to test whether the card surfaces still work as expected, or if anything is broken.\"\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerArea, composerStatus);\n composer.appendChild(composerInner);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n const withConnection = (msg: T): T =>\n state.connectionId ? { ...msg, connectionId: state.connectionId } : msg;\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"(No character)\", sublabel: \"general chat + workspace only\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.id === state.characterId ? `${c.name} (Active)` : c.name,\n sublabel: `${c.chat_count} chats · ${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n // Re-list responses can land mid-generation; reassert the busy lock so a\n // refresh never silently re-enables character switching.\n updateLocks();\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n updateLocks();\n };\n\n // The scope the active session points at. A no-character chat has no\n // active scope, so its header badge is always 0 (Lumiverse-scope edits\n // surface in the workshop, not the header).\n const activeScope = (): ScopeRef | null =>\n state.characterId !== null ? characterScope(state.characterId) : null;\n\n // Pure projection of one authoritative snapshot. Never reconciles.\n const scopeEntries = (scope: ScopeRef | null): readonly EditLogEntry[] =>\n scope ? state.scopeLedgers.get(scopeKeyString(scope)) ?? [] : [];\n\n // What the workshop modal shows: the combo-focused scope if the user\n // picked one, otherwise the active session's scope.\n const workshopScope = (): ScopeRef | null => state.workshopFocusScope ?? activeScope();\n\n // Combo options for the workshop. scopeStorage only lists scopes that\n // already have a ledger, so a freshly-selected diffless character would be\n // absent and the combo would fall through to the first character that does\n // have edits. Always surface the active character so opening the workshop\n // lands on it (empty diff is the correct, ephemeral view).\n const buildScopeOptions = (): ReadonlyArray<{ scope: ScopeRef; label: string; changeCount: number; chatCount?: number; msgCount?: number; attachedToPersona?: { id: string; name: string } }> => {\n const opts = state.scopeStorage.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n // Reverted commits are purged, so editCount is the live change count.\n changeCount: e.editCount,\n ...(e.chatCount !== undefined ? { chatCount: e.chatCount } : {}),\n ...(e.msgCount !== undefined ? { msgCount: e.msgCount } : {}),\n ...(e.attachedToPersona ? { attachedToPersona: e.attachedToPersona } : {}),\n }));\n const active = activeScope();\n if (active && !opts.some((o) => scopeKeyString(o.scope) === scopeKeyString(active))) {\n opts.unshift({ scope: active, label: state.characterName ?? \"Current character\", changeCount: 0 });\n }\n return opts;\n };\n\n const updateSessionBar = () => {\n const scope = activeScope();\n if (scope) {\n const source = scopeEntries(scope);\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n editsBadge.classList.toggle(\"has-edits\", source.length > 0);\n return;\n }\n // No-character session: there is no active scope, so the badge reflects\n // Lumiverse-scope changes (persona / preset / world_book / regex / chat),\n // summed from the scope storage list (non-character entries carry `scope`).\n let total = 0;\n for (const e of state.scopeStorage) if (e.scope && e.scope.kind !== \"character\") total += e.liveEditCount;\n editsCount.textContent = String(total);\n editsBadge.classList.toggle(\"has-edits\", total > 0);\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n // What a Send click does, derived from the backend-authoritative status\n // (with a local-message fallback only for the pre-first-status window of a\n // brand-new session). The click handler dispatches on this; it never\n // recomputes the decision.\n let sendMode: \"new\" | \"continue\" | \"recover\" | \"disabled\" = \"disabled\";\n\n // Lock the surfaces that mutate the session the agent is using. Anything\n // other than an idle session means a generation or compaction is live.\n const updateLocks = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n charCombo.setDisabled(busy);\n chatPinBtn.disabled = busy;\n // connCombo is also disabled when there are no connections; don't\n // re-enable it here in that case.\n if (state.connections.length > 0) connCombo.setDisabled(busy);\n };\n\n const setComposerStatus = (text: string) => {\n // Never stomp a sticky error (e.g. the start_session timeout notice).\n // doSend clears is-error before a fresh attempt.\n if (composerStatus.classList.contains(\"is-error\")) return;\n composerStatus.textContent = text;\n };\n\n const updateComposer = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n if (busy) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n // Can't abort before the backend has acknowledged the session.\n cancelBtn.disabled = state.startingSession;\n textarea.disabled = false;\n setComposerStatus(\n state.startingSession ? \"starting session...\"\n : state.compacting ? \"compacting context...\"\n : \"agent is working...\",\n );\n sendMode = \"disabled\";\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n\n const st = state.sessionStatus;\n const statusFresh = !!st && st.sessionId === state.sessionId;\n const hasText = textarea.value.trim().length > 0;\n const recoverable = statusFresh && st!.phase === \"idle\"\n && st!.lastAssistantStatus === \"errored\" && st!.lastAssistantEmpty\n && st!.lastAssistantId !== null;\n // Authoritative last-role; fall back to the local mirror only before the\n // first status arrives (typing into a brand-new unsent session).\n const lastRole = statusFresh\n ? st!.lastMessageRole\n : (state.messages[state.messages.length - 1]?.role ?? null);\n\n if (hasText) {\n // Typed text always sends: new session, new user turn, or a follow-up\n // after a completed assistant message.\n sendMode = \"new\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n } else if (recoverable) {\n sendMode = \"recover\";\n setComposerStatus(\"Last response failed empty. Press Send to retry it.\");\n } else if (lastRole === \"user\" && !!state.sessionId) {\n sendMode = \"continue\";\n setComposerStatus(\"\");\n } else {\n // Assistant message last (completed/cancelled), or empty thread with\n // no text: nothing to send.\n sendMode = \"disabled\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n }\n sendBtn.disabled = sendMode === \"disabled\";\n }\n updateLocks();\n updateCompactButton();\n };\n\n // The single point where backend-authoritative status becomes UI state.\n // Every session_loaded / session_status flows through here so a reload mid\n // generation reconstructs the button, locks, and ring correctly.\n const applyStatus = (st: SessionStatusWire): void => {\n if (st.sessionId !== state.sessionId) return;\n state.sessionStatus = st;\n state.isGenerating = st.phase === \"generating\";\n state.compacting = st.phase === \"compacting\";\n if (st.phase !== \"idle\") {\n state.startingSession = false;\n clearStartTimeout();\n }\n state.contextPromptTokens = st.promptTokens;\n if (st.contextTokens > 0) state.contextTokens = st.contextTokens;\n updateComposer();\n updateSessionBar();\n };\n\n // Optimistic prune so the UI doesn't flash the reverted row before the\n // backend's authoritative scope_edits_pushed arrives. The session mirror\n // and every scope slot drop the id; the push then re-syncs the one scope.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n for (const [key, entries] of state.scopeLedgers) {\n state.scopeLedgers.set(key, entries.filter((e) => !ids.has(e.id)));\n }\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed && state.characterId) {\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId, force: true });\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => scopeEntries(workshopScope()),\n getScopes: () => buildScopeOptions(),\n getSelectedScope: () => workshopScope(),\n onSelectScope: (scope) => {\n state.workshopFocusScope = scope;\n sendBackend({ type: \"load_character_workshop\", characterId: scope.id, scope });\n },\n onRevert: async (editId) => {\n const scope = workshopScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertAll: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Revert all edits\",\n message: \"Revert every live edit in this scope? Cascade-aware. The ledger keeps history so reverts can be undone individually.\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_character_all\", characterId: scope.id, scope });\n },\n onForget: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget changes\",\n message: \"Permanently clear this scope's edit ledger? The underlying data is NOT touched, but you won't be able to revert any of these edits afterwards.\",\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (c.confirmed) sendBackend({ type: \"squash_character\", characterId: scope.id, scope });\n },\n onScopesNeeded: () => sendBackend({ type: \"list_characters_storage\" }),\n onClose: () => {\n state.diffModal = null;\n // Drop the combo focus so reopening follows the active session's\n // scope again. No refetch: the active scope's slot is already cached\n // and the badge reads it directly.\n state.workshopFocusScope = null;\n },\n filesPanel: state.workspacePanel.root,\n }, {\n ...(initialEditId !== undefined ? { initialEditId } : {}),\n // No character in this chat: open straight to the Lumiverse tab,\n // since the Characters tab would have nothing to show.\n ...(state.characterId === null ? { initialTab: \"lumiverse\" as const } : {}),\n });\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n // Per-message banners count edits this session attributed to a message,\n // across every scope, so the session mirror is the right source.\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n return state.edits.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n return state.edits.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (!state.characterId || editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revert_edits_bulk\", characterId: state.characterId, editIds: [...editIds], ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n const switchingAway = state.sessionId !== null && id !== state.characterId;\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId, switchingAway });\n if (switchingAway) {\n // The active session belongs to a different character. Drop it from the\n // UI so state.characterId stays the source of truth. The session file is\n // safe on the backend, reloadable via Sessions modal.\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n persistUiPrefs();\n }\n state.characterId = id;\n state.chatsForCharacter = [];\n state.pinnedChatId = null;\n state.autoPinNeeded = !!id;\n // Refresh the picker so the \"(Active)\" marker tracks the new selection.\n renderCharOptions();\n setChatPinned(false);\n if (switchingAway) rerenderThread();\n updateComposer();\n updateSessionBar();\n if (id) {\n // Mirror the New chat button: spin up a fresh session for the picked\n // character so the queued autopin from chats_pushed actually lands.\n // Without this the user would see \"no pin\" until they manually click New.\n startNewSession();\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n } else {\n // No-character mode: still spin up a fresh session so the user can talk\n // to the agent. Ledger/chat data stay empty; tool filtering and the\n // one-sentence system-prompt directive handle the rest.\n startNewSession();\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n // A fresh pending session gets no status / context_usage push from the\n // backend (nothing generates yet), so reset the context-derived UI here\n // or the compaction wheel keeps showing the previous session's usage.\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.reattachedGeneration = false;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n const tpmRow = el(\"div\", \"la-settings-row\");\n tpmRow.append(el(\"label\", \"la-settings-row-label\", \"TPM limit (tk/min)\"));\n const tpmInput = document.createElement(\"input\");\n tpmInput.type = \"number\";\n tpmInput.className = \"la-slider-input\";\n tpmInput.min = \"1\";\n tpmInput.step = \"1\";\n tpmInput.placeholder = \"off\";\n tpmRow.appendChild(tpmInput);\n wrap.appendChild(tpmRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Pauses requests when prompt+completion tokens in the last 60s would exceed this. Set to your provider's tokens-per-minute quota (e.g. 250000 for Gemini free tier). Empty = no throttle.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n const debugLogRow = el(\"div\", \"la-settings-row\");\n debugLogRow.append(el(\"label\", \"la-settings-row-label\", \"Debug logging\"));\n const debugLogInput = document.createElement(\"input\");\n debugLogInput.type = \"checkbox\";\n debugLogInput.className = \"la-checkbox\";\n debugLogRow.appendChild(debugLogInput);\n wrap.appendChild(debugLogRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"OFF by default. When ON, verbose backend diagnostics (send_message, loop turns, llm stream, phone-line) are written to the host log. Warnings and errors always log.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n debugLogInput.checked = s.debugLogging ?? false;\n tpmInput.value = s.tpmLimit ? String(s.tpmLimit) : \"\";\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n tpmLimit: parsePosInt(tpmInput.value),\n debugLogging: debugLogInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput, tpmInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput, debugLogInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const appendUserMessage = (text: string): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n }));\n // This client is tracking the generation from its start, so the live\n // stream is coherent: never treat it as a reattach.\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n const doSend = (): void => {\n const text = textarea.value.trim();\n if (state.isGenerating || state.startingSession || state.compacting) return;\n\n // Empty send: the action is whatever sendMode resolved to in\n // updateComposer (authoritative-status driven). Never recompute here.\n if (text.length === 0) {\n if (sendMode === \"recover\") {\n const targetId = state.sessionStatus?.lastAssistantId;\n if (!targetId || !state.sessionId) return;\n composerStatus.classList.remove(\"is-error\");\n // Drop the empty errored bubble locally so it doesn't linger; the\n // backend regenerate truncates from the preceding user message and\n // re-runs, replacing it.\n state.messages = state.messages.filter((m) => m.id !== targetId);\n rerenderThread();\n sendBackend(withConnection({\n type: \"regenerate_assistant_message\",\n sessionId: state.sessionId,\n assistantMessageId: targetId,\n editsAction: \"keep\",\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n if (sendMode === \"continue\" && state.sessionId) {\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId,\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n return; // disabled: nothing to send\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n if (state.sessionId) {\n const msg = appendUserMessage(text);\n dispatchSendForExisting(state.sessionId, msg.id, text);\n return;\n }\n\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n sendBtn.addEventListener(\"click\", doSend);\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n doSend();\n }\n });\n\n // Returns the id of the user message that's currently the rolling cache\n // anchor (2 user-turns behind the latest). Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker\n // stamping. Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes\n // to them) auto-cache the prompt prefix upstream regardless, so the\n // visual cue should still appear.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 2) return null;\n return userMsgs[userMsgs.length - 1 - 2]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n // A live handle proves a turn is in flight, so the message it backs is\n // the last assistant message regardless of its status field. Binding\n // only to status===\"streaming\" left token/tool events writing to the\n // DOM handle but not the message model when the status had already been\n // flipped, so the post-finalize static rerender drew an empty bubble.\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n if (!handle) return;\n // Recover the message pointer if it was cleared mid-stream. Bailing here\n // used to leave the turn half-finalized: the handle's DOM had the streamed\n // content but the message stayed status=\"streaming\" with whatever blocks\n // it had, and the next rerenderThread drew it from the (possibly empty)\n // model instead of the live DOM, so the whole response vanished until a\n // reload rebuilt it from the backend-persisted session.\n rebindCurrentAssistantMessage();\n const msg = state.currentAssistantMessage;\n handle.setStatus(status);\n if (msg) msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n // Loading a session is a clean boundary. A sticky composer error from\n // a prior failed start would otherwise suppress setComposerStatus for\n // the freshly loaded session (the is-error guard never lifts on its\n // own across a session switch).\n composerStatus.classList.remove(\"is-error\");\n composerStatus.textContent = \"\";\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n // Refresh picker labels so \"(Active)\" tracks the loaded session's char.\n renderCharOptions();\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n applyStatus(msg.status);\n // Loaded into a live generation we never tracked from turn_started.\n // Suppress its partial live stream; reload on the terminal event.\n state.reattachedGeneration = msg.status.phase === \"generating\";\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n } else {\n // No-character session: the header badge counts Lumiverse-scope\n // changes, sourced from the scope storage list. Fetch it so the\n // badge is populated without opening the workshop.\n sendBackend({ type: \"list_characters_storage\" });\n }\n break;\n case \"session_status\":\n applyStatus(msg.status);\n break;\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.edits);\n break;\n case \"chat_event\":\n // While reattached to a generation we didn't track from its start,\n // the live stream is missing everything before the refresh. Rendering\n // it would build an incoherent partial bubble and fight the\n // authoritative reload on the terminal event. Drop it.\n if (state.reattachedGeneration) break;\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n // No-character turn may have produced Lumiverse-scope edits; refresh\n // the scope storage so the header badge reflects them.\n if (state.characterId === null) sendBackend({ type: \"list_characters_storage\" });\n break;\n case \"generation_cancelled\":\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n clearStartTimeout();\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"scope_edits_pushed\": {\n // Authoritative snapshot for exactly one scope. Replace its slot,\n // refresh the badge, and only re-render the modal if it is currently\n // showing this scope (a background push for another scope must not\n // yank the view the user is looking at).\n state.scopeLedgers.set(scopeKeyString(msg.scope), [...msg.entries]);\n updateSessionBar();\n const shown = workshopScope();\n if (state.diffModal && shown && scopeKeyString(shown) === scopeKeyString(msg.scope)) {\n state.diffModal.setEdits(msg.entries);\n }\n break;\n }\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n tpmLimit: msg.tpmLimit,\n debugLogging: msg.debugLogging,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\": {\n // Scope list for the workshop combo only. The badge is NOT touched\n // here: it is a pure projection of scopeLedgers, refreshed by the\n // authoritative scope_edits_pushed the backend sends alongside this.\n state.scopeStorage = msg.entries;\n state.diffModal?.setScopes(buildScopeOptions());\n // No-character badge is derived from scopeStorage, so refresh it when\n // the list lands. (With a character it reads scopeLedgers; harmless.)\n updateSessionBar();\n break;\n }\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"scope_squashed\": {\n // The scope's ledger was cleared. Drop its cached slot; if the combo\n // was focused on it, fall back to the active scope.\n const key = scopeKeyString(msg.scope);\n state.scopeLedgers.set(key, []);\n if (state.workshopFocusScope && scopeKeyString(state.workshopFocusScope) === key) {\n state.workshopFocusScope = null;\n }\n if (activeScope() && scopeKeyString(activeScope()!) === key) {\n state.edits = state.edits.map((e) => ({ ...e, reverted: true }));\n rerenderThread();\n }\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n }\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "b1deaa4bf2c56c65337e2d306203b607c58e2878": {"t": 1785771289.5547335, "n": 124932, "h": "e2bc397416fd8311", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n CharacterStorageEntry,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n ScopeRef,\n SessionStatusWire,\n SessionSummaryWire,\n} from \"../types\";\nimport { characterScope, scopeKeyString } from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n // Authoritative per-scope ledger snapshots, keyed by scopeKeyString. The\n // backend is the only writer of truth: every scope_edits_pushed replaces\n // one slot. The header badge and the workshop modal both read from here,\n // so there is no second cache to reconcile.\n scopeLedgers: Map;\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean; tpmLimit?: number | null } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n // Backend-authoritative session status. isGenerating / compacting are\n // derived from this on every push/load, never inferred locally. Null until\n // the first session_loaded / session_status arrives.\n sessionStatus: SessionStatusWire | null;\n // True when this client loaded into a generation it never tracked from\n // turn_started (a refresh mid-stream). The backend doesn't replay the\n // in-flight turn, so live events would build an incoherent partial bubble;\n // we suppress them and reload the authoritative session on the terminal\n // event instead.\n reattachedGeneration: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n scopeStorage: readonly CharacterStorageEntry[];\n // View-only: which scope the workshop combo is focused on. Null = follow\n // the active session's scope. Never an authority for the header badge.\n workshopFocusScope: ScopeRef | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n scopeLedgers: new Map(),\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n sessionStatus: null,\n reattachedGeneration: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n scopeStorage: [],\n workshopFocusScope: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card to English\",\n send: \"Translate this card to English. Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings), UI surfaces (regex replace_string, trigger displays/values, bg-html, scriptstate_defaults), and card System/Post-History instructions. Skip anything already bilingual and internal identifiers. For greetings, `rewrite` the message and be careful of strings that have regex matches. Avoid using mechanical translation methods as sub-string matching will kill your translation, always manually edit surfaces -- unfortunately, there is no easy way to do this, you must inspect every surface and write to it. Ensure you read the full trigger scripts/lua, and find regex surfaces, and default variables before you touch anything, as those contain sensitive string semantics. Avoid touching any regex_find strings and their occurances in the rest of the card. Summarise findings and ask which surfaces to touch before writing. At each stage, please check in with the user to get them to test whether the card surfaces still work as expected, or if anything is broken.\"\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerArea, composerStatus);\n composer.appendChild(composerInner);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n const withConnection = (msg: T): T =>\n state.connectionId ? { ...msg, connectionId: state.connectionId } : msg;\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"(No character)\", sublabel: \"general chat + workspace only\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.name,\n sublabel: `${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n // Re-list responses can land mid-generation; reassert the busy lock so a\n // refresh never silently re-enables character switching.\n updateLocks();\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n updateLocks();\n };\n\n // The scope the active session points at. A no-character chat has no\n // active scope, so its header badge is always 0 (Lumiverse-scope edits\n // surface in the workshop, not the header).\n const activeScope = (): ScopeRef | null =>\n state.characterId !== null ? characterScope(state.characterId) : null;\n\n // Pure projection of one authoritative snapshot. Never reconciles.\n const scopeEntries = (scope: ScopeRef | null): readonly EditLogEntry[] =>\n scope ? state.scopeLedgers.get(scopeKeyString(scope)) ?? [] : [];\n\n // What the workshop modal shows: the combo-focused scope if the user\n // picked one, otherwise the active session's scope.\n const workshopScope = (): ScopeRef | null => state.workshopFocusScope ?? activeScope();\n\n // Combo options for the workshop. scopeStorage only lists scopes that\n // already have a ledger, so a freshly-selected diffless character would be\n // absent and the combo would fall through to the first character that does\n // have edits. Always surface the active character so opening the workshop\n // lands on it (empty diff is the correct, ephemeral view).\n const buildScopeOptions = (): ReadonlyArray<{ scope: ScopeRef; label: string; liveCount: number; totalCount: number }> => {\n const opts = state.scopeStorage.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n liveCount: e.liveEditCount,\n totalCount: e.editCount,\n }));\n const active = activeScope();\n if (active && !opts.some((o) => scopeKeyString(o.scope) === scopeKeyString(active))) {\n opts.unshift({ scope: active, label: state.characterName ?? \"Current character\", liveCount: 0, totalCount: 0 });\n }\n return opts;\n };\n\n const updateSessionBar = () => {\n const source = scopeEntries(activeScope());\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n editsBadge.classList.toggle(\"has-edits\", source.length > 0);\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n // What a Send click does, derived from the backend-authoritative status\n // (with a local-message fallback only for the pre-first-status window of a\n // brand-new session). The click handler dispatches on this; it never\n // recomputes the decision.\n let sendMode: \"new\" | \"continue\" | \"recover\" | \"disabled\" = \"disabled\";\n\n // Lock the surfaces that mutate the session the agent is using. Anything\n // other than an idle session means a generation or compaction is live.\n const updateLocks = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n charCombo.setDisabled(busy);\n chatPinBtn.disabled = busy;\n // connCombo is also disabled when there are no connections; don't\n // re-enable it here in that case.\n if (state.connections.length > 0) connCombo.setDisabled(busy);\n };\n\n const setComposerStatus = (text: string) => {\n // Never stomp a sticky error (e.g. the start_session timeout notice).\n // doSend clears is-error before a fresh attempt.\n if (composerStatus.classList.contains(\"is-error\")) return;\n composerStatus.textContent = text;\n };\n\n const updateComposer = () => {\n const busy = state.isGenerating || state.startingSession || state.compacting;\n if (busy) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n // Can't abort before the backend has acknowledged the session.\n cancelBtn.disabled = state.startingSession;\n textarea.disabled = false;\n setComposerStatus(\n state.startingSession ? \"starting session...\"\n : state.compacting ? \"compacting context...\"\n : \"agent is working...\",\n );\n sendMode = \"disabled\";\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n\n const st = state.sessionStatus;\n const statusFresh = !!st && st.sessionId === state.sessionId;\n const hasText = textarea.value.trim().length > 0;\n const recoverable = statusFresh && st!.phase === \"idle\"\n && st!.lastAssistantStatus === \"errored\" && st!.lastAssistantEmpty\n && st!.lastAssistantId !== null;\n // Authoritative last-role; fall back to the local mirror only before the\n // first status arrives (typing into a brand-new unsent session).\n const lastRole = statusFresh\n ? st!.lastMessageRole\n : (state.messages[state.messages.length - 1]?.role ?? null);\n\n if (hasText) {\n // Typed text always sends: new session, new user turn, or a follow-up\n // after a completed assistant message.\n sendMode = \"new\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n } else if (recoverable) {\n sendMode = \"recover\";\n setComposerStatus(\"Last response failed empty. Press Send to retry it.\");\n } else if (lastRole === \"user\" && !!state.sessionId) {\n sendMode = \"continue\";\n setComposerStatus(\"\");\n } else {\n // Assistant message last (completed/cancelled), or empty thread with\n // no text: nothing to send.\n sendMode = \"disabled\";\n setComposerStatus(state.sessionId ? \"\" : \"Type a message and press Send. A new session will start automatically.\");\n }\n sendBtn.disabled = sendMode === \"disabled\";\n }\n updateLocks();\n updateCompactButton();\n };\n\n // The single point where backend-authoritative status becomes UI state.\n // Every session_loaded / session_status flows through here so a reload mid\n // generation reconstructs the button, locks, and ring correctly.\n const applyStatus = (st: SessionStatusWire): void => {\n if (st.sessionId !== state.sessionId) return;\n state.sessionStatus = st;\n state.isGenerating = st.phase === \"generating\";\n state.compacting = st.phase === \"compacting\";\n if (st.phase !== \"idle\") {\n state.startingSession = false;\n clearStartTimeout();\n }\n state.contextPromptTokens = st.promptTokens;\n if (st.contextTokens > 0) state.contextTokens = st.contextTokens;\n updateComposer();\n updateSessionBar();\n };\n\n // Optimistic prune so the UI doesn't flash the reverted row before the\n // backend's authoritative scope_edits_pushed arrives. The session mirror\n // and every scope slot drop the id; the push then re-syncs the one scope.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n for (const [key, entries] of state.scopeLedgers) {\n state.scopeLedgers.set(key, entries.filter((e) => !ids.has(e.id)));\n }\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed && state.characterId) {\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId, force: true });\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => scopeEntries(workshopScope()),\n getScopes: () => buildScopeOptions(),\n getSelectedScope: () => workshopScope(),\n onSelectScope: (scope) => {\n state.workshopFocusScope = scope;\n sendBackend({ type: \"load_character_workshop\", characterId: scope.id, scope });\n },\n onRevert: async (editId) => {\n const scope = workshopScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertAll: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Revert all edits\",\n message: \"Revert every live edit in this scope? Cascade-aware. The ledger keeps history so reverts can be undone individually.\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_character_all\", characterId: scope.id, scope });\n },\n onForget: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget changes\",\n message: \"Permanently clear this scope's edit ledger? The underlying data is NOT touched, but you won't be able to revert any of these edits afterwards.\",\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (c.confirmed) sendBackend({ type: \"squash_character\", characterId: scope.id, scope });\n },\n onScopesNeeded: () => sendBackend({ type: \"list_characters_storage\" }),\n onClose: () => {\n state.diffModal = null;\n // Drop the combo focus so reopening follows the active session's\n // scope again. No refetch: the active scope's slot is already cached\n // and the badge reads it directly.\n state.workshopFocusScope = null;\n },\n filesPanel: state.workspacePanel.root,\n }, {\n ...(initialEditId !== undefined ? { initialEditId } : {}),\n // No character in this chat: open straight to the Lumiverse tab,\n // since the Characters tab would have nothing to show.\n ...(state.characterId === null ? { initialTab: \"lumiverse\" as const } : {}),\n });\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n // Per-message banners count edits this session attributed to a message,\n // across every scope, so the session mirror is the right source.\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n return state.edits.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n return state.edits.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (!state.characterId || editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revert_edits_bulk\", characterId: state.characterId, editIds: [...editIds], ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n const switchingAway = state.sessionId !== null && id !== state.characterId;\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId, switchingAway });\n if (switchingAway) {\n // The active session belongs to a different character. Drop it from the\n // UI so state.characterId stays the source of truth. The session file is\n // safe on the backend, reloadable via Sessions modal.\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n persistUiPrefs();\n }\n state.characterId = id;\n state.chatsForCharacter = [];\n state.pinnedChatId = null;\n state.autoPinNeeded = !!id;\n setChatPinned(false);\n if (switchingAway) rerenderThread();\n updateComposer();\n updateSessionBar();\n if (id) {\n // Mirror the New chat button: spin up a fresh session for the picked\n // character so the queued autopin from chats_pushed actually lands.\n // Without this the user would see \"no pin\" until they manually click New.\n startNewSession();\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n } else {\n // No-character mode: still spin up a fresh session so the user can talk\n // to the agent. Ledger/chat data stay empty; tool filtering and the\n // one-sentence system-prompt directive handle the rest.\n startNewSession();\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n // A fresh pending session gets no status / context_usage push from the\n // backend (nothing generates yet), so reset the context-derived UI here\n // or the compaction wheel keeps showing the previous session's usage.\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.reattachedGeneration = false;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n const tpmRow = el(\"div\", \"la-settings-row\");\n tpmRow.append(el(\"label\", \"la-settings-row-label\", \"TPM limit (tk/min)\"));\n const tpmInput = document.createElement(\"input\");\n tpmInput.type = \"number\";\n tpmInput.className = \"la-slider-input\";\n tpmInput.min = \"1\";\n tpmInput.step = \"1\";\n tpmInput.placeholder = \"off\";\n tpmRow.appendChild(tpmInput);\n wrap.appendChild(tpmRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Pauses requests when prompt+completion tokens in the last 60s would exceed this. Set to your provider's tokens-per-minute quota (e.g. 250000 for Gemini free tier). Empty = no throttle.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n tpmInput.value = s.tpmLimit ? String(s.tpmLimit) : \"\";\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n tpmLimit: parsePosInt(tpmInput.value),\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput, tpmInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const appendUserMessage = (text: string): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n }));\n // This client is tracking the generation from its start, so the live\n // stream is coherent: never treat it as a reattach.\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n const doSend = (): void => {\n const text = textarea.value.trim();\n if (state.isGenerating || state.startingSession || state.compacting) return;\n\n // Empty send: the action is whatever sendMode resolved to in\n // updateComposer (authoritative-status driven). Never recompute here.\n if (text.length === 0) {\n if (sendMode === \"recover\") {\n const targetId = state.sessionStatus?.lastAssistantId;\n if (!targetId || !state.sessionId) return;\n composerStatus.classList.remove(\"is-error\");\n // Drop the empty errored bubble locally so it doesn't linger; the\n // backend regenerate truncates from the preceding user message and\n // re-runs, replacing it.\n state.messages = state.messages.filter((m) => m.id !== targetId);\n rerenderThread();\n sendBackend(withConnection({\n type: \"regenerate_assistant_message\",\n sessionId: state.sessionId,\n assistantMessageId: targetId,\n editsAction: \"keep\",\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n if (sendMode === \"continue\" && state.sessionId) {\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId,\n }));\n state.reattachedGeneration = false;\n state.isGenerating = true;\n updateComposer();\n return;\n }\n return; // disabled: nothing to send\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n if (state.sessionId) {\n const msg = appendUserMessage(text);\n dispatchSendForExisting(state.sessionId, msg.id, text);\n return;\n }\n\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.contextPromptTokens = 0;\n state.sessionStatus = null;\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n sendBtn.addEventListener(\"click\", doSend);\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n doSend();\n }\n });\n\n // Returns the id of the user message that's currently the rolling cache\n // anchor (2 user-turns behind the latest). Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker\n // stamping. Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes\n // to them) auto-cache the prompt prefix upstream regardless, so the\n // visual cue should still appear.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 2) return null;\n return userMsgs[userMsgs.length - 1 - 2]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n // A live handle proves a turn is in flight, so the message it backs is\n // the last assistant message regardless of its status field. Binding\n // only to status===\"streaming\" left token/tool events writing to the\n // DOM handle but not the message model when the status had already been\n // flipped, so the post-finalize static rerender drew an empty bubble.\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n if (!handle) return;\n // Recover the message pointer if it was cleared mid-stream. Bailing here\n // used to leave the turn half-finalized: the handle's DOM had the streamed\n // content but the message stayed status=\"streaming\" with whatever blocks\n // it had, and the next rerenderThread drew it from the (possibly empty)\n // model instead of the live DOM, so the whole response vanished until a\n // reload rebuilt it from the backend-persisted session.\n rebindCurrentAssistantMessage();\n const msg = state.currentAssistantMessage;\n handle.setStatus(status);\n if (msg) msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n // Loading a session is a clean boundary. A sticky composer error from\n // a prior failed start would otherwise suppress setComposerStatus for\n // the freshly loaded session (the is-error guard never lifts on its\n // own across a session switch).\n composerStatus.classList.remove(\"is-error\");\n composerStatus.textContent = \"\";\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n applyStatus(msg.status);\n // Loaded into a live generation we never tracked from turn_started.\n // Suppress its partial live stream; reload on the terminal event.\n state.reattachedGeneration = msg.status.phase === \"generating\";\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n }\n break;\n case \"session_status\":\n applyStatus(msg.status);\n break;\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.edits);\n break;\n case \"chat_event\":\n // While reattached to a generation we didn't track from its start,\n // the live stream is missing everything before the refresh. Rendering\n // it would build an incoherent partial bubble and fight the\n // authoritative reload on the terminal event. Drop it.\n if (state.reattachedGeneration) break;\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_cancelled\":\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n if (state.reattachedGeneration && msg.sessionId === state.sessionId) {\n state.reattachedGeneration = false;\n state.isGenerating = false;\n clearStartTimeout();\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n break;\n }\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"scope_edits_pushed\": {\n // Authoritative snapshot for exactly one scope. Replace its slot,\n // refresh the badge, and only re-render the modal if it is currently\n // showing this scope (a background push for another scope must not\n // yank the view the user is looking at).\n state.scopeLedgers.set(scopeKeyString(msg.scope), [...msg.entries]);\n updateSessionBar();\n const shown = workshopScope();\n if (state.diffModal && shown && scopeKeyString(shown) === scopeKeyString(msg.scope)) {\n state.diffModal.setEdits(msg.entries);\n }\n break;\n }\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n tpmLimit: msg.tpmLimit,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\": {\n // Scope list for the workshop combo only. The badge is NOT touched\n // here: it is a pure projection of scopeLedgers, refreshed by the\n // authoritative scope_edits_pushed the backend sends alongside this.\n state.scopeStorage = msg.entries;\n state.diffModal?.setScopes(buildScopeOptions());\n break;\n }\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"scope_squashed\": {\n // The scope's ledger was cleared. Drop its cached slot; if the combo\n // was focused on it, fall back to the active scope.\n const key = scopeKeyString(msg.scope);\n state.scopeLedgers.set(key, []);\n if (state.workshopFocusScope && scopeKeyString(state.workshopFocusScope) === key) {\n state.workshopFocusScope = null;\n }\n if (activeScope() && scopeKeyString(activeScope()!) === key) {\n state.edits = state.edits.map((e) => ({ ...e, reverted: true }));\n rerenderThread();\n }\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n }\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "7e6773eebe167b4dcfcada35162f9952399df74f": {"t": 1785771291.4764388, "n": 116743, "h": "09aa53b46a572f11", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n CharacterStorageEntry,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n ScopeRef,\n SessionSummaryWire,\n} from \"../types\";\nimport { characterScope, scopeKeyString } from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n // Authoritative per-scope ledger snapshots, keyed by scopeKeyString. The\n // backend is the only writer of truth: every scope_edits_pushed replaces\n // one slot. The header badge and the workshop modal both read from here,\n // so there is no second cache to reconcile.\n scopeLedgers: Map;\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n scopeStorage: readonly CharacterStorageEntry[];\n // View-only: which scope the workshop combo is focused on. Null = follow\n // the active session's scope. Never an authority for the header badge.\n workshopFocusScope: ScopeRef | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n scopeLedgers: new Map(),\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n scopeStorage: [],\n workshopFocusScope: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card to English\",\n send: \"Translate this card to English. Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings), UI surfaces (regex find_string, replace_string, trigger displays/values, bg-html, scriptstate_defaults), and card System/Post-History instructions. Skip anything already bilingual and internal identifiers. For greetings, `rewrite` the message and be careful of strings that have regex matches, as you should ensure that the find_regex is translated along with the match. Avoid using mechanical translation methods as sub-string matching will kill your translation, always manually edit surfaces. Summarise findings and ask which surfaces to touch before writing.\"\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerArea, composerStatus);\n composer.appendChild(composerInner);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n const withConnection = (msg: T): T =>\n state.connectionId ? { ...msg, connectionId: state.connectionId } : msg;\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"(No character)\", sublabel: \"general chat + workspace only\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.name,\n sublabel: `${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n };\n\n // The scope the active session points at. A no-character chat has no\n // active scope, so its header badge is always 0 (Lumiverse-scope edits\n // surface in the workshop, not the header).\n const activeScope = (): ScopeRef | null =>\n state.characterId !== null ? characterScope(state.characterId) : null;\n\n // Pure projection of one authoritative snapshot. Never reconciles.\n const scopeEntries = (scope: ScopeRef | null): readonly EditLogEntry[] =>\n scope ? state.scopeLedgers.get(scopeKeyString(scope)) ?? [] : [];\n\n // What the workshop modal shows: the combo-focused scope if the user\n // picked one, otherwise the active session's scope.\n const workshopScope = (): ScopeRef | null => state.workshopFocusScope ?? activeScope();\n\n // Combo options for the workshop. scopeStorage only lists scopes that\n // already have a ledger, so a freshly-selected diffless character would be\n // absent and the combo would fall through to the first character that does\n // have edits. Always surface the active character so opening the workshop\n // lands on it (empty diff is the correct, ephemeral view).\n const buildScopeOptions = (): ReadonlyArray<{ scope: ScopeRef; label: string; liveCount: number; totalCount: number }> => {\n const opts = state.scopeStorage.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n liveCount: e.liveEditCount,\n totalCount: e.editCount,\n }));\n const active = activeScope();\n if (active && !opts.some((o) => scopeKeyString(o.scope) === scopeKeyString(active))) {\n opts.unshift({ scope: active, label: state.characterName ?? \"Current character\", liveCount: 0, totalCount: 0 });\n }\n return opts;\n };\n\n const updateSessionBar = () => {\n const source = scopeEntries(activeScope());\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n editsBadge.classList.toggle(\"has-edits\", source.length > 0);\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n const updateComposer = () => {\n if (state.isGenerating || state.startingSession) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n textarea.disabled = false;\n composerStatus.textContent = state.startingSession ? \"starting session...\" : \"agent is working...\";\n composerStatus.classList.remove(\"is-error\");\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n if (state.sessionId) {\n composerStatus.textContent = \"\";\n } else {\n composerStatus.textContent = \"Type a message and press Send. A new session will start automatically.\";\n }\n }\n // Empty textarea behaviour:\n // - last message was a user message → button enabled, click resumes that turn\n // - last message was an assistant message (or empty thread) → button greyed\n const hasText = textarea.value.trim().length > 0;\n const last = state.messages[state.messages.length - 1];\n const canContinue = !hasText && !!last && last.role === \"user\";\n const sendDisabled = state.startingSession || (!hasText && !canContinue);\n sendBtn.disabled = sendDisabled;\n updateCompactButton();\n };\n\n // Optimistic prune so the UI doesn't flash the reverted row before the\n // backend's authoritative scope_edits_pushed arrives. The session mirror\n // and every scope slot drop the id; the push then re-syncs the one scope.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n for (const [key, entries] of state.scopeLedgers) {\n state.scopeLedgers.set(key, entries.filter((e) => !ids.has(e.id)));\n }\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed && state.characterId) {\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId, force: true });\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => scopeEntries(workshopScope()),\n getScopes: () => buildScopeOptions(),\n getSelectedScope: () => workshopScope(),\n onSelectScope: (scope) => {\n state.workshopFocusScope = scope;\n sendBackend({ type: \"load_character_workshop\", characterId: scope.id, scope });\n },\n onRevert: async (editId) => {\n const scope = workshopScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertAll: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Revert all edits\",\n message: \"Revert every live edit in this scope? Cascade-aware. The ledger keeps history so reverts can be undone individually.\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_character_all\", characterId: scope.id, scope });\n },\n onForget: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget changes\",\n message: \"Permanently clear this scope's edit ledger? The underlying data is NOT touched, but you won't be able to revert any of these edits afterwards.\",\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (c.confirmed) sendBackend({ type: \"squash_character\", characterId: scope.id, scope });\n },\n onScopesNeeded: () => sendBackend({ type: \"list_characters_storage\" }),\n onClose: () => {\n state.diffModal = null;\n // Drop the combo focus so reopening follows the active session's\n // scope again. No refetch: the active scope's slot is already cached\n // and the badge reads it directly.\n state.workshopFocusScope = null;\n },\n filesPanel: state.workspacePanel.root,\n }, {\n ...(initialEditId !== undefined ? { initialEditId } : {}),\n // No character in this chat: open straight to the Lumiverse tab,\n // since the Characters tab would have nothing to show.\n ...(state.characterId === null ? { initialTab: \"lumiverse\" as const } : {}),\n });\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n // Per-message banners count edits this session attributed to a message,\n // across every scope, so the session mirror is the right source.\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n return state.edits.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n return state.edits.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (!state.characterId || editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revert_edits_bulk\", characterId: state.characterId, editIds: [...editIds], ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n const switchingAway = state.sessionId !== null && id !== state.characterId;\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId, switchingAway });\n if (switchingAway) {\n // The active session belongs to a different character. Drop it from the\n // UI so state.characterId stays the source of truth. The session file is\n // safe on the backend, reloadable via Sessions modal.\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n persistUiPrefs();\n }\n state.characterId = id;\n state.chatsForCharacter = [];\n state.pinnedChatId = null;\n state.autoPinNeeded = !!id;\n setChatPinned(false);\n if (switchingAway) rerenderThread();\n updateComposer();\n updateSessionBar();\n if (id) {\n // Mirror the New chat button: spin up a fresh session for the picked\n // character so the queued autopin from chats_pushed actually lands.\n // Without this the user would see \"no pin\" until they manually click New.\n startNewSession();\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n } else {\n // No-character mode: still spin up a fresh session so the user can talk\n // to the agent. Ledger/chat data stay empty; tool filtering and the\n // one-sentence system-prompt directive handle the rest.\n startNewSession();\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const appendUserMessage = (text: string): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n }));\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n const doSend = (): void => {\n const text = textarea.value.trim();\n if (state.isGenerating || state.startingSession) return;\n\n // Empty send: resume the existing turn if the last message is the user's\n // own (the agent hasn't replied yet). Bail silently otherwise — the\n // button should already be greyed out via updateComposer in that case.\n if (text.length === 0) {\n const last = state.messages[state.messages.length - 1];\n const canContinue = !!last && last.role === \"user\" && !!state.sessionId;\n if (!canContinue) return;\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId!,\n }));\n state.isGenerating = true;\n updateComposer();\n return;\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n if (state.sessionId) {\n const msg = appendUserMessage(text);\n dispatchSendForExisting(state.sessionId, msg.id, text);\n return;\n }\n\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n sendBtn.addEventListener(\"click\", doSend);\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n doSend();\n }\n });\n\n // Returns the id of the user message that's currently the rolling cache\n // anchor (2 user-turns behind the latest). Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker\n // stamping. Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes\n // to them) auto-cache the prompt prefix upstream regardless, so the\n // visual cue should still appear.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 2) return null;\n return userMsgs[userMsgs.length - 1 - 2]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n // A live handle proves a turn is in flight, so the message it backs is\n // the last assistant message regardless of its status field. Binding\n // only to status===\"streaming\" left token/tool events writing to the\n // DOM handle but not the message model when the status had already been\n // flipped, so the post-finalize static rerender drew an empty bubble.\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n if (!handle) return;\n // Recover the message pointer if it was cleared mid-stream. Bailing here\n // used to leave the turn half-finalized: the handle's DOM had the streamed\n // content but the message stayed status=\"streaming\" with whatever blocks\n // it had, and the next rerenderThread drew it from the (possibly empty)\n // model instead of the live DOM, so the whole response vanished until a\n // reload rebuilt it from the backend-persisted session.\n rebindCurrentAssistantMessage();\n const msg = state.currentAssistantMessage;\n handle.setStatus(status);\n if (msg) msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n }\n break;\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.edits);\n break;\n case \"chat_event\":\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_cancelled\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"scope_edits_pushed\": {\n // Authoritative snapshot for exactly one scope. Replace its slot,\n // refresh the badge, and only re-render the modal if it is currently\n // showing this scope (a background push for another scope must not\n // yank the view the user is looking at).\n state.scopeLedgers.set(scopeKeyString(msg.scope), [...msg.entries]);\n updateSessionBar();\n const shown = workshopScope();\n if (state.diffModal && shown && scopeKeyString(shown) === scopeKeyString(msg.scope)) {\n state.diffModal.setEdits(msg.entries);\n }\n break;\n }\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\": {\n // Scope list for the workshop combo only. The badge is NOT touched\n // here: it is a pure projection of scopeLedgers, refreshed by the\n // authoritative scope_edits_pushed the backend sends alongside this.\n state.scopeStorage = msg.entries;\n state.diffModal?.setScopes(buildScopeOptions());\n break;\n }\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"scope_squashed\": {\n // The scope's ledger was cleared. Drop its cached slot; if the combo\n // was focused on it, fall back to the active scope.\n const key = scopeKeyString(msg.scope);\n state.scopeLedgers.set(key, []);\n if (state.workshopFocusScope && scopeKeyString(state.workshopFocusScope) === key) {\n state.workshopFocusScope = null;\n }\n if (activeScope() && scopeKeyString(activeScope()!) === key) {\n state.edits = state.edits.map((e) => ({ ...e, reverted: true }));\n rerenderThread();\n }\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n }\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "76bd1f2b996b840cc8f99cb25391ea075031e293": {"t": 1785771293.7007394, "n": 116546, "h": "2f81f840666bb040", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n CharacterStorageEntry,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n ScopeRef,\n SessionSummaryWire,\n} from \"../types\";\nimport { characterScope, scopeKeyString } from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n // Authoritative per-scope ledger snapshots, keyed by scopeKeyString. The\n // backend is the only writer of truth: every scope_edits_pushed replaces\n // one slot. The header badge and the workshop modal both read from here,\n // so there is no second cache to reconcile.\n scopeLedgers: Map;\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n scopeStorage: readonly CharacterStorageEntry[];\n // View-only: which scope the workshop combo is focused on. Null = follow\n // the active session's scope. Never an authority for the header badge.\n workshopFocusScope: ScopeRef | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n scopeLedgers: new Map(),\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n scopeStorage: [],\n workshopFocusScope: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card\",\n send: \"Translate this card to English (or another target if I name one). Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings) and UI surfaces (regex replace_string, trigger displays/values, bg-html, scriptstate_defaults). Skip find_regex patterns, anything already bilingual, and internal identifiers. For greetings, `rewrite` the whole message and keep tags / markers / capture groups byte-identical. Summarise findings and ask which surfaces to touch before writing.\",\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerArea, composerStatus);\n composer.appendChild(composerInner);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n const withConnection = (msg: T): T =>\n state.connectionId ? { ...msg, connectionId: state.connectionId } : msg;\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"(No character)\", sublabel: \"general chat + workspace only\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.name,\n sublabel: `${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n };\n\n // The scope the active session points at. A no-character chat has no\n // active scope, so its header badge is always 0 (Lumiverse-scope edits\n // surface in the workshop, not the header).\n const activeScope = (): ScopeRef | null =>\n state.characterId !== null ? characterScope(state.characterId) : null;\n\n // Pure projection of one authoritative snapshot. Never reconciles.\n const scopeEntries = (scope: ScopeRef | null): readonly EditLogEntry[] =>\n scope ? state.scopeLedgers.get(scopeKeyString(scope)) ?? [] : [];\n\n // What the workshop modal shows: the combo-focused scope if the user\n // picked one, otherwise the active session's scope.\n const workshopScope = (): ScopeRef | null => state.workshopFocusScope ?? activeScope();\n\n // Combo options for the workshop. scopeStorage only lists scopes that\n // already have a ledger, so a freshly-selected diffless character would be\n // absent and the combo would fall through to the first character that does\n // have edits. Always surface the active character so opening the workshop\n // lands on it (empty diff is the correct, ephemeral view).\n const buildScopeOptions = (): ReadonlyArray<{ scope: ScopeRef; label: string; liveCount: number; totalCount: number }> => {\n const opts = state.scopeStorage.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n liveCount: e.liveEditCount,\n totalCount: e.editCount,\n }));\n const active = activeScope();\n if (active && !opts.some((o) => scopeKeyString(o.scope) === scopeKeyString(active))) {\n opts.unshift({ scope: active, label: state.characterName ?? \"Current character\", liveCount: 0, totalCount: 0 });\n }\n return opts;\n };\n\n const updateSessionBar = () => {\n const source = scopeEntries(activeScope());\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n editsBadge.classList.toggle(\"has-edits\", source.length > 0);\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n const updateComposer = () => {\n if (state.isGenerating || state.startingSession) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n textarea.disabled = false;\n composerStatus.textContent = state.startingSession ? \"starting session...\" : \"agent is working...\";\n composerStatus.classList.remove(\"is-error\");\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n if (state.sessionId) {\n composerStatus.textContent = \"\";\n } else {\n composerStatus.textContent = \"Type a message and press Send. A new session will start automatically.\";\n }\n }\n // Empty textarea behaviour:\n // - last message was a user message → button enabled, click resumes that turn\n // - last message was an assistant message (or empty thread) → button greyed\n const hasText = textarea.value.trim().length > 0;\n const last = state.messages[state.messages.length - 1];\n const canContinue = !hasText && !!last && last.role === \"user\";\n const sendDisabled = state.startingSession || (!hasText && !canContinue);\n sendBtn.disabled = sendDisabled;\n updateCompactButton();\n };\n\n // Optimistic prune so the UI doesn't flash the reverted row before the\n // backend's authoritative scope_edits_pushed arrives. The session mirror\n // and every scope slot drop the id; the push then re-syncs the one scope.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n for (const [key, entries] of state.scopeLedgers) {\n state.scopeLedgers.set(key, entries.filter((e) => !ids.has(e.id)));\n }\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed && state.characterId) {\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId, force: true });\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => scopeEntries(workshopScope()),\n getScopes: () => buildScopeOptions(),\n getSelectedScope: () => workshopScope(),\n onSelectScope: (scope) => {\n state.workshopFocusScope = scope;\n sendBackend({ type: \"load_character_workshop\", characterId: scope.id, scope });\n },\n onRevert: async (editId) => {\n const scope = workshopScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertAll: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Revert all edits\",\n message: \"Revert every live edit in this scope? Cascade-aware. The ledger keeps history so reverts can be undone individually.\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_character_all\", characterId: scope.id, scope });\n },\n onForget: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget changes\",\n message: \"Permanently clear this scope's edit ledger? The underlying data is NOT touched, but you won't be able to revert any of these edits afterwards.\",\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (c.confirmed) sendBackend({ type: \"squash_character\", characterId: scope.id, scope });\n },\n onScopesNeeded: () => sendBackend({ type: \"list_characters_storage\" }),\n onClose: () => {\n state.diffModal = null;\n // Drop the combo focus so reopening follows the active session's\n // scope again. No refetch: the active scope's slot is already cached\n // and the badge reads it directly.\n state.workshopFocusScope = null;\n },\n filesPanel: state.workspacePanel.root,\n }, {\n ...(initialEditId !== undefined ? { initialEditId } : {}),\n // No character in this chat: open straight to the Lumiverse tab,\n // since the Characters tab would have nothing to show.\n ...(state.characterId === null ? { initialTab: \"lumiverse\" as const } : {}),\n });\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n // Per-message banners count edits this session attributed to a message,\n // across every scope, so the session mirror is the right source.\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n return state.edits.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n return state.edits.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (!state.characterId || editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revert_edits_bulk\", characterId: state.characterId, editIds: [...editIds], ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n const switchingAway = state.sessionId !== null && id !== state.characterId;\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId, switchingAway });\n if (switchingAway) {\n // The active session belongs to a different character. Drop it from the\n // UI so state.characterId stays the source of truth. The session file is\n // safe on the backend, reloadable via Sessions modal.\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n persistUiPrefs();\n }\n state.characterId = id;\n state.chatsForCharacter = [];\n state.pinnedChatId = null;\n state.autoPinNeeded = !!id;\n setChatPinned(false);\n if (switchingAway) rerenderThread();\n updateComposer();\n updateSessionBar();\n if (id) {\n // Mirror the New chat button: spin up a fresh session for the picked\n // character so the queued autopin from chats_pushed actually lands.\n // Without this the user would see \"no pin\" until they manually click New.\n startNewSession();\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n } else {\n // No-character mode: still spin up a fresh session so the user can talk\n // to the agent. Ledger/chat data stay empty; tool filtering and the\n // one-sentence system-prompt directive handle the rest.\n startNewSession();\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const appendUserMessage = (text: string): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n }));\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n const doSend = (): void => {\n const text = textarea.value.trim();\n if (state.isGenerating || state.startingSession) return;\n\n // Empty send: resume the existing turn if the last message is the user's\n // own (the agent hasn't replied yet). Bail silently otherwise — the\n // button should already be greyed out via updateComposer in that case.\n if (text.length === 0) {\n const last = state.messages[state.messages.length - 1];\n const canContinue = !!last && last.role === \"user\" && !!state.sessionId;\n if (!canContinue) return;\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId!,\n }));\n state.isGenerating = true;\n updateComposer();\n return;\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n if (state.sessionId) {\n const msg = appendUserMessage(text);\n dispatchSendForExisting(state.sessionId, msg.id, text);\n return;\n }\n\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n sendBtn.addEventListener(\"click\", doSend);\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n doSend();\n }\n });\n\n // Returns the id of the user message that's currently the rolling cache\n // anchor (2 user-turns behind the latest). Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker\n // stamping. Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes\n // to them) auto-cache the prompt prefix upstream regardless, so the\n // visual cue should still appear.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 2) return null;\n return userMsgs[userMsgs.length - 1 - 2]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n // A live handle proves a turn is in flight, so the message it backs is\n // the last assistant message regardless of its status field. Binding\n // only to status===\"streaming\" left token/tool events writing to the\n // DOM handle but not the message model when the status had already been\n // flipped, so the post-finalize static rerender drew an empty bubble.\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n if (!handle) return;\n // Recover the message pointer if it was cleared mid-stream. Bailing here\n // used to leave the turn half-finalized: the handle's DOM had the streamed\n // content but the message stayed status=\"streaming\" with whatever blocks\n // it had, and the next rerenderThread drew it from the (possibly empty)\n // model instead of the live DOM, so the whole response vanished until a\n // reload rebuilt it from the backend-persisted session.\n rebindCurrentAssistantMessage();\n const msg = state.currentAssistantMessage;\n handle.setStatus(status);\n if (msg) msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n }\n break;\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.edits);\n break;\n case \"chat_event\":\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_cancelled\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"scope_edits_pushed\": {\n // Authoritative snapshot for exactly one scope. Replace its slot,\n // refresh the badge, and only re-render the modal if it is currently\n // showing this scope (a background push for another scope must not\n // yank the view the user is looking at).\n state.scopeLedgers.set(scopeKeyString(msg.scope), [...msg.entries]);\n updateSessionBar();\n const shown = workshopScope();\n if (state.diffModal && shown && scopeKeyString(shown) === scopeKeyString(msg.scope)) {\n state.diffModal.setEdits(msg.entries);\n }\n break;\n }\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\": {\n // Scope list for the workshop combo only. The badge is NOT touched\n // here: it is a pure projection of scopeLedgers, refreshed by the\n // authoritative scope_edits_pushed the backend sends alongside this.\n state.scopeStorage = msg.entries;\n state.diffModal?.setScopes(buildScopeOptions());\n break;\n }\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"scope_squashed\": {\n // The scope's ledger was cleared. Drop its cached slot; if the combo\n // was focused on it, fall back to the active scope.\n const key = scopeKeyString(msg.scope);\n state.scopeLedgers.set(key, []);\n if (state.workshopFocusScope && scopeKeyString(state.workshopFocusScope) === key) {\n state.workshopFocusScope = null;\n }\n if (activeScope() && scopeKeyString(activeScope()!) === key) {\n state.edits = state.edits.map((e) => ({ ...e, reverted: true }));\n rerenderThread();\n }\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n }\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "a25cfe71687bb7408c8da3542f04181f05347ddf": {"t": 1785771295.5614707, "n": 115709, "h": "5a30cd54472f70a1", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n CharacterStorageEntry,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n ScopeRef,\n SessionSummaryWire,\n} from \"../types\";\nimport { characterScope, scopeKeyString } from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n // Authoritative per-scope ledger snapshots, keyed by scopeKeyString. The\n // backend is the only writer of truth: every scope_edits_pushed replaces\n // one slot. The header badge and the workshop modal both read from here,\n // so there is no second cache to reconcile.\n scopeLedgers: Map;\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n scopeStorage: readonly CharacterStorageEntry[];\n // View-only: which scope the workshop combo is focused on. Null = follow\n // the active session's scope. Never an authority for the header badge.\n workshopFocusScope: ScopeRef | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n scopeLedgers: new Map(),\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n scopeStorage: [],\n workshopFocusScope: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card\",\n send: \"Translate this card to English (or another target if I name one). Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings) and UI surfaces (regex replace_string, trigger displays/values, bg-html, scriptstate_defaults). Skip find_regex patterns, anything already bilingual, and internal identifiers. For greetings, `rewrite` the whole message and keep tags / markers / capture groups byte-identical. Summarise findings and ask which surfaces to touch before writing.\",\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerArea, composerStatus);\n composer.appendChild(composerInner);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n const withConnection = (msg: T): T =>\n state.connectionId ? { ...msg, connectionId: state.connectionId } : msg;\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"(No character)\", sublabel: \"general chat + workspace only\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.name,\n sublabel: `${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n };\n\n // The scope the active session points at. A no-character chat has no\n // active scope, so its header badge is always 0 (Lumiverse-scope edits\n // surface in the workshop, not the header).\n const activeScope = (): ScopeRef | null =>\n state.characterId !== null ? characterScope(state.characterId) : null;\n\n // Pure projection of one authoritative snapshot. Never reconciles.\n const scopeEntries = (scope: ScopeRef | null): readonly EditLogEntry[] =>\n scope ? state.scopeLedgers.get(scopeKeyString(scope)) ?? [] : [];\n\n // What the workshop modal shows: the combo-focused scope if the user\n // picked one, otherwise the active session's scope.\n const workshopScope = (): ScopeRef | null => state.workshopFocusScope ?? activeScope();\n\n // Combo options for the workshop. scopeStorage only lists scopes that\n // already have a ledger, so a freshly-selected diffless character would be\n // absent and the combo would fall through to the first character that does\n // have edits. Always surface the active character so opening the workshop\n // lands on it (empty diff is the correct, ephemeral view).\n const buildScopeOptions = (): ReadonlyArray<{ scope: ScopeRef; label: string; liveCount: number; totalCount: number }> => {\n const opts = state.scopeStorage.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n liveCount: e.liveEditCount,\n totalCount: e.editCount,\n }));\n const active = activeScope();\n if (active && !opts.some((o) => scopeKeyString(o.scope) === scopeKeyString(active))) {\n opts.unshift({ scope: active, label: state.characterName ?? \"Current character\", liveCount: 0, totalCount: 0 });\n }\n return opts;\n };\n\n const updateSessionBar = () => {\n const source = scopeEntries(activeScope());\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n editsBadge.classList.toggle(\"has-edits\", source.length > 0);\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n const updateComposer = () => {\n if (state.isGenerating || state.startingSession) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n textarea.disabled = false;\n composerStatus.textContent = state.startingSession ? \"starting session...\" : \"agent is working...\";\n composerStatus.classList.remove(\"is-error\");\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n if (state.sessionId) {\n composerStatus.textContent = \"\";\n } else {\n composerStatus.textContent = \"Type a message and press Send. A new session will start automatically.\";\n }\n }\n // Empty textarea behaviour:\n // - last message was a user message → button enabled, click resumes that turn\n // - last message was an assistant message (or empty thread) → button greyed\n const hasText = textarea.value.trim().length > 0;\n const last = state.messages[state.messages.length - 1];\n const canContinue = !hasText && !!last && last.role === \"user\";\n const sendDisabled = state.startingSession || (!hasText && !canContinue);\n sendBtn.disabled = sendDisabled;\n updateCompactButton();\n };\n\n // Optimistic prune so the UI doesn't flash the reverted row before the\n // backend's authoritative scope_edits_pushed arrives. The session mirror\n // and every scope slot drop the id; the push then re-syncs the one scope.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n for (const [key, entries] of state.scopeLedgers) {\n state.scopeLedgers.set(key, entries.filter((e) => !ids.has(e.id)));\n }\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed && state.characterId) {\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId, force: true });\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(scopeEntries(workshopScope()));\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => scopeEntries(workshopScope()),\n getScopes: () => buildScopeOptions(),\n getSelectedScope: () => workshopScope(),\n onSelectScope: (scope) => {\n state.workshopFocusScope = scope;\n sendBackend({ type: \"load_character_workshop\", characterId: scope.id, scope });\n },\n onRevert: async (editId) => {\n const scope = workshopScope();\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertAll: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Revert all edits\",\n message: \"Revert every live edit in this scope? Cascade-aware. The ledger keeps history so reverts can be undone individually.\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_character_all\", characterId: scope.id, scope });\n },\n onForget: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget changes\",\n message: \"Permanently clear this scope's edit ledger? The underlying data is NOT touched, but you won't be able to revert any of these edits afterwards.\",\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (c.confirmed) sendBackend({ type: \"squash_character\", characterId: scope.id, scope });\n },\n onScopesNeeded: () => sendBackend({ type: \"list_characters_storage\" }),\n onClose: () => {\n state.diffModal = null;\n // Drop the combo focus so reopening follows the active session's\n // scope again. No refetch: the active scope's slot is already cached\n // and the badge reads it directly.\n state.workshopFocusScope = null;\n },\n filesPanel: state.workspacePanel.root,\n }, {\n ...(initialEditId !== undefined ? { initialEditId } : {}),\n // No character in this chat: open straight to the Lumiverse tab,\n // since the Characters tab would have nothing to show.\n ...(state.characterId === null ? { initialTab: \"lumiverse\" as const } : {}),\n });\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n // Per-message banners count edits this session attributed to a message,\n // across every scope, so the session mirror is the right source.\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n return state.edits.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n return state.edits.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (!state.characterId || editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revert_edits_bulk\", characterId: state.characterId, editIds: [...editIds], ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n const switchingAway = state.sessionId !== null && id !== state.characterId;\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId, switchingAway });\n if (switchingAway) {\n // The active session belongs to a different character. Drop it from the\n // UI so state.characterId stays the source of truth. The session file is\n // safe on the backend, reloadable via Sessions modal.\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n persistUiPrefs();\n }\n state.characterId = id;\n state.chatsForCharacter = [];\n state.pinnedChatId = null;\n state.autoPinNeeded = !!id;\n setChatPinned(false);\n if (switchingAway) rerenderThread();\n updateComposer();\n updateSessionBar();\n if (id) {\n // Mirror the New chat button: spin up a fresh session for the picked\n // character so the queued autopin from chats_pushed actually lands.\n // Without this the user would see \"no pin\" until they manually click New.\n startNewSession();\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n } else {\n // No-character mode: still spin up a fresh session so the user can talk\n // to the agent. Ledger/chat data stay empty; tool filtering and the\n // one-sentence system-prompt directive handle the rest.\n startNewSession();\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const appendUserMessage = (text: string): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n }));\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n const doSend = (): void => {\n const text = textarea.value.trim();\n if (state.isGenerating || state.startingSession) return;\n\n // Empty send: resume the existing turn if the last message is the user's\n // own (the agent hasn't replied yet). Bail silently otherwise — the\n // button should already be greyed out via updateComposer in that case.\n if (text.length === 0) {\n const last = state.messages[state.messages.length - 1];\n const canContinue = !!last && last.role === \"user\" && !!state.sessionId;\n if (!canContinue) return;\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId!,\n }));\n state.isGenerating = true;\n updateComposer();\n return;\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n if (state.sessionId) {\n const msg = appendUserMessage(text);\n dispatchSendForExisting(state.sessionId, msg.id, text);\n return;\n }\n\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n sendBtn.addEventListener(\"click\", doSend);\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n doSend();\n }\n });\n\n // Returns the id of the user message that's currently the rolling cache\n // anchor (2 user-turns behind the latest). Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker\n // stamping. Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes\n // to them) auto-cache the prompt prefix upstream regardless, so the\n // visual cue should still appear.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 2) return null;\n return userMsgs[userMsgs.length - 1 - 2]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\" && m.status === \"streaming\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n const msg = state.currentAssistantMessage;\n if (!handle || !msg) return;\n handle.setStatus(status);\n msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n }\n break;\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.edits);\n break;\n case \"chat_event\":\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_cancelled\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"scope_edits_pushed\": {\n // Authoritative snapshot for exactly one scope. Replace its slot,\n // refresh the badge, and only re-render the modal if it is currently\n // showing this scope (a background push for another scope must not\n // yank the view the user is looking at).\n state.scopeLedgers.set(scopeKeyString(msg.scope), [...msg.entries]);\n updateSessionBar();\n const shown = workshopScope();\n if (state.diffModal && shown && scopeKeyString(shown) === scopeKeyString(msg.scope)) {\n state.diffModal.setEdits(msg.entries);\n }\n break;\n }\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\": {\n // Scope list for the workshop combo only. The badge is NOT touched\n // here: it is a pure projection of scopeLedgers, refreshed by the\n // authoritative scope_edits_pushed the backend sends alongside this.\n state.scopeStorage = msg.entries;\n state.diffModal?.setScopes(buildScopeOptions());\n break;\n }\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"scope_squashed\": {\n // The scope's ledger was cleared. Drop its cached slot; if the combo\n // was focused on it, fall back to the active scope.\n const key = scopeKeyString(msg.scope);\n state.scopeLedgers.set(key, []);\n if (state.workshopFocusScope && scopeKeyString(state.workshopFocusScope) === key) {\n state.workshopFocusScope = null;\n }\n if (activeScope() && scopeKeyString(activeScope()!) === key) {\n state.edits = state.edits.map((e) => ({ ...e, reverted: true }));\n rerenderThread();\n }\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(scopeEntries(workshopScope()));\n break;\n }\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "0e307882221eaee2bbd54d09f9ae60909dc115fb": {"t": 1785771297.9792788, "n": 115016, "h": "44e5156fedbca772", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n CharacterStorageEntry,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n ScopeRef,\n SessionSummaryWire,\n} from \"../types\";\nimport { characterScope } from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n characterLedger: EditLogEntry[];\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n scopeStorage: readonly CharacterStorageEntry[];\n workshopFocusCharacterId: string | null;\n workshopFocusCharacterName: string | null;\n workshopFocusScope: ScopeRef | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n characterLedger: [],\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n scopeStorage: [],\n workshopFocusCharacterId: null,\n workshopFocusCharacterName: null,\n workshopFocusScope: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card\",\n send: \"Translate this card to English (or another target if I name one). Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings) and UI surfaces (regex replace_string, trigger displays/values, bg-html, scriptstate_defaults). Skip find_regex patterns, anything already bilingual, and internal identifiers. For greetings, `rewrite` the whole message and keep tags / markers / capture groups byte-identical. Summarise findings and ask which surfaces to touch before writing.\",\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerArea, composerStatus);\n composer.appendChild(composerInner);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n const withConnection = (msg: T): T =>\n state.connectionId ? { ...msg, connectionId: state.connectionId } : msg;\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"(No character)\", sublabel: \"general chat + workspace only\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.name,\n sublabel: `${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n };\n\n // Ledger fallback: use the character ledger only when there's a character\n // anchor for it. Without this gate, a session_loaded from a no-character\n // session would surface the previously-loaded character's ledger.\n const ledgerSource = (): readonly EditLogEntry[] => {\n const hasAnchor = state.characterId !== null || state.workshopFocusCharacterId !== null;\n if (hasAnchor && state.characterLedger.length > 0) return state.characterLedger;\n return state.edits;\n };\n\n const updateSessionBar = () => {\n const source = ledgerSource();\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n if (liveEdits === 0 && source.length === 0) {\n editsBadge.classList.remove(\"has-edits\");\n } else {\n editsBadge.classList.add(\"has-edits\");\n }\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n const updateComposer = () => {\n if (state.isGenerating || state.startingSession) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n textarea.disabled = false;\n composerStatus.textContent = state.startingSession ? \"starting session...\" : \"agent is working...\";\n composerStatus.classList.remove(\"is-error\");\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n if (state.sessionId) {\n composerStatus.textContent = \"\";\n } else {\n composerStatus.textContent = \"Type a message and press Send. A new session will start automatically.\";\n }\n }\n // Empty textarea behaviour:\n // - last message was a user message → button enabled, click resumes that turn\n // - last message was an assistant message (or empty thread) → button greyed\n const hasText = textarea.value.trim().length > 0;\n const last = state.messages[state.messages.length - 1];\n const canContinue = !hasText && !!last && last.role === \"user\";\n const sendDisabled = state.startingSession || (!hasText && !canContinue);\n sendBtn.disabled = sendDisabled;\n updateCompactButton();\n };\n\n // Reverts are permanent: the entry is removed from the ledger and from\n // the session's edit mirror so the UI shouldn't keep it around either.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n state.characterLedger = state.characterLedger.filter((e) => !ids.has(e.id));\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed && state.characterId) {\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId, force: true });\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(state.edits);\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => ledgerSource(),\n getScopes: () => state.scopeStorage.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n liveCount: e.liveEditCount,\n totalCount: e.editCount,\n })),\n getSelectedScope: () => state.workshopFocusScope\n ?? (state.characterId ? characterScope(state.characterId) : null),\n onSelectScope: (scope) => {\n state.workshopFocusScope = scope;\n state.workshopFocusCharacterId = scope.id;\n const opt = state.scopeStorage.find((e) => (e.scope?.id ?? e.characterId) === scope.id);\n state.workshopFocusCharacterName = opt?.label ?? opt?.characterName ?? null;\n sendBackend({ type: \"load_character_workshop\", characterId: scope.id, scope });\n },\n onRevert: async (editId) => {\n const scope = state.workshopFocusScope\n ?? (state.characterId ? characterScope(state.characterId) : null);\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertAll: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Revert all edits\",\n message: \"Revert every live edit in this scope? Cascade-aware. The ledger keeps history so reverts can be undone individually.\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_character_all\", characterId: scope.id, scope });\n },\n onForget: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget changes\",\n message: \"Permanently clear this scope's edit ledger? The underlying data is NOT touched, but you won't be able to revert any of these edits afterwards.\",\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (c.confirmed) sendBackend({ type: \"squash_character\", characterId: scope.id, scope });\n },\n onScopesNeeded: () => sendBackend({ type: \"list_characters_storage\" }),\n onClose: () => {\n state.diffModal = null;\n // Reset the focus override so reopening goes back to the active\n // session's character.\n if (state.workshopFocusCharacterId) {\n state.workshopFocusScope = null;\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n }\n },\n filesPanel: state.workspacePanel.root,\n }, {\n ...(initialEditId !== undefined ? { initialEditId } : {}),\n // No character in this chat: open straight to the Lumiverse tab,\n // since the Characters tab would have nothing to show.\n ...(state.characterId === null ? { initialTab: \"lumiverse\" as const } : {}),\n });\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n const source = ledgerSource();\n return source.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n const source = ledgerSource();\n return source.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (!state.characterId || editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revert_edits_bulk\", characterId: state.characterId, editIds: [...editIds], ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n const switchingAway = state.sessionId !== null && id !== state.characterId;\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId, switchingAway });\n if (switchingAway) {\n // The active session belongs to a different character. Drop it from the\n // UI so state.characterId stays the source of truth. The session file is\n // safe on the backend, reloadable via Sessions modal.\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n persistUiPrefs();\n }\n state.characterId = id;\n state.characterLedger = [];\n state.chatsForCharacter = [];\n state.pinnedChatId = null;\n state.autoPinNeeded = !!id;\n setChatPinned(false);\n if (switchingAway) rerenderThread();\n updateComposer();\n updateSessionBar();\n if (id) {\n // Mirror the New chat button: spin up a fresh session for the picked\n // character so the queued autopin from chats_pushed actually lands.\n // Without this the user would see \"no pin\" until they manually click New.\n startNewSession();\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n } else {\n // No-character mode: still spin up a fresh session so the user can talk\n // to the agent. Ledger/chat data stay empty; tool filtering and the\n // one-sentence system-prompt directive handle the rest.\n startNewSession();\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const appendUserMessage = (text: string): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n }));\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n const doSend = (): void => {\n const text = textarea.value.trim();\n if (state.isGenerating || state.startingSession) return;\n\n // Empty send: resume the existing turn if the last message is the user's\n // own (the agent hasn't replied yet). Bail silently otherwise — the\n // button should already be greyed out via updateComposer in that case.\n if (text.length === 0) {\n const last = state.messages[state.messages.length - 1];\n const canContinue = !!last && last.role === \"user\" && !!state.sessionId;\n if (!canContinue) return;\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId!,\n }));\n state.isGenerating = true;\n updateComposer();\n return;\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n if (state.sessionId) {\n const msg = appendUserMessage(text);\n dispatchSendForExisting(state.sessionId, msg.id, text);\n return;\n }\n\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n sendBtn.addEventListener(\"click\", doSend);\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n doSend();\n }\n });\n\n // Returns the id of the user message that's currently the rolling cache\n // anchor (2 user-turns behind the latest). Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker\n // stamping. Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes\n // to them) auto-cache the prompt prefix upstream regardless, so the\n // visual cue should still appear.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 2) return null;\n return userMsgs[userMsgs.length - 1 - 2]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\" && m.status === \"streaming\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n const msg = state.currentAssistantMessage;\n if (!handle || !msg) return;\n handle.setStatus(status);\n msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n state.sessionId = msg.sessionId;\n if (state.characterId !== msg.characterId) state.characterLedger = [];\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n }\n break;\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.edits);\n break;\n case \"chat_event\":\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_cancelled\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"character_edits_pushed\":\n state.characterLedger = [...msg.entries];\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n break;\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\": {\n state.scopeStorage = msg.entries;\n state.diffModal?.setScopes(msg.entries.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n liveCount: e.liveEditCount,\n totalCount: e.editCount,\n })));\n // Authoritatively re-sync the header badge to the active character's\n // live edit count. The backend fires this after every revert / forget\n // / squash, so the counter self-heals regardless of which modal or\n // path did the revert (the client-side splice can miss cases).\n if (state.characterId !== null) {\n const e = msg.entries.find((x) =>\n (x.scope && x.scope.kind === \"character\" ? x.scope.id : x.characterId) === state.characterId);\n editsCount.textContent = String(e ? e.liveEditCount : 0);\n editsBadge.classList.toggle(\"has-edits\", (e?.editCount ?? 0) > 0);\n }\n break;\n }\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"character_squashed\":\n // If we just squashed the focused character, drop the focus override\n // and reload the active character's ledger so the Edits tab is sane.\n if (state.workshopFocusCharacterId === msg.characterId) {\n state.workshopFocusScope = null;\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n }\n if (state.characterId === msg.characterId) {\n state.characterLedger = [];\n state.edits = state.edits.map((e) => ({ ...e, reverted: true }));\n rerenderThread();\n updateSessionBar();\n }\n break;\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n state.characterLedger = [];\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "bc7f06d712e0f94506142e1302cf535335515d6a": {"t": 1785771299.8215256, "n": 114353, "h": "5284337008234a9a", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n CharacterStorageEntry,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n ScopeRef,\n SessionSummaryWire,\n} from \"../types\";\nimport { characterScope } from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n characterLedger: EditLogEntry[];\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n scopeStorage: readonly CharacterStorageEntry[];\n workshopFocusCharacterId: string | null;\n workshopFocusCharacterName: string | null;\n workshopFocusScope: ScopeRef | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n characterLedger: [],\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n scopeStorage: [],\n workshopFocusCharacterId: null,\n workshopFocusCharacterName: null,\n workshopFocusScope: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card\",\n send: \"Translate this card to English (or another target if I name one). Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings) and UI surfaces (regex replace_string, trigger displays/values, bg-html, scriptstate_defaults). Skip find_regex patterns, anything already bilingual, and internal identifiers. For greetings, `rewrite` the whole message and keep tags / markers / capture groups byte-identical. Summarise findings and ask which surfaces to touch before writing.\",\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerArea, composerStatus);\n composer.appendChild(composerInner);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n const withConnection = (msg: T): T =>\n state.connectionId ? { ...msg, connectionId: state.connectionId } : msg;\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"(No character)\", sublabel: \"general chat + workspace only\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.name,\n sublabel: `${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n };\n\n // Ledger fallback: use the character ledger only when there's a character\n // anchor for it. Without this gate, a session_loaded from a no-character\n // session would surface the previously-loaded character's ledger.\n const ledgerSource = (): readonly EditLogEntry[] => {\n const hasAnchor = state.characterId !== null || state.workshopFocusCharacterId !== null;\n if (hasAnchor && state.characterLedger.length > 0) return state.characterLedger;\n return state.edits;\n };\n\n const updateSessionBar = () => {\n const source = ledgerSource();\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n if (liveEdits === 0 && source.length === 0) {\n editsBadge.classList.remove(\"has-edits\");\n } else {\n editsBadge.classList.add(\"has-edits\");\n }\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n const updateComposer = () => {\n if (state.isGenerating || state.startingSession) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n textarea.disabled = false;\n composerStatus.textContent = state.startingSession ? \"starting session...\" : \"agent is working...\";\n composerStatus.classList.remove(\"is-error\");\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n if (state.sessionId) {\n composerStatus.textContent = \"\";\n } else {\n composerStatus.textContent = \"Type a message and press Send. A new session will start automatically.\";\n }\n }\n // Empty textarea behaviour:\n // - last message was a user message → button enabled, click resumes that turn\n // - last message was an assistant message (or empty thread) → button greyed\n const hasText = textarea.value.trim().length > 0;\n const last = state.messages[state.messages.length - 1];\n const canContinue = !hasText && !!last && last.role === \"user\";\n const sendDisabled = state.startingSession || (!hasText && !canContinue);\n sendBtn.disabled = sendDisabled;\n updateCompactButton();\n };\n\n // Reverts are permanent: the entry is removed from the ledger and from\n // the session's edit mirror so the UI shouldn't keep it around either.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n state.characterLedger = state.characterLedger.filter((e) => !ids.has(e.id));\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed && state.characterId) {\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId, force: true });\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(state.edits);\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => ledgerSource(),\n getScopes: () => state.scopeStorage.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n liveCount: e.liveEditCount,\n totalCount: e.editCount,\n })),\n getSelectedScope: () => state.workshopFocusScope\n ?? (state.characterId ? characterScope(state.characterId) : null),\n onSelectScope: (scope) => {\n state.workshopFocusScope = scope;\n state.workshopFocusCharacterId = scope.id;\n const opt = state.scopeStorage.find((e) => (e.scope?.id ?? e.characterId) === scope.id);\n state.workshopFocusCharacterName = opt?.label ?? opt?.characterName ?? null;\n sendBackend({ type: \"load_character_workshop\", characterId: scope.id, scope });\n },\n onRevert: async (editId) => {\n const scope = state.workshopFocusScope\n ?? (state.characterId ? characterScope(state.characterId) : null);\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertAll: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Revert all edits\",\n message: \"Revert every live edit in this scope? Cascade-aware. The ledger keeps history so reverts can be undone individually.\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_character_all\", characterId: scope.id, scope });\n },\n onForget: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget changes\",\n message: \"Permanently clear this scope's edit ledger? The underlying data is NOT touched, but you won't be able to revert any of these edits afterwards.\",\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (c.confirmed) sendBackend({ type: \"squash_character\", characterId: scope.id, scope });\n },\n onScopesNeeded: () => sendBackend({ type: \"list_characters_storage\" }),\n onClose: () => {\n state.diffModal = null;\n // Reset the focus override so reopening goes back to the active\n // session's character.\n if (state.workshopFocusCharacterId) {\n state.workshopFocusScope = null;\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n }\n },\n filesPanel: state.workspacePanel.root,\n }, {\n ...(initialEditId !== undefined ? { initialEditId } : {}),\n // No character in this chat: open straight to the Lumiverse tab,\n // since the Characters tab would have nothing to show.\n ...(state.characterId === null ? { initialTab: \"lumiverse\" as const } : {}),\n });\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n const source = ledgerSource();\n return source.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n const source = ledgerSource();\n return source.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (!state.characterId || editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revert_edits_bulk\", characterId: state.characterId, editIds: [...editIds], ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n const switchingAway = state.sessionId !== null && id !== state.characterId;\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId, switchingAway });\n if (switchingAway) {\n // The active session belongs to a different character. Drop it from the\n // UI so state.characterId stays the source of truth. The session file is\n // safe on the backend, reloadable via Sessions modal.\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n persistUiPrefs();\n }\n state.characterId = id;\n state.characterLedger = [];\n state.chatsForCharacter = [];\n state.pinnedChatId = null;\n state.autoPinNeeded = !!id;\n setChatPinned(false);\n if (switchingAway) rerenderThread();\n updateComposer();\n updateSessionBar();\n if (id) {\n // Mirror the New chat button: spin up a fresh session for the picked\n // character so the queued autopin from chats_pushed actually lands.\n // Without this the user would see \"no pin\" until they manually click New.\n startNewSession();\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n } else {\n // No-character mode: still spin up a fresh session so the user can talk\n // to the agent. Ledger/chat data stay empty; tool filtering and the\n // one-sentence system-prompt directive handle the rest.\n startNewSession();\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const appendUserMessage = (text: string): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n }));\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n const doSend = (): void => {\n const text = textarea.value.trim();\n if (state.isGenerating || state.startingSession) return;\n\n // Empty send: resume the existing turn if the last message is the user's\n // own (the agent hasn't replied yet). Bail silently otherwise — the\n // button should already be greyed out via updateComposer in that case.\n if (text.length === 0) {\n const last = state.messages[state.messages.length - 1];\n const canContinue = !!last && last.role === \"user\" && !!state.sessionId;\n if (!canContinue) return;\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId!,\n }));\n state.isGenerating = true;\n updateComposer();\n return;\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n if (state.sessionId) {\n const msg = appendUserMessage(text);\n dispatchSendForExisting(state.sessionId, msg.id, text);\n return;\n }\n\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n sendBtn.addEventListener(\"click\", doSend);\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n doSend();\n }\n });\n\n // Returns the id of the user message that's currently the rolling cache\n // anchor (2 user-turns behind the latest). Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker\n // stamping. Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes\n // to them) auto-cache the prompt prefix upstream regardless, so the\n // visual cue should still appear.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 2) return null;\n return userMsgs[userMsgs.length - 1 - 2]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\" && m.status === \"streaming\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n const msg = state.currentAssistantMessage;\n if (!handle || !msg) return;\n handle.setStatus(status);\n msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n state.sessionId = msg.sessionId;\n if (state.characterId !== msg.characterId) state.characterLedger = [];\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n }\n break;\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.edits);\n break;\n case \"chat_event\":\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_cancelled\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"character_edits_pushed\":\n state.characterLedger = [...msg.entries];\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n break;\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\":\n state.scopeStorage = msg.entries;\n state.diffModal?.setScopes(msg.entries.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n liveCount: e.liveEditCount,\n totalCount: e.editCount,\n })));\n break;\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"character_squashed\":\n // If we just squashed the focused character, drop the focus override\n // and reload the active character's ledger so the Edits tab is sane.\n if (state.workshopFocusCharacterId === msg.characterId) {\n state.workshopFocusScope = null;\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n }\n if (state.characterId === msg.characterId) {\n state.characterLedger = [];\n state.edits = state.edits.map((e) => ({ ...e, reverted: true }));\n rerenderThread();\n updateSessionBar();\n }\n break;\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n state.characterLedger = [];\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "6c1a5631c7ba285ae0f846712e2938270c747a79": {"t": 1785771301.9979424, "n": 114116, "h": "8a8022ecd6b01d3f", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n CharacterStorageEntry,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n ScopeRef,\n SessionSummaryWire,\n} from \"../types\";\nimport { characterScope } from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n characterLedger: EditLogEntry[];\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n scopeStorage: readonly CharacterStorageEntry[];\n workshopFocusCharacterId: string | null;\n workshopFocusCharacterName: string | null;\n workshopFocusScope: ScopeRef | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n characterLedger: [],\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n scopeStorage: [],\n workshopFocusCharacterId: null,\n workshopFocusCharacterName: null,\n workshopFocusScope: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card\",\n send: \"Translate this card to English (or another target if I name one). Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings) and UI surfaces (regex replace_string, trigger displays/values, bg-html, scriptstate_defaults). Skip find_regex patterns, anything already bilingual, and internal identifiers. For greetings, `rewrite` the whole message and keep tags / markers / capture groups byte-identical. Summarise findings and ask which surfaces to touch before writing.\",\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerArea, composerStatus);\n composer.appendChild(composerInner);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n const withConnection = (msg: T): T =>\n state.connectionId ? { ...msg, connectionId: state.connectionId } : msg;\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"(No character)\", sublabel: \"general chat + workspace only\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.name,\n sublabel: `${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n };\n\n // Ledger fallback: use the character ledger only when there's a character\n // anchor for it. Without this gate, a session_loaded from a no-character\n // session would surface the previously-loaded character's ledger.\n const ledgerSource = (): readonly EditLogEntry[] => {\n const hasAnchor = state.characterId !== null || state.workshopFocusCharacterId !== null;\n if (hasAnchor && state.characterLedger.length > 0) return state.characterLedger;\n return state.edits;\n };\n\n const updateSessionBar = () => {\n const source = ledgerSource();\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n if (liveEdits === 0 && source.length === 0) {\n editsBadge.classList.remove(\"has-edits\");\n } else {\n editsBadge.classList.add(\"has-edits\");\n }\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n const updateComposer = () => {\n if (state.isGenerating || state.startingSession) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n textarea.disabled = false;\n composerStatus.textContent = state.startingSession ? \"starting session...\" : \"agent is working...\";\n composerStatus.classList.remove(\"is-error\");\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n if (state.sessionId) {\n composerStatus.textContent = \"\";\n } else {\n composerStatus.textContent = \"Type a message and press Send. A new session will start automatically.\";\n }\n }\n // Empty textarea behaviour:\n // - last message was a user message → button enabled, click resumes that turn\n // - last message was an assistant message (or empty thread) → button greyed\n const hasText = textarea.value.trim().length > 0;\n const last = state.messages[state.messages.length - 1];\n const canContinue = !hasText && !!last && last.role === \"user\";\n const sendDisabled = state.startingSession || (!hasText && !canContinue);\n sendBtn.disabled = sendDisabled;\n updateCompactButton();\n };\n\n // Reverts are permanent: the entry is removed from the ledger and from\n // the session's edit mirror so the UI shouldn't keep it around either.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n state.characterLedger = state.characterLedger.filter((e) => !ids.has(e.id));\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed && state.characterId) {\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId, force: true });\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(state.edits);\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => ledgerSource(),\n getScopes: () => state.scopeStorage.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n liveCount: e.liveEditCount,\n totalCount: e.editCount,\n })),\n getSelectedScope: () => state.workshopFocusScope\n ?? (state.characterId ? characterScope(state.characterId) : null),\n onSelectScope: (scope) => {\n state.workshopFocusScope = scope;\n state.workshopFocusCharacterId = scope.id;\n const opt = state.scopeStorage.find((e) => (e.scope?.id ?? e.characterId) === scope.id);\n state.workshopFocusCharacterName = opt?.label ?? opt?.characterName ?? null;\n sendBackend({ type: \"load_character_workshop\", characterId: scope.id, scope });\n },\n onRevert: async (editId) => {\n const scope = state.workshopFocusScope\n ?? (state.characterId ? characterScope(state.characterId) : null);\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onRevertAll: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Revert all edits\",\n message: \"Revert every live edit in this scope? Cascade-aware. The ledger keeps history so reverts can be undone individually.\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_character_all\", characterId: scope.id, scope });\n },\n onForget: async (scope) => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget changes\",\n message: \"Permanently clear this scope's edit ledger? The underlying data is NOT touched, but you won't be able to revert any of these edits afterwards.\",\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (c.confirmed) sendBackend({ type: \"squash_character\", characterId: scope.id, scope });\n },\n onScopesNeeded: () => sendBackend({ type: \"list_characters_storage\" }),\n onClose: () => {\n state.diffModal = null;\n // Reset the focus override so reopening goes back to the active\n // session's character.\n if (state.workshopFocusCharacterId) {\n state.workshopFocusScope = null;\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n }\n },\n filesPanel: state.workspacePanel.root,\n }, initialEditId !== undefined ? { initialEditId } : {});\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n const source = ledgerSource();\n return source.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n const source = ledgerSource();\n return source.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (!state.characterId || editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revert_edits_bulk\", characterId: state.characterId, editIds: [...editIds], ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n const switchingAway = state.sessionId !== null && id !== state.characterId;\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId, switchingAway });\n if (switchingAway) {\n // The active session belongs to a different character. Drop it from the\n // UI so state.characterId stays the source of truth. The session file is\n // safe on the backend, reloadable via Sessions modal.\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n persistUiPrefs();\n }\n state.characterId = id;\n state.characterLedger = [];\n state.chatsForCharacter = [];\n state.pinnedChatId = null;\n state.autoPinNeeded = !!id;\n setChatPinned(false);\n if (switchingAway) rerenderThread();\n updateComposer();\n updateSessionBar();\n if (id) {\n // Mirror the New chat button: spin up a fresh session for the picked\n // character so the queued autopin from chats_pushed actually lands.\n // Without this the user would see \"no pin\" until they manually click New.\n startNewSession();\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n } else {\n // No-character mode: still spin up a fresh session so the user can talk\n // to the agent. Ledger/chat data stay empty; tool filtering and the\n // one-sentence system-prompt directive handle the rest.\n startNewSession();\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const appendUserMessage = (text: string): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n }));\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n const doSend = (): void => {\n const text = textarea.value.trim();\n if (state.isGenerating || state.startingSession) return;\n\n // Empty send: resume the existing turn if the last message is the user's\n // own (the agent hasn't replied yet). Bail silently otherwise — the\n // button should already be greyed out via updateComposer in that case.\n if (text.length === 0) {\n const last = state.messages[state.messages.length - 1];\n const canContinue = !!last && last.role === \"user\" && !!state.sessionId;\n if (!canContinue) return;\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId!,\n }));\n state.isGenerating = true;\n updateComposer();\n return;\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n if (state.sessionId) {\n const msg = appendUserMessage(text);\n dispatchSendForExisting(state.sessionId, msg.id, text);\n return;\n }\n\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n sendBtn.addEventListener(\"click\", doSend);\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n doSend();\n }\n });\n\n // Returns the id of the user message that's currently the rolling cache\n // anchor (2 user-turns behind the latest). Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker\n // stamping. Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes\n // to them) auto-cache the prompt prefix upstream regardless, so the\n // visual cue should still appear.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 2) return null;\n return userMsgs[userMsgs.length - 1 - 2]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\" && m.status === \"streaming\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n const msg = state.currentAssistantMessage;\n if (!handle || !msg) return;\n handle.setStatus(status);\n msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n state.sessionId = msg.sessionId;\n if (state.characterId !== msg.characterId) state.characterLedger = [];\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n }\n break;\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.edits);\n break;\n case \"chat_event\":\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_cancelled\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"character_edits_pushed\":\n state.characterLedger = [...msg.entries];\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n break;\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\":\n state.scopeStorage = msg.entries;\n state.diffModal?.setScopes(msg.entries.map((e) => ({\n scope: e.scope ?? characterScope(e.characterId),\n label: e.label ?? e.characterName,\n liveCount: e.liveEditCount,\n totalCount: e.editCount,\n })));\n break;\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"character_squashed\":\n // If we just squashed the focused character, drop the focus override\n // and reload the active character's ledger so the Edits tab is sane.\n if (state.workshopFocusCharacterId === msg.characterId) {\n state.workshopFocusScope = null;\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n }\n if (state.characterId === msg.characterId) {\n state.characterLedger = [];\n state.edits = state.edits.map((e) => ({ ...e, reverted: true }));\n rerenderThread();\n updateSessionBar();\n }\n break;\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n state.characterLedger = [];\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "74b9a3ccceab0ed59e374a9a2e6ca5f58fe42d78": {"t": 1785771303.7909887, "n": 113309, "h": "7cd0b784d36077be", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n ScopeRef,\n SessionSummaryWire,\n} from \"../types\";\nimport { characterScope } from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCharactersPanel, type CharactersPanelHandle } from \"./characters-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n characterLedger: EditLogEntry[];\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n charactersPanel: CharactersPanelHandle | null;\n workshopFocusCharacterId: string | null;\n workshopFocusCharacterName: string | null;\n workshopFocusScope: ScopeRef | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n characterLedger: [],\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n charactersPanel: null,\n workshopFocusCharacterId: null,\n workshopFocusCharacterName: null,\n workshopFocusScope: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card\",\n send: \"Translate this card to English (or another target if I name one). Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings) and UI surfaces (regex replace_string, trigger displays/values, bg-html, scriptstate_defaults). Skip find_regex patterns, anything already bilingual, and internal identifiers. For greetings, `rewrite` the whole message and keep tags / markers / capture groups byte-identical. Summarise findings and ask which surfaces to touch before writing.\",\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerArea, composerStatus);\n composer.appendChild(composerInner);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n const withConnection = (msg: T): T =>\n state.connectionId ? { ...msg, connectionId: state.connectionId } : msg;\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"(No character)\", sublabel: \"general chat + workspace only\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.name,\n sublabel: `${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n };\n\n // Ledger fallback: use the character ledger only when there's a character\n // anchor for it. Without this gate, a session_loaded from a no-character\n // session would surface the previously-loaded character's ledger.\n const ledgerSource = (): readonly EditLogEntry[] => {\n const hasAnchor = state.characterId !== null || state.workshopFocusCharacterId !== null;\n if (hasAnchor && state.characterLedger.length > 0) return state.characterLedger;\n return state.edits;\n };\n\n const updateSessionBar = () => {\n const source = ledgerSource();\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n if (liveEdits === 0 && source.length === 0) {\n editsBadge.classList.remove(\"has-edits\");\n } else {\n editsBadge.classList.add(\"has-edits\");\n }\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n const updateComposer = () => {\n if (state.isGenerating || state.startingSession) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n textarea.disabled = false;\n composerStatus.textContent = state.startingSession ? \"starting session...\" : \"agent is working...\";\n composerStatus.classList.remove(\"is-error\");\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n if (state.sessionId) {\n composerStatus.textContent = \"\";\n } else {\n composerStatus.textContent = \"Type a message and press Send. A new session will start automatically.\";\n }\n }\n // Empty textarea behaviour:\n // - last message was a user message → button enabled, click resumes that turn\n // - last message was an assistant message (or empty thread) → button greyed\n const hasText = textarea.value.trim().length > 0;\n const last = state.messages[state.messages.length - 1];\n const canContinue = !hasText && !!last && last.role === \"user\";\n const sendDisabled = state.startingSession || (!hasText && !canContinue);\n sendBtn.disabled = sendDisabled;\n updateCompactButton();\n };\n\n // Reverts are permanent: the entry is removed from the ledger and from\n // the session's edit mirror so the UI shouldn't keep it around either.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n state.characterLedger = state.characterLedger.filter((e) => !ids.has(e.id));\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed && state.characterId) {\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId, force: true });\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(state.edits);\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n if (!state.charactersPanel) {\n state.charactersPanel = mountCharactersPanel({\n ctx,\n sendBackend,\n onFocusCharacter: (scope, label) => {\n state.workshopFocusScope = scope;\n state.workshopFocusCharacterId = scope.id;\n state.workshopFocusCharacterName = label;\n sendBackend({ type: \"load_character_workshop\", characterId: scope.id, scope });\n state.diffModal?.focusTab(\"edits\");\n },\n });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => {\n // Focus override: Characters tab can swap the Edits view to another\n // character's ledger. characterLedger is reused as the carrier.\n return ledgerSource();\n },\n getScopeLabel: () => {\n if (state.workshopFocusCharacterId) return state.workshopFocusCharacterName ?? \"focused character\";\n if (state.characterId === null) return \"no character attached\";\n return state.characterName ?? null;\n },\n onRevert: async (editId) => {\n const scope = state.workshopFocusScope\n ?? (state.characterId ? characterScope(state.characterId) : null);\n if (!scope) return;\n sendBackend({ type: \"revert_edit\", characterId: scope.id, editId, scope });\n },\n onClose: () => {\n state.diffModal = null;\n // Reset the focus override so reopening goes back to the active\n // session's character.\n if (state.workshopFocusCharacterId) {\n state.workshopFocusScope = null;\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n }\n },\n filesPanel: state.workspacePanel.root,\n charactersPanel: state.charactersPanel.root,\n onCharactersTabActivated: () => state.charactersPanel?.refresh(),\n }, initialEditId !== undefined ? { initialEditId } : {});\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n const source = ledgerSource();\n return source.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n const source = ledgerSource();\n return source.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (!state.characterId || editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revert_edits_bulk\", characterId: state.characterId, editIds: [...editIds], ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n const switchingAway = state.sessionId !== null && id !== state.characterId;\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId, switchingAway });\n if (switchingAway) {\n // The active session belongs to a different character. Drop it from the\n // UI so state.characterId stays the source of truth. The session file is\n // safe on the backend, reloadable via Sessions modal.\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n persistUiPrefs();\n }\n state.characterId = id;\n state.characterLedger = [];\n state.chatsForCharacter = [];\n state.pinnedChatId = null;\n state.autoPinNeeded = !!id;\n setChatPinned(false);\n if (switchingAway) rerenderThread();\n updateComposer();\n updateSessionBar();\n if (id) {\n // Mirror the New chat button: spin up a fresh session for the picked\n // character so the queued autopin from chats_pushed actually lands.\n // Without this the user would see \"no pin\" until they manually click New.\n startNewSession();\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n } else {\n // No-character mode: still spin up a fresh session so the user can talk\n // to the agent. Ledger/chat data stay empty; tool filtering and the\n // one-sentence system-prompt directive handle the rest.\n startNewSession();\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const appendUserMessage = (text: string): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n }));\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n const doSend = (): void => {\n const text = textarea.value.trim();\n if (state.isGenerating || state.startingSession) return;\n\n // Empty send: resume the existing turn if the last message is the user's\n // own (the agent hasn't replied yet). Bail silently otherwise — the\n // button should already be greyed out via updateComposer in that case.\n if (text.length === 0) {\n const last = state.messages[state.messages.length - 1];\n const canContinue = !!last && last.role === \"user\" && !!state.sessionId;\n if (!canContinue) return;\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId!,\n }));\n state.isGenerating = true;\n updateComposer();\n return;\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n if (state.sessionId) {\n const msg = appendUserMessage(text);\n dispatchSendForExisting(state.sessionId, msg.id, text);\n return;\n }\n\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n sendBtn.addEventListener(\"click\", doSend);\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n doSend();\n }\n });\n\n // Returns the id of the user message that's currently the rolling cache\n // anchor (2 user-turns behind the latest). Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker\n // stamping. Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes\n // to them) auto-cache the prompt prefix upstream regardless, so the\n // visual cue should still appear.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 2) return null;\n return userMsgs[userMsgs.length - 1 - 2]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\" && m.status === \"streaming\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n const msg = state.currentAssistantMessage;\n if (!handle || !msg) return;\n handle.setStatus(status);\n msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n state.sessionId = msg.sessionId;\n if (state.characterId !== msg.characterId) state.characterLedger = [];\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n }\n break;\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.edits);\n break;\n case \"chat_event\":\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_cancelled\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"character_edits_pushed\":\n state.characterLedger = [...msg.entries];\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n break;\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\":\n state.charactersPanel?.onPushed(\n msg.entries,\n msg.workspaceUsedBytes,\n msg.workspaceCapBytes,\n );\n break;\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"character_squashed\":\n // If we just squashed the focused character, drop the focus override\n // and reload the active character's ledger so the Edits tab is sane.\n if (state.workshopFocusCharacterId === msg.characterId) {\n state.workshopFocusScope = null;\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n }\n if (state.characterId === msg.characterId) {\n state.characterLedger = [];\n state.edits = state.edits.map((e) => ({ ...e, reverted: true }));\n rerenderThread();\n updateSessionBar();\n }\n break;\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n state.characterLedger = [];\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "aee8f6d9803179830b7f8d8ac680fe345d56a54b": {"t": 1785771305.838137, "n": 112969, "h": "b649c988109be289", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n SessionSummaryWire,\n} from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCharactersPanel, type CharactersPanelHandle } from \"./characters-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n characterLedger: EditLogEntry[];\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n charactersPanel: CharactersPanelHandle | null;\n workshopFocusCharacterId: string | null;\n workshopFocusCharacterName: string | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n characterLedger: [],\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n charactersPanel: null,\n workshopFocusCharacterId: null,\n workshopFocusCharacterName: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate this card\",\n send: \"Translate this card to English (or another target if I name one). Run `survey_cjk` first to see where the source-language content lives; plan from what it reports, not memory. Cover greetings (first_mes + alternate_greetings) and UI surfaces (regex replace_string, trigger displays/values, bg-html, scriptstate_defaults). Skip find_regex patterns, anything already bilingual, and internal identifiers. For greetings, `rewrite` the whole message and keep tags / markers / capture groups byte-identical. Summarise findings and ask which surfaces to touch before writing.\",\n },\n {\n label: \"Add/update a lorebook entry\",\n send: \"Help me add or update a lorebook entry for a character in this card. First ask which character and what to cover. Use `grep` / `list({path:'wb'})` to check for an existing entry; if one exists, summarise it and ask whether to update or replace. Apply via tool after I confirm, don't paste the entry's prose into chat.\",\n },\n {\n label: \"Explain this card\",\n send: \"Audit this card and explain it in plain English: what it's about (setting, premise, characters), what mechanics it runs (status panels, command syntax, regex outputs, time/weather/location systems, mode toggles, dice — name the actual fields), and how the user interacts with it. Skim metadata + world book + regex; if a chat is pinned, glance at a few recent messages for tone. Read-only, no edits.\",\n },\n {\n label: \"Change a character's gender\",\n send: \"Help me change the gender of one or more characters. First ask which character(s) and what new gender for each. Then `grep` for every reference: name, pronouns, honorifics, gendered nouns. Map out the surfaces that need to change and show me the plan as (surface, what changes) before applying. Pronouns, possessives, and gendered nouns all need to flip together.\",\n },\n\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerArea, composerStatus);\n composer.appendChild(composerInner);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n const withConnection = (msg: T): T =>\n state.connectionId ? { ...msg, connectionId: state.connectionId } : msg;\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"(No character)\", sublabel: \"general chat + workspace only\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.name,\n sublabel: `${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n };\n\n // Ledger fallback: use the character ledger only when there's a character\n // anchor for it. Without this gate, a session_loaded from a no-character\n // session would surface the previously-loaded character's ledger.\n const ledgerSource = (): readonly EditLogEntry[] => {\n const hasAnchor = state.characterId !== null || state.workshopFocusCharacterId !== null;\n if (hasAnchor && state.characterLedger.length > 0) return state.characterLedger;\n return state.edits;\n };\n\n const updateSessionBar = () => {\n const source = ledgerSource();\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n if (liveEdits === 0 && source.length === 0) {\n editsBadge.classList.remove(\"has-edits\");\n } else {\n editsBadge.classList.add(\"has-edits\");\n }\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n const updateComposer = () => {\n if (state.isGenerating || state.startingSession) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n textarea.disabled = false;\n composerStatus.textContent = state.startingSession ? \"starting session...\" : \"agent is working...\";\n composerStatus.classList.remove(\"is-error\");\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n if (state.sessionId) {\n composerStatus.textContent = \"\";\n } else {\n composerStatus.textContent = \"Type a message and press Send. A new session will start automatically.\";\n }\n }\n // Empty textarea behaviour:\n // - last message was a user message → button enabled, click resumes that turn\n // - last message was an assistant message (or empty thread) → button greyed\n const hasText = textarea.value.trim().length > 0;\n const last = state.messages[state.messages.length - 1];\n const canContinue = !hasText && !!last && last.role === \"user\";\n const sendDisabled = state.startingSession || (!hasText && !canContinue);\n sendBtn.disabled = sendDisabled;\n updateCompactButton();\n };\n\n // Reverts are permanent: the entry is removed from the ledger and from\n // the session's edit mirror so the UI shouldn't keep it around either.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n state.characterLedger = state.characterLedger.filter((e) => !ids.has(e.id));\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed && state.characterId) {\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId, force: true });\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(state.edits);\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n if (!state.charactersPanel) {\n state.charactersPanel = mountCharactersPanel({\n ctx,\n sendBackend,\n onFocusCharacter: (cid, name) => {\n state.workshopFocusCharacterId = cid;\n state.workshopFocusCharacterName = name;\n sendBackend({ type: \"load_character_workshop\", characterId: cid });\n state.diffModal?.focusTab(\"edits\");\n },\n });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => {\n // Focus override: Characters tab can swap the Edits view to another\n // character's ledger. characterLedger is reused as the carrier.\n return ledgerSource();\n },\n getScopeLabel: () => {\n if (state.workshopFocusCharacterId) return state.workshopFocusCharacterName ?? \"focused character\";\n if (state.characterId === null) return \"no character attached\";\n return state.characterName ?? null;\n },\n onRevert: async (editId) => {\n const cid = state.workshopFocusCharacterId ?? state.characterId;\n if (!cid) return;\n sendBackend({ type: \"revert_edit\", characterId: cid, editId });\n },\n onClose: () => {\n state.diffModal = null;\n // Reset the focus override so reopening goes back to the active\n // session's character.\n if (state.workshopFocusCharacterId) {\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n }\n },\n filesPanel: state.workspacePanel.root,\n charactersPanel: state.charactersPanel.root,\n onCharactersTabActivated: () => state.charactersPanel?.refresh(),\n }, initialEditId !== undefined ? { initialEditId } : {});\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n const source = ledgerSource();\n return source.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n const source = ledgerSource();\n return source.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (!state.characterId || editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revert_edits_bulk\", characterId: state.characterId, editIds: [...editIds], ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n const switchingAway = state.sessionId !== null && id !== state.characterId;\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId, switchingAway });\n if (switchingAway) {\n // The active session belongs to a different character. Drop it from the\n // UI so state.characterId stays the source of truth. The session file is\n // safe on the backend, reloadable via Sessions modal.\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n persistUiPrefs();\n }\n state.characterId = id;\n state.characterLedger = [];\n state.chatsForCharacter = [];\n state.pinnedChatId = null;\n state.autoPinNeeded = !!id;\n setChatPinned(false);\n if (switchingAway) rerenderThread();\n updateComposer();\n updateSessionBar();\n if (id) {\n // Mirror the New chat button: spin up a fresh session for the picked\n // character so the queued autopin from chats_pushed actually lands.\n // Without this the user would see \"no pin\" until they manually click New.\n startNewSession();\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n } else {\n // No-character mode: still spin up a fresh session so the user can talk\n // to the agent. Ledger/chat data stay empty; tool filtering and the\n // one-sentence system-prompt directive handle the rest.\n startNewSession();\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const appendUserMessage = (text: string): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n }));\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n const doSend = (): void => {\n const text = textarea.value.trim();\n if (state.isGenerating || state.startingSession) return;\n\n // Empty send: resume the existing turn if the last message is the user's\n // own (the agent hasn't replied yet). Bail silently otherwise — the\n // button should already be greyed out via updateComposer in that case.\n if (text.length === 0) {\n const last = state.messages[state.messages.length - 1];\n const canContinue = !!last && last.role === \"user\" && !!state.sessionId;\n if (!canContinue) return;\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId!,\n }));\n state.isGenerating = true;\n updateComposer();\n return;\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n if (state.sessionId) {\n const msg = appendUserMessage(text);\n dispatchSendForExisting(state.sessionId, msg.id, text);\n return;\n }\n\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n sendBtn.addEventListener(\"click\", doSend);\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n doSend();\n }\n });\n\n // Returns the id of the user message that's currently the rolling cache\n // anchor (2 user-turns behind the latest). Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker\n // stamping. Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes\n // to them) auto-cache the prompt prefix upstream regardless, so the\n // visual cue should still appear.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 2) return null;\n return userMsgs[userMsgs.length - 1 - 2]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\" && m.status === \"streaming\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n const msg = state.currentAssistantMessage;\n if (!handle || !msg) return;\n handle.setStatus(status);\n msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n state.sessionId = msg.sessionId;\n if (state.characterId !== msg.characterId) state.characterLedger = [];\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n }\n break;\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.edits);\n break;\n case \"chat_event\":\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_cancelled\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"character_edits_pushed\":\n state.characterLedger = [...msg.entries];\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n break;\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\":\n state.charactersPanel?.onPushed(\n msg.entries,\n msg.workspaceUsedBytes,\n msg.workspaceCapBytes,\n );\n break;\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"character_squashed\":\n // If we just squashed the focused character, drop the focus override\n // and reload the active character's ledger so the Edits tab is sane.\n if (state.workshopFocusCharacterId === msg.characterId) {\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n }\n if (state.characterId === msg.characterId) {\n state.characterLedger = [];\n state.edits = state.edits.map((e) => ({ ...e, reverted: true }));\n rerenderThread();\n updateSessionBar();\n }\n break;\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n state.characterLedger = [];\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "3db3786ef6c2bb1c2d0191a21b61efdc49ef7118": {"t": 1785771307.9364522, "n": 116288, "h": "1a46a7c5223956c7", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n SessionSummaryWire,\n} from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCharactersPanel, type CharactersPanelHandle } from \"./characters-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP, ICON_EXPAND, ICON_COLLAPSE } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n characterLedger: EditLogEntry[];\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n charactersPanel: CharactersPanelHandle | null;\n workshopFocusCharacterId: string | null;\n workshopFocusCharacterName: string | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n characterLedger: [],\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n charactersPanel: null,\n workshopFocusCharacterId: null,\n workshopFocusCharacterName: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const expandBtn = makeIconBtn(\"la-expand-btn\", ICON_EXPAND, \"Expand to fullscreen\", \"Expand to fullscreen\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, expandBtn, settingsBtn, menuBtn);\n\n let isExpanded = false;\n let originalParent: Node | null = null;\n let originalNextSibling: Node | null = null;\n const setExpanded = (next: boolean): void => {\n if (isExpanded === next) return;\n isExpanded = next;\n if (next) {\n originalParent = root.parentNode;\n originalNextSibling = root.nextSibling;\n document.body.appendChild(root);\n } else if (originalParent) {\n if (originalNextSibling && originalNextSibling.parentNode === originalParent) {\n originalParent.insertBefore(root, originalNextSibling);\n } else {\n originalParent.appendChild(root);\n }\n originalParent = null;\n originalNextSibling = null;\n }\n root.classList.toggle(\"la-drawer-expanded\", next);\n expandBtn.innerHTML = next ? ICON_COLLAPSE : ICON_EXPAND;\n expandBtn.title = next ? \"Collapse to drawer\" : \"Expand to fullscreen\";\n expandBtn.setAttribute(\"aria-label\", expandBtn.title);\n };\n expandBtn.addEventListener(\"click\", () => setExpanded(!isExpanded));\n // Esc collapses while focus is anywhere inside the drawer. Doesn't fight the\n // composer textarea because the composer doesn't capture Esc.\n root.addEventListener(\"keydown\", (ev) => {\n if (ev.key === \"Escape\" && isExpanded) {\n ev.preventDefault();\n setExpanded(false);\n }\n });\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate the greeting messages of this card\",\n send: \"Translate every greeting message of this card to English. That means the canonical first_mes (path `char/first_mes`) AND every alternate greeting (paths `char/alternate_greetings/`). For each one: `read` the path, construct the full English version, then `rewrite({path, new_content})` with the whole English version. Keep all tags, markdown headings, status-panel emoji markers, and named regex capture groups byte-identical — only translate the natural-language prose between them. SKIP any segment that is already in English or already has an English counterpart nearby (a parenthetical English gloss, a bilingual line, a label/value pair where one side is English). Only translate text that has no English equivalent anywhere in the surrounding context. If this card has a LumiRealm payload, mirror the canonical edits into the payload so the change survives translator schema migrations.\",\n },\n {\n label: \"Translate the UI in this card\",\n send: \"Translate every user-visible Korean label in this card to English.\\n\\nRun `survey_cjk({scopes:['regex_scripts','extensions'], top_n:200, min_length:1})` first — its `sample_surfaces` is the truth about where Korean actually lives. Don't list paths from memory; walk what the survey reports. For each surface, the LumiRealm guidance already loaded in your context tells you which path is the authoring layer (and which paths are derived / regenerated / read-only).\\n\\nSkip:\\n- regex `find_regex` patterns (never edit — they match LLM output)\\n- anything already in English or sitting next to an English form\\n- map keys / state names / internal identifiers that aren't user-visible\\n\\nAfter each regex `replace_string` edit, `test_regex` with the original `find_regex` against representative LLM output. Before writing anything, summarise what survey_cjk found and ask which surfaces I want translated.\",\n },\n {\n label: \"Add/update a lorebook entry on this chat's characters\",\n send: \"I want to add or update a lorebook entry on a character that appears in this chat. Before you do anything: ask me WHICH character the entry should cover (look at the pinned chat's recent messages for context if a chat is pinned). Then use `grep` and `list({path: 'wb'})` to check whether an entry already exists for that character. If one exists, briefly summarise its current content and ask whether I want to UPDATE it (extend / refine) or REPLACE it. If not, ask what details I want included — personality, role in the story, relationships, appearance, key facts — before creating it. Only after I confirm the plan do you call `edit` / `rewrite` / `create_world_book_entry`. Do not write the entry's prose in chat without applying it.\",\n },\n {\n label: \"Explain the features of this chat and what it's about\",\n send: \"Read this character's metadata (description, personality, scenario, first_mes, system_prompt, post_history_instructions, alternate_greetings count) and skim the world book entries and regex scripts. If a chat is pinned, look at a few of the most recent messages for tone and current state. Then explain to me in plain English: what this card is ABOUT (setting, premise, key characters), what MECHANICS the card runs (status panels, command syntax, themed regex outputs, time/weather/location systems, mode toggles, dice rolls, anything special), and HOW a user typically interacts with it (what kind of inputs the card responds to, what UI features will appear). Be specific — quote the actual regex panel markers, name the actual status fields. Don't edit anything; this is a read-only audit.\",\n },\n {\n label: \"Change the gender/sex of certain characters\",\n send: \"I want to change the gender or sex of one or more characters in this card. Before any edits, ask me WHICH character(s) I want to change and WHAT the new gender should be for each. The change needs to be COMPLETE — once you have the list:\\n\\n1. Use `grep` to find every reference to each character: their name + all pronouns currently used for them (he/she/they/her/his/them) + any gendered honorifics in the source language (Mr./Ms./onee-chan/onii-san/etc.) + any explicitly gendered nouns (man/woman/boy/girl/lady/sir/etc.).\\n2. Map out an edit plan covering EVERY surface that references them: their lorebook entry, all alternate_greetings, first_mes, scenario, description, personality, system_prompt, post_history_instructions, regex replace_string templates that mention them, and the LumiRealm payload mirror if this is a LumiRealm-imported card.\\n3. Show me the plan as a list of (surface, field, what changes) BEFORE applying.\\n4. After I confirm, apply. apply_glossary is the right tool for the pronoun pass — but be careful with single-character CJK keys (banned by default for substring-collision safety). Pronouns, possessives, honorifics, and gendered nouns all need to flip consistently.\",\n },\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerArea, composerStatus);\n composer.appendChild(composerInner);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n const withConnection = (msg: T): T =>\n state.connectionId ? { ...msg, connectionId: state.connectionId } : msg;\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"(No character)\", sublabel: \"general chat + workspace only\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.name,\n sublabel: `${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n };\n\n // Ledger fallback: use the character ledger only when there's a character\n // anchor for it. Without this gate, a session_loaded from a no-character\n // session would surface the previously-loaded character's ledger.\n const ledgerSource = (): readonly EditLogEntry[] => {\n const hasAnchor = state.characterId !== null || state.workshopFocusCharacterId !== null;\n if (hasAnchor && state.characterLedger.length > 0) return state.characterLedger;\n return state.edits;\n };\n\n const updateSessionBar = () => {\n const source = ledgerSource();\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n if (liveEdits === 0 && source.length === 0) {\n editsBadge.classList.remove(\"has-edits\");\n } else {\n editsBadge.classList.add(\"has-edits\");\n }\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n const updateComposer = () => {\n if (state.isGenerating || state.startingSession) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n textarea.disabled = false;\n composerStatus.textContent = state.startingSession ? \"starting session...\" : \"agent is working...\";\n composerStatus.classList.remove(\"is-error\");\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n if (state.sessionId) {\n composerStatus.textContent = \"\";\n } else {\n composerStatus.textContent = \"Type a message and press Send. A new session will start automatically.\";\n }\n }\n // Empty textarea behaviour:\n // - last message was a user message → button enabled, click resumes that turn\n // - last message was an assistant message (or empty thread) → button greyed\n const hasText = textarea.value.trim().length > 0;\n const last = state.messages[state.messages.length - 1];\n const canContinue = !hasText && !!last && last.role === \"user\";\n const sendDisabled = state.startingSession || (!hasText && !canContinue);\n sendBtn.disabled = sendDisabled;\n updateCompactButton();\n };\n\n // Reverts are permanent: the entry is removed from the ledger and from\n // the session's edit mirror so the UI shouldn't keep it around either.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n state.characterLedger = state.characterLedger.filter((e) => !ids.has(e.id));\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed && state.characterId) {\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId, force: true });\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n state.diffModal?.focusTab(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(state.edits);\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n if (!state.charactersPanel) {\n state.charactersPanel = mountCharactersPanel({\n ctx,\n sendBackend,\n onFocusCharacter: (cid, name) => {\n state.workshopFocusCharacterId = cid;\n state.workshopFocusCharacterName = name;\n sendBackend({ type: \"load_character_workshop\", characterId: cid });\n state.diffModal?.focusTab(\"edits\");\n },\n });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => {\n // Focus override: Characters tab can swap the Edits view to another\n // character's ledger. characterLedger is reused as the carrier.\n return ledgerSource();\n },\n getScopeLabel: () => {\n if (state.workshopFocusCharacterId) return state.workshopFocusCharacterName ?? \"focused character\";\n if (state.characterId === null) return \"no character attached\";\n return state.characterName ?? null;\n },\n onRevert: async (editId) => {\n const cid = state.workshopFocusCharacterId ?? state.characterId;\n if (!cid) return;\n sendBackend({ type: \"revert_edit\", characterId: cid, editId });\n },\n onClose: () => {\n state.diffModal = null;\n // Reset the focus override so reopening goes back to the active\n // session's character.\n if (state.workshopFocusCharacterId) {\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n }\n },\n filesPanel: state.workspacePanel.root,\n charactersPanel: state.charactersPanel.root,\n onCharactersTabActivated: () => state.charactersPanel?.refresh(),\n }, initialEditId !== undefined ? { initialEditId } : {});\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n const source = ledgerSource();\n return source.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n const source = ledgerSource();\n return source.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (!state.characterId || editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revert_edits_bulk\", characterId: state.characterId, editIds: [...editIds], ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n const switchingAway = state.sessionId !== null && id !== state.characterId;\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId, switchingAway });\n if (switchingAway) {\n // The active session belongs to a different character. Drop it from the\n // UI so state.characterId stays the source of truth. The session file is\n // safe on the backend, reloadable via Sessions modal.\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n persistUiPrefs();\n }\n state.characterId = id;\n state.characterLedger = [];\n state.chatsForCharacter = [];\n state.pinnedChatId = null;\n state.autoPinNeeded = !!id;\n setChatPinned(false);\n if (switchingAway) rerenderThread();\n updateComposer();\n updateSessionBar();\n if (id) {\n // Mirror the New chat button: spin up a fresh session for the picked\n // character so the queued autopin from chats_pushed actually lands.\n // Without this the user would see \"no pin\" until they manually click New.\n startNewSession();\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n } else {\n // No-character mode: still spin up a fresh session so the user can talk\n // to the agent. Ledger/chat data stay empty; tool filtering and the\n // one-sentence system-prompt directive handle the rest.\n startNewSession();\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const appendUserMessage = (text: string): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n }));\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n const doSend = (): void => {\n const text = textarea.value.trim();\n if (state.isGenerating || state.startingSession) return;\n\n // Empty send: resume the existing turn if the last message is the user's\n // own (the agent hasn't replied yet). Bail silently otherwise — the\n // button should already be greyed out via updateComposer in that case.\n if (text.length === 0) {\n const last = state.messages[state.messages.length - 1];\n const canContinue = !!last && last.role === \"user\" && !!state.sessionId;\n if (!canContinue) return;\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId!,\n }));\n state.isGenerating = true;\n updateComposer();\n return;\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n if (state.sessionId) {\n const msg = appendUserMessage(text);\n dispatchSendForExisting(state.sessionId, msg.id, text);\n return;\n }\n\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n sendBtn.addEventListener(\"click\", doSend);\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n doSend();\n }\n });\n\n // Returns the id of the user message that's currently the rolling cache\n // anchor (2 user-turns behind the latest). Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker\n // stamping. Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes\n // to them) auto-cache the prompt prefix upstream regardless, so the\n // visual cue should still appear.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 2) return null;\n return userMsgs[userMsgs.length - 1 - 2]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\" && m.status === \"streaming\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n const msg = state.currentAssistantMessage;\n if (!handle || !msg) return;\n handle.setStatus(status);\n msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n state.sessionId = msg.sessionId;\n if (state.characterId !== msg.characterId) state.characterLedger = [];\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n }\n break;\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.edits);\n break;\n case \"chat_event\":\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_cancelled\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"character_edits_pushed\":\n state.characterLedger = [...msg.entries];\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n break;\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\":\n state.charactersPanel?.onPushed(\n msg.entries,\n msg.workspaceUsedBytes,\n msg.workspaceCapBytes,\n );\n break;\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else if (msg.op === \"phoneline_consent\") {\n const { showPhonelineConsent } = await import(\"./phoneline-consent-modal\");\n result = await showPhonelineConsent(msg.args as Parameters[0]);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"character_squashed\":\n // If we just squashed the focused character, drop the focus override\n // and reload the active character's ledger so the Edits tab is sane.\n if (state.workshopFocusCharacterId === msg.characterId) {\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n }\n if (state.characterId === msg.characterId) {\n state.characterLedger = [];\n state.edits = state.edits.map((e) => ({ ...e, reverted: true }));\n rerenderThread();\n updateSessionBar();\n }\n break;\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n state.characterLedger = [];\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "fcc106e5283fd1aea8d643c3fa2488d939643f79": {"t": 1785771310.361366, "n": 114489, "h": "108028f04d2896f3", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n SessionSummaryWire,\n} from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCharactersPanel, type CharactersPanelHandle } from \"./characters-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n characterLedger: EditLogEntry[];\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n charactersPanel: CharactersPanelHandle | null;\n workshopFocusCharacterId: string | null;\n workshopFocusCharacterName: string | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n characterLedger: [],\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n charactersPanel: null,\n workshopFocusCharacterId: null,\n workshopFocusCharacterName: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, settingsBtn, menuBtn);\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate the greeting messages of this card\",\n send: \"Translate every greeting message of this card to English. That means the canonical first_mes (path `char/first_mes`) AND every alternate greeting (paths `char/alternate_greetings/`). For each one: `read` the path, construct the full English version, then `rewrite({path, new_content})` with the whole English version. Keep all tags, markdown headings, status-panel emoji markers, and named regex capture groups byte-identical — only translate the natural-language prose between them. SKIP any segment that is already in English or already has an English counterpart nearby (a parenthetical English gloss, a bilingual line, a label/value pair where one side is English). Only translate text that has no English equivalent anywhere in the surrounding context. If this card has a LumiRealm payload, mirror the canonical edits into the payload so the change survives translator schema migrations.\",\n },\n {\n label: \"Translate the UI in this card\",\n send: \"Translate every user-visible Korean label in this card to English.\\n\\nRun `survey_cjk({scopes:['regex_scripts','extensions'], top_n:200, min_length:1})` first — its `sample_surfaces` is the truth about where Korean actually lives. Don't list paths from memory; walk what the survey reports. For each surface, the LumiRealm guidance already loaded in your context tells you which path is the authoring layer (and which paths are derived / regenerated / read-only).\\n\\nSkip:\\n- regex `find_regex` patterns (never edit — they match LLM output)\\n- anything already in English or sitting next to an English form\\n- map keys / state names / internal identifiers that aren't user-visible\\n\\nAfter each regex `replace_string` edit, `test_regex` with the original `find_regex` against representative LLM output. Before writing anything, summarise what survey_cjk found and ask which surfaces I want translated.\",\n },\n {\n label: \"Add/update a lorebook entry on this chat's characters\",\n send: \"I want to add or update a lorebook entry on a character that appears in this chat. Before you do anything: ask me WHICH character the entry should cover (look at the pinned chat's recent messages for context if a chat is pinned). Then use `grep` and `list({path: 'wb'})` to check whether an entry already exists for that character. If one exists, briefly summarise its current content and ask whether I want to UPDATE it (extend / refine) or REPLACE it. If not, ask what details I want included — personality, role in the story, relationships, appearance, key facts — before creating it. Only after I confirm the plan do you call `edit` / `rewrite` / `create_world_book_entry`. Do not write the entry's prose in chat without applying it.\",\n },\n {\n label: \"Explain the features of this chat and what it's about\",\n send: \"Read this character's metadata (description, personality, scenario, first_mes, system_prompt, post_history_instructions, alternate_greetings count) and skim the world book entries and regex scripts. If a chat is pinned, look at a few of the most recent messages for tone and current state. Then explain to me in plain English: what this card is ABOUT (setting, premise, key characters), what MECHANICS the card runs (status panels, command syntax, themed regex outputs, time/weather/location systems, mode toggles, dice rolls, anything special), and HOW a user typically interacts with it (what kind of inputs the card responds to, what UI features will appear). Be specific — quote the actual regex panel markers, name the actual status fields. Don't edit anything; this is a read-only audit.\",\n },\n {\n label: \"Change the gender/sex of certain characters\",\n send: \"I want to change the gender or sex of one or more characters in this card. Before any edits, ask me WHICH character(s) I want to change and WHAT the new gender should be for each. The change needs to be COMPLETE — once you have the list:\\n\\n1. Use `grep` to find every reference to each character: their name + all pronouns currently used for them (he/she/they/her/his/them) + any gendered honorifics in the source language (Mr./Ms./onee-chan/onii-san/etc.) + any explicitly gendered nouns (man/woman/boy/girl/lady/sir/etc.).\\n2. Map out an edit plan covering EVERY surface that references them: their lorebook entry, all alternate_greetings, first_mes, scenario, description, personality, system_prompt, post_history_instructions, regex replace_string templates that mention them, and the LumiRealm payload mirror if this is a LumiRealm-imported card.\\n3. Show me the plan as a list of (surface, field, what changes) BEFORE applying.\\n4. After I confirm, apply. apply_glossary is the right tool for the pronoun pass — but be careful with single-character CJK keys (banned by default for substring-collision safety). Pronouns, possessives, honorifics, and gendered nouns all need to flip consistently.\",\n },\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerArea, composerStatus);\n composer.appendChild(composerInner);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n const withConnection = (msg: T): T =>\n state.connectionId ? { ...msg, connectionId: state.connectionId } : msg;\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"(No character)\", sublabel: \"general chat + workspace only\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.name,\n sublabel: `${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n };\n\n const updateSessionBar = () => {\n const source = state.characterLedger.length > 0 ? state.characterLedger : state.edits;\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n if (liveEdits === 0 && source.length === 0) {\n editsBadge.classList.remove(\"has-edits\");\n } else {\n editsBadge.classList.add(\"has-edits\");\n }\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n const updateComposer = () => {\n if (state.isGenerating || state.startingSession) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n textarea.disabled = false;\n composerStatus.textContent = state.startingSession ? \"starting session...\" : \"agent is working...\";\n composerStatus.classList.remove(\"is-error\");\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n if (state.sessionId) {\n composerStatus.textContent = \"\";\n } else {\n composerStatus.textContent = \"Type a message and press Send. A new session will start automatically.\";\n }\n }\n // Empty textarea behaviour:\n // - last message was a user message → button enabled, click resumes that turn\n // - last message was an assistant message (or empty thread) → button greyed\n const hasText = textarea.value.trim().length > 0;\n const last = state.messages[state.messages.length - 1];\n const canContinue = !hasText && !!last && last.role === \"user\";\n const sendDisabled = state.startingSession || (!hasText && !canContinue);\n sendBtn.disabled = sendDisabled;\n updateCompactButton();\n };\n\n // Reverts are permanent: the entry is removed from the ledger and from\n // the session's edit mirror so the UI shouldn't keep it around either.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n state.characterLedger = state.characterLedger.filter((e) => !ids.has(e.id));\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed && state.characterId) {\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId, force: true });\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n const rootEl = state.diffModal as unknown as { __focusTab?: (t: string) => void } | null;\n rootEl?.__focusTab?.(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(state.edits);\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n if (!state.charactersPanel) {\n state.charactersPanel = mountCharactersPanel({\n ctx,\n sendBackend,\n onFocusCharacter: (cid, name) => {\n state.workshopFocusCharacterId = cid;\n state.workshopFocusCharacterName = name;\n sendBackend({ type: \"load_character_workshop\", characterId: cid });\n // Swap the modal to the Edits tab so the user sees the loaded ledger.\n const rootEl = state.diffModal as unknown as { __focusTab?: (t: string) => void } | null;\n rootEl?.__focusTab?.(\"edits\");\n },\n });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => {\n // Focus override: Characters tab can swap the Edits view to another\n // character's ledger. characterLedger is reused as the carrier.\n return state.characterLedger.length > 0 ? state.characterLedger : state.edits;\n },\n onRevert: async (editId) => {\n const cid = state.workshopFocusCharacterId ?? state.characterId;\n if (!cid) return;\n sendBackend({ type: \"revert_edit\", characterId: cid, editId });\n },\n onClose: () => {\n state.diffModal = null;\n // Reset the focus override so reopening goes back to the active\n // session's character.\n if (state.workshopFocusCharacterId) {\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n }\n },\n filesPanel: state.workspacePanel.root,\n charactersPanel: state.charactersPanel.root,\n onCharactersTabActivated: () => state.charactersPanel?.refresh(),\n }, initialEditId !== undefined ? { initialEditId } : {});\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n const source = state.characterLedger.length > 0 ? state.characterLedger : state.edits;\n return source.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n const source = state.characterLedger.length > 0 ? state.characterLedger : state.edits;\n return source.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (!state.characterId || editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revert_edits_bulk\", characterId: state.characterId, editIds: [...editIds], ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n const switchingAway = state.sessionId !== null && id !== state.characterId;\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId, switchingAway });\n if (switchingAway) {\n // The active session belongs to a different character. Drop it from the\n // UI so state.characterId stays the source of truth. The session file is\n // safe on the backend, reloadable via Sessions modal.\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n persistUiPrefs();\n }\n state.characterId = id;\n state.characterLedger = [];\n state.chatsForCharacter = [];\n state.pinnedChatId = null;\n state.autoPinNeeded = !!id;\n setChatPinned(false);\n if (switchingAway) rerenderThread();\n updateComposer();\n updateSessionBar();\n if (id) {\n // Mirror the New chat button: spin up a fresh session for the picked\n // character so the queued autopin from chats_pushed actually lands.\n // Without this the user would see \"no pin\" until they manually click New.\n startNewSession();\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n } else {\n // No-character mode: still spin up a fresh session so the user can talk\n // to the agent. Ledger/chat data stay empty; tool filtering and the\n // one-sentence system-prompt directive handle the rest.\n startNewSession();\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const appendUserMessage = (text: string): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n }));\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n const doSend = (): void => {\n const text = textarea.value.trim();\n if (state.isGenerating || state.startingSession) return;\n\n // Empty send: resume the existing turn if the last message is the user's\n // own (the agent hasn't replied yet). Bail silently otherwise — the\n // button should already be greyed out via updateComposer in that case.\n if (text.length === 0) {\n const last = state.messages[state.messages.length - 1];\n const canContinue = !!last && last.role === \"user\" && !!state.sessionId;\n if (!canContinue) return;\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId!,\n }));\n state.isGenerating = true;\n updateComposer();\n return;\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n if (state.sessionId) {\n const msg = appendUserMessage(text);\n dispatchSendForExisting(state.sessionId, msg.id, text);\n return;\n }\n\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n sendBtn.addEventListener(\"click\", doSend);\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n doSend();\n }\n });\n\n // Returns the id of the user message that's currently the rolling cache\n // anchor (2 user-turns behind the latest). Anthropic-only gate on\n // cacheMode, because that setting only governs cache_control marker\n // stamping. Other providers (OpenAI, DeepSeek, Gemini, OpenRouter routes\n // to them) auto-cache the prompt prefix upstream regardless, so the\n // visual cue should still appear.\n const rollingAnchorId = (): string | null => {\n const conn = state.connections.find((c) => c.id === state.connectionId);\n const isAnthropic = (conn?.provider ?? \"\").toLowerCase().startsWith(\"anthropic\");\n if (isAnthropic && (state.settings?.cacheMode ?? \"full\") !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 2) return null;\n return userMsgs[userMsgs.length - 1 - 2]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\" && m.status === \"streaming\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n const msg = state.currentAssistantMessage;\n if (!handle || !msg) return;\n handle.setStatus(status);\n msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n }\n break;\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.edits);\n break;\n case \"chat_event\":\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_cancelled\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"character_edits_pushed\":\n state.characterLedger = [...msg.entries];\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n break;\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\":\n state.charactersPanel?.onPushed(\n msg.entries,\n msg.workspaceUsedBytes,\n msg.workspaceCapBytes,\n );\n break;\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else if (msg.op === \"phoneline_consent\") {\n const { showPhonelineConsent } = await import(\"./phoneline-consent-modal\");\n result = await showPhonelineConsent(msg.args as Parameters[0]);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"character_squashed\":\n // If we just squashed the focused character, drop the focus override\n // and reload the active character's ledger so the Edits tab is sane.\n if (state.workshopFocusCharacterId === msg.characterId) {\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n }\n if (state.characterId === msg.characterId) {\n state.characterLedger = [];\n state.edits = state.edits.map((e) => ({ ...e, reverted: true }));\n rerenderThread();\n updateSessionBar();\n }\n break;\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n state.characterLedger = [];\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "606b37f29e9602944443a1441b3c336a1f2e3d82": {"t": 1785771312.5184848, "n": 114121, "h": "8e5a647cf54bf327", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n SessionSummaryWire,\n} from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCharactersPanel, type CharactersPanelHandle } from \"./characters-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\n// Combobox sentinel for the \"(No character)\" entry. The dropdown stores it as\n// a string id; everywhere else (state.characterId, wire messages, persisted\n// sessions) carries the null directly.\nconst NO_CHARACTER_SENTINEL = \"__none__\";\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n characterLedger: EditLogEntry[];\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n charactersPanel: CharactersPanelHandle | null;\n workshopFocusCharacterId: string | null;\n workshopFocusCharacterName: string | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n characterLedger: [],\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n charactersPanel: null,\n workshopFocusCharacterId: null,\n workshopFocusCharacterName: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, settingsBtn, menuBtn);\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate the greeting messages of this card\",\n send: \"Translate every greeting message of this card to English. That means the canonical first_mes (path `char/first_mes`) AND every alternate greeting (paths `char/alternate_greetings/`). For each one: `read` the path, construct the full English version, then `rewrite({path, new_content})` with the whole English version. Keep all tags, markdown headings, status-panel emoji markers, and named regex capture groups byte-identical — only translate the natural-language prose between them. SKIP any segment that is already in English or already has an English counterpart nearby (a parenthetical English gloss, a bilingual line, a label/value pair where one side is English). Only translate text that has no English equivalent anywhere in the surrounding context. If this card has a LumiRealm payload, mirror the canonical edits into the payload so the change survives translator schema migrations.\",\n },\n {\n label: \"Translate the UI in this card\",\n send: \"Translate every user-visible Korean label in this card to English.\\n\\nRun `survey_cjk({scopes:['regex_scripts','extensions'], top_n:200, min_length:1})` first — its `sample_surfaces` is the truth about where Korean actually lives. Don't list paths from memory; walk what the survey reports. For each surface, the LumiRealm guidance already loaded in your context tells you which path is the authoring layer (and which paths are derived / regenerated / read-only).\\n\\nSkip:\\n- regex `find_regex` patterns (never edit — they match LLM output)\\n- anything already in English or sitting next to an English form\\n- map keys / state names / internal identifiers that aren't user-visible\\n\\nAfter each regex `replace_string` edit, `test_regex` with the original `find_regex` against representative LLM output. Before writing anything, summarise what survey_cjk found and ask which surfaces I want translated.\",\n },\n {\n label: \"Add/update a lorebook entry on this chat's characters\",\n send: \"I want to add or update a lorebook entry on a character that appears in this chat. Before you do anything: ask me WHICH character the entry should cover (look at the pinned chat's recent messages for context if a chat is pinned). Then use `grep` and `list({path: 'wb'})` to check whether an entry already exists for that character. If one exists, briefly summarise its current content and ask whether I want to UPDATE it (extend / refine) or REPLACE it. If not, ask what details I want included — personality, role in the story, relationships, appearance, key facts — before creating it. Only after I confirm the plan do you call `edit` / `rewrite` / `create_world_book_entry`. Do not write the entry's prose in chat without applying it.\",\n },\n {\n label: \"Explain the features of this chat and what it's about\",\n send: \"Read this character's metadata (description, personality, scenario, first_mes, system_prompt, post_history_instructions, alternate_greetings count) and skim the world book entries and regex scripts. If a chat is pinned, look at a few of the most recent messages for tone and current state. Then explain to me in plain English: what this card is ABOUT (setting, premise, key characters), what MECHANICS the card runs (status panels, command syntax, themed regex outputs, time/weather/location systems, mode toggles, dice rolls, anything special), and HOW a user typically interacts with it (what kind of inputs the card responds to, what UI features will appear). Be specific — quote the actual regex panel markers, name the actual status fields. Don't edit anything; this is a read-only audit.\",\n },\n {\n label: \"Change the gender/sex of certain characters\",\n send: \"I want to change the gender or sex of one or more characters in this card. Before any edits, ask me WHICH character(s) I want to change and WHAT the new gender should be for each. The change needs to be COMPLETE — once you have the list:\\n\\n1. Use `grep` to find every reference to each character: their name + all pronouns currently used for them (he/she/they/her/his/them) + any gendered honorifics in the source language (Mr./Ms./onee-chan/onii-san/etc.) + any explicitly gendered nouns (man/woman/boy/girl/lady/sir/etc.).\\n2. Map out an edit plan covering EVERY surface that references them: their lorebook entry, all alternate_greetings, first_mes, scenario, description, personality, system_prompt, post_history_instructions, regex replace_string templates that mention them, and the LumiRealm payload mirror if this is a LumiRealm-imported card.\\n3. Show me the plan as a list of (surface, field, what changes) BEFORE applying.\\n4. After I confirm, apply. apply_glossary is the right tool for the pronoun pass — but be careful with single-character CJK keys (banned by default for substring-collision safety). Pronouns, possessives, honorifics, and gendered nouns all need to flip consistently.\",\n },\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerArea, composerStatus);\n composer.appendChild(composerInner);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n const withConnection = (msg: T): T =>\n state.connectionId ? { ...msg, connectionId: state.connectionId } : msg;\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n // Top entry: general-purpose chat with no character context. Tools that\n // need a character are filtered out of the schema in this mode.\n const noneItem = { id: NO_CHARACTER_SENTINEL, label: \"(No character)\", sublabel: \"general chat + workspace only\" };\n if (state.characters.length === 0) {\n charCombo.setItems([noneItem]);\n } else {\n charCombo.setItems([\n noneItem,\n ...state.characters.map((c) => ({\n id: c.id,\n label: c.name,\n sublabel: `${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })),\n ]);\n }\n charCombo.setValue(state.characterId ?? NO_CHARACTER_SENTINEL, true);\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n };\n\n const updateSessionBar = () => {\n const source = state.characterLedger.length > 0 ? state.characterLedger : state.edits;\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n if (liveEdits === 0 && source.length === 0) {\n editsBadge.classList.remove(\"has-edits\");\n } else {\n editsBadge.classList.add(\"has-edits\");\n }\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n const updateComposer = () => {\n if (state.isGenerating || state.startingSession) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n textarea.disabled = false;\n composerStatus.textContent = state.startingSession ? \"starting session...\" : \"agent is working...\";\n composerStatus.classList.remove(\"is-error\");\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n if (state.sessionId) {\n composerStatus.textContent = \"\";\n } else {\n composerStatus.textContent = \"Type a message and press Send. A new session will start automatically.\";\n }\n }\n // Empty textarea behaviour:\n // - last message was a user message → button enabled, click resumes that turn\n // - last message was an assistant message (or empty thread) → button greyed\n const hasText = textarea.value.trim().length > 0;\n const last = state.messages[state.messages.length - 1];\n const canContinue = !hasText && !!last && last.role === \"user\";\n const sendDisabled = state.startingSession || (!hasText && !canContinue);\n sendBtn.disabled = sendDisabled;\n updateCompactButton();\n };\n\n // Reverts are permanent: the entry is removed from the ledger and from\n // the session's edit mirror so the UI shouldn't keep it around either.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n state.characterLedger = state.characterLedger.filter((e) => !ids.has(e.id));\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed && state.characterId) {\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId, force: true });\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n const rootEl = state.diffModal as unknown as { __focusTab?: (t: string) => void } | null;\n rootEl?.__focusTab?.(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(state.edits);\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n if (!state.charactersPanel) {\n state.charactersPanel = mountCharactersPanel({\n ctx,\n sendBackend,\n onFocusCharacter: (cid, name) => {\n state.workshopFocusCharacterId = cid;\n state.workshopFocusCharacterName = name;\n sendBackend({ type: \"load_character_workshop\", characterId: cid });\n // Swap the modal to the Edits tab so the user sees the loaded ledger.\n const rootEl = state.diffModal as unknown as { __focusTab?: (t: string) => void } | null;\n rootEl?.__focusTab?.(\"edits\");\n },\n });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => {\n // Focus override: Characters tab can swap the Edits view to another\n // character's ledger. characterLedger is reused as the carrier.\n return state.characterLedger.length > 0 ? state.characterLedger : state.edits;\n },\n onRevert: async (editId) => {\n const cid = state.workshopFocusCharacterId ?? state.characterId;\n if (!cid) return;\n sendBackend({ type: \"revert_edit\", characterId: cid, editId });\n },\n onClose: () => {\n state.diffModal = null;\n // Reset the focus override so reopening goes back to the active\n // session's character.\n if (state.workshopFocusCharacterId) {\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n }\n },\n filesPanel: state.workspacePanel.root,\n charactersPanel: state.charactersPanel.root,\n onCharactersTabActivated: () => state.charactersPanel?.refresh(),\n }, initialEditId !== undefined ? { initialEditId } : {});\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n const source = state.characterLedger.length > 0 ? state.characterLedger : state.edits;\n return source.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n const source = state.characterLedger.length > 0 ? state.characterLedger : state.edits;\n return source.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (!state.characterId || editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revert_edits_bulk\", characterId: state.characterId, editIds: [...editIds], ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n };\n }\n\n charCombo.onChange((rawId) => {\n // The \"(No character)\" entry in the dropdown carries a sentinel id; the\n // rest of the UI/backend models the no-character state as null.\n const id = rawId === NO_CHARACTER_SENTINEL ? null : rawId;\n const switchingAway = state.sessionId !== null && id !== state.characterId;\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId, switchingAway });\n if (switchingAway) {\n // The active session belongs to a different character. Drop it from the\n // UI so state.characterId stays the source of truth. The session file is\n // safe on the backend, reloadable via Sessions modal.\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n persistUiPrefs();\n }\n state.characterId = id;\n state.characterLedger = [];\n state.chatsForCharacter = [];\n state.pinnedChatId = null;\n state.autoPinNeeded = !!id;\n setChatPinned(false);\n if (switchingAway) rerenderThread();\n updateComposer();\n updateSessionBar();\n if (id) {\n // Mirror the New chat button: spin up a fresh session for the picked\n // character so the queued autopin from chats_pushed actually lands.\n // Without this the user would see \"no pin\" until they manually click New.\n startNewSession();\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n } else {\n // No-character mode: still spin up a fresh session so the user can talk\n // to the agent. Ledger/chat data stay empty; tool filtering and the\n // one-sentence system-prompt directive handle the rest.\n startNewSession();\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, s.characterId === null ? \"(No character)\" : s.characterName));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Changes save automatically on blur and on close, and apply to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Anthropic-only. OpenAI, Gemini, DeepSeek, and other providers cache the prompt prefix automatically upstream regardless of this setting.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission.\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n // Forward-declared so the sampler slider closures can call it. Real impl\n // installs below once all inputs are in scope.\n let commit: () => void = () => {};\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n // Mirror of state/samplers.ts SAMPLER_DEFS. Keep in sync.\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 65536 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 400000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); commit(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); commit(); });\n const commitFromInput = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); commit(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); commit(); }\n };\n numInput.addEventListener(\"change\", () => commitFromInput(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n\n let lastCommitted = \"\";\n commit = (): void => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // Body matching default verbatim becomes \"no override\" so future tweaks\n // to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n const payload = {\n type: \"update_settings\" as const,\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n };\n const key = JSON.stringify(payload);\n if (key === lastCommitted) return;\n lastCommitted = key;\n const before = state.settings;\n const promptCacheBroken = !!before && (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n sendBackend(payload);\n status.textContent = promptCacheBroken ? \"Saved. Prompt cache invalidates on next message.\" : \"Saved.\";\n status.classList.remove(\"is-error\");\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => { commit(); detach(); });\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) { personaArea.value = state.settings.defaultPersona; commit(); } });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) { promptArea.value = state.settings.defaultSystemPromptBody; commit(); } });\n samplersResetBtn.addEventListener(\"click\", () => { resetAllSamplers(); commit(); });\n\n for (const inp of [personaArea, promptArea, jbArea, wsCapInput, toolCapInput]) {\n inp.addEventListener(\"blur\", () => commit());\n }\n for (const inp of [jbPlacement, cacheModeSelect, parallelToolsInput]) {\n inp.addEventListener(\"change\", () => commit());\n }\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const appendUserMessage = (text: string): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n }));\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n const doSend = (): void => {\n const text = textarea.value.trim();\n if (state.isGenerating || state.startingSession) return;\n\n // Empty send: resume the existing turn if the last message is the user's\n // own (the agent hasn't replied yet). Bail silently otherwise — the\n // button should already be greyed out via updateComposer in that case.\n if (text.length === 0) {\n const last = state.messages[state.messages.length - 1];\n const canContinue = !!last && last.role === \"user\" && !!state.sessionId;\n if (!canContinue) return;\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId!,\n }));\n state.isGenerating = true;\n updateComposer();\n return;\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n if (state.sessionId) {\n const msg = appendUserMessage(text);\n dispatchSendForExisting(state.sessionId, msg.id, text);\n return;\n }\n\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n sendBtn.addEventListener(\"click\", doSend);\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n doSend();\n }\n });\n\n // Returns the id of the user message that's currently the rolling cache\n // anchor (2 user-turns behind the latest). Null when conversation is too\n // short to lag or caching is off.\n const rollingAnchorId = (): string | null => {\n const mode = state.settings?.cacheMode ?? \"full\";\n if (mode !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 2) return null;\n return userMsgs[userMsgs.length - 1 - 2]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // Re-attach currentAssistantMessage when the streaming handle is still live\n // but the message pointer was cleared, so block-mutation events don't\n // silently no-op into a missing reference and desync from the live bubble.\n const rebindCurrentAssistantMessage = (): void => {\n if (state.currentAssistantMessage || !state.streamingAssistant) return;\n for (let i = state.messages.length - 1; i >= 0; i--) {\n const m = state.messages[i];\n if (m && m.role === \"assistant\" && m.status === \"streaming\") {\n state.currentAssistantMessage = m;\n return;\n }\n }\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(assistantMessageId);\n };\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle => {\n if (state.streamingAssistant) { rebindCurrentAssistantMessage(); return state.streamingAssistant; }\n return createStreamingTurn(makeId(\"msg\"));\n };\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n const msg = state.currentAssistantMessage;\n if (!handle || !msg) return;\n handle.setStatus(status);\n msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n rebindCurrentAssistantMessage,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n persistUiPrefs();\n if (msg.characterId !== null) sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n if (msg.characterId === null) charCombo.setValue(NO_CHARACTER_SENTINEL, true);\n else if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n if (msg.characterId !== null) {\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n }\n break;\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.edits);\n break;\n case \"chat_event\":\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"generation_done\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_cancelled\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"character_edits_pushed\":\n state.characterLedger = [...msg.entries];\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n break;\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\":\n state.charactersPanel?.onPushed(\n msg.entries,\n msg.workspaceUsedBytes,\n msg.workspaceCapBytes,\n );\n break;\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else if (msg.op === \"phoneline_consent\") {\n const { showPhonelineConsent } = await import(\"./phoneline-consent-modal\");\n result = await showPhonelineConsent(msg.args as Parameters[0]);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"character_squashed\":\n // If we just squashed the focused character, drop the focus override\n // and reload the active character's ledger so the Edits tab is sane.\n if (state.workshopFocusCharacterId === msg.characterId) {\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n }\n if (state.characterId === msg.characterId) {\n state.characterLedger = [];\n state.edits = state.edits.map((e) => ({ ...e, reverted: true }));\n rerenderThread();\n updateSessionBar();\n }\n break;\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n state.characterLedger = [];\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "05a2f20aae94313f03334d1d3b2726f2d49d7d08": {"t": 1785771314.3284829, "n": 115275, "h": "e1dc822398c6240d", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n SessionSummaryWire,\n} from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCharactersPanel, type CharactersPanelHandle } from \"./characters-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_TICK, ICON_WORKSHOP } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n characterLedger: EditLogEntry[];\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; connectionSupportsPromptCaching?: boolean; autoFreeOldToolResults?: boolean; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n charactersPanel: CharactersPanelHandle | null;\n workshopFocusCharacterId: string | null;\n workshopFocusCharacterName: string | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n characterLedger: [],\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n charactersPanel: null,\n workshopFocusCharacterId: null,\n workshopFocusCharacterName: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, settingsBtn, menuBtn);\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate the greeting messages of this card\",\n send: \"Translate every greeting message of this card to English. That means the canonical first_mes (path `char/first_mes`) AND every alternate greeting (paths `char/alternate_greetings/`). For each one: `read` the path, construct the full English version, then `rewrite({path, new_content})` with the whole English version. Keep all tags, markdown headings, status-panel emoji markers, and named regex capture groups byte-identical — only translate the natural-language prose between them. SKIP any segment that is already in English or already has an English counterpart nearby (a parenthetical English gloss, a bilingual line, a label/value pair where one side is English). Only translate text that has no English equivalent anywhere in the surrounding context. If this card has a LumiRealm payload, mirror the canonical edits into the payload so the change survives translator schema migrations.\",\n },\n {\n label: \"Translate the UI in this card\",\n send: \"Translate every user-visible Korean label in this card to English.\\n\\nRun `survey_cjk({scopes:['regex_scripts','extensions'], top_n:200, min_length:1})` first — its `sample_surfaces` is the truth about where Korean actually lives. Don't list paths from memory; walk what the survey reports. For each surface, the LumiRealm guidance already loaded in your context tells you which path is the authoring layer (and which paths are derived / regenerated / read-only).\\n\\nSkip:\\n- regex `find_regex` patterns (never edit — they match LLM output)\\n- anything already in English or sitting next to an English form\\n- map keys / state names / internal identifiers that aren't user-visible\\n\\nAfter each regex `replace_string` edit, `test_regex` with the original `find_regex` against representative LLM output. Before writing anything, summarise what survey_cjk found and ask which surfaces I want translated.\",\n },\n {\n label: \"Add/update a lorebook entry on this chat's characters\",\n send: \"I want to add or update a lorebook entry on a character that appears in this chat. Before you do anything: ask me WHICH character the entry should cover (look at the pinned chat's recent messages for context if a chat is pinned). Then use `grep` and `list({path: 'wb'})` to check whether an entry already exists for that character. If one exists, briefly summarise its current content and ask whether I want to UPDATE it (extend / refine) or REPLACE it. If not, ask what details I want included — personality, role in the story, relationships, appearance, key facts — before creating it. Only after I confirm the plan do you call `edit` / `rewrite` / `create_world_book_entry`. Do not write the entry's prose in chat without applying it.\",\n },\n {\n label: \"Explain the features of this chat and what it's about\",\n send: \"Read this character's metadata (description, personality, scenario, first_mes, system_prompt, post_history_instructions, alternate_greetings count) and skim the world book entries and regex scripts. If a chat is pinned, look at a few of the most recent messages for tone and current state. Then explain to me in plain English: what this card is ABOUT (setting, premise, key characters), what MECHANICS the card runs (status panels, command syntax, themed regex outputs, time/weather/location systems, mode toggles, dice rolls, anything special), and HOW a user typically interacts with it (what kind of inputs the card responds to, what UI features will appear). Be specific — quote the actual regex panel markers, name the actual status fields. Don't edit anything; this is a read-only audit.\",\n },\n {\n label: \"Change the gender/sex of certain characters\",\n send: \"I want to change the gender or sex of one or more characters in this card. Before any edits, ask me WHICH character(s) I want to change and WHAT the new gender should be for each. The change needs to be COMPLETE — once you have the list:\\n\\n1. Use `grep` to find every reference to each character: their name + all pronouns currently used for them (he/she/they/her/his/them) + any gendered honorifics in the source language (Mr./Ms./onee-chan/onii-san/etc.) + any explicitly gendered nouns (man/woman/boy/girl/lady/sir/etc.).\\n2. Map out an edit plan covering EVERY surface that references them: their lorebook entry, all alternate_greetings, first_mes, scenario, description, personality, system_prompt, post_history_instructions, regex replace_string templates that mention them, and the LumiRealm payload mirror if this is a LumiRealm-imported card.\\n3. Show me the plan as a list of (surface, field, what changes) BEFORE applying.\\n4. After I confirm, apply. apply_glossary is the right tool for the pronoun pass — but be careful with single-character CJK keys (banned by default for substring-collision safety). Pronouns, possessives, honorifics, and gendered nouns all need to flip consistently.\",\n },\n ];\n const suggestions = el(\"div\", \"la-empty-suggestions\");\n for (const item of SUGGESTIONS) {\n const s = el(\"button\", \"la-empty-suggestion\", item.label);\n s.title = item.send;\n s.addEventListener(\"click\", () => {\n textarea.value = item.send;\n autosizeTextarea();\n doSend();\n });\n suggestions.appendChild(s);\n }\n emptyState.appendChild(suggestions);\n\n // Composer\n const composer = el(\"div\", \"la-composer\");\n const mouseyImg = document.createElement(\"img\");\n mouseyImg.className = \"la-mousey\";\n mouseyImg.src = resolveMouseyImageUrl();\n mouseyImg.alt = \"\";\n mouseyImg.setAttribute(\"aria-hidden\", \"true\");\n composer.appendChild(mouseyImg);\n const composerInner = el(\"div\", \"la-composer-inner\");\n const composerArea = el(\"div\", \"la-composer-area\");\n const textarea = document.createElement(\"textarea\");\n textarea.className = \"la-textarea\";\n textarea.rows = 1;\n textarea.placeholder = \"Ask anything\";\n const composerActions = el(\"div\", \"la-composer-actions\");\n const sendBtn = el(\"button\", \"la-send-btn\") as HTMLButtonElement;\n sendBtn.setAttribute(\"aria-label\", \"Send\");\n sendBtn.innerHTML = '';\n const cancelBtn = el(\"button\", \"la-cancel-btn\") as HTMLButtonElement;\n cancelBtn.setAttribute(\"aria-label\", \"Stop\");\n cancelBtn.innerHTML = '';\n cancelBtn.style.display = \"none\";\n\n // Compaction indicator: a small circular ring whose fill grows with prompt-\n // token usage. Click (when idle) to compact early; the runtime auto-compacts\n // when usage crosses ~84% of the configured context size.\n const compactBtn = el(\"button\", \"la-compact-btn\") as HTMLButtonElement;\n compactBtn.type = \"button\";\n compactBtn.setAttribute(\"aria-label\", \"Compact context\");\n const compactRing = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n compactRing.setAttribute(\"viewBox\", \"0 0 36 36\");\n compactRing.classList.add(\"la-compact-ring\");\n compactRing.innerHTML = `\n \n \n `;\n compactBtn.appendChild(compactRing);\n const compactTip = el(\"div\", \"la-compact-tooltip\");\n const compactTipMain = el(\"div\", \"la-compact-tooltip-main\", \"Context fully available.\");\n const compactTipSub = el(\"div\", \"la-compact-tooltip-sub\", \"Click to compact now.\");\n compactTip.append(compactTipMain, compactTipSub);\n compactBtn.appendChild(compactTip);\n\n composerActions.append(compactBtn, sendBtn, cancelBtn);\n composerArea.append(textarea, composerActions);\n const composerStatus = el(\"div\", \"la-composer-status\");\n composerInner.append(composerArea, composerStatus);\n composer.appendChild(composerInner);\n\n // ───── debug snapshot ─────\n // Call __laGeom() from devtools to dump every layout number relevant to\n // the mousey / message / composer overlap. Paste the output back here.\n // Cheap, attached once, never wired into hot paths.\n const dumpGeometry = (): Record => {\n const threadRect = thread.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const mouseyRect = mouseyImg.getBoundingClientRect();\n const threadCS = getComputedStyle(thread);\n const composerCS = getComputedStyle(composer);\n const mouseyCS = getComputedStyle(mouseyImg);\n const drawerCS = thread.parentElement ? getComputedStyle(thread.parentElement) : null;\n // Last rendered message in the virtualizer's inner block.\n const innerEl = thread.querySelector(\".la-virt-inner\") as HTMLElement | null;\n const messages = innerEl ? Array.from(innerEl.children) as HTMLElement[] : [];\n const lastMsg = messages[messages.length - 1] ?? null;\n const lastMsgRect = lastMsg?.getBoundingClientRect() ?? null;\n const lastMsgActions = lastMsg?.querySelector(\".la-msg-actions\") as HTMLElement | null;\n const lastActionsRect = lastMsgActions?.getBoundingClientRect() ?? null;\n const spacerEl = thread.querySelector(\".la-virt-spacer\") as HTMLElement | null;\n const spacerRect = spacerEl?.getBoundingClientRect() ?? null;\n\n return {\n viewport: { innerWidth: window.innerWidth, innerHeight: window.innerHeight },\n drawer: {\n containerType: drawerCS?.containerType,\n containerName: drawerCS?.containerName,\n width: thread.parentElement?.getBoundingClientRect().width ?? null,\n },\n thread: {\n rect: { top: threadRect.top, bottom: threadRect.bottom, height: threadRect.height, width: threadRect.width },\n paddingTop: threadCS.paddingTop,\n paddingBottom: threadCS.paddingBottom,\n scrollTop: thread.scrollTop,\n scrollHeight: thread.scrollHeight,\n clientHeight: thread.clientHeight,\n overflowAnchor: threadCS.overflowAnchor,\n contain: threadCS.contain,\n },\n composer: {\n rect: { top: composerRect.top, bottom: composerRect.bottom, height: composerRect.height },\n paddingTop: composerCS.paddingTop,\n },\n mousey: {\n rect: { top: mouseyRect.top, bottom: mouseyRect.bottom, height: mouseyRect.height, width: mouseyRect.width },\n cssHeight: mouseyCS.height,\n transform: mouseyCS.transform,\n bottom: mouseyCS.bottom,\n position: mouseyCS.position,\n // How far does mousey extend ABOVE composer top?\n extentAboveComposer: composerRect.top - mouseyRect.top,\n // Where does mousey's TOP sit relative to thread bottom?\n topRelativeToThreadBottom: mouseyRect.top - threadRect.bottom,\n },\n spacer: spacerRect ? {\n rect: { top: spacerRect.top, bottom: spacerRect.bottom, height: spacerRect.height },\n cssHeight: spacerEl ? getComputedStyle(spacerEl).height : null,\n } : null,\n lastMessage: lastMsgRect ? {\n rect: { top: lastMsgRect.top, bottom: lastMsgRect.bottom, height: lastMsgRect.height },\n // The number that matters: distance between message bottom and composer top.\n clearanceToComposerTop: composerRect.top - lastMsgRect.bottom,\n // And from the visible mousey top (the actual overlap hazard).\n clearanceToMouseyTop: mouseyRect.top - lastMsgRect.bottom,\n } : null,\n lastActions: lastActionsRect ? {\n rect: { top: lastActionsRect.top, bottom: lastActionsRect.bottom, height: lastActionsRect.height },\n clearanceToComposerTop: composerRect.top - lastActionsRect.bottom,\n clearanceToMouseyTop: mouseyRect.top - lastActionsRect.bottom,\n } : null,\n messageCount: messages.length,\n };\n };\n type DebugGlobal = { __laGeom?: () => Record };\n (globalThis as DebugGlobal).__laGeom = dumpGeometry;\n // Log once after first paint so the user has a baseline without typing in\n // devtools. Run again with __laGeom() any time the layout looks wrong.\n queueMicrotask(() => {\n // eslint-disable-next-line no-console\n console.log(\"[la-geom] initial layout snapshot\", dumpGeometry());\n });\n\n // Auto-grow textarea\n const TEXTAREA_MAX_PX = 84;\n const autosizeTextarea = (): void => {\n textarea.style.height = \"auto\";\n textarea.style.height = Math.min(textarea.scrollHeight, TEXTAREA_MAX_PX) + \"px\";\n };\n textarea.addEventListener(\"input\", autosizeTextarea);\n // Refresh send-button enabled state as the user types (text vs empty\n // changes whether send is allowed depending on last-message role).\n textarea.addEventListener(\"input\", () => updateComposer());\n\n // Toggle the mousey image's bottom-edge alpha falloff ONLY when there is text\n // that would otherwise hard-edge through the figure. Idle drawer (no typed\n // input, empty / scrolled-away thread) keeps the figure fully opaque.\n let mouseyOverlapRaf: number | null = null;\n const detectMouseyOverlap = (): void => {\n if (mouseyOverlapRaf !== null) return;\n mouseyOverlapRaf = requestAnimationFrame(() => {\n mouseyOverlapRaf = null;\n const m = mouseyImg.getBoundingClientRect();\n if (m.width === 0 || m.height === 0) {\n mouseyImg.classList.remove(\"la-mousey-overlap\");\n return;\n }\n // Only fade against the chat-bar textarea. Thread messages were also\n // being checked, which fired the fade whenever the user scrolled up\n // far enough for a message to pass under the figure.\n const t = textarea.getBoundingClientRect();\n const overlap =\n textarea.value.length > 0 &&\n t.right > m.left && t.left < m.right &&\n t.bottom > m.top && t.top < m.bottom;\n mouseyImg.classList.toggle(\"la-mousey-overlap\", overlap);\n });\n };\n textarea.addEventListener(\"input\", detectMouseyOverlap);\n window.addEventListener(\"resize\", detectMouseyOverlap);\n\n root.append(header, thread, composer);\n\n const sendBackend = (msg: FrontendToBackend) => ctx.sendToBackend(msg);\n\n // Splice the currently-selected connection id into a wire message when set.\n // Backend heals s.connectionId from this on every entry path; sites that\n // skip the spread would silently use whatever was last persisted.\n const withConnection = (msg: T): T =>\n state.connectionId ? { ...msg, connectionId: state.connectionId } : msg;\n\n const refreshLists = () => {\n sendBackend({ type: \"list_characters\" });\n sendBackend({ type: \"list_connections\" });\n sendBackend({ type: \"list_sessions\" });\n sendBackend({ type: \"get_ui_prefs\" });\n sendBackend({ type: \"get_phoneline_pairings\" });\n };\n\n const renderCharOptions = () => {\n if (state.characters.length === 0) {\n charCombo.setItems([]);\n charCombo.setPlaceholder(\"No characters\");\n charCombo.setDisabled(true);\n return;\n }\n charCombo.setDisabled(false);\n charCombo.setPlaceholder(\"Pick character\");\n charCombo.setItems(state.characters.map((c) => ({\n id: c.id,\n label: c.name,\n sublabel: `${c.world_book_ids.length} WB · ${c.regex_script_count} regex`,\n })));\n if (state.characterId) charCombo.setValue(state.characterId, true);\n };\n\n const renderConnOptions = () => {\n const cur = connCombo.getValue();\n if (state.connections.length === 0) {\n connCombo.setItems([]);\n connCombo.setPlaceholder(\"No connections\");\n connCombo.setDisabled(true);\n return;\n }\n connCombo.setDisabled(false);\n connCombo.setPlaceholder(\"Default connection\");\n connCombo.setItems(state.connections.map((c) => ({\n id: c.id,\n label: `${c.name}${c.is_default ? \" *\" : \"\"}`,\n sublabel: `${c.provider}${c.model ? ` · ${c.model}` : \"\"}`,\n })));\n // Resolution: persisted -> current value -> default. connections_pushed\n // often arrives BEFORE ui_prefs_pushed; without this ordering the initial\n // render picks the default and pins it as the \"current\" value, then the\n // persisted choice that arrives moments later would lose the tiebreak.\n if (state.connectionId && state.connections.some((c) => c.id === state.connectionId)) {\n connCombo.setValue(state.connectionId, true);\n } else if (cur && state.connections.some((c) => c.id === cur)) {\n connCombo.setValue(cur, true);\n } else {\n const def = state.connections.find((c) => c.is_default);\n if (def) connCombo.setValue(def.id, true);\n }\n };\n\n const updateSessionBar = () => {\n const source = state.characterLedger.length > 0 ? state.characterLedger : state.edits;\n const liveEdits = source.filter((e) => !e.reverted).length;\n editsCount.textContent = String(liveEdits);\n if (liveEdits === 0 && source.length === 0) {\n editsBadge.classList.remove(\"has-edits\");\n } else {\n editsBadge.classList.add(\"has-edits\");\n }\n };\n\n const COMPACT_RING_CIRC = 94.2;\n const COMPACT_AUTO_THRESHOLD = 0.84;\n const updateCompactButton = (): void => {\n const ctxTokens = Math.max(1, state.contextTokens);\n const used = state.contextPromptTokens / ctxTokens;\n const clamped = Math.max(0, Math.min(1, used));\n const fill = compactRing.querySelector(\".la-compact-fill\") as SVGCircleElement | null;\n if (fill) {\n fill.style.strokeDashoffset = String(COMPACT_RING_CIRC * (1 - clamped));\n }\n compactBtn.classList.toggle(\"is-near-limit\", clamped >= 0.6 && clamped < COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-at-limit\", clamped >= COMPACT_AUTO_THRESHOLD);\n compactBtn.classList.toggle(\"is-busy\", state.compacting);\n const remaining = Math.max(0, 1 - clamped);\n const remainingPct = Math.round(remaining * 100);\n if (state.compacting) {\n compactTipMain.textContent = \"Compacting...\";\n compactTipSub.textContent = \"Replacing history with a handoff primer.\";\n } else if (state.contextPromptTokens === 0) {\n compactTipMain.textContent = \"Context fully available.\";\n compactTipSub.textContent = \"Click to compact now.\";\n } else {\n compactTipMain.textContent = `${remainingPct}% context remaining until auto-compact.`;\n compactTipSub.textContent = \"Click to compact now.\";\n }\n const canClick = !!state.sessionId && !state.isGenerating && !state.startingSession && !state.compacting;\n compactBtn.disabled = !canClick;\n };\n\n compactBtn.addEventListener(\"click\", () => {\n if (!state.sessionId || state.isGenerating || state.startingSession || state.compacting) return;\n state.compacting = true;\n updateCompactButton();\n sendBackend({ type: \"compact_session\", sessionId: state.sessionId });\n });\n\n const updateComposer = () => {\n if (state.isGenerating || state.startingSession) {\n sendBtn.style.display = \"none\";\n cancelBtn.style.display = \"\";\n textarea.disabled = false;\n composerStatus.textContent = state.startingSession ? \"starting session...\" : \"agent is working...\";\n composerStatus.classList.remove(\"is-error\");\n } else {\n sendBtn.style.display = \"\";\n cancelBtn.style.display = \"none\";\n textarea.disabled = false;\n if (state.sessionId) {\n composerStatus.textContent = \"\";\n } else if (state.characterId) {\n composerStatus.textContent = \"Type a message and press Send. A new session will start automatically.\";\n } else {\n composerStatus.textContent = \"Pick a character first.\";\n }\n }\n // Empty textarea behaviour:\n // - last message was a user message → button enabled, click resumes that turn\n // - last message was an assistant message (or empty thread) → button greyed\n const hasText = textarea.value.trim().length > 0;\n const last = state.messages[state.messages.length - 1];\n const canContinue = !hasText && !!last && last.role === \"user\";\n const sendDisabled = !state.characterId || state.startingSession || (!hasText && !canContinue);\n sendBtn.disabled = sendDisabled;\n updateCompactButton();\n };\n\n // Reverts are permanent: the entry is removed from the ledger and from\n // the session's edit mirror so the UI shouldn't keep it around either.\n const spliceEntries = (ids: ReadonlySet): void => {\n if (ids.size === 0) return;\n state.edits = state.edits.filter((e) => !ids.has(e.id));\n state.characterLedger = state.characterLedger.filter((e) => !ids.has(e.id));\n };\n\n const handleRevertOutcome = async (editId: string, outcome: RevertOutcomeWire): Promise => {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n const removed = new Set([editId]);\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds && outcome.cascadedEditIds.length > 0) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n const n = outcome.cascadedEditIds.length;\n composerStatus.textContent = `Reverted, along with ${n} dependent edit${n === 1 ? \"\" : \"s\"} that built on it.`;\n composerStatus.classList.remove(\"is-error\");\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n return;\n }\n if (outcome.kind === \"failed\") {\n composerStatus.textContent = `Revert failed: ${outcome.error}`;\n composerStatus.classList.add(\"is-error\");\n return;\n }\n // External-divergence path: spindle value drifted outside the agent.\n // (superseded is now a defensive case; cascade-by-default in the backend\n // means we shouldn't normally land here for chained edits.)\n const message = outcome.kind === \"superseded\"\n ? `${outcome.laterEditIds.length} later edit(s) couldn't be re-applied without this one. Force-revert anyway? Affected later edits will also be marked reverted.`\n : `The field has been changed outside the agent since this edit.\\n\\nCurrent value starts with:\\n${outcome.currentSample.slice(0, 200)}\\n\\nForce-revert anyway (this overwrites the external change)?`;\n const c = await ctx.ui.showConfirm({\n title: outcome.kind === \"superseded\" ? \"Dependent edits exist\" : \"External change detected\",\n message,\n variant: \"warning\",\n confirmLabel: \"Force revert\",\n });\n if (c.confirmed && state.characterId) {\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId, force: true });\n }\n };\n\n // Open the workshop on the Files tab and focus a specific file. Used by\n // the Settings → Open agent notes shortcut. Mounts the panels lazily if\n // the modal has never been opened.\n const openWorkshopOnFile = (path: string): void => {\n openDiffs();\n const rootEl = state.diffModal as unknown as { __focusTab?: (t: string) => void } | null;\n rootEl?.__focusTab?.(\"files\");\n state.workspacePanel?.focusFile(path);\n };\n\n const openDiffs = (initialEditId?: string) => {\n if (state.diffModal && state.diffModal.isOpen()) {\n state.diffModal.setEdits(state.edits);\n if (initialEditId) state.diffModal.focusEdit(initialEditId);\n return;\n }\n state.diffModal = null;\n // Build (or reuse) the workspace panel so its tree survives modal reopens\n // and its in-flight requests don't get re-fired every time.\n if (!state.workspacePanel) {\n state.workspacePanel = mountWorkspacePanel({ ctx, sendBackend });\n }\n if (!state.charactersPanel) {\n state.charactersPanel = mountCharactersPanel({\n ctx,\n sendBackend,\n onFocusCharacter: (cid, name) => {\n state.workshopFocusCharacterId = cid;\n state.workshopFocusCharacterName = name;\n sendBackend({ type: \"load_character_workshop\", characterId: cid });\n // Swap the modal to the Edits tab so the user sees the loaded ledger.\n const rootEl = state.diffModal as unknown as { __focusTab?: (t: string) => void } | null;\n rootEl?.__focusTab?.(\"edits\");\n },\n });\n }\n state.diffModal = openDiffModal(ctx, {\n getEdits: () => {\n // Focus override: Characters tab can swap the Edits view to another\n // character's ledger. characterLedger is reused as the carrier.\n return state.characterLedger.length > 0 ? state.characterLedger : state.edits;\n },\n onRevert: async (editId) => {\n const cid = state.workshopFocusCharacterId ?? state.characterId;\n if (!cid) return;\n sendBackend({ type: \"revert_edit\", characterId: cid, editId });\n },\n onClose: () => {\n state.diffModal = null;\n // Reset the focus override so reopening goes back to the active\n // session's character.\n if (state.workshopFocusCharacterId) {\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n }\n },\n filesPanel: state.workspacePanel.root,\n charactersPanel: state.charactersPanel.root,\n onCharactersTabActivated: () => state.charactersPanel?.refresh(),\n }, initialEditId !== undefined ? { initialEditId } : {});\n };\n\n // ChatVirtualizer renders only the messages currently in the viewport\n // (plus an overscan window). For long sessions this drops per-rerender\n // cost from O(messages) to O(visible), and the per-message edit-id\n // lookup becomes O(1) via the indexed map.\n let renderEditIndex = buildEditIndex(state.edits);\n const virtualizer = new ChatVirtualizer({\n scrollContainer: thread,\n getMessages: () => state.messages,\n renderMessage: (msg) => {\n // Streaming message → hand back the live AssistantHandle's root so\n // tokens / tool events continue to mutate the same DOM node.\n if (state.streamingAssistant && msg.id === state.currentAssistantMessage?.id) {\n return state.streamingAssistant.root;\n }\n const deps = makeThreadDeps();\n const anchorId = rollingAnchorId();\n const node = renderMessage(msg, deps, renderEditIndex);\n if (anchorId !== null && msg.id === anchorId) {\n const wrap = document.createElement(\"div\");\n wrap.appendChild(node);\n const divider = el(\"div\", \"la-cache-divider\");\n divider.appendChild(el(\"span\", \"la-cache-divider-label\", \"messages above this line are cached\"));\n wrap.appendChild(divider);\n return wrap;\n }\n return node;\n },\n // Rough first-paint estimates by role; ResizeObserver corrects after\n // mount. User bubbles are short; assistant turns with tool cards are\n // taller.\n estimateSize: (msg) => msg.role === \"user\" ? 80 : 280,\n });\n\n const rerenderThread = () => {\n // Loading lives inside the streaming bubble now; flipping the flag is\n // sufficient. The bubble's own setLoading handler clears the DOM node.\n state.loading = false;\n if (!state.sessionId || state.messages.length === 0) {\n virtualizer.clear();\n if (!thread.contains(emptyState)) thread.appendChild(emptyState);\n virtualizer.setCount();\n return;\n }\n if (thread.contains(emptyState)) thread.removeChild(emptyState);\n renderEditIndex = buildEditIndex(state.edits);\n // Evict every cached message so revert / edit-id splices propagate to\n // the per-message \"Edits\" cards. Preserve a bubble in inline-edit mode\n // so its live textarea's focus and cursor survive the re-sync.\n if (state.editingMessageId) virtualizer.clearExcept(state.editingMessageId);\n else virtualizer.clear();\n virtualizer.setCount();\n };\n\n // Error banners are appended directly to the scroll container (outside the\n // virtualizer), so they survive rerenderThread by design. Call this on\n // session change and on the next generation start so they don't accumulate.\n const clearErrorBanners = (): void => {\n for (const node of Array.from(thread.querySelectorAll(\".la-error-banner\"))) {\n node.remove();\n }\n };\n\n // Single render pass: thread + session bar + composer in one call. Handlers\n // that change \"shape of conversation\" state (session switch, message\n // add/edit/delete, edit revert, generation lifecycle) call this so no one\n // forgets one of the three. Hot-path streaming events still update parts\n // surgically; this is for state transitions, not per-token churn.\n const render = (): void => {\n rerenderThread();\n updateSessionBar();\n updateComposer();\n };\n\n const liveEditsForAssistantMessage = (assistantMessageId: string): number => {\n const source = state.characterLedger.length > 0 ? state.characterLedger : state.edits;\n return source.filter((e) => e.assistantMessageId === assistantMessageId && !e.reverted).length;\n };\n const liveEditsAfterUserMessage = (userMessageId: string): number => {\n const idx = state.messages.findIndex((m) => m.id === userMessageId && m.role === \"user\");\n if (idx < 0) return 0;\n const tailAssistantIds = new Set(state.messages.slice(idx + 1).filter((m) => m.role === \"assistant\").map((m) => m.id));\n const source = state.characterLedger.length > 0 ? state.characterLedger : state.edits;\n return source.filter((e) => e.assistantMessageId !== undefined && tailAssistantIds.has(e.assistantMessageId) && !e.reverted).length;\n };\n const promptEditsAction = async (opts: { liveEditCount: number; action: \"edit\" | \"regenerate\" | \"delete\" }): Promise<\"keep\" | \"revert\" | \"cancel\"> => {\n const verb = opts.action === \"edit\"\n ? \"editing this message\"\n : opts.action === \"regenerate\"\n ? \"regenerating this response\"\n : \"deleting this message\";\n const tail = opts.action === \"delete\"\n ? `${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \" was\" : \"s were\"} made by this response. Revert ${opts.liveEditCount === 1 ? \"it\" : \"them\"} on the character now, or leave ${opts.liveEditCount === 1 ? \"it\" : \"them\"} applied?`\n : `${verb} will discard the AI turns after this point. ${opts.liveEditCount} edit${opts.liveEditCount === 1 ? \"\" : \"s\"} the agent made are tracked in the ledger.\\n\\nRevert those edits to the character now, or leave them applied?`;\n const c = await ctx.ui.showConfirm({\n title: `${opts.liveEditCount} character edit${opts.liveEditCount === 1 ? \"\" : \"s\"} in this thread`,\n message: tail,\n variant: \"warning\",\n confirmLabel: \"Revert edits\",\n cancelLabel: \"Keep edits\",\n });\n return c.confirmed ? \"revert\" : \"keep\";\n };\n\n function makeThreadDeps() {\n return {\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onRevertManyEdits: async (editIds: readonly string[]) => {\n if (!state.characterId || editIds.length === 0) return;\n const c = await ctx.ui.showConfirm({\n title: `Revert ${editIds.length} edit${editIds.length === 1 ? \"\" : \"s\"}?`,\n message: \"Reverts every live edit in this card. Cascade-affected siblings revert too. Workshop history keeps the records (use Undo revert to restore individual ones).\",\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revert_edits_bulk\", characterId: state.characterId, editIds: [...editIds], ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n },\n onOpenDiffModal: (initialEditId?: string) => openDiffs(initialEditId),\n onEditUserMessage: async (messageId: string, newContent: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n if (isCacheInvalidating(messageId)) {\n const c = await ctx.ui.showConfirm({\n title: \"Editing this message will invalidate the prompt cache\",\n message: \"This message sits at or before the rolling cache anchor (2 user-turns back). Editing it forces the provider to rebuild ~the entire conversation prefix on the next send. Continue?\",\n variant: \"danger\",\n confirmLabel: \"Edit anyway\",\n });\n if (!c.confirmed) return;\n }\n sendBackend(withConnection({ type: \"edit_user_message\", sessionId: state.sessionId, messageId, newContent, editsAction }));\n },\n onRegenerateAssistant: async (assistantMessageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n sendBackend(withConnection({ type: \"regenerate_assistant_message\", sessionId: state.sessionId, assistantMessageId, editsAction }));\n },\n onEditingChange: (messageId: string | null) => {\n state.editingMessageId = messageId;\n },\n onDeleteMessage: async (messageId: string, editsAction: \"keep\" | \"revert\") => {\n if (!state.sessionId) return;\n const cacheWarn = isCacheInvalidating(messageId);\n const c = await ctx.ui.showConfirm({\n title: \"Delete message\",\n message: cacheWarn\n ? \"Permanently remove this message. It sits at or before the rolling cache anchor, so deleting it will invalidate the prompt cache and the provider rebuilds the prefix on the next send.\"\n : \"Permanently remove this message from the conversation? Other messages stay in place.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"delete_message\", sessionId: state.sessionId, messageId, editsAction });\n },\n onFreeToolResult: async (callId: string) => {\n if (!state.sessionId) return;\n const ownerId = findAssistantMessageIdForCallId(callId);\n const willInvalidate = ownerId ? isCacheInvalidating(ownerId) : false;\n if (willInvalidate) {\n const c = await ctx.ui.showConfirm({\n title: \"Free this tool result\",\n message: \"This tool result sits at or before the rolling cache anchor, so freeing it invalidates the prompt cache and the provider rebuilds the entire prefix on the next send. Free anyway?\",\n variant: \"danger\",\n confirmLabel: \"Free and rebuild cache\",\n });\n if (!c.confirmed) return;\n }\n // Below the cache anchor (or caching off): the chat-thread free button\n // already did inline two-click confirmation, so just send.\n sendBackend({ type: \"free_tool_result\", sessionId: state.sessionId, callId });\n },\n isToolResultInCache: (callId: string) => {\n const ownerId = findAssistantMessageIdForCallId(callId);\n return ownerId ? isCacheInvalidating(ownerId) : false;\n },\n promptEditsAction,\n liveEditsForAssistantMessage,\n liveEditsAfterUserMessage,\n };\n }\n\n charCombo.onChange((id) => {\n const switchingAway = state.sessionId !== null && id !== state.characterId;\n dlog(\"charCombo change\", { newCharacterId: id, prevCharacterId: state.characterId, sessionId: state.sessionId, switchingAway });\n if (switchingAway) {\n // The active session belongs to a different character. Drop it from the\n // UI so state.characterId stays the source of truth. The session file is\n // safe on the backend, reloadable via Sessions modal.\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n persistUiPrefs();\n }\n state.characterId = id;\n state.characterLedger = [];\n state.chatsForCharacter = [];\n state.pinnedChatId = null;\n state.autoPinNeeded = !!id;\n setChatPinned(false);\n if (switchingAway) rerenderThread();\n updateComposer();\n updateSessionBar();\n if (id) {\n // Mirror the New chat button: spin up a fresh session for the picked\n // character so the queued autopin from chats_pushed actually lands.\n // Without this the user would see \"no pin\" until they manually click New.\n startNewSession();\n sendBackend({ type: \"list_character_edits\", characterId: id });\n sendBackend({ type: \"list_chats\", characterId: id, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n }\n });\n // Sends the full ui-prefs blob to the backend. Cheap enough to call on every\n // change to either field; the alternative (separate connection / session\n // endpoints) duplicates plumbing without buying anything.\n const persistUiPrefs = (): void => {\n sendBackend({\n type: \"update_ui_prefs\",\n connectionId: state.connectionId,\n lastSessionId: state.sessionId,\n });\n };\n\n connCombo.onChange((id) => {\n state.connectionId = id;\n persistUiPrefs();\n });\n\n // Spins up a fresh session for the active character. Shared by the\n // New-session button and the char-selector autopin flow (selecting a\n // character implicitly \"moves us to a new chat\" the same way).\n function startNewSession(): void {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.isGenerating || state.startingSession) {\n composerStatus.textContent = \"Wait for the current generation to finish.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n state.sessionId = makeId(\"sess\");\n state.messages = [];\n state.edits = [];\n state.currentAssistantMessage = null;\n state.streamingAssistant = null;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startingSession = true;\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId: state.sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n }\n\n newSessionBtn.addEventListener(\"click\", startNewSession);\n\n // Pins the chat to the active session, or queues the pin if no session exists yet.\n const pinChatOrQueue = (chatId: string | null): void => {\n dlog(\"pinChatOrQueue\", { chatId, sessionId: state.sessionId, characterId: state.characterId, startingSession: state.startingSession, pendingMessage: state.pendingMessage });\n if (state.sessionId) {\n dlog(\"pinChatOrQueue: pinning to existing session\", { sessionId: state.sessionId, chatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId });\n return;\n }\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.startingSession) {\n state.pendingPinChatId = chatId;\n return;\n }\n composerStatus.classList.remove(\"is-error\");\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n state.pendingPinChatId = chatId;\n dlog(\"pinChatOrQueue: auto-starting session for pin\", { sessionId, characterId: state.characterId, chatId });\n render();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingPinChatId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse and hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n const openChatPickerModal = (): void => {\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n sendBackend({ type: \"list_chats\", characterId: state.characterId, ...(state.sessionId ? { sessionId: state.sessionId } : {}) });\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Pin a chat\", width: 520, maxHeight: 560 });\n const note = el(\"p\", \"la-modal-note\", \"Pick a chat to give the agent read access to its message history. The agent uses the pinned chat when you reference 'this chat', 'the conversation', etc. Pin nothing to keep the agent isolated from your chat data.\");\n const list = el(\"div\", \"la-sessions-modal-list\");\n const render = (): void => {\n list.innerHTML = \"\";\n const unpin = el(\"button\", `la-session-item ${state.pinnedChatId === null ? \"is-active\" : \"\"}`);\n unpin.append(\n Object.assign(el(\"div\"), { textContent: \"(No chat pinned)\" }),\n el(\"div\", \"la-session-item-meta\", \"Agent has no message-history access.\"),\n );\n unpin.addEventListener(\"click\", () => {\n pinChatOrQueue(null);\n handle.dismiss();\n });\n list.appendChild(unpin);\n if (state.chatsForCharacter.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No chats yet for this character.\"));\n return;\n }\n for (const c of state.chatsForCharacter) {\n const row = el(\"div\", `la-session-item ${c.isPinned ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(Object.assign(el(\"div\"), { textContent: c.name + (c.isActive ? \" (currently open)\" : \"\") }));\n main.append(el(\"div\", \"la-session-item-meta\", `updated ${new Date(c.updatedAt).toLocaleString()}`));\n row.appendChild(main);\n if (c.isPinned) {\n // The chat this session is pinned to. Marker mirrors the Sessions\n // modal's \"currently loaded\" tick — both flag the manually-chosen\n // state. Colour stays the row's; tick adds redundant clarity.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Currently pinned\";\n tick.setAttribute(\"aria-label\", \"Currently pinned\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.addEventListener(\"click\", () => {\n pinChatOrQueue(c.id);\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n handle.root.append(note, list);\n render();\n // Re-render when chats_pushed arrives (e.g. after a pin click).\n const detach = pushChatsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n const pushChatsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n // Same shape as pushChatsListeners — re-renders the open sessions modal when\n // a fresh sessions_pushed lands, so e.g. deletes drop their row immediately.\n const pushSessionsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n chatPinBtn.addEventListener(\"click\", () => openChatPickerModal());\n\n const openSessionsModal = (): void => {\n const handle: SpindleModalHandle = ctx.ui.showModal({ title: \"Sessions\", width: 520 });\n const list = el(\"div\", \"la-sessions-modal-list\");\n handle.root.appendChild(list);\n\n const render = (): void => {\n list.innerHTML = \"\";\n if (state.sessions.length === 0) {\n list.appendChild(el(\"div\", \"la-diff-pane-empty\", \"No sessions yet.\"));\n return;\n }\n for (const s of state.sessions) {\n const isCurrent = s.sessionId === state.sessionId;\n const row = el(\"div\", `la-session-item ${isCurrent ? \"is-active\" : \"\"}`);\n const main = el(\"div\", \"la-session-item-main\");\n main.append(el(\"div\", undefined, `${s.characterName}`));\n main.append(el(\"div\", \"la-session-item-meta\", `${s.messageCount} msg . ${s.editCount} edits${s.revertedEditCount ? ` (${s.revertedEditCount} reverted)` : \"\"} . ${new Date(s.lastActivityAt).toLocaleString()}`));\n const exportBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n exportBtn.type = \"button\";\n exportBtn.title = \"Export session as Markdown\";\n exportBtn.setAttribute(\"aria-label\", \"Export session as Markdown\");\n exportBtn.innerHTML = ICON_DOWNLOAD;\n exportBtn.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n sendBackend({ type: \"export_session_markdown\", sessionId: s.sessionId });\n });\n const delBtn = el(\"button\", \"la-session-item-delete\") as HTMLButtonElement;\n delBtn.type = \"button\";\n delBtn.title = \"Delete session\";\n delBtn.setAttribute(\"aria-label\", \"Delete session\");\n delBtn.innerHTML = ICON_TRASH;\n delBtn.addEventListener(\"click\", async (ev) => {\n ev.stopPropagation();\n const r = await ctx.ui.showConfirm({ title: \"Delete session\", message: \"Permanently delete this session? Edits already committed to the card will NOT be reverted; use 'Revert session' first if you want those undone.\", variant: \"danger\", confirmLabel: \"Delete\" });\n if (r.confirmed) {\n delBtn.disabled = true;\n sendBackend({ type: \"delete_session\", sessionId: s.sessionId });\n }\n });\n row.append(main);\n if (isCurrent) {\n // Marker only, no colouring beyond what is-active already provides.\n const tick = el(\"span\", \"la-session-item-tick\");\n tick.title = \"Active session\";\n tick.setAttribute(\"aria-label\", \"Active session\");\n tick.innerHTML = ICON_TICK;\n row.appendChild(tick);\n }\n row.append(exportBtn, delBtn);\n row.addEventListener(\"click\", () => {\n sendBackend({ type: \"load_session\", sessionId: s.sessionId });\n handle.dismiss();\n });\n list.appendChild(row);\n }\n };\n\n render();\n // Refresh the list whenever sessions_pushed comes in (e.g. after a delete).\n // Also re-request immediately in case state.sessions is stale.\n sendBackend({ type: \"list_sessions\" });\n const detach = pushSessionsListeners.push(render);\n handle.onDismiss(() => detach());\n };\n\n editsBadge.addEventListener(\"click\", () => openDiffs());\n\n switchSessionBtn.addEventListener(\"click\", () => openSessionsModal());\n settingsBtn.addEventListener(\"click\", () => openAgentSettingsModal());\n\n menuBtn.addEventListener(\"click\", async () => {\n const rect = menuBtn.getBoundingClientRect();\n const res = await ctx.ui.showContextMenu({\n position: { x: rect.left, y: rect.bottom + 4 },\n items: [\n { key: \"icon\", label: \"Visuals & display name...\" },\n { key: \"revert_active\", label: \"Revert all edits in this session\", disabled: !state.sessionId, danger: true },\n { key: \"delete_active\", label: \"Delete current session\", disabled: !state.sessionId, danger: true },\n ],\n });\n if (res.selectedKey === \"icon\") openIconSettingsModal();\n else if (res.selectedKey === \"revert_active\" && state.sessionId) {\n const liveCount = state.edits.filter((e) => !e.reverted).length;\n if (liveCount === 0) {\n composerStatus.textContent = \"No live edits in this session to revert.\";\n return;\n }\n const c = await ctx.ui.showConfirm({\n title: \"Revert all session edits\",\n message: `Revert every live edit made during this session (${liveCount} edit${liveCount === 1 ? \"\" : \"s\"})? This restores the character to what it was before the conversation started. Cascade-aware. Cannot be undone in one click.`,\n variant: \"danger\",\n confirmLabel: \"Revert all\",\n });\n if (c.confirmed) sendBackend({ type: \"revert_session\", sessionId: state.sessionId });\n }\n else if (res.selectedKey === \"delete_active\" && state.sessionId) {\n const c = await ctx.ui.showConfirm({\n title: \"Delete session\",\n message: \"Delete this conversation? Edits already applied to the character are NOT reverted; use the Changes panel to revert individual edits first if needed.\",\n variant: \"danger\",\n confirmLabel: \"Delete\",\n });\n if (c.confirmed) sendBackend({ type: \"delete_session\", sessionId: state.sessionId });\n }\n });\n\n const MAX_ICON_BYTES = 2 * 1024 * 1024; // 2 MB\n\n const readImageAsDataUrl = (bytes: Uint8Array, mimeType: string): string => {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);\n return `data:${mimeType};base64,${btoa(binary)}`;\n };\n\n const openAgentSettingsModal = (): void => {\n sendBackend({ type: \"get_settings\" });\n // Width = 90vw clamped to [560, 1360] at open time. Host clamps to\n // viewport anyway, this just spends the slack on narrow screens\n // (laptops, side-by-side windows) instead of always hitting the cap.\n const viewportW = typeof window !== \"undefined\" ? window.innerWidth : 1360;\n const modalWidth = Math.max(560, Math.min(Math.floor(viewportW * 0.9), 1360));\n const handle = ctx.ui.showModal({ title: \"Agent settings\", width: modalWidth, maxHeight: 1080 });\n const wrap = el(\"div\", \"la-agent-settings\");\n wrap.appendChild(el(\"p\", \"la-modal-note\", \"Customize how LumiAgent behaves. Saved per-user; applies to your next message.\"));\n\n // --- Persona ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Persona\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Defines who the agent is. Prepended above the technical instructions. Default = the LumiAgent mousegirl persona.\"));\n const personaArea = document.createElement(\"textarea\");\n personaArea.className = \"la-settings-textarea\";\n personaArea.rows = 8;\n wrap.appendChild(personaArea);\n const personaResetRow = el(\"div\", \"la-settings-reset-row\");\n const personaResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n personaResetRow.appendChild(personaResetBtn);\n wrap.appendChild(personaResetRow);\n\n // --- System prompt body ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"System prompt body\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The technical body. Tool guidance, working principles, edit discipline. The persona, LumiRealm, pinned-chat, and external-provider sections are appended automatically; you only own this body.\"));\n const promptArea = document.createElement(\"textarea\");\n promptArea.className = \"la-settings-textarea la-settings-textarea-tall\";\n promptArea.rows = 12;\n wrap.appendChild(promptArea);\n const promptResetRow = el(\"div\", \"la-settings-reset-row\");\n const promptResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset to default\") as HTMLButtonElement;\n promptResetRow.appendChild(promptResetBtn);\n wrap.appendChild(promptResetRow);\n\n // --- Samplers ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Samplers\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Drag a slider to set, double-click to reset that sampler, empty number = inherit from the connection's preset.\"));\n const samplersList = el(\"div\", \"la-samplers-list\");\n wrap.appendChild(samplersList);\n const samplersResetRow = el(\"div\", \"la-settings-reset-row\");\n const samplersResetBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Reset all\") as HTMLButtonElement;\n samplersResetRow.appendChild(samplersResetBtn);\n wrap.appendChild(samplersResetRow);\n\n // --- Jailbreak ---\n const jbHead = el(\"div\", \"la-settings-section-head\");\n jbHead.append(el(\"label\", \"la-settings-label\", \"Jailbreak / prefill\"));\n wrap.appendChild(jbHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Optional text injected per message. Leave empty to disable.\"));\n const jbArea = document.createElement(\"textarea\");\n jbArea.className = \"la-settings-textarea\";\n jbArea.rows = 4;\n wrap.appendChild(jbArea);\n const jbPlacementRow = el(\"div\", \"la-settings-row\");\n jbPlacementRow.append(el(\"label\", \"la-settings-row-label\", \"Placement\"));\n const jbPlacement = document.createElement(\"select\");\n jbPlacement.className = \"la-select\";\n for (const [val, lbl] of [\n [\"system_suffix\", \"End of system prompt\"],\n [\"user_suffix\", \"End of message list as user\"],\n [\"assistant_prefill\", \"End of message list as agent (prefill)\"],\n ] as const) {\n const opt = document.createElement(\"option\");\n opt.value = val;\n opt.textContent = lbl;\n jbPlacement.appendChild(opt);\n }\n jbPlacementRow.appendChild(jbPlacement);\n wrap.appendChild(jbPlacementRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Agent notes shortcut ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Agent notes\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Long-term memory file the agent reads at the start of every session. Anything you put there is preloaded into context.\"));\n const notesRow = el(\"div\", \"la-settings-row\");\n const notesBtn = el(\"button\", \"la-btn la-btn-mini\", \"Open agent notes\") as HTMLButtonElement;\n notesBtn.addEventListener(\"click\", () => {\n handle.dismiss();\n openWorkshopOnFile(\"agent/agent.md\");\n });\n notesRow.appendChild(notesBtn);\n wrap.appendChild(notesRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n // --- Storage caps ---\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Storage limits\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Per-user storage cap for the workspace.\"));\n const wsCapRow = el(\"div\", \"la-settings-row\");\n wsCapRow.append(el(\"label\", \"la-settings-row-label\", \"Workspace cap (MB)\"));\n const wsCapInput = document.createElement(\"input\");\n wsCapInput.type = \"number\";\n wsCapInput.className = \"la-slider-input\";\n wsCapInput.min = \"1\";\n wsCapInput.step = \"1\";\n wsCapRow.appendChild(wsCapInput);\n wrap.appendChild(wsCapRow);\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Tool output cap\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Set to dump any single tool result over that many tokens to a tmp file the agent can grep/read to avoid blowing up context.\"));\n const toolCapRow = el(\"div\", \"la-settings-row\");\n toolCapRow.append(el(\"label\", \"la-settings-row-label\", \"Tool output cap (tk)\"));\n const toolCapInput = document.createElement(\"input\");\n toolCapInput.type = \"number\";\n toolCapInput.className = \"la-slider-input\";\n toolCapInput.min = \"1\";\n toolCapInput.step = \"1\";\n toolCapRow.appendChild(toolCapInput);\n wrap.appendChild(toolCapRow);\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Prompt caching\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Marks parts of every request as cacheable so supported providers (Anthropic, OpenAI, Bedrock, Gemini) charge a fraction on cache reads. Full mode caches two turns behind the latest message. System only caches the system prompt. Off attaches no markers.\"));\n const cacheModeRow = el(\"div\", \"la-settings-row\");\n cacheModeRow.append(el(\"label\", \"la-settings-row-label\", \"Cache mode\"));\n const cacheModeSelect = document.createElement(\"select\");\n cacheModeSelect.className = \"la-select\";\n for (const [val, label] of [[\"full\", \"Full\"], [\"system_only\", \"System only\"], [\"off\", \"Off\"]] as const) {\n const o = document.createElement(\"option\");\n o.value = val;\n o.textContent = label;\n cacheModeSelect.appendChild(o);\n }\n cacheModeRow.appendChild(cacheModeSelect);\n wrap.appendChild(cacheModeRow);\n\n const cacheSupportRow = el(\"div\", \"la-settings-row\");\n cacheSupportRow.append(el(\"label\", \"la-settings-row-label\", \"Connection supports caching\"));\n const cacheSupportInput = document.createElement(\"input\");\n cacheSupportInput.type = \"checkbox\";\n cacheSupportInput.className = \"la-checkbox\";\n cacheSupportRow.appendChild(cacheSupportInput);\n wrap.appendChild(cacheSupportRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Bedrock, Gemini, OpenRouter (Anthropic routes). Turn OFF for proxies or local models that don't honour cache_control.\"));\n\n const autoFreeRow = el(\"div\", \"la-settings-row\");\n autoFreeRow.append(el(\"label\", \"la-settings-row-label\", \"Auto-free old tool results\"));\n const autoFreeInput = document.createElement(\"input\");\n autoFreeInput.type = \"checkbox\";\n autoFreeInput.className = \"la-checkbox\";\n autoFreeRow.appendChild(autoFreeInput);\n wrap.appendChild(autoFreeRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Stub-replace insensitive tool results after 10 user turns to save context. Off by default. Turn on if you're on a provider that doesn't honour cache markers AND you see context grow unchecked.\"));\n\n const parallelToolsRow = el(\"div\", \"la-settings-row\");\n parallelToolsRow.append(el(\"label\", \"la-settings-row-label\", \"Parallel tool calls\"));\n const parallelToolsInput = document.createElement(\"input\");\n parallelToolsInput.type = \"checkbox\";\n parallelToolsInput.className = \"la-checkbox\";\n parallelToolsRow.appendChild(parallelToolsInput);\n wrap.appendChild(parallelToolsRow);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Leave ON for Anthropic, OpenAI, Google, most OpenRouter routes. Turn OFF for providers that error on parallel tool emission (some Mistral configurations, certain self-hosted setups).\"));\n\n wrap.appendChild(el(\"hr\", \"la-settings-divider\"));\n\n wrap.appendChild(el(\"label\", \"la-settings-label\", \"Extension pairings\"));\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Other extensions that can communicate with LumiAgent.\"));\n const pairingsPanel = el(\"div\", \"la-pairings-panel\");\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"Loading...\"));\n wrap.appendChild(pairingsPanel);\n const renderPairings = (pairings: readonly PairingWire[]): void => {\n while (pairingsPanel.firstChild) pairingsPanel.removeChild(pairingsPanel.firstChild);\n if (pairings.length === 0) {\n pairingsPanel.appendChild(el(\"div\", \"la-pairings-empty\", \"No pairings yet.\"));\n return;\n }\n for (const p of pairings) {\n const row = el(\"div\", \"la-pairing-row\");\n const nameCol = el(\"div\", \"la-pairing-name-col\");\n nameCol.appendChild(el(\"div\", \"la-pairing-name\", p.displayName));\n nameCol.appendChild(el(\"div\", \"la-pairing-id\", p.identifier));\n row.appendChild(nameCol);\n const toggleLabel = el(\"label\", \"la-pairing-toggle\");\n const cb = document.createElement(\"input\");\n cb.type = \"checkbox\";\n cb.className = \"la-checkbox\";\n cb.checked = p.allowed;\n // Pairing state is read into the External Providers section of the\n // system message, so toggling it busts the prompt cache. Confirm.\n cb.addEventListener(\"click\", async (ev) => {\n ev.preventDefault();\n const nextAllowed = !p.allowed;\n const c = await ctx.ui.showConfirm({\n title: nextAllowed ? \"Allow this pairing?\" : \"Block this pairing?\",\n message: `Pairing state is part of the system prompt's External Providers section. Toggling it invalidates the prompt cache on your next message in every active chat. ${nextAllowed ? \"Allow\" : \"Block\"} \"${p.displayName}\"?`,\n variant: \"danger\",\n confirmLabel: nextAllowed ? \"Allow\" : \"Block\",\n });\n if (!c.confirmed) return;\n cb.checked = nextAllowed;\n sendBackend({ type: \"set_phoneline_pairing\", identifier: p.identifier, allowed: nextAllowed });\n });\n toggleLabel.appendChild(cb);\n toggleLabel.appendChild(el(\"span\", \"la-pairing-toggle-label\", \"Allowed\"));\n row.appendChild(toggleLabel);\n const revokeBtn = el(\"button\", \"la-btn la-btn-mini la-btn-ghost\", \"Forget\") as HTMLButtonElement;\n revokeBtn.addEventListener(\"click\", async () => {\n const c = await ctx.ui.showConfirm({\n title: \"Forget this pairing?\",\n message: `Removing \"${p.displayName}\" wipes its stored consent and invalidates the system prompt cache on the next message. You will be re-prompted for consent if the extension dials again.`,\n variant: \"danger\",\n confirmLabel: \"Forget\",\n });\n if (!c.confirmed) return;\n sendBackend({ type: \"revoke_phoneline_pairing\", identifier: p.identifier });\n });\n row.appendChild(revokeBtn);\n pairingsPanel.appendChild(row);\n }\n };\n const unregisterPairings = pairingsListeners.push((p) => renderPairings(p));\n handle.onDismiss(unregisterPairings);\n sendBackend({ type: \"get_phoneline_pairings\" });\n\n const status = el(\"div\", \"la-composer-status\");\n wrap.appendChild(status);\n\n const actions = el(\"div\", \"la-settings-actions\");\n const cancelBtn = el(\"button\", \"la-btn\", \"Cancel\") as HTMLButtonElement;\n const saveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save\") as HTMLButtonElement;\n actions.append(cancelBtn, saveBtn);\n wrap.appendChild(actions);\n\n let samplerBag: Record = {\n temperature: null, maxTokens: null, contextSize: null,\n topP: null, minP: null, topK: null,\n frequencyPenalty: null, presencePenalty: null, repetitionPenalty: null,\n };\n\n const populate = (): void => {\n const s = state.settings;\n if (!s) {\n personaArea.value = \"\";\n personaArea.placeholder = \"Loading...\";\n promptArea.value = \"\";\n return;\n }\n personaArea.value = s.persona;\n personaArea.placeholder = \"(empty: agent has no persona)\";\n // Show the default body when override is null so the user can see what's\n // active. We track an \"isDefault\" flag implicitly via the textarea value;\n // saving sets override to null only when it exactly matches the default.\n promptArea.value = s.systemPromptOverride ?? (s.defaultSystemPromptBody ?? \"\");\n if (s.samplers) samplerBag = { ...s.samplers };\n jbArea.value = s.jailbreak ?? \"\";\n jbPlacement.value = s.jailbreakPlacement ?? \"system_suffix\";\n const wsDefault = s.workspaceCapDefaultBytes ?? (5 * 1024 * 1024 * 1024);\n wsCapInput.placeholder = `${Math.round(wsDefault / 1024 / 1024)}`;\n wsCapInput.value = s.workspaceCapBytes ? String(Math.round(s.workspaceCapBytes / 1024 / 1024)) : \"\";\n const toolDefault = s.toolOutputCapDefaultTokens ?? 8000;\n toolCapInput.placeholder = `${toolDefault}`;\n toolCapInput.value = s.toolOutputCapTokens ? String(s.toolOutputCapTokens) : \"\";\n cacheModeSelect.value = s.cacheMode ?? \"full\";\n cacheSupportInput.checked = s.connectionSupportsPromptCaching ?? true;\n autoFreeInput.checked = s.autoFreeOldToolResults ?? false;\n parallelToolsInput.checked = s.parallelToolCalls ?? true;\n renderSamplers();\n };\n\n const resetAllSamplers = (): void => {\n for (const k of Object.keys(samplerBag)) samplerBag[k] = null;\n renderSamplers();\n };\n\n const SAMPLER_DEFS: ReadonlyArray<{ key: string; label: string; type: \"int\" | \"float\"; min: number; max: number; step: number; defaultHint: number }> = [\n { key: \"temperature\", label: \"Temperature\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 1.0 },\n { key: \"maxTokens\", label: \"Max Response\", type: \"int\", min: 1, max: 128000, step: 1, defaultHint: 16384 },\n { key: \"contextSize\", label: \"Context Size\", type: \"int\", min: 1, max: 2000000, step: 1, defaultHint: 128000 },\n { key: \"topP\", label: \"Top P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0.95 },\n { key: \"minP\", label: \"Min P\", type: \"float\", min: 0, max: 1, step: 0.01, defaultHint: 0 },\n { key: \"topK\", label: \"Top K\", type: \"int\", min: 0, max: 500, step: 1, defaultHint: 0 },\n { key: \"frequencyPenalty\", label: \"Freq Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"presencePenalty\", label: \"Pres Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n { key: \"repetitionPenalty\", label: \"Rep Penalty\", type: \"float\", min: 0, max: 2, step: 0.01, defaultHint: 0 },\n ];\n\n const buildSamplerSlider = (def: typeof SAMPLER_DEFS[number]): HTMLElement => {\n const row = el(\"div\", \"la-slider-row\");\n const header = el(\"div\", \"la-slider-header\");\n const label = el(\"span\", \"la-slider-label\", def.label);\n const numInput = document.createElement(\"input\");\n numInput.type = \"number\";\n numInput.className = \"la-slider-input\";\n numInput.min = String(def.min);\n numInput.max = String(def.max);\n numInput.step = String(def.step);\n numInput.placeholder = String(def.defaultHint);\n header.append(label, numInput);\n const track = el(\"div\", \"la-slider-track\");\n track.title = \"Drag to set, double-click to reset\";\n const fill = el(\"div\", \"la-slider-fill\");\n const thumb = el(\"div\", \"la-slider-thumb\");\n track.append(fill, thumb);\n row.append(header, track);\n\n const decimals = (String(def.step).split(\".\")[1] || \"\").length;\n const snap = (raw: number): number => {\n const clamped = Math.min(def.max, Math.max(def.min, raw));\n const stepped = Math.round((clamped - def.min) / def.step) * def.step + def.min;\n return def.type === \"int\" ? Math.round(stepped) : parseFloat(stepped.toFixed(decimals));\n };\n const posToValue = (clientX: number): number => {\n const rect = track.getBoundingClientRect();\n if (!rect || rect.width === 0) return def.defaultHint;\n const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n return snap(def.min + ratio * (def.max - def.min));\n };\n const applyVisual = (displayValue: number, isSet: boolean): void => {\n const range = def.max - def.min;\n const pct = range > 0 ? Math.max(0, Math.min(100, ((displayValue - def.min) / range) * 100)) : 0;\n fill.style.width = `${pct}%`;\n thumb.style.left = `${pct}%`;\n track.classList.toggle(\"la-slider-track-set\", isSet);\n label.classList.toggle(\"la-slider-label-set\", isSet);\n numInput.classList.toggle(\"la-slider-input-set\", isSet);\n };\n const sync = (): void => {\n const v = samplerBag[def.key] ?? null;\n const isSet = v !== null;\n const display = isSet ? v! : def.defaultHint;\n if (document.activeElement !== numInput) numInput.value = isSet ? String(v) : \"\";\n applyVisual(display, isSet);\n };\n\n let dragging = false;\n let dragValue: number | null = null;\n track.addEventListener(\"pointerdown\", (e) => {\n e.preventDefault();\n dragging = true;\n try { track.setPointerCapture(e.pointerId); } catch { /* */ }\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointermove\", (e) => {\n if (!dragging) return;\n dragValue = posToValue(e.clientX);\n applyVisual(dragValue, true);\n });\n track.addEventListener(\"pointerup\", (e) => {\n if (!dragging) return;\n dragging = false;\n try { track.releasePointerCapture(e.pointerId); } catch { /* */ }\n if (dragValue !== null) { samplerBag[def.key] = dragValue; sync(); }\n dragValue = null;\n });\n track.addEventListener(\"dblclick\", () => { samplerBag[def.key] = null; sync(); });\n const commit = (raw: string): void => {\n if (raw === \"\") { samplerBag[def.key] = null; sync(); return; }\n const num = def.type === \"int\" ? parseInt(raw, 10) : parseFloat(raw);\n if (Number.isFinite(num)) { samplerBag[def.key] = snap(num); sync(); }\n };\n numInput.addEventListener(\"change\", () => commit(numInput.value));\n numInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") numInput.blur();\n else if (e.key === \"Escape\") { numInput.blur(); sync(); }\n });\n sync();\n return row;\n };\n\n const renderSamplers = (): void => {\n samplersList.innerHTML = \"\";\n for (const def of SAMPLER_DEFS) samplersList.appendChild(buildSamplerSlider(def));\n };\n\n populate();\n const detach = settingsListeners.push(populate);\n handle.onDismiss(() => detach());\n\n personaResetBtn.addEventListener(\"click\", () => { if (state.settings) personaArea.value = state.settings.defaultPersona; });\n promptResetBtn.addEventListener(\"click\", () => { if (state.settings?.defaultSystemPromptBody) promptArea.value = state.settings.defaultSystemPromptBody; });\n samplersResetBtn.addEventListener(\"click\", () => resetAllSamplers());\n\n cancelBtn.addEventListener(\"click\", () => handle.dismiss());\n saveBtn.addEventListener(\"click\", async () => {\n const persona = personaArea.value.trim();\n const promptValue = promptArea.value.trim();\n const defaultBody = state.settings?.defaultSystemPromptBody?.trim() ?? \"\";\n // If the body matches default verbatim, treat as \"no override\" so future\n // tweaks to the default flow through automatically.\n const systemPromptOverride = promptValue.length === 0 || promptValue === defaultBody ? null : promptValue;\n const placement = (jbPlacement.value as \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\");\n const newCacheMode = (cacheModeSelect.value as \"off\" | \"system_only\" | \"full\");\n\n // Anything that ends up in the system message invalidates the prompt\n // cache when changed. Warn only if one of those actually moved, so users\n // who only tweak samplers / caps don't get a confirm prompt.\n const before = state.settings;\n const promptAffected = !before || (\n before.persona !== persona\n || (before.systemPromptOverride ?? null) !== systemPromptOverride\n || (before.jailbreak ?? \"\") !== jbArea.value\n || (before.jailbreakPlacement ?? \"system_suffix\") !== placement\n || (before.cacheMode ?? \"full\") !== newCacheMode\n );\n if (promptAffected && before) {\n const c = await ctx.ui.showConfirm({\n title: \"Saving will invalidate the prompt cache\",\n message: \"You changed persona, system prompt body, jailbreak, or cache mode. These live in the system message, so your next message in every active chat pays full uncached pricing while the provider rebuilds the cache prefix. Save anyway?\",\n variant: \"danger\",\n confirmLabel: \"Save\",\n });\n if (!c.confirmed) return;\n }\n\n const parseCapMb = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;\n };\n const parsePosInt = (raw: string): number | null => {\n const n = parseInt(raw.trim(), 10);\n return Number.isFinite(n) && n > 0 ? n : null;\n };\n sendBackend({\n type: \"update_settings\",\n persona,\n systemPromptOverride,\n samplers: samplerBag,\n jailbreak: jbArea.value,\n jailbreakPlacement: placement,\n workspaceCapBytes: parseCapMb(wsCapInput.value),\n toolOutputCapTokens: parsePosInt(toolCapInput.value),\n connectionSupportsPromptCaching: cacheSupportInput.checked,\n autoFreeOldToolResults: autoFreeInput.checked,\n cacheMode: newCacheMode,\n parallelToolCalls: parallelToolsInput.checked,\n });\n status.textContent = \"Saved.\";\n status.classList.remove(\"is-error\");\n setTimeout(() => handle.dismiss(), 600);\n });\n\n handle.root.appendChild(wrap);\n };\n\n type PairingWire = { identifier: string; displayName: string; allowed: boolean; decidedAt: number };\n const pairingsListeners: { handlers: Array<(p: readonly PairingWire[]) => void>; push(h: (p: readonly PairingWire[]) => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const settingsListeners: { handlers: Array<() => void>; push(h: () => void): () => void } = {\n handlers: [],\n push(h) { this.handlers.push(h); return () => { this.handlers = this.handlers.filter((x) => x !== h); }; },\n };\n\n const MAX_MOUSEY_BYTES = 4 * 1024 * 1024;\n\n const openIconSettingsModal = (): void => {\n const handle = ctx.ui.showModal({ title: \"Visuals & display name\", width: 520, maxHeight: 720 });\n const wrap = el(\"div\", \"la-icon-settings\");\n const note = el(\"p\", \"la-modal-note\", \"Customise the drawer icon, the sitting character image, and the display name. Stored in your browser. Reload the tab to apply.\");\n wrap.appendChild(note);\n const status = el(\"div\", \"la-composer-status\");\n\n // --- Display name section ---\n const nameHead = el(\"div\", \"la-settings-section-head\");\n nameHead.append(el(\"label\", \"la-settings-label\", \"Display name\"));\n wrap.appendChild(nameHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"What this extension calls itself in the drawer tab + sidebar. Default: LumiAgent.\"));\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"la-input\";\n const currentName = resolveDisplayName();\n nameInput.value = currentName.full === DEFAULT_DISPLAY_NAME ? \"\" : currentName.full;\n nameInput.placeholder = DEFAULT_DISPLAY_NAME;\n nameInput.maxLength = 40;\n wrap.appendChild(nameInput);\n const nameActions = el(\"div\", \"la-icon-settings-actions\");\n const nameSaveBtn = el(\"button\", \"la-btn la-btn-primary\", \"Save name\") as HTMLButtonElement;\n const nameResetBtn = el(\"button\", \"la-btn\", \"Reset\") as HTMLButtonElement;\n nameActions.append(nameSaveBtn, nameResetBtn);\n wrap.appendChild(nameActions);\n nameSaveBtn.addEventListener(\"click\", () => {\n const v = nameInput.value.trim();\n try {\n if (v.length > 0) localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, v);\n else localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY);\n } catch { /* localStorage unavailable */ }\n status.textContent = \"Display name saved. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n nameResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n nameInput.value = \"\";\n status.textContent = \"Display name reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Drawer icon section ---\n const iconHead = el(\"div\", \"la-settings-section-head\");\n iconHead.append(el(\"label\", \"la-settings-label\", \"Drawer icon\"));\n wrap.appendChild(iconHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"Replaces the icon shown in the Lumiverse sidebar.\"));\n const iconPreview = el(\"div\", \"la-icon-settings-preview\");\n const iconImg = document.createElement(\"img\");\n iconImg.src = resolveDrawerIconUrl();\n iconImg.alt = \"current icon\";\n iconImg.className = \"la-icon-settings-image\";\n const iconCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n iconPreview.append(iconCaption, iconImg);\n wrap.appendChild(iconPreview);\n const iconActions = el(\"div\", \"la-icon-settings-actions\");\n const iconPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const iconResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n iconActions.append(iconPickBtn, iconResetBtn);\n wrap.appendChild(iconActions);\n\n iconPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_ICON_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_ICON_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 2 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(ICON_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n iconImg.src = dataUrl;\n iconCaption.textContent = \"Selected (reload to apply)\";\n status.textContent = \"Icon saved. Reload to apply.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n iconResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(ICON_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n iconImg.src = DEFAULT_ICON_DATA_URL;\n iconCaption.textContent = \"Default (reload to apply)\";\n status.textContent = \"Icon reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n // --- Mousey image section ---\n const mouseyHead = el(\"div\", \"la-settings-section-head\");\n mouseyHead.append(el(\"label\", \"la-settings-label\", \"Sitting character image\"));\n wrap.appendChild(mouseyHead);\n wrap.appendChild(el(\"div\", \"la-settings-hint\", \"The image perched on the composer ledge. Transparent PNG works best. For correct positioning the figure should be sitting around 2/3 of the way down the image.\"));\n const mouseyPreview = el(\"div\", \"la-icon-settings-preview\");\n const mouseyImgPreview = document.createElement(\"img\");\n mouseyImgPreview.src = resolveMouseyImageUrl();\n mouseyImgPreview.alt = \"current sitting image\";\n mouseyImgPreview.className = \"la-icon-settings-image la-icon-settings-image-tall\";\n const mouseyCaption = el(\"div\", \"la-icon-settings-caption\", \"Current\");\n mouseyPreview.append(mouseyCaption, mouseyImgPreview);\n wrap.appendChild(mouseyPreview);\n const mouseyActions = el(\"div\", \"la-icon-settings-actions\");\n const mouseyPickBtn = el(\"button\", \"la-btn la-btn-primary\", \"Choose image...\") as HTMLButtonElement;\n const mouseyResetBtn = el(\"button\", \"la-btn la-btn-danger\", \"Reset to default\") as HTMLButtonElement;\n mouseyActions.append(mouseyPickBtn, mouseyResetBtn);\n wrap.appendChild(mouseyActions);\n\n mouseyPickBtn.addEventListener(\"click\", async () => {\n status.textContent = \"\";\n status.classList.remove(\"is-error\");\n try {\n const files = await ctx.uploads.pickFile({\n accept: [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/svg+xml\"],\n multiple: false,\n maxSizeBytes: MAX_MOUSEY_BYTES,\n });\n if (files.length === 0) return;\n const file = files[0]!;\n if (file.sizeBytes > MAX_MOUSEY_BYTES) {\n status.textContent = `Image too large (${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB). Max 4 MB.`;\n status.classList.add(\"is-error\");\n return;\n }\n const dataUrl = readImageAsDataUrl(file.bytes, file.mimeType || \"image/png\");\n try { localStorage.setItem(MOUSEY_STORAGE_KEY, dataUrl); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = dataUrl;\n mouseyCaption.textContent = \"Selected (reload to apply)\";\n // Live-update the actual sitting image on the composer for instant gratification.\n mouseyImg.src = dataUrl;\n status.textContent = \"Sitting image saved. Reload to apply across the rest of the drawer.\";\n } catch (err) {\n status.textContent = `Failed: ${(err as Error).message}`;\n status.classList.add(\"is-error\");\n }\n });\n mouseyResetBtn.addEventListener(\"click\", () => {\n try { localStorage.removeItem(MOUSEY_STORAGE_KEY); } catch { /* localStorage unavailable */ }\n mouseyImgPreview.src = MOUSEY_SITTING_DATA_URL;\n mouseyCaption.textContent = \"Default (reload to apply)\";\n mouseyImg.src = MOUSEY_SITTING_DATA_URL;\n status.textContent = \"Sitting image reset. Reload to apply.\";\n status.classList.remove(\"is-error\");\n });\n\n wrap.appendChild(status);\n handle.root.appendChild(wrap);\n };\n\n const appendUserMessage = (text: string): ChatUserMessage => {\n const msg: ChatUserMessage = {\n id: makeId(\"msg\"),\n role: \"user\",\n ts: Date.now(),\n content: text,\n };\n state.messages.push(msg);\n if (state.messages.length === 1 && thread.contains(emptyState)) thread.removeChild(emptyState);\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n return msg;\n };\n\n const dispatchSendForExisting = (sessionId: string, messageId: string, text: string): void => {\n dlog(\"dispatchSendForExisting (LLM call)\", { sessionId, messageId, textLen: text.length });\n sendBackend(withConnection({\n type: \"send_message\",\n sessionId,\n userMessageId: messageId,\n content: text,\n }));\n state.isGenerating = true;\n updateComposer();\n };\n\n const clearStartTimeout = (): void => {\n if (state.startSessionTimeout !== null) {\n clearTimeout(state.startSessionTimeout);\n state.startSessionTimeout = null;\n }\n };\n\n const doSend = (): void => {\n const text = textarea.value.trim();\n if (!state.characterId) {\n composerStatus.textContent = \"Pick a character first.\";\n composerStatus.classList.add(\"is-error\");\n return;\n }\n if (state.isGenerating || state.startingSession) return;\n\n // Empty send: resume the existing turn if the last message is the user's\n // own (the agent hasn't replied yet). Bail silently otherwise — the\n // button should already be greyed out via updateComposer in that case.\n if (text.length === 0) {\n const last = state.messages[state.messages.length - 1];\n const canContinue = !!last && last.role === \"user\" && !!state.sessionId;\n if (!canContinue) return;\n composerStatus.classList.remove(\"is-error\");\n sendBackend(withConnection({\n type: \"continue_session\",\n sessionId: state.sessionId!,\n }));\n state.isGenerating = true;\n updateComposer();\n return;\n }\n\n textarea.value = \"\";\n composerStatus.classList.remove(\"is-error\");\n\n if (state.sessionId) {\n const msg = appendUserMessage(text);\n dispatchSendForExisting(state.sessionId, msg.id, text);\n return;\n }\n\n const sessionId = makeId(\"sess\");\n state.sessionId = sessionId;\n state.messages = [];\n state.edits = [];\n state.startingSession = true;\n rerenderThread();\n const userMsg = appendUserMessage(text);\n state.pendingMessage = text;\n state.pendingMessageId = userMsg.id;\n updateSessionBar();\n updateComposer();\n sendBackend(withConnection({\n type: \"start_session\",\n sessionId,\n characterId: state.characterId,\n }));\n clearStartTimeout();\n state.startSessionTimeout = setTimeout(() => {\n if (!state.startingSession) return;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n state.startSessionTimeout = null;\n composerStatus.textContent = \"Backend did not respond to start_session. Restart Lumiverse (start.ps1 -b) to pick up the new backend, then hard-refresh.\";\n composerStatus.classList.add(\"is-error\");\n updateComposer();\n }, 8000);\n };\n\n sendBtn.addEventListener(\"click\", doSend);\n cancelBtn.addEventListener(\"click\", () => {\n if (!state.sessionId) return;\n sendBackend({ type: \"cancel_generation\", sessionId: state.sessionId });\n });\n textarea.addEventListener(\"keydown\", (ev) => {\n if ((ev.metaKey || ev.ctrlKey) && ev.key === \"Enter\") {\n ev.preventDefault();\n doSend();\n }\n });\n\n // Returns the id of the user message that's currently the rolling cache\n // anchor (2 user-turns behind the latest). Null when conversation is too\n // short to lag or caching is off.\n const rollingAnchorId = (): string | null => {\n const mode = state.settings?.cacheMode ?? \"full\";\n if (mode !== \"full\") return null;\n const userMsgs = state.messages.filter((m) => m.role === \"user\");\n if (userMsgs.length <= 2) return null;\n return userMsgs[userMsgs.length - 1 - 2]?.id ?? null;\n };\n\n const isCacheInvalidating = (targetMessageId: string): boolean => {\n const anchorId = rollingAnchorId();\n if (!anchorId) return false;\n const anchorIdx = state.messages.findIndex((m) => m.id === anchorId);\n const targetIdx = state.messages.findIndex((m) => m.id === targetMessageId);\n if (anchorIdx < 0 || targetIdx < 0) return false;\n return targetIdx <= anchorIdx;\n };\n\n const findAssistantMessageIdForCallId = (callId: string): string | null => {\n for (const m of state.messages) {\n if (m.role !== \"assistant\") continue;\n for (const b of m.blocks) {\n if (b.type === \"tool\" && b.call_id === callId) return m.id;\n }\n }\n return null;\n };\n\n // Loading is rendered inside the streaming bubble. The bubble's own\n // setLoading mounts the indicator at the bubble's tail and re-appends it\n // after every content event, so visual position always tracks the\n // current end-of-stream rather than floating outside the virtualizer.\n const hideLoading = (): void => {\n if (!state.loading) return;\n state.loading = false;\n state.streamingAssistant?.setLoading(false);\n };\n\n // Build a fresh streaming bubble + handle keyed by `id`. Caller owns the\n // decision of whether `id` is backend-provided (turn_started) or local\n // (token/reasoning/tool fired before turn_started arrived).\n const createStreamingTurn = (id: string): AssistantHandle => {\n const assistant: ChatAssistantMessage = {\n id,\n role: \"assistant\",\n ts: Date.now(),\n turn: 0,\n blocks: [],\n status: \"streaming\",\n };\n state.messages.push(assistant);\n state.currentAssistantMessage = assistant;\n const handle = createStreamingAssistant({\n onRevertEdit: async (editId: string) => {\n if (!state.characterId) return;\n sendBackend({ type: \"revert_edit\", characterId: state.characterId, editId });\n },\n onOpenDiffModal: (eid?: string) => openDiffs(eid),\n });\n state.streamingAssistant = handle;\n virtualizer.setCount();\n virtualizer.scrollToBottom();\n handle.setLoading(true);\n state.loading = true;\n return handle;\n };\n\n // turn_started entry point. Adopts the backend's assistantMessageId so\n // regenerate / edit lookups match server-side records.\n const adoptStreamingTurn = (assistantMessageId: string): AssistantHandle =>\n state.streamingAssistant ?? createStreamingTurn(assistantMessageId);\n\n // Token / reasoning / tool entry point. Gets the existing handle, or\n // synthesizes one with a local id when an event raced ahead of turn_started.\n const ensureStreamingTurn = (): AssistantHandle =>\n state.streamingAssistant ?? createStreamingTurn(makeId(\"msg\"));\n\n const finalizeAssistantTurn = (status: ChatAssistantMessage[\"status\"]) => {\n hideLoading();\n const handle = state.streamingAssistant;\n const msg = state.currentAssistantMessage;\n if (!handle || !msg) return;\n handle.setStatus(status);\n msg.status = status;\n state.streamingAssistant = null;\n state.currentAssistantMessage = null;\n };\n\n const agentEventCtx: AgentEventCtx = {\n state,\n adoptStreamingTurn,\n ensureStreamingTurn,\n finalizeAssistantTurn,\n rerenderThread,\n updateSessionBar,\n clearErrorBanners,\n };\n\n ctx.onBackendMessage((raw) => {\n const msg = raw as BackendToFrontend;\n switch (msg.type) {\n case \"characters_pushed\":\n state.characters = [...msg.characters];\n renderCharOptions();\n break;\n case \"connections_pushed\":\n state.connections = [...msg.connections];\n renderConnOptions();\n break;\n case \"sessions_pushed\":\n state.sessions = [...msg.sessions];\n for (const h of pushSessionsListeners.handlers) h();\n break;\n case \"session_started\":\n clearStartTimeout();\n dlog(\"session_started received\", { sessionId: msg.sessionId, characterId: msg.characterId, pendingPinChatId: state.pendingPinChatId, pendingMessage: state.pendingMessage, pendingMessageId: state.pendingMessageId });\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.startingSession = false;\n persistUiPrefs();\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n // Flush a queued pin (from the chat picker auto-start flow).\n if (state.pendingPinChatId !== undefined && state.pendingPinChatId !== null) {\n dlog(\"session_started: flushing queued pin\", { sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n sendBackend({ type: \"set_pinned_chat\", sessionId: msg.sessionId, chatId: state.pendingPinChatId });\n state.pendingPinChatId = null;\n } else if (state.pendingPinChatId === null) {\n // Was explicitly \"unpin\" — clear pending flag.\n state.pendingPinChatId = null;\n }\n // If we got here from the auto-start flow, the user message is already\n // in state.messages and the thread. Keep them and just send the queued\n // message. Otherwise (manual + New chat with no pending), reset thread.\n if (state.pendingMessage !== null && state.pendingMessageId !== null) {\n const text = state.pendingMessage;\n const id = state.pendingMessageId;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n dlog(\"session_started: dispatching queued message (this triggers an LLM call)\", { sessionId: msg.sessionId, messageId: id, textLen: text.length });\n updateSessionBar();\n dispatchSendForExisting(msg.sessionId, id, text);\n } else {\n state.messages = [];\n state.edits = [];\n render();\n }\n break;\n case \"session_loaded\":\n clearErrorBanners();\n state.sessionId = msg.sessionId;\n state.characterId = msg.characterId;\n state.characterName = msg.characterName;\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n if (state.characters.some((c) => c.id === msg.characterId)) charCombo.setValue(msg.characterId, true);\n render();\n // Default to the latest message on every session open. The\n // virtualizer's sticky-bottom check would otherwise compare against\n // the prior session's scrollTop.\n virtualizer.scrollToBottom();\n persistUiPrefs();\n sendBackend({ type: \"list_character_edits\", characterId: msg.characterId });\n sendBackend({ type: \"list_chats\", characterId: msg.characterId, sessionId: msg.sessionId });\n break;\n case \"session_deleted\":\n if (state.sessionId === msg.sessionId) {\n state.sessionId = null;\n state.messages = [];\n state.edits = [];\n render();\n persistUiPrefs();\n }\n sendBackend({ type: \"list_sessions\" });\n break;\n case \"session_markdown_ready\": {\n const blob = new Blob([msg.content], { type: \"text/markdown;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = msg.filename;\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke after a tick so the browser's save dialog has time to start.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n break;\n }\n case \"session_markdown_error\":\n composerStatus.textContent = `Export failed: ${msg.error}`;\n break;\n case \"session_reverted\":\n composerStatus.textContent = `Session reverted: ${msg.entriesRestored} entries, ${msg.scriptsRestored} scripts. ${msg.entriesFailed + msg.scriptsFailed} failed.`;\n for (const e of state.edits) (e as { reverted: boolean }).reverted = true;\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.edits);\n break;\n case \"chat_event\":\n handleAgentEvent(msg.event, agentEventCtx);\n break;\n case \"auto_freed\": {\n const notice = el(\"div\", \"la-error-banner\");\n notice.appendChild(el(\"div\", \"la-error-banner-title\", \"Old tool results auto-freed\"));\n notice.appendChild(el(\"pre\", \"la-error-banner-body\", `Freed ${msg.count} insensitive tool result${msg.count === 1 ? \"\" : \"s\"} (${Math.round(msg.bytes / 1024)} KB) to keep context small. Turn 'Auto-free old tool results' off in Agent Settings to disable.`));\n thread.appendChild(notice);\n setTimeout(() => notice.remove(), 8000);\n break;\n }\n case \"generation_done\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"complete\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_cancelled\":\n state.isGenerating = false;\n finalizeAssistantTurn(\"cancelled\");\n rerenderThread();\n updateComposer();\n break;\n case \"generation_error\":\n clearStartTimeout();\n state.isGenerating = false;\n state.startingSession = false;\n state.pendingMessage = null;\n state.pendingMessageId = null;\n finalizeAssistantTurn(\"errored\");\n // Rerender so the errored assistant message gets the Regenerate\n // button via renderAssistantBubble (status === \"errored\" qualifies).\n rerenderThread();\n // Loud inline error block in the thread so the user can't miss it.\n // Cleared on the next turn_started, on session switch, or manually\n // via the dismiss button.\n const errBlock = el(\"div\", \"la-error-banner\");\n const errHeader = el(\"div\", \"la-error-banner-header\");\n const errTitle = el(\"div\", \"la-error-banner-title\", \"Generation failed\");\n const errDismiss = el(\"button\", \"la-error-banner-dismiss\", \"✕\") as HTMLButtonElement;\n errDismiss.setAttribute(\"aria-label\", \"Dismiss error\");\n errDismiss.title = \"Dismiss\";\n errDismiss.addEventListener(\"click\", () => { errBlock.remove(); });\n errHeader.append(errTitle, errDismiss);\n const errBody = el(\"pre\", \"la-error-banner-body\", msg.error);\n errBlock.append(errHeader, errBody);\n // Banners sit below the virtualizer's spacer at the end of the\n // scroll container, outside the virtualized list.\n thread.appendChild(errBlock);\n virtualizer.scrollToBottom();\n composerStatus.textContent = \"\";\n composerStatus.classList.remove(\"is-error\");\n updateComposer();\n break;\n case \"edit_reverted\":\n handleRevertOutcome(msg.editId, msg.outcome);\n break;\n case \"edits_reverted_bulk\": {\n const removed = new Set();\n let okCount = 0; let cascadeCount = 0; let failed = 0;\n for (const { editId, outcome } of msg.outcomes) {\n if (outcome.kind === \"clean\" || outcome.kind === \"noop_already_reverted\") {\n removed.add(editId);\n okCount++;\n if (outcome.kind === \"clean\" && outcome.cascadedEditIds) {\n for (const id of outcome.cascadedEditIds) removed.add(id);\n cascadeCount += outcome.cascadedEditIds.length;\n }\n } else if (outcome.kind === \"failed\") {\n failed++;\n }\n }\n spliceEntries(removed);\n rerenderThread();\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n const parts: string[] = [];\n if (okCount > 0) parts.push(`Reverted ${okCount} edit${okCount === 1 ? \"\" : \"s\"}`);\n if (cascadeCount > 0) parts.push(`+${cascadeCount} cascaded`);\n if (failed > 0) parts.push(`${failed} failed`);\n composerStatus.textContent = parts.join(\", \");\n composerStatus.classList.toggle(\"is-error\", failed > 0);\n break;\n }\n case \"character_edits_pushed\":\n state.characterLedger = [...msg.entries];\n updateSessionBar();\n if (state.diffModal) state.diffModal.setEdits(state.characterLedger);\n break;\n case \"session_truncated\":\n state.messages = [...msg.messages];\n state.edits = [...msg.edits];\n rerenderThread();\n virtualizer.scrollToBottom();\n updateSessionBar();\n updateComposer();\n if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n break;\n case \"chats_pushed\":\n dlog(\"chats_pushed received\", { msgCharacterId: msg.characterId, stateCharacterId: state.characterId, pinnedChatId: msg.pinnedChatId, chatCount: msg.chats.length, applies: state.characterId === msg.characterId });\n if (state.characterId === msg.characterId) {\n state.chatsForCharacter = [...msg.chats];\n state.pinnedChatId = msg.pinnedChatId;\n setChatPinned(msg.pinnedChatId !== null);\n if (state.autoPinNeeded && msg.pinnedChatId === null && msg.chats.length > 0) {\n state.autoPinNeeded = false;\n const newestId = msg.chats[0]!.id;\n if (state.sessionId) sendBackend({ type: \"set_pinned_chat\", sessionId: state.sessionId, chatId: newestId });\n else state.pendingPinChatId = newestId;\n }\n for (const h of pushChatsListeners.handlers) h();\n }\n break;\n case \"pinned_chat_set\":\n dlog(\"pinned_chat_set received\", { sessionId: msg.sessionId, chatId: msg.chatId, stateSessionId: state.sessionId, stateCharacterId: state.characterId });\n // Gate on session match, a stale message from another session would otherwise light the pin icon falsely.\n if (msg.sessionId === state.sessionId) {\n state.pinnedChatId = msg.chatId;\n setChatPinned(msg.chatId !== null);\n }\n if (state.characterId) sendBackend({ type: \"list_chats\", characterId: state.characterId, sessionId: msg.sessionId });\n break;\n case \"settings_pushed\":\n state.settings = {\n persona: msg.persona,\n systemPromptOverride: msg.systemPromptOverride,\n defaultPersona: msg.defaultPersona,\n defaultSystemPromptBody: msg.defaultSystemPromptBody,\n samplers: msg.samplers,\n jailbreak: msg.jailbreak,\n jailbreakPlacement: msg.jailbreakPlacement,\n workspaceCapBytes: msg.workspaceCapBytes,\n workspaceCapDefaultBytes: msg.workspaceCapDefaultBytes,\n workspaceFileCapBytes: msg.workspaceFileCapBytes,\n toolOutputCapTokens: msg.toolOutputCapTokens,\n toolOutputCapDefaultTokens: msg.toolOutputCapDefaultTokens,\n connectionSupportsPromptCaching: msg.connectionSupportsPromptCaching,\n autoFreeOldToolResults: msg.autoFreeOldToolResults,\n cacheMode: msg.cacheMode,\n parallelToolCalls: msg.parallelToolCalls,\n };\n for (const h of settingsListeners.handlers) h();\n break;\n case \"phoneline_pairings_pushed\":\n for (const h of pairingsListeners.handlers) h(msg.pairings);\n break;\n case \"ui_prefs_pushed\":\n // Apply the stored selection once it arrives from the backend.\n // renderConnOptions has its own current-value / default fallback,\n // so if connections were already pushed we just re-render to pick up\n // the resolved id without churning the dropdown.\n state.connectionId = msg.connectionId;\n if (state.connections.length > 0) renderConnOptions();\n // Restore the last-open session if the user doesn't already have one\n // mounted. If the session file is gone the backend will log a warning\n // and we stay in fresh state.\n if (msg.lastSessionId && !state.sessionId && !state.startingSession) {\n sendBackend({ type: \"load_session\", sessionId: msg.lastSessionId });\n }\n break;\n case \"ws_listed\":\n state.workspacePanel?.onListed(msg.path, msg.entries);\n break;\n case \"ws_text_pushed\":\n state.workspacePanel?.onTextPushed(msg.path, msg.content, msg.sizeBytes);\n break;\n case \"ws_changed\":\n state.workspacePanel?.onChanged();\n break;\n case \"ws_download_ready\":\n state.workspacePanel?.onDownloadReady(msg.path, msg.dataBase64, msg.mimeType);\n break;\n case \"ws_zip_ready\":\n state.workspacePanel?.onZipReady(msg.dataBase64, msg.filename);\n break;\n case \"ws_error\":\n state.workspacePanel?.onError(msg.error);\n break;\n case \"context_usage\":\n if (msg.sessionId === state.sessionId) {\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n }\n break;\n case \"compaction_started\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = true;\n updateCompactButton();\n }\n break;\n case \"compaction_completed\":\n if (msg.sessionId === state.sessionId) {\n state.compacting = false;\n state.contextPromptTokens = msg.promptTokens;\n state.contextTokens = msg.contextTokens;\n updateCompactButton();\n // Reload the session so the user sees the inline compaction marker\n // that the backend appended.\n sendBackend({ type: \"load_session\", sessionId: msg.sessionId });\n }\n break;\n case \"characters_storage_pushed\":\n state.charactersPanel?.onPushed(\n msg.entries,\n msg.workspaceUsedBytes,\n msg.workspaceCapBytes,\n );\n break;\n case \"frontend_rpc_request\": {\n // Backend asked us to do something only the browser can (currently:\n // run Chrome's on-device Translator API). Dispatch by op, post the\n // result (or error) back via frontend_rpc_response.\n void (async () => {\n try {\n let result: unknown;\n if (msg.op === \"translate_batch\") {\n const { handleTranslateBatch } = await import(\"./translator-bridge\");\n result = await handleTranslateBatch(msg.args);\n } else if (msg.op === \"ask_user_question\") {\n const { showAskUserQuestion } = await import(\"./ask-user-modal\");\n result = await showAskUserQuestion(msg.args as Parameters[0]);\n } else if (msg.op === \"phoneline_consent\") {\n const { showPhonelineConsent } = await import(\"./phoneline-consent-modal\");\n result = await showPhonelineConsent(msg.args as Parameters[0]);\n } else {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: `unknown rpc op '${msg.op}'` });\n return;\n }\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, result });\n } catch (err) {\n sendBackend({ type: \"frontend_rpc_response\", rpcId: msg.rpcId, error: (err as Error).message });\n }\n })();\n break;\n }\n case \"character_squashed\":\n // If we just squashed the focused character, drop the focus override\n // and reload the active character's ledger so the Edits tab is sane.\n if (state.workshopFocusCharacterId === msg.characterId) {\n state.workshopFocusCharacterId = null;\n state.workshopFocusCharacterName = null;\n }\n if (state.characterId === msg.characterId) {\n state.characterLedger = [];\n state.edits = state.edits.map((e) => ({ ...e, reverted: true }));\n rerenderThread();\n updateSessionBar();\n }\n break;\n }\n });\n\n render();\n refreshLists();\n\n const adoptActiveChat = (): void => {\n // Don't fight a restored / loaded session: if the user has one open, its\n // character is the source of truth even when the host navigates to a chat\n // for a different character.\n if (state.sessionId || state.startingSession) return;\n const active = ctx.getActiveChat();\n if (!active.characterId) return;\n if (state.characterId === active.characterId) return;\n state.characterId = active.characterId;\n charCombo.setValue(active.characterId, true);\n state.characterLedger = [];\n updateSessionBar();\n updateComposer();\n sendBackend({ type: \"list_character_edits\", characterId: active.characterId });\n };\n adoptActiveChat();\n const offChatSwitched = ctx.events.on(\"CHAT_SWITCHED\", () => adoptActiveChat());\n\n const off = tab.onActivate(() => {\n refreshLists();\n adoptActiveChat();\n if (state.sessionId) sendBackend({ type: \"load_session\", sessionId: state.sessionId });\n else if (state.characterId) sendBackend({ type: \"list_character_edits\", characterId: state.characterId });\n });\n\n return () => {\n off();\n offChatSwitched();\n charCombo.destroy();\n removeStyle();\n tab.destroy();\n };\n}\n"}, "d578f7a7f70c843ff45c946f654d5439118aeb10": {"t": 1785771316.5042903, "n": 113444, "h": "6c907fcecd38d8e8", "c": "import type { SpindleFrontendContext, SpindleModalHandle } from \"lumiverse-spindle-types\";\nimport type {\n BackendToFrontend,\n ChatAssistantMessage,\n ChatMessage,\n ChatSummary,\n ChatUserMessage,\n CharacterSummary,\n ConnectionSummary,\n EditLogEntry,\n FrontendToBackend,\n RevertOutcomeWire,\n SessionSummaryWire,\n} from \"../types\";\nimport { STYLES } from \"./styles\";\nimport {\n type AssistantHandle,\n buildEditIndex,\n createStreamingAssistant,\n renderMessage,\n} from \"./chat-thread\";\nimport { ChatVirtualizer } from \"./chat-virtualizer\";\nimport { openDiffModal, type DiffModalHandle } from \"./diff-modal\";\nimport { mountWorkspacePanel, type WorkspacePanelHandle } from \"./workspace-panel\";\nimport { mountCharactersPanel, type CharactersPanelHandle } from \"./characters-panel\";\nimport { mountCombo, type ComboHandle } from \"./combo\";\nimport { handleAgentEvent, type AgentEventCtx } from \"./agent-event-handler\";\nimport { ICON_TRASH, ICON_DOWNLOAD, ICON_PIN, ICON_PIN_OFF, ICON_NEW, ICON_SESSIONS, ICON_SETTINGS, ICON_WORKSHOP } from \"./icons\";\nimport { DEFAULT_ICON_DATA_URL } from \"../generated/default-icon\";\nimport { MOUSEY_SITTING_DATA_URL } from \"../generated/mousey\";\n\nconst ICON_STORAGE_KEY = \"lumiagent.customIcon.v1\";\nconst MOUSEY_STORAGE_KEY = \"lumiagent.customMousey.v1\";\nconst DISPLAY_NAME_STORAGE_KEY = \"lumiagent.displayName.v1\";\nconst DEFAULT_DISPLAY_NAME = \"LumiAgent\";\nconst DEFAULT_DISPLAY_SHORT = \"Agent\";\n\nfunction resolveDrawerIconUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(ICON_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return DEFAULT_ICON_DATA_URL;\n}\n\nfunction resolveMouseyImageUrl(): string {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(MOUSEY_STORAGE_KEY) : null;\n if (v && v.startsWith(\"data:image/\")) return v;\n } catch { /* localStorage unavailable */ }\n return MOUSEY_SITTING_DATA_URL;\n}\n\nfunction resolveDisplayName(): { full: string; short: string } {\n try {\n const v = typeof localStorage !== \"undefined\" ? localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) : null;\n if (v && v.trim().length > 0) {\n const trimmed = v.trim();\n return { full: trimmed, short: trimmed.length > 8 ? trimmed.slice(0, 8) : trimmed };\n }\n } catch { /* localStorage unavailable */ }\n return { full: DEFAULT_DISPLAY_NAME, short: DEFAULT_DISPLAY_SHORT };\n}\n\n\ninterface UiState {\n characters: CharacterSummary[];\n connections: ConnectionSummary[];\n sessions: SessionSummaryWire[];\n sessionId: string | null;\n characterId: string | null;\n characterName: string | null;\n connectionId: string | null;\n messages: ChatMessage[];\n edits: EditLogEntry[];\n characterLedger: EditLogEntry[];\n chatsForCharacter: ChatSummary[];\n pinnedChatId: string | null;\n settings: { persona: string; systemPromptOverride: string | null; defaultPersona: string; defaultSystemPromptBody?: string; samplers?: Readonly>; jailbreak?: string; jailbreakPlacement?: \"system_suffix\" | \"user_suffix\" | \"assistant_prefill\"; workspaceCapBytes?: number | null; workspaceCapDefaultBytes?: number; workspaceFileCapBytes?: number; toolOutputCapTokens?: number | null; toolOutputCapDefaultTokens?: number; connectionSupportsPromptCaching?: boolean; autoFreeOldToolResults?: boolean; cacheMode?: \"off\" | \"system_only\" | \"full\"; parallelToolCalls?: boolean } | null;\n pendingPinChatId: string | null;\n // Single-shot, reset after consume so a later list_chats won't re-pin after the user explicitly unpinned.\n autoPinNeeded: boolean;\n isGenerating: boolean;\n startingSession: boolean;\n compacting: boolean;\n contextPromptTokens: number;\n contextTokens: number;\n pendingMessage: string | null;\n pendingMessageId: string | null;\n startSessionTimeout: ReturnType | null;\n streamingAssistant: AssistantHandle | null;\n currentAssistantMessage: ChatAssistantMessage | null;\n diffModal: DiffModalHandle | null;\n workspacePanel: WorkspacePanelHandle | null;\n charactersPanel: CharactersPanelHandle | null;\n workshopFocusCharacterId: string | null;\n workshopFocusCharacterName: string | null;\n // Whether the streaming bubble currently shows the cycling thinking\n // indicator. The indicator itself lives inside the bubble; this flag just\n // gates re-toggles so we don't churn DOM nodes on every event.\n loading: boolean;\n // Message id whose bubble currently hosts an inline edit textarea.\n // rerenderThread preserves that bubble while this is set.\n editingMessageId: string | null;\n}\n\nfunction makeId(prefix: string): string {\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined) e.textContent = text;\n return e;\n}\n\nexport function mountDrawer(ctx: SpindleFrontendContext): () => void {\n const removeStyle = ctx.dom.addStyle(STYLES);\n // Namespaced diagnostic logger. Cheap enough to leave on; filterable via the\n // browser console's text filter (\"[lumiagent]\").\n const dlog = (...args: unknown[]): void => { console.log(\"[lumiagent]\", ...args); };\n const displayName = resolveDisplayName();\n const tab = ctx.ui.registerDrawerTab({\n id: \"lumiagent\",\n title: displayName.full,\n shortName: displayName.short,\n description: \"Agentic editor for character cards\",\n keywords: [\"agent\", \"edit\", \"translate\", \"lorebook\", \"regex\"],\n iconUrl: resolveDrawerIconUrl(),\n });\n\n const root = tab.root;\n root.classList.add(\"la-drawer\");\n\n const state: UiState = {\n characters: [],\n connections: [],\n sessions: [],\n sessionId: null,\n characterId: null,\n characterName: null,\n connectionId: null,\n messages: [],\n edits: [],\n characterLedger: [],\n chatsForCharacter: [],\n pinnedChatId: null,\n settings: null,\n pendingPinChatId: null,\n autoPinNeeded: false,\n isGenerating: false,\n startingSession: false,\n compacting: false,\n contextPromptTokens: 0,\n contextTokens: 128_000,\n pendingMessage: null,\n pendingMessageId: null,\n startSessionTimeout: null,\n streamingAssistant: null,\n currentAssistantMessage: null,\n diffModal: null,\n workspacePanel: null,\n charactersPanel: null,\n workshopFocusCharacterId: null,\n workshopFocusCharacterName: null,\n loading: false,\n editingMessageId: null,\n };\n\n const header = el(\"header\", \"la-header\");\n\n const makeIconBtn = (cls: string, svg: string, label: string, hint: string): HTMLButtonElement => {\n const b = el(\"button\", `la-btn la-icon-btn ${cls}`) as HTMLButtonElement;\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = hint;\n b.innerHTML = svg;\n return b;\n };\n\n const rowChar = el(\"div\", \"la-header-row la-header-row-char\");\n const charComboRoot = el(\"div\", \"la-combo-host la-combo-host-char\");\n charComboRoot.setAttribute(\"aria-label\", \"Character\");\n const charCombo: ComboHandle = mountCombo(charComboRoot);\n charCombo.setPlaceholder(\"Pick character\");\n const chatPinBtn = makeIconBtn(\"la-chat-pin-btn\", ICON_PIN_OFF, \"Pin a chat to share with the agent\",\n \"Pin a chat (gives the agent message-history access)\");\n // Swap both the class (drives the active-tint CSS) and the icon SVG.\n const setChatPinned = (pinned: boolean): void => {\n chatPinBtn.classList.toggle(\"has-pinned\", pinned);\n chatPinBtn.innerHTML = pinned ? ICON_PIN : ICON_PIN_OFF;\n };\n const switchSessionBtn = makeIconBtn(\"\", ICON_SESSIONS, \"Switch session\", \"Switch session\");\n const newSessionBtn = makeIconBtn(\"\", ICON_NEW, \"Start a new chat session\", \"New session\");\n const settingsBtn = makeIconBtn(\"\", ICON_SETTINGS, \"Agent settings\", \"Agent settings (persona & prompt)\");\n const menuBtn = el(\"button\", \"la-btn la-icon-btn\") as HTMLButtonElement;\n menuBtn.setAttribute(\"aria-label\", \"More\");\n menuBtn.innerHTML = '';\n const connComboRoot = el(\"div\", \"la-combo-host la-combo-host-conn\");\n connComboRoot.setAttribute(\"aria-label\", \"Connection\");\n const connCombo: ComboHandle = mountCombo(connComboRoot);\n connCombo.setPlaceholder(\"Default connection\");\n rowChar.append(charComboRoot, chatPinBtn, switchSessionBtn, newSessionBtn);\n\n const rowMeta = el(\"div\", \"la-header-row la-header-row-meta\");\n const editsBadge = makeIconBtn(\"la-changes-btn\", ICON_WORKSHOP, \"Open diff viewer\", \"Workshop\");\n const editsCount = el(\"span\", \"la-changes-count\", \"0\");\n editsBadge.appendChild(editsCount);\n rowMeta.append(connComboRoot, editsBadge, settingsBtn, menuBtn);\n\n header.append(rowChar, rowMeta);\n\n // Thread\n const thread = el(\"div\", \"la-thread\");\n const emptyState = el(\"div\", \"la-empty\");\n emptyState.append(\n Object.assign(el(\"h3\"), { textContent: \"What can I do?\" }),\n Object.assign(el(\"p\"), { textContent: \"Pick a character and ask me to translate, refactor, audit, add lorebook entries, or anything else. Every edit shows as a diff you can review and revert. Here are some examples:\" }),\n );\n\n const SUGGESTIONS: ReadonlyArray<{ label: string; send: string }> = [\n {\n label: \"Translate the greeting messages of this card\",\n send: \"Translate every greeting message of this card to English. That means the canonical first_mes (path `char/first_mes`) AND every alternate greeting (paths `char/alternate_greetings/`). For each one: `read` the path, construct the full English version, then `rewrite({path, new_content})` with the whole English version. Keep all tags, markdown headings, status-panel emoji markers, and named regex capture groups byte-identical — only translate the natural-language prose between them. SKIP any segment that is already in English or already has an English counterpart nearby (a parenthetical English gloss, a bilingual line, a label/value pair where one side is English). Only translate text that has no English equivalent anywhere in the surrounding context. If this card has a LumiRealm payload, mirror the canonical edits into the payload so the change survives translator schema migrations.\",\n },\n {\n label: \"Translate the UI in this card\",\n send: \"Translate the user-visible labels inside this card's UI surfaces to English. UI lives in THREE places on Risu/LumiRealm cards: regex scripts (replace_string content), Lua scripts (paths under `char/extensions/lumirealm.payload.lua_scripts`, often the bigger source — button labels, dialog choices, status-panel text), AND background HTML (`char/extensions/lumirealm.payload.background_html_source` if present, else `char/extensions/lumirealm.payload.background_html`). Cover all three.\\n\\nCRITICAL SAFETY RULES — read these before touching anything:\\n\\n1. NEVER modify regex find_regex patterns. Those are matched against LLM output; changing the pattern breaks the rule.\\n2. In regex scripts: only edit replace_string content, and only the user-visible HTML/text inside it. Do NOT touch capture group refs ($1, $&, $), HTML attribute names, CSS class names, JSON keys, or regex syntax characters.\\n3. If a label is inside a structural tag (e.g.
Label
), translate ONLY the inner text — leave the tag and its attributes alone.\\n4. In Lua scripts: edit ONLY content inside quoted string literals (`\\\"...\\\"`, `'...'`, `[[...]]`). NEVER touch code logic — opcodes, function/variable names, table keys, operators, control flow, comments. Use `set({path, value})` to write back the whole `lumirealm.payload.lua_scripts` array.\\n5. In background HTML: edit ONLY user-visible inner text. NEVER touch tag names, attribute names (id/class/data-*/style/etc.), attribute values that drive behaviour or styling, CSS selectors, CSS property names, JS code inside