arnaucube
arnaucube/study-labPublic Self-hosted repository
study-lab/app.js
app.jsRaw
   1(() => {
   2  'use strict';
   3
   4  const APP_PREFIX = 'study-lab';
   5  const STORAGE_KEY = `${APP_PREFIX}:v1`;
   6  const SETTINGS_KEY = `${APP_PREFIX}:settings:v1`;
   7  const USAGE_KEY = `${APP_PREFIX}:usage:v1`;
   8  const TREE_ZOOM_KEY = `${APP_PREFIX}:tree-zoom`;
   9  const THEME_KEY = `${APP_PREFIX}:theme`;
  10  const PDF_REQUEST_LIMIT = 50 * 1024 * 1024;
  11  // USD per million tokens. Last checked against OpenAI Docs on 2026-08-17.
  12  const MODEL_PRICES = [
  13    { match: /^gpt-5\.6-terra(?:-|$)/, input: 2, cached: .2, output: 12 },
  14    { match: /^gpt-5\.6-luna(?:-|$)/, input: .2, cached: .02, output: 1.2 },
  15    { match: /^gpt-5\.6-sol(?:-|$)/, input: 5, cached: .5, output: 30 },
  16    { match: /^gpt-5\.6$/, input: 5, cached: .5, output: 30 },
  17    { match: /^gpt-5\.4-mini(?:-|$)/, input: .75, cached: .075, output: 4.5 },
  18    { match: /^gpt-5\.4(?:-20\d{2}|$)/, input: 2.5, cached: .25, output: 15 },
  19    { match: /^gpt-5-mini(?:-|$)/, input: .25, cached: .025, output: 2 },
  20    { match: /^gpt-5-nano(?:-|$)/, input: .05, cached: .005, output: .4 },
  21    { match: /^gpt-5(?:-20\d{2}|$)/, input: 1.25, cached: .125, output: 10 },
  22    { match: /^gpt-4\.1-mini(?:-|$)/, input: .4, cached: .1, output: 1.6 },
  23    { match: /^gpt-4\.1(?:-20\d{2}|$)/, input: 2, cached: .5, output: 8 },
  24    { match: /^gpt-4o-mini(?:-|$)/, input: .15, cached: .075, output: .6 }
  25  ];
  26  const DEFAULT_SETTINGS = {
  27    apiKey: '',
  28    model: 'gpt-5-mini',
  29    apiUrl: 'https://api.openai.com/v1/responses',
  30    systemPrompt: 'You are a patient technical tutor. Use clear Markdown and LaTeX where useful. Use mathematical language, assume graduate level knowledge. Build on the conversation context and explain assumptions.'
  31  };
  32
  33  const $ = (selector, root = document) => root.querySelector(selector);
  34  const els = {
  35    welcome: $('#welcome'), conversation: $('#conversation'), composer: $('#composer'),
  36    prompt: $('#prompt'), send: $('#send'), tree: $('#tree'), mapEmpty: $('#map-empty'),
  37    nodeCount: $('#node-count'), branchCount: $('#branch-count'), branchContext: $('#branch-context'),
  38    branchLabel: $('#branch-label'), selectionMenu: $('#selection-menu'), selectionPreview: $('#selection-preview'),
  39    selectionAction: $('#selection-action'), selectionCustom: $('#selection-custom'), settings: $('#settings-dialog'),
  40    apiKey: $('#api-key'), model: $('#model'), apiUrl: $('#api-url'), systemPrompt: $('#system-prompt'),
  41    keyStatus: $('#key-status'), toast: $('#toast'), usageDialog: $('#usage-dialog'),
  42    monthTokens: $('#month-tokens'), usageMonth: $('#usage-month'), usageTotal: $('#usage-total'),
  43    usageCost: $('#usage-cost'), usageRequests: $('#usage-requests'), usageInput: $('#usage-input'),
  44    usageCached: $('#usage-cached'), usageOutput: $('#usage-output'), usageModels: $('#usage-models'),
  45    usageRecent: $('#usage-recent'), usageNote: $('#usage-note'), historyList: $('#history-list'),
  46    historyEmpty: $('#history-empty'), historyToggle: $('#toggle-history'),
  47    attachments: $('#attachments'), uploadPDF: $('#upload-pdf'), pdfInput: $('#pdf-input'),
  48    conversationSearch: $('#conversation-search'), searchResults: $('#search-results'),
  49    treeZoom: $('#tree-zoom'), shortcutsDialog: $('#shortcuts-dialog')
  50  };
  51
  52  migrateLegacyStorage();
  53  let settings = loadJSON(SETTINGS_KEY, DEFAULT_SETTINGS);
  54  let workspace = loadWorkspace();
  55  let state = currentMap();
  56  let usageRecords = loadUsage();
  57  let activeRequest = null;
  58  let uploadingPDFs = false;
  59  let uploadTargetMapId = null;
  60  let selectedText = null;
  61  let saveTimer = 0;
  62  let toastTimer = 0;
  63  let searchTimer = 0;
  64  let searchIndex = null;
  65  let pendingG = false;
  66  let pendingGTimer = 0;
  67  let treeZoom = loadTreeZoom();
  68
  69  function loadJSON(key, fallback) {
  70    try { return { ...fallback, ...JSON.parse(localStorage.getItem(key) || '{}') }; }
  71    catch { return { ...fallback }; }
  72  }
  73
  74  function migrateLegacyStorage() {
  75    const legacyPrefix = ['study', 'pal'].join('-');
  76    const suffixes = [':v1', ':settings:v1', ':usage:v1', ':theme', ':tree-zoom'];
  77    for (const suffix of suffixes) {
  78      const currentKey = `${APP_PREFIX}${suffix}`;
  79      const legacyKey = `${legacyPrefix}${suffix}`;
  80      try {
  81        if (localStorage.getItem(currentKey) === null && localStorage.getItem(legacyKey) !== null) {
  82          localStorage.setItem(currentKey, localStorage.getItem(legacyKey));
  83        }
  84      } catch {}
  85    }
  86  }
  87
  88  function loadUsage() {
  89    try {
  90      const records = JSON.parse(localStorage.getItem(USAGE_KEY) || '[]');
  91      return Array.isArray(records) ? records.filter(record => record && Number.isFinite(record.timestamp)) : [];
  92    } catch { return []; }
  93  }
  94
  95  function loadTreeZoom() {
  96    try { return Math.max(.8, Math.min(1.4, Number(localStorage.getItem(TREE_ZOOM_KEY)) || 1)); }
  97    catch { return 1; }
  98  }
  99
 100  function blankMap() {
 101    const now = Date.now();
 102    return { id: uid(), title: 'Untitled map', titleCustom: false, nodes: [], attachments: [], collapsedIds: [], activeId: null, createdAt: now, updatedAt: now };
 103  }
 104
 105  function titleFromNodes(nodes) {
 106    const first = nodes.find(node => !node.parentId) || nodes[0];
 107    if (!first) return 'Untitled map';
 108    const title = String(first.question).replace(/\s+/g, ' ').trim();
 109    return title.length > 54 ? `${title.slice(0, 54)}…` : title;
 110  }
 111
 112  function normalizeMap(value = {}) {
 113    const normalized = normalizeState(value);
 114    const attachments = Array.isArray(value.attachments) ? value.attachments
 115      .filter(file => file && file.id && file.name)
 116      .map(file => ({
 117        id: String(file.id), name: String(file.name), size: Number(file.size) || 0,
 118        createdAt: Number(file.createdAt) || Date.now(), detail: file.detail === 'high' ? 'high' : 'low'
 119      })) : [];
 120    const createdAt = Number(value.createdAt) || Number(normalized.nodes[0]?.createdAt) || Date.now();
 121    return {
 122      id: String(value.id || uid()),
 123      title: String(value.title || titleFromNodes(normalized.nodes)),
 124      titleCustom: Boolean(value.titleCustom),
 125      ...normalized,
 126      attachments,
 127      collapsedIds: Array.isArray(value.collapsedIds) ? [...new Set(value.collapsedIds.map(String))] : [],
 128      createdAt,
 129      updatedAt: Number(value.updatedAt) || Number(normalized.nodes.at(-1)?.createdAt) || createdAt
 130    };
 131  }
 132
 133  function loadWorkspace() {
 134    let raw = null;
 135    try { raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch {}
 136    let maps = [];
 137    let activeMapId = null;
 138    if (raw && Array.isArray(raw.maps)) {
 139      maps = raw.maps.filter(Boolean).map(normalizeMap);
 140      activeMapId = raw.activeMapId;
 141    } else if (raw && Array.isArray(raw.nodes) && raw.nodes.length) {
 142      // Migrate the original single-map schema in place.
 143      const migrated = normalizeMap(raw);
 144      maps = [migrated];
 145      activeMapId = migrated.id;
 146    }
 147    if (!maps.length) maps.push(blankMap());
 148    if (!maps.some(map => map.id === activeMapId)) activeMapId = maps[0].id;
 149    return { version: 2, maps, activeMapId };
 150  }
 151
 152  function currentMap() {
 153    return workspace.maps.find(map => map.id === workspace.activeMapId) || workspace.maps[0];
 154  }
 155
 156  function normalizeState(value) {
 157    const nodes = Array.isArray(value.nodes) ? value.nodes.filter(n => n && n.id && n.question).map(node => {
 158      if (node.status !== 'loading') return node;
 159      return { ...node, status: 'error', error: 'The page was closed before this response finished.' };
 160    }) : [];
 161    const activeId = nodes.some(n => n.id === value.activeId) ? value.activeId : (nodes.at(-1)?.id || null);
 162    return { nodes, activeId };
 163  }
 164
 165  function persistSoon() {
 166    clearTimeout(saveTimer);
 167    saveTimer = setTimeout(() => {
 168      try { localStorage.setItem(STORAGE_KEY, JSON.stringify(workspace)); }
 169      catch { showToast('Browser storage is full — conversations could not be saved'); }
 170    }, 180);
 171  }
 172
 173  function persistNow() {
 174    clearTimeout(saveTimer);
 175    try { localStorage.setItem(STORAGE_KEY, JSON.stringify(workspace)); return true; }
 176    catch { showToast('Browser storage is full — conversations could not be saved'); return false; }
 177  }
 178
 179  function touchMap(map = state) { map.updatedAt = Date.now(); searchIndex = null; }
 180
 181  function refreshMapTitle(map = state) {
 182    if (!map.titleCustom) map.title = titleFromNodes(map.nodes);
 183  }
 184
 185  function nodeById(id, map = state) { return map.nodes.find(node => node.id === id); }
 186
 187  function pathTo(id, map = state) {
 188    const path = [];
 189    const seen = new Set();
 190    let node = nodeById(id, map);
 191    while (node && !seen.has(node.id)) {
 192      seen.add(node.id);
 193      path.push(node);
 194      node = nodeById(node.parentId, map);
 195    }
 196    return path.reverse();
 197  }
 198
 199  function childrenOf(id) { return state.nodes.filter(node => node.parentId === id); }
 200
 201  function uid() {
 202    return globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
 203  }
 204
 205  function escapeHTML(value = '') {
 206    return value.replace(/[&<>'"]/g, char => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', "'":'&#39;', '"':'&quot;' })[char]);
 207  }
 208
 209  function inlineMarkdown(text) {
 210    let output = escapeHTML(text);
 211    output = output.replace(/`([^`]+)`/g, '<code>$1</code>');
 212    output = output.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
 213    output = output.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
 214    output = output.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
 215    return output;
 216  }
 217
 218  function renderMath(source, displayMode) {
 219    if (!globalThis.katex) return `<code class="math-fallback">${escapeHTML(source)}</code>`;
 220    try { return katex.renderToString(source, { displayMode, throwOnError: false, strict: false, trust: false }); }
 221    catch { return `<code class="math-fallback">${escapeHTML(source)}</code>`; }
 222  }
 223
 224  function markdown(source = '') {
 225    const stash = [];
 226    const token = html => `\u0000${stash.push(html) - 1}\u0000`;
 227    let text = source.replace(/\r\n?/g, '\n');
 228    text = text.replace(/```([\w+-]*)\n([\s\S]*?)```/g, (_, lang, code) => token(`<pre data-language="${escapeHTML(lang)}"><code>${escapeHTML(code.trimEnd())}</code></pre>`));
 229    text = text.replace(/\$\$([\s\S]*?)\$\$/g, (_, formula) => token(renderMath(formula.trim(), true)));
 230    text = text.replace(/\\\[([\s\S]*?)\\\]/g, (_, formula) => token(renderMath(formula.trim(), true)));
 231    text = text.replace(/\\\((.*?)\\\)/g, (_, formula) => token(renderMath(formula.trim(), false)));
 232    text = text.replace(/(?<!\\)\$([^$\n]+?)\$/g, (_, formula) => token(renderMath(formula.trim(), false)));
 233
 234    const lines = text.split('\n');
 235    const html = [];
 236    let paragraph = [];
 237    let list = null;
 238    const flushParagraph = () => {
 239      if (paragraph.length) html.push(`<p>${inlineMarkdown(paragraph.join('\n')).replace(/\n/g, '<br>')}</p>`);
 240      paragraph = [];
 241    };
 242    const closeList = () => { if (list) html.push(`</${list}>`); list = null; };
 243
 244    for (const line of lines) {
 245      const heading = line.match(/^(#{1,3})\s+(.+)/);
 246      const bullet = line.match(/^\s*[-*+]\s+(.+)/);
 247      const numbered = line.match(/^\s*\d+[.)]\s+(.+)/);
 248      const quote = line.match(/^>\s?(.*)/);
 249      const blockToken = line.match(/^\u0000(\d+)\u0000$/);
 250      if (blockToken) { flushParagraph(); closeList(); html.push(stash[Number(blockToken[1])]); }
 251      else if (heading) { flushParagraph(); closeList(); const level = heading[1].length; html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`); }
 252      else if (bullet || numbered) {
 253        flushParagraph();
 254        const wanted = bullet ? 'ul' : 'ol';
 255        if (list !== wanted) { closeList(); html.push(`<${wanted}>`); list = wanted; }
 256        html.push(`<li>${inlineMarkdown((bullet || numbered)[1])}</li>`);
 257      } else if (quote) { flushParagraph(); closeList(); html.push(`<blockquote>${inlineMarkdown(quote[1])}</blockquote>`); }
 258      else if (!line.trim()) { flushParagraph(); closeList(); }
 259      else paragraph.push(line);
 260    }
 261    flushParagraph(); closeList();
 262    return html.join('').replace(/\u0000(\d+)\u0000/g, (_, index) => stash[Number(index)]);
 263  }
 264
 265  function answerHTML(node) {
 266    if (node.status === 'loading' && !node.answer) return '<span class="thinking" aria-label="Thinking"><i></i><i></i><i></i></span>';
 267    if (node.status === 'error') return `<p class="error-text">${escapeHTML(node.error || 'The request failed.')}</p>`;
 268    return markdown(node.answer || '');
 269  }
 270
 271  function renderConversation({ scroll = false } = {}) {
 272    const path = pathTo(state.activeId);
 273    els.welcome.hidden = path.length > 0;
 274    els.conversation.hidden = path.length === 0;
 275    const fragment = document.createDocumentFragment();
 276    for (const node of path) {
 277      const article = document.createElement('article');
 278      article.className = 'message';
 279      article.dataset.nodeId = node.id;
 280      article.innerHTML = `
 281        <div class="question-row"><div class="question-bubble">${escapeHTML(node.question)}</div></div>
 282        <div class="answer"><span class="answer-mark" aria-hidden="true">S</span><div class="answer-body">${answerHTML(node)}</div></div>
 283        <div class="answer-actions">
 284          <button class="mini-action copy-answer" type="button">Copy answer</button>
 285          <button class="mini-action branch-here" type="button">Branch here</button>
 286          ${node.status === 'error' ? '<button class="mini-action retry-node" type="button">Retry</button>' : ''}
 287        </div>`;
 288      fragment.append(article);
 289    }
 290    els.conversation.replaceChildren(fragment);
 291    renderBranchContext();
 292    if (scroll && state.activeId) requestAnimationFrame(() => {
 293      if (scroll === 'bottom') {
 294        window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });
 295        return;
 296      }
 297      const answer = $(`.message[data-node-id="${CSS.escape(state.activeId)}"] .answer`, els.conversation);
 298      answer?.scrollIntoView({ block: 'start', behavior: 'smooth' });
 299    });
 300  }
 301
 302  function renderTree() {
 303    const ids = new Set(state.nodes.map(node => node.id));
 304    const grouped = new Map();
 305    for (const node of state.nodes) {
 306      const parentId = node.parentId && ids.has(node.parentId) ? node.parentId : null;
 307      if (!grouped.has(parentId)) grouped.set(parentId, []);
 308      grouped.get(parentId).push(node);
 309    }
 310    const roots = grouped.get(null) || [];
 311    const activePath = new Set(pathTo(state.activeId).map(node => node.id));
 312    const collapsed = new Set(state.collapsedIds);
 313    const build = nodes => {
 314      const ul = document.createElement('ul');
 315      for (const node of nodes) {
 316        const li = document.createElement('li');
 317        li.dataset.nodeId = node.id;
 318        const wrap = document.createElement('div');
 319        wrap.className = 'tree-node-wrap';
 320        const button = document.createElement('button');
 321        button.type = 'button';
 322        button.className = `tree-node${node.id === state.activeId ? ' active' : ''}${activePath.has(node.id) ? ' on-path' : ''}`;
 323        button.dataset.nodeId = node.id;
 324        button.setAttribute('aria-current', node.id === state.activeId ? 'true' : 'false');
 325        const children = grouped.get(node.id) || [];
 326        const isCollapsed = children.length > 0 && collapsed.has(node.id);
 327        const toggle = document.createElement(children.length ? 'button' : 'span');
 328        toggle.className = children.length ? 'toggle-subtree' : 'toggle-placeholder';
 329        if (children.length) {
 330          toggle.type = 'button';
 331          toggle.dataset.nodeId = node.id;
 332          toggle.setAttribute('aria-label', `${isCollapsed ? 'Expand' : 'Collapse'} branch: ${node.question}`);
 333          toggle.setAttribute('aria-expanded', String(!isCollapsed));
 334          toggle.textContent = isCollapsed ? '▸' : '▾';
 335        }
 336        button.innerHTML = `<span class="node-dot">${node.status === 'loading' ? '…' : (activePath.has(node.id) ? '●' : '○')}</span><span class="node-copy"><span class="node-title">${escapeHTML(node.question)}</span><span class="node-meta">${children.length} ${children.length === 1 ? 'reply' : 'branches'}${isCollapsed ? ' · hidden' : ''}</span></span>`;
 337        wrap.append(toggle, button);
 338        li.append(wrap);
 339        if (children.length && !isCollapsed) li.append(build(children));
 340        ul.append(li);
 341      }
 342      return ul;
 343    };
 344    els.tree.replaceChildren(build(roots));
 345    els.mapEmpty.hidden = state.nodes.length > 0;
 346    els.tree.hidden = state.nodes.length === 0;
 347    els.nodeCount.textContent = String(state.nodes.length);
 348    let branchCount = 0;
 349    for (const children of grouped.values()) branchCount += Math.max(0, children.length - 1);
 350    els.branchCount.textContent = String(branchCount);
 351  }
 352
 353  function revealNode(map, nodeId) {
 354    if (!nodeId) return;
 355    const hidden = new Set(map.collapsedIds);
 356    for (const node of pathTo(nodeId, map).slice(0, -1)) hidden.delete(node.id);
 357    map.collapsedIds = [...hidden];
 358  }
 359
 360  function toggleSubtree(nodeId, force) {
 361    if (!nodeById(nodeId)) return;
 362    const collapsed = new Set(state.collapsedIds);
 363    const shouldCollapse = force ?? !collapsed.has(nodeId);
 364    if (shouldCollapse) collapsed.add(nodeId);
 365    else collapsed.delete(nodeId);
 366    state.collapsedIds = [...collapsed];
 367    persistSoon();
 368    renderTree();
 369  }
 370
 371  function setAllSubtrees(collapsed) {
 372    if (!collapsed) state.collapsedIds = [];
 373    else {
 374      const parents = new Set(state.nodes.map(node => node.parentId).filter(Boolean));
 375      state.collapsedIds = [...parents];
 376    }
 377    persistSoon();
 378    renderTree();
 379  }
 380
 381  function updateTreeZoom(delta = 0) {
 382    treeZoom = Math.round(Math.max(.8, Math.min(1.4, treeZoom + delta)) * 10) / 10;
 383    els.tree.style.fontSize = `${.76 * treeZoom}rem`;
 384    els.tree.style.setProperty('--tree-indent', `${20 * treeZoom}px`);
 385    els.tree.style.setProperty('--tree-dot-size', `${17 * treeZoom}px`);
 386    els.tree.style.setProperty('--tree-node-pad-y', `${8 * treeZoom}px`);
 387    els.treeZoom.textContent = `${Math.round(treeZoom * 100)}%`;
 388    try { localStorage.setItem(TREE_ZOOM_KEY, String(treeZoom)); } catch {}
 389  }
 390
 391  function renderBranchContext() {
 392    const active = nodeById(state.activeId);
 393    const latest = state.nodes.at(-1);
 394    const branching = active && latest && active.id !== latest.id;
 395    els.branchContext.hidden = !branching;
 396    if (branching) els.branchLabel.textContent = active.question;
 397  }
 398
 399  function renderHistory() {
 400    const maps = workspace.maps
 401      .filter(map => map.nodes.length > 0 || map.attachments.length > 0)
 402      .sort((a, b) => b.updatedAt - a.updatedAt);
 403    const fragment = document.createDocumentFragment();
 404    for (const map of maps) {
 405      const row = document.createElement('div');
 406      row.className = 'history-row';
 407      const button = document.createElement('button');
 408      button.type = 'button';
 409      button.className = `history-item${map.id === workspace.activeMapId ? ' active' : ''}`;
 410      button.dataset.mapId = map.id;
 411      button.setAttribute('aria-current', map.id === workspace.activeMapId ? 'page' : 'false');
 412      const date = new Date(map.updatedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
 413      const pdfMeta = map.attachments.length ? ` · ${map.attachments.length} PDF${map.attachments.length === 1 ? '' : 's'}` : '';
 414      button.innerHTML = `<span class="history-glyph" aria-hidden="true">${map.nodes.length || map.attachments.length ? '◇' : '+'}</span><span class="history-copy"><span class="history-title">${escapeHTML(map.title)}</span><span class="history-meta">${map.nodes.length} ${map.nodes.length === 1 ? 'node' : 'nodes'}${pdfMeta} · ${date}</span></span>`;
 415      const rename = document.createElement('button');
 416      rename.type = 'button';
 417      rename.className = 'rename-map';
 418      rename.dataset.mapId = map.id;
 419      rename.setAttribute('aria-label', `Rename conversation: ${map.title}`);
 420      rename.title = 'Rename this conversation';
 421      rename.textContent = '✎';
 422      const remove = document.createElement('button');
 423      remove.type = 'button';
 424      remove.className = 'delete-map';
 425      remove.dataset.mapId = map.id;
 426      remove.setAttribute('aria-label', `Delete conversation: ${map.title}`);
 427      remove.title = 'Delete this conversation';
 428      remove.textContent = '×';
 429      row.append(button, rename, remove);
 430      fragment.append(row);
 431    }
 432    els.historyList.replaceChildren(fragment);
 433    els.historyEmpty.hidden = maps.length > 0;
 434    els.historyList.hidden = maps.length === 0;
 435    if (els.conversationSearch.value.trim()) renderSearchResults();
 436    else els.searchResults.hidden = true;
 437  }
 438
 439  function buildSearchIndex() {
 440    const entries = [];
 441    for (const map of workspace.maps) {
 442      if (!map.nodes.length && !map.attachments.length) continue;
 443      entries.push({ mapId: map.id, nodeId: null, kind: 'Map', label: map.title, fields: [map.title] });
 444      for (const node of map.nodes) {
 445        entries.push({
 446          mapId: map.id, nodeId: node.id, kind: 'Node', label: node.question,
 447          fields: [node.question, node.answer || ''], mapTitle: map.title
 448        });
 449      }
 450      for (const file of map.attachments) {
 451        entries.push({ mapId: map.id, nodeId: null, kind: 'PDF', label: file.name, fields: [file.name], mapTitle: map.title });
 452      }
 453    }
 454    searchIndex = entries;
 455    return entries;
 456  }
 457
 458  function searchExcerpt(fields, expression) {
 459    for (const field of fields) {
 460      const source = String(field || '').replace(/\s+/g, ' ').trim();
 461      const index = source.search(expression);
 462      if (index < 0) continue;
 463      const start = Math.max(0, index - 45);
 464      const end = Math.min(source.length, index + 115);
 465      return `${start ? '…' : ''}${source.slice(start, end)}${end < source.length ? '…' : ''}`;
 466    }
 467    return '';
 468  }
 469
 470  function renderSearchResults() {
 471    const query = els.conversationSearch.value.trim();
 472    if (!query) {
 473      els.searchResults.hidden = true;
 474      els.historyList.hidden = false;
 475      els.historyEmpty.hidden = workspace.maps.some(map => map.nodes.length || map.attachments.length);
 476      return;
 477    }
 478    const expression = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
 479    const matches = [];
 480    for (const entry of searchIndex || buildSearchIndex()) {
 481      const excerpt = searchExcerpt(entry.fields, expression);
 482      if (!excerpt) continue;
 483      matches.push({ ...entry, excerpt });
 484      if (matches.length >= 60) break;
 485    }
 486    els.searchResults.innerHTML = matches.length ? matches.map(entry => `
 487      <button class="search-result" type="button" data-map-id="${escapeHTML(entry.mapId)}"${entry.nodeId ? ` data-node-id="${escapeHTML(entry.nodeId)}"` : ''}>
 488        <span class="search-result-top"><strong>${escapeHTML(entry.label)}</strong><small>${escapeHTML(entry.kind)} · ${escapeHTML(entry.mapTitle || entry.label)}</small></span>
 489        <span class="search-excerpt">${escapeHTML(entry.excerpt)}</span>
 490      </button>`).join('') : `<p class="search-empty">No results for “${escapeHTML(query)}”.</p>`;
 491    els.searchResults.hidden = false;
 492    els.historyList.hidden = true;
 493    els.historyEmpty.hidden = true;
 494  }
 495
 496  function clearSearch() {
 497    clearTimeout(searchTimer);
 498    els.conversationSearch.value = '';
 499    renderSearchResults();
 500    renderHistory();
 501  }
 502
 503  function formatFileSize(bytes) {
 504    if (!bytes) return '';
 505    if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
 506    return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
 507  }
 508
 509  function renderAttachments() {
 510    const fragment = document.createDocumentFragment();
 511    for (const file of state.attachments) {
 512      const chip = document.createElement('span');
 513      chip.className = 'attachment-chip';
 514      chip.title = `${file.name}${file.size ? ` · ${formatFileSize(file.size)}` : ''} · Included with every question in this map`;
 515      chip.innerHTML = `<span class="pdf-badge" aria-hidden="true">PDF</span><span class="attachment-name">${escapeHTML(file.name)}</span><button class="remove-attachment" type="button" data-file-id="${escapeHTML(file.id)}" aria-label="Remove ${escapeHTML(file.name)} from this map">×</button>`;
 516      fragment.append(chip);
 517    }
 518    if (uploadingPDFs && state.id === uploadTargetMapId) {
 519      const status = document.createElement('span');
 520      status.className = 'attachment-chip upload-status';
 521      status.innerHTML = '<span class="upload-spinner" aria-hidden="true"></span> Uploading…';
 522      fragment.append(status);
 523    }
 524    els.attachments.replaceChildren(fragment);
 525    els.attachments.hidden = !state.attachments.length && !(uploadingPDFs && state.id === uploadTargetMapId);
 526    els.uploadPDF.disabled = uploadingPDFs || Boolean(activeRequest);
 527  }
 528
 529  function renderAll(options) { renderConversation(options); renderTree(); renderHistory(); renderAttachments(); }
 530
 531  function updateStreamedNode(node) {
 532    if (state.activeId !== node.id || !state.nodes.includes(node)) return;
 533    const body = $(`.message[data-node-id="${CSS.escape(node.id)}"] .answer-body`, els.conversation);
 534    if (body) body.innerHTML = answerHTML(node);
 535  }
 536
 537  function inputFor(map, parentId, question) {
 538    const messages = [];
 539    for (const node of pathTo(parentId, map)) {
 540      messages.push({ role: 'user', content: node.question });
 541      if (node.answer) messages.push({ role: 'assistant', content: node.answer });
 542    }
 543    const content = map.attachments.map(file => ({ type: 'input_file', file_id: file.id, detail: file.detail || 'low' }));
 544    content.push({ type: 'input_text', text: question });
 545    messages.push({ role: 'user', content: map.attachments.length ? content : question });
 546    return messages;
 547  }
 548
 549  function filesEndpoint() {
 550    const url = new URL(settings.apiUrl);
 551    if (!/\/responses\/?$/.test(url.pathname)) throw new Error('The configured endpoint is not a Responses API URL, so its Files endpoint cannot be derived.');
 552    url.pathname = url.pathname.replace(/\/responses\/?$/, '/files');
 553    url.search = '';
 554    url.hash = '';
 555    return url.toString();
 556  }
 557
 558  async function uploadPDFs(fileList) {
 559    const files = [...fileList];
 560    if (!files.length) return;
 561    if (activeRequest || uploadingPDFs) { showToast('Finish the current operation first'); return; }
 562    if (!settings.apiKey) { openSettings(); showToast('Add an OpenAI API key before uploading'); return; }
 563
 564    const valid = files.filter(file => file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'));
 565    if (valid.length !== files.length) { showToast('Only PDF files can be added'); return; }
 566    if (valid.some(file => file.size >= PDF_REQUEST_LIMIT)) { showToast('Each PDF must be smaller than 50 MB'); return; }
 567    const existingBytes = state.attachments.reduce((total, file) => total + file.size, 0);
 568    const addedBytes = valid.reduce((total, file) => total + file.size, 0);
 569    if (existingBytes + addedBytes >= PDF_REQUEST_LIMIT) { showToast('PDFs in one map must total less than 50 MB'); return; }
 570
 571    const targetMap = state;
 572    uploadingPDFs = true;
 573    uploadTargetMapId = targetMap.id;
 574    renderAttachments();
 575    let uploaded = 0;
 576    try {
 577      const endpoint = filesEndpoint();
 578      for (const file of valid) {
 579        const form = new FormData();
 580        form.append('purpose', 'user_data');
 581        form.append('file', file, file.name);
 582        const response = await fetch(endpoint, {
 583          method: 'POST', headers: { 'Authorization': `Bearer ${settings.apiKey}` }, body: form
 584        });
 585        if (!response.ok) {
 586          let message = `PDF upload failed (${response.status})`;
 587          try { const data = await response.json(); message = data.error?.message || message; } catch {}
 588          throw new Error(message);
 589        }
 590        const data = await response.json();
 591        if (!data.id) throw new Error('The Files API did not return a file ID.');
 592        targetMap.attachments.push({
 593          id: String(data.id), name: String(data.filename || file.name),
 594          size: Number(data.bytes) || file.size, createdAt: Date.now(), detail: 'low'
 595        });
 596        uploaded += 1;
 597        touchMap(targetMap);
 598        persistNow();
 599        if (state === targetMap) renderAll();
 600      }
 601      showToast(`${uploaded} PDF${uploaded === 1 ? '' : 's'} added to this map`);
 602    } catch (error) {
 603      showToast(error.message || 'PDF upload failed');
 604    } finally {
 605      uploadingPDFs = false;
 606      uploadTargetMapId = null;
 607      els.pdfInput.value = '';
 608      renderAll();
 609    }
 610  }
 611
 612  function removeAttachment(fileId) {
 613    const file = state.attachments.find(candidate => candidate.id === fileId);
 614    if (!file) return;
 615    if (!confirm(`Remove “${file.name}” from this map? The uploaded copy is not deleted from the API provider.`)) return;
 616    state.attachments = state.attachments.filter(candidate => candidate.id !== fileId);
 617    touchMap();
 618    persistNow();
 619    renderAll();
 620    showToast('PDF removed from this map');
 621  }
 622
 623  async function ask(question, parentId = state.activeId) {
 624    question = question.trim();
 625    if (!question) return;
 626    if (activeRequest) { showToast('Stop or finish the current response first'); return; }
 627    if (uploadingPDFs) { showToast('Wait for the PDF upload to finish'); return; }
 628    if (!settings.apiKey) { openSettings(); showToast('Add an OpenAI API key to begin'); return; }
 629
 630    const requestMap = state;
 631    const node = { id: uid(), parentId: parentId || null, question, answer: '', status: 'loading', createdAt: Date.now() };
 632    requestMap.nodes.push(node);
 633    requestMap.activeId = node.id;
 634    revealNode(requestMap, node.id);
 635    refreshMapTitle(requestMap);
 636    touchMap(requestMap);
 637    persistSoon();
 638    renderAll({ scroll: true });
 639    setBusy(true);
 640    const controller = new AbortController();
 641    activeRequest = controller;
 642    let completedUsage = null;
 643
 644    try {
 645      const response = await fetch(settings.apiUrl, {
 646        method: 'POST',
 647        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${settings.apiKey}` },
 648        body: JSON.stringify({ model: settings.model, instructions: settings.systemPrompt, input: inputFor(requestMap, parentId, question), stream: true }),
 649        signal: controller.signal
 650      });
 651      if (!response.ok) {
 652        let message = `API request failed (${response.status})`;
 653        try { const data = await response.json(); message = data.error?.message || message; } catch {}
 654        throw new Error(message);
 655      }
 656      if (!response.body) throw new Error('This browser did not provide a streaming response body.');
 657      await consumeSSE(response.body, event => {
 658        if (event.type === 'response.output_text.delta') node.answer += event.delta || '';
 659        if (event.type === 'response.completed' || event.type === 'response.done') {
 660          completedUsage = event.response?.usage || event.usage || completedUsage;
 661        }
 662        if (event.type === 'response.failed') throw new Error(event.response?.error?.message || 'The model response failed.');
 663      }, () => updateStreamedNode(node));
 664      node.status = 'complete';
 665      if (completedUsage) recordUsage(node.id, settings.model, settings.apiUrl, completedUsage);
 666    } catch (error) {
 667      node.status = 'error';
 668      node.error = error.name === 'AbortError' ? 'Response stopped.' : error.message;
 669    } finally {
 670      activeRequest = null;
 671      touchMap(requestMap);
 672      persistSoon();
 673      setBusy(false);
 674      renderAll();
 675    }
 676  }
 677
 678  async function consumeSSE(stream, onEvent, onPaint) {
 679    const reader = stream.getReader();
 680    const decoder = new TextDecoder();
 681    let buffer = '';
 682    let paintQueued = false;
 683    const queuePaint = () => {
 684      if (paintQueued) return;
 685      paintQueued = true;
 686      setTimeout(() => { paintQueued = false; onPaint(); }, 70);
 687    };
 688    const processBlock = block => {
 689      for (const line of block.split(/\r?\n/)) {
 690        if (!line.startsWith('data:')) continue;
 691        const data = line.slice(5).trim();
 692        if (!data || data === '[DONE]') continue;
 693        onEvent(JSON.parse(data));
 694        queuePaint();
 695      }
 696    };
 697    while (true) {
 698      const { done, value } = await reader.read();
 699      buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
 700      const blocks = buffer.split(/\r?\n\r?\n/);
 701      buffer = blocks.pop() || '';
 702      for (const block of blocks) processBlock(block);
 703      if (done) break;
 704    }
 705    if (buffer.trim()) processBlock(buffer);
 706    onPaint();
 707  }
 708
 709  function setBusy(busy) {
 710    els.prompt.disabled = busy;
 711    els.send.textContent = busy ? '■' : '↑';
 712    els.send.setAttribute('aria-label', busy ? 'Stop response' : 'Send message');
 713    els.uploadPDF.disabled = busy || uploadingPDFs;
 714  }
 715
 716  function openSettings() {
 717    els.apiKey.value = settings.apiKey || '';
 718    els.model.value = settings.model;
 719    els.apiUrl.value = settings.apiUrl;
 720    els.systemPrompt.value = settings.systemPrompt;
 721    els.settings.showModal();
 722    setTimeout(() => els.apiKey.focus(), 50);
 723  }
 724
 725  function showToast(message) {
 726    els.toast.textContent = message;
 727    els.toast.classList.add('show');
 728    clearTimeout(toastTimer);
 729    toastTimer = setTimeout(() => els.toast.classList.remove('show'), 2200);
 730  }
 731
 732  function updateKeyStatus() { els.keyStatus.classList.toggle('ready', Boolean(settings.apiKey)); }
 733
 734  function updateThemeButton() {
 735    const dark = document.documentElement.dataset.theme === 'dark';
 736    const button = $('#theme-toggle');
 737    button.querySelector('span').textContent = dark ? '☀' : '☾';
 738    button.setAttribute('aria-label', `Switch to ${dark ? 'light' : 'dark'} theme`);
 739    button.title = `Switch to ${dark ? 'light' : 'dark'} theme`;
 740  }
 741
 742  function toggleTheme() {
 743    const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
 744    document.documentElement.dataset.theme = next;
 745    try { localStorage.setItem(THEME_KEY, next); } catch {}
 746    updateThemeButton();
 747  }
 748
 749  function priceForModel(model) {
 750    const key = String(model || '').toLowerCase();
 751    return MODEL_PRICES.find(price => price.match.test(key)) || null;
 752  }
 753
 754  function isOpenAIEndpoint(apiUrl) {
 755    try { return new URL(apiUrl).hostname === 'api.openai.com'; }
 756    catch { return false; }
 757  }
 758
 759  function recordUsage(nodeId, model, apiUrl, rawUsage) {
 760    if (usageRecords.some(record => record.nodeId === nodeId)) return;
 761    const inputTokens = Number(rawUsage.input_tokens) || 0;
 762    const outputTokens = Number(rawUsage.output_tokens) || 0;
 763    const cachedTokens = Math.min(inputTokens, Number(rawUsage.input_tokens_details?.cached_tokens) || 0);
 764    const totalTokens = Number(rawUsage.total_tokens) || inputTokens + outputTokens;
 765    const price = isOpenAIEndpoint(apiUrl) ? priceForModel(model) : null;
 766    const costUSD = price
 767      ? (((inputTokens - cachedTokens) * price.input) + (cachedTokens * price.cached) + (outputTokens * price.output)) / 1_000_000
 768      : null;
 769    usageRecords.push({
 770      id: uid(), nodeId, timestamp: Date.now(), model: String(model || 'unknown'),
 771      inputTokens, cachedTokens, outputTokens, totalTokens, costUSD,
 772      rates: price ? { input: price.input, cached: price.cached, output: price.output } : null
 773    });
 774    // Keep bounded history while retaining roughly a year of normal personal use.
 775    usageRecords = usageRecords.slice(-2000);
 776    try { localStorage.setItem(USAGE_KEY, JSON.stringify(usageRecords)); }
 777    catch { showToast('Usage was returned, but local usage history is full'); }
 778    updateUsageButton();
 779  }
 780
 781  function currentMonthRecords() {
 782    const now = new Date();
 783    return usageRecords.filter(record => {
 784      const date = new Date(record.timestamp);
 785      return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth();
 786    });
 787  }
 788
 789  function sum(records, key) { return records.reduce((total, record) => total + (Number(record[key]) || 0), 0); }
 790
 791  function formatTokens(value, compact = false) {
 792    const number = Number(value) || 0;
 793    if (!compact || number < 1000) return Math.round(number).toLocaleString();
 794    if (number < 1_000_000) return `${(number / 1000).toFixed(number < 10_000 ? 1 : 0)}K`;
 795    return `${(number / 1_000_000).toFixed(number < 10_000_000 ? 1 : 0)}M`;
 796  }
 797
 798  function formatCost(value) {
 799    if (!Number.isFinite(value)) return 'Unpriced';
 800    if (value > 0 && value < .01) return '<$0.01';
 801    return value.toLocaleString(undefined, { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: value < 1 ? 4 : 2 });
 802  }
 803
 804  function updateUsageButton() {
 805    const total = sum(currentMonthRecords(), 'totalTokens');
 806    els.monthTokens.textContent = formatTokens(total, true);
 807    const label = `${formatTokens(total)} tokens tracked this month`;
 808    $('#open-usage').title = label;
 809    $('#open-usage').setAttribute('aria-label', `${label}. Open API usage details`);
 810  }
 811
 812  function usageRowsByModel(records) {
 813    const groups = new Map();
 814    for (const record of records) {
 815      const model = String(record.model || 'unknown');
 816      if (!groups.has(model)) groups.set(model, { model, totalTokens: 0, costUSD: 0, unpriced: 0 });
 817      const group = groups.get(model);
 818      group.totalTokens += Number(record.totalTokens) || 0;
 819      if (Number.isFinite(record.costUSD)) group.costUSD += record.costUSD;
 820      else group.unpriced += 1;
 821    }
 822    return [...groups.values()].sort((a, b) => b.totalTokens - a.totalTokens);
 823  }
 824
 825  function renderUsage() {
 826    const records = currentMonthRecords().sort((a, b) => b.timestamp - a.timestamp);
 827    const totalCost = records.reduce((total, record) => total + (Number.isFinite(record.costUSD) ? record.costUSD : 0), 0);
 828    const unpriced = records.filter(record => !Number.isFinite(record.costUSD)).length;
 829    els.usageMonth.textContent = new Date().toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
 830    els.usageTotal.textContent = formatTokens(sum(records, 'totalTokens'));
 831    els.usageCost.textContent = records.length && unpriced === records.length
 832      ? 'Unpriced'
 833      : `${formatCost(totalCost)}${unpriced ? ' +' : ''}`;
 834    els.usageRequests.textContent = formatTokens(records.length);
 835    els.usageInput.textContent = formatTokens(sum(records, 'inputTokens'));
 836    els.usageCached.textContent = formatTokens(sum(records, 'cachedTokens'));
 837    els.usageOutput.textContent = formatTokens(sum(records, 'outputTokens'));
 838
 839    const groups = usageRowsByModel(records);
 840    els.usageModels.innerHTML = groups.length ? groups.map(group => `
 841      <div class="usage-row">
 842        <span class="model-name" title="${escapeHTML(group.model)}">${escapeHTML(group.model)}</span>
 843        <span class="row-tokens">${formatTokens(group.totalTokens)} tokens</span>
 844        <span class="row-cost">${group.unpriced ? 'Partly unpriced' : formatCost(group.costUSD)}</span>
 845      </div>`).join('') : '<p class="usage-empty">No tracked requests this month.</p>';
 846
 847    els.usageRecent.innerHTML = records.length ? records.slice(0, 12).map(record => `
 848      <div class="usage-row">
 849        <span class="model-name">${new Date(record.timestamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</span>
 850        <span class="row-tokens">${formatTokens(record.totalTokens)} tokens</span>
 851        <span class="row-cost">${formatCost(record.costUSD)}</span>
 852      </div>`).join('') : '<p class="usage-empty">Completed requests will appear here.</p>';
 853
 854    els.usageNote.textContent = unpriced
 855      ? `${unpriced} request${unpriced === 1 ? '' : 's'} could not be estimated because the model or endpoint has no local price entry.`
 856      : 'Estimate uses standard OpenAI text-token prices recorded when each request completed. Pricing table checked August 17, 2026.';
 857  }
 858
 859  function openUsage() {
 860    renderUsage();
 861    els.usageDialog.showModal();
 862  }
 863
 864  function createNewMap() {
 865    const existingBlank = workspace.maps.find(map => map.nodes.length === 0 && map.attachments.length === 0);
 866    const map = existingBlank || blankMap();
 867    if (!existingBlank) workspace.maps.push(map);
 868    workspace.activeMapId = map.id;
 869    state = map;
 870    els.conversationSearch.value = '';
 871    persistNow();
 872    renderAll();
 873    closeHistoryOnSmallScreen();
 874    window.scrollTo({ top: 0, behavior: 'smooth' });
 875    els.prompt.focus();
 876  }
 877
 878  function switchMap(mapId, nodeId = null) {
 879    const map = workspace.maps.find(candidate => candidate.id === mapId);
 880    if (!map) { closeHistoryOnSmallScreen(); return; }
 881    workspace.activeMapId = map.id;
 882    state = map;
 883    state.activeId = nodeId && nodeById(nodeId, state) ? nodeId : (state.nodes.at(-1)?.id || null);
 884    revealNode(state, state.activeId);
 885    persistSoon();
 886    hideSelectionMenu();
 887    renderAll({ scroll: state.activeId ? (nodeId ? true : 'bottom') : false });
 888    closeHistoryOnSmallScreen();
 889  }
 890
 891  function activateNode(nodeId, scroll = true) {
 892    if (!nodeById(nodeId)) return;
 893    state.activeId = nodeId;
 894    revealNode(state, nodeId);
 895    persistSoon();
 896    renderAll({ scroll });
 897    document.body.classList.remove('map-open');
 898  }
 899
 900  function visibleTreeNodeIds() {
 901    return [...els.tree.querySelectorAll('.tree-node')].map(button => button.dataset.nodeId);
 902  }
 903
 904  function moveVisibleNode(offset) {
 905    const ids = visibleTreeNodeIds();
 906    if (!ids.length) return;
 907    const current = Math.max(0, ids.indexOf(state.activeId));
 908    activateNode(ids[Math.max(0, Math.min(ids.length - 1, current + offset))]);
 909  }
 910
 911  function moveMap(offset) {
 912    const maps = workspace.maps
 913      .filter(map => map.nodes.length || map.attachments.length)
 914      .sort((a, b) => b.updatedAt - a.updatedAt);
 915    if (!maps.length) return;
 916    const current = Math.max(0, maps.findIndex(map => map.id === state.id));
 917    switchMap(maps[Math.max(0, Math.min(maps.length - 1, current + offset))].id);
 918  }
 919
 920  function focusConversationSearch() {
 921    document.body.classList.remove('history-collapsed', 'map-open');
 922    if (matchMedia('(max-width: 1100px)').matches) document.body.classList.add('history-open');
 923    updateHistoryToggle();
 924    els.conversationSearch.focus();
 925    els.conversationSearch.select();
 926  }
 927
 928  function handleVimNavigation(event) {
 929    if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return;
 930    const editable = event.target.closest?.('input, textarea, select, [contenteditable="true"]');
 931    if (editable) {
 932      if (event.key === 'Escape') {
 933        event.preventDefault();
 934        if (editable === els.conversationSearch) clearSearch();
 935        editable.blur();
 936      }
 937      return;
 938    }
 939    if ($('dialog[open]')) return;
 940    if (pendingG && event.key !== 'g') {
 941      clearTimeout(pendingGTimer);
 942      pendingG = false;
 943    }
 944    if (event.key === '?') { event.preventDefault(); els.shortcutsDialog.showModal(); return; }
 945    if (event.key === '/') { event.preventDefault(); focusConversationSearch(); return; }
 946    if (event.key === 'i') { event.preventDefault(); els.prompt.focus(); return; }
 947    if (event.key === 'j') { event.preventDefault(); moveVisibleNode(1); return; }
 948    if (event.key === 'k') { event.preventDefault(); moveVisibleNode(-1); return; }
 949    if (event.key === 'J') { event.preventDefault(); moveMap(1); return; }
 950    if (event.key === 'K') { event.preventDefault(); moveMap(-1); return; }
 951    if (event.key === 'h') {
 952      const parentId = nodeById(state.activeId)?.parentId;
 953      if (parentId) { event.preventDefault(); activateNode(parentId); }
 954      return;
 955    }
 956    if (event.key === 'l') {
 957      const children = childrenOf(state.activeId);
 958      if (!children.length) return;
 959      event.preventDefault();
 960      if (state.collapsedIds.includes(state.activeId)) toggleSubtree(state.activeId, false);
 961      else activateNode(children[0].id);
 962      return;
 963    }
 964    if (event.key === ' ') {
 965      if (childrenOf(state.activeId).length) { event.preventDefault(); toggleSubtree(state.activeId); }
 966      return;
 967    }
 968    if (event.key === 'G') {
 969      const ids = visibleTreeNodeIds();
 970      if (ids.length) { event.preventDefault(); activateNode(ids.at(-1)); }
 971      return;
 972    }
 973    if (event.key === 'g') {
 974      event.preventDefault();
 975      if (pendingG) {
 976        clearTimeout(pendingGTimer);
 977        pendingG = false;
 978        const ids = visibleTreeNodeIds();
 979        if (ids.length) activateNode(ids[0]);
 980      } else {
 981        pendingG = true;
 982        pendingGTimer = setTimeout(() => { pendingG = false; }, 900);
 983      }
 984    }
 985  }
 986
 987  function closeHistoryOnSmallScreen() {
 988    if (matchMedia('(max-width: 1100px)').matches) document.body.classList.remove('history-open');
 989    updateHistoryToggle();
 990  }
 991
 992  function updateHistoryToggle() {
 993    const small = matchMedia('(max-width: 1100px)').matches;
 994    const open = small ? document.body.classList.contains('history-open') : !document.body.classList.contains('history-collapsed');
 995    els.historyToggle.setAttribute('aria-expanded', String(open));
 996  }
 997
 998  function toggleHistory() {
 999    if (matchMedia('(max-width: 1100px)').matches) {
1000      document.body.classList.remove('history-collapsed');
1001      document.body.classList.toggle('history-open');
1002      if (document.body.classList.contains('history-open')) document.body.classList.remove('map-open');
1003    } else document.body.classList.toggle('history-collapsed');
1004    updateHistoryToggle();
1005  }
1006
1007  function clearChatHistory(message = 'Clear all saved conversations? Your API key and usage records will be kept. Uploaded API files will not be deleted.') {
1008    if (!workspace.maps.some(map => map.nodes.length || map.attachments.length)) { showToast('Chat history is already empty'); return false; }
1009    if (!confirm(message)) return false;
1010    if (activeRequest) activeRequest.abort();
1011    const map = blankMap();
1012    workspace = { version: 2, maps: [map], activeMapId: map.id };
1013    state = map;
1014    searchIndex = null;
1015    els.conversationSearch.value = '';
1016    persistNow();
1017    renderAll();
1018    showToast('All conversations cleared');
1019    return true;
1020  }
1021
1022  function deleteMap(mapId) {
1023    const map = workspace.maps.find(candidate => candidate.id === mapId);
1024    if (!map) return;
1025    if (uploadingPDFs && uploadTargetMapId === map.id) { showToast('Wait for this map’s PDF upload to finish'); return; }
1026    const remoteNote = map.attachments.length ? ' Uploaded API files will not be deleted.' : '';
1027    if (!confirm(`Delete “${map.title}” and its entire conversation? This cannot be undone.${remoteNote}`)) return;
1028    if (activeRequest && map.nodes.some(node => node.status === 'loading')) activeRequest.abort();
1029
1030    workspace.maps = workspace.maps.filter(candidate => candidate.id !== map.id);
1031    searchIndex = null;
1032    if (!workspace.maps.length) workspace.maps.push(blankMap());
1033    if (state === map) {
1034      const next = [...workspace.maps]
1035        .filter(candidate => candidate.nodes.length || candidate.attachments.length)
1036        .sort((a, b) => b.updatedAt - a.updatedAt)[0] || workspace.maps[0];
1037      workspace.activeMapId = next.id;
1038      state = next;
1039      state.activeId = state.nodes.at(-1)?.id || null;
1040      revealNode(state, state.activeId);
1041    }
1042    persistNow();
1043    renderAll({ scroll: state.activeId ? 'bottom' : false });
1044    showToast('Conversation deleted');
1045  }
1046
1047  function renameMap(mapId) {
1048    const map = workspace.maps.find(candidate => candidate.id === mapId);
1049    if (!map) return;
1050    const value = prompt('Rename this conversation. Leave blank to restore its automatic title.', map.title);
1051    if (value === null) return;
1052    const title = value.replace(/\s+/g, ' ').trim();
1053    if (title) {
1054      map.title = title.slice(0, 120);
1055      map.titleCustom = true;
1056    } else {
1057      map.titleCustom = false;
1058      refreshMapTitle(map);
1059    }
1060    touchMap(map);
1061    persistNow();
1062    renderHistory();
1063    showToast(title ? 'Conversation renamed' : 'Automatic title restored');
1064  }
1065
1066  function showSelectionMenu(text, nodeId, rect) {
1067    selectedText = { text: text.slice(0, 5000), nodeId };
1068    els.selectionPreview.textContent = text.length > 240 ? `${text.slice(0, 240)}…` : text;
1069    els.selectionMenu.hidden = false;
1070    const width = 330;
1071    const left = Math.max(12, Math.min(innerWidth - width - 12, rect.left + rect.width / 2 - width / 2));
1072    const estimatedHeight = 235;
1073    const top = rect.bottom + estimatedHeight < innerHeight ? rect.bottom + 10 : Math.max(10, rect.top - estimatedHeight - 8);
1074    Object.assign(els.selectionMenu.style, { left: `${left}px`, top: `${top}px` });
1075  }
1076
1077  function hideSelectionMenu() { els.selectionMenu.hidden = true; selectedText = null; }
1078
1079  els.composer.addEventListener('submit', event => {
1080    event.preventDefault();
1081    if (activeRequest) { activeRequest.abort(); return; }
1082    const question = els.prompt.value;
1083    els.prompt.value = '';
1084    els.prompt.style.height = '';
1085    ask(question);
1086  });
1087  els.prompt.addEventListener('keydown', event => {
1088    if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); els.composer.requestSubmit(); }
1089  });
1090  els.prompt.addEventListener('input', () => {
1091    els.prompt.style.height = 'auto';
1092    els.prompt.style.height = `${Math.min(els.prompt.scrollHeight, 180)}px`;
1093  });
1094  document.addEventListener('click', event => {
1095    const starter = event.target.closest('.starter');
1096    if (starter) { els.prompt.value = starter.dataset.prompt; els.prompt.focus(); }
1097    const historyItem = event.target.closest('.history-item');
1098    if (historyItem) { switchMap(historyItem.dataset.mapId); return; }
1099    const searchResult = event.target.closest('.search-result');
1100    if (searchResult) {
1101      const { mapId, nodeId } = searchResult.dataset;
1102      clearSearch();
1103      switchMap(mapId, nodeId || null);
1104      return;
1105    }
1106    const renameConversation = event.target.closest('.rename-map');
1107    if (renameConversation) { renameMap(renameConversation.dataset.mapId); return; }
1108    const deleteConversation = event.target.closest('.delete-map');
1109    if (deleteConversation) { deleteMap(deleteConversation.dataset.mapId); return; }
1110    const removeFile = event.target.closest('.remove-attachment');
1111    if (removeFile) { removeAttachment(removeFile.dataset.fileId); return; }
1112    const subtreeToggle = event.target.closest('.toggle-subtree');
1113    if (subtreeToggle) { toggleSubtree(subtreeToggle.dataset.nodeId); return; }
1114    const treeNode = event.target.closest('.tree-node');
1115    if (treeNode) { activateNode(treeNode.dataset.nodeId); return; }
1116    const branch = event.target.closest('.branch-here');
1117    if (branch) { state.activeId = branch.closest('.message').dataset.nodeId; persistSoon(); renderAll(); els.prompt.focus(); }
1118    const retry = event.target.closest('.retry-node');
1119    if (retry) { const old = nodeById(retry.closest('.message').dataset.nodeId); state.activeId = old.parentId; ask(old.question, old.parentId); }
1120    const copy = event.target.closest('.copy-answer');
1121    if (copy) { const node = nodeById(copy.closest('.message').dataset.nodeId); navigator.clipboard.writeText(node.answer).then(() => showToast('Answer copied')); }
1122  });
1123  document.addEventListener('mouseup', event => {
1124    if (event.target.closest('.selection-menu')) return;
1125    requestAnimationFrame(() => {
1126      const selection = getSelection();
1127      const text = selection?.toString().trim();
1128      if (!text || text.length < 2 || !selection.rangeCount) return;
1129      const answer = selection.anchorNode?.parentElement?.closest('.answer-body');
1130      if (!answer || !answer.contains(selection.focusNode)) return;
1131      const message = answer.closest('.message');
1132      showSelectionMenu(text, message.dataset.nodeId, selection.getRangeAt(0).getBoundingClientRect());
1133    });
1134  });
1135
1136  $('#ask-selection').addEventListener('click', () => {
1137    if (!selectedText) return;
1138    const action = els.selectionAction.value === 'custom' ? els.selectionCustom.value.trim() : els.selectionAction.value;
1139    if (!action) { els.selectionCustom.focus(); return; }
1140    const question = `${action}\n\nSelected passage:\n> ${selectedText.text.replace(/\n/g, '\n> ')}`;
1141    const parentId = selectedText.nodeId;
1142    hideSelectionMenu();
1143    getSelection()?.removeAllRanges();
1144    ask(question, parentId);
1145  });
1146  els.selectionAction.addEventListener('change', () => {
1147    els.selectionCustom.hidden = els.selectionAction.value !== 'custom';
1148    if (!els.selectionCustom.hidden) els.selectionCustom.focus();
1149  });
1150  $('#close-selection').addEventListener('click', hideSelectionMenu);
1151  window.addEventListener('scroll', hideSelectionMenu, { passive: true });
1152  $('#open-settings').addEventListener('click', openSettings);
1153  $('#open-usage').addEventListener('click', openUsage);
1154  $('#theme-toggle').addEventListener('click', toggleTheme);
1155  $('#toggle-history').addEventListener('click', toggleHistory);
1156  els.conversationSearch.addEventListener('input', () => {
1157    clearTimeout(searchTimer);
1158    searchTimer = setTimeout(renderSearchResults, 110);
1159  });
1160  els.conversationSearch.addEventListener('keydown', event => {
1161    if (event.key !== 'Enter') return;
1162    const first = $('.search-result', els.searchResults);
1163    if (first) { event.preventDefault(); first.click(); }
1164  });
1165  $('#study-lab-home').addEventListener('click', event => { event.preventDefault(); createNewMap(); });
1166  $('#new-map-sidebar').addEventListener('click', createNewMap);
1167  els.uploadPDF.addEventListener('click', () => els.pdfInput.click());
1168  els.pdfInput.addEventListener('change', () => uploadPDFs(els.pdfInput.files));
1169  $('#close-settings').addEventListener('click', () => els.settings.close());
1170  $('#close-usage').addEventListener('click', () => els.usageDialog.close());
1171  $('#open-shortcuts').addEventListener('click', () => els.shortcutsDialog.showModal());
1172  $('#close-shortcuts').addEventListener('click', () => els.shortcutsDialog.close());
1173  $('#expand-all-nodes').addEventListener('click', () => setAllSubtrees(false));
1174  $('#collapse-all-nodes').addEventListener('click', () => setAllSubtrees(true));
1175  $('#zoom-out-tree').addEventListener('click', () => updateTreeZoom(-.1));
1176  $('#zoom-in-tree').addEventListener('click', () => updateTreeZoom(.1));
1177  $('#reset-usage').addEventListener('click', () => {
1178    if (!usageRecords.length || confirm('Reset the locally tracked API usage history? This cannot be undone.')) {
1179      usageRecords = [];
1180      localStorage.removeItem(USAGE_KEY);
1181      updateUsageButton();
1182      renderUsage();
1183      showToast('Local usage history reset');
1184    }
1185  });
1186  $('#toggle-key').addEventListener('click', event => { const show = els.apiKey.type === 'password'; els.apiKey.type = show ? 'text' : 'password'; event.target.textContent = show ? 'Hide' : 'Show'; });
1187  $('#settings-form').addEventListener('submit', event => {
1188    event.preventDefault();
1189    settings = { apiKey: els.apiKey.value.trim(), model: els.model.value.trim() || DEFAULT_SETTINGS.model, apiUrl: els.apiUrl.value.trim() || DEFAULT_SETTINGS.apiUrl, systemPrompt: els.systemPrompt.value.trim() };
1190    localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
1191    updateKeyStatus(); els.settings.close(); showToast('Settings saved');
1192  });
1193  $('#forget-key').addEventListener('click', () => { settings.apiKey = ''; els.apiKey.value = ''; localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); updateKeyStatus(); showToast('API key removed'); });
1194  $('#clear-history').addEventListener('click', () => clearChatHistory());
1195  $('#clear-branch').addEventListener('click', () => { state.activeId = state.nodes.at(-1)?.id || null; revealNode(state, state.activeId); persistSoon(); renderAll({ scroll: true }); });
1196  $('#collapse-map').addEventListener('click', () => { document.body.classList.remove('map-open'); document.body.classList.add('map-collapsed'); $('#expand-map').hidden = false; });
1197  $('#expand-map').addEventListener('click', () => { document.body.classList.remove('map-collapsed', 'history-open'); document.body.classList.add('map-open'); $('#expand-map').hidden = true; updateHistoryToggle(); });
1198  addEventListener('resize', updateHistoryToggle, { passive: true });
1199  document.addEventListener('keydown', handleVimNavigation);
1200
1201  updateKeyStatus();
1202  updateThemeButton();
1203  updateUsageButton();
1204  updateHistoryToggle();
1205  updateTreeZoom();
1206  state.activeId = state.nodes.at(-1)?.id || null;
1207  revealNode(state, state.activeId);
1208  renderAll({ scroll: state.activeId ? 'bottom' : false });
1209})();