EXTENSION for TreeOS
treeos-base
The reference implementation of how the AI thinks inside a tree. Eleven modes, thirty-plus MCP tools, and a navigation hook that keeps the frontend synchronized with every operation the AI performs. This is the foundation that every tree conversation builds on. Converse is the default mode for free-form nodes. It reads the node's notes, children, and path, then talks from that position's perspective. Every node has a voice. No extension needed. Navigate resolves natural language references to nodes. Librarian walks branches and gathers context. Structure creates, moves, and deletes nodes. Edit modifies node fields. Notes reads and writes note content. Respond synthesizes a turn into a natural language answer. Get Context fetches node data silently. Be mode is focused, present, guided work on one step at a time. Two home modes handle the space outside of trees. Home Default is a warm, conversational landing assistant. Home Reflect reviews notes and contributions across all trees. The navigation hook fires afterToolCall and emits a WebSocket navigate event that synchronizes the HTML frontend with the AI's actions. Every mode and tool is replaceable. Extensions register custom modes that override defaults at any node via per-node mode metadata. Remove this extension and the tree has no AI behavior. Install it and the tree thinks at every position.
v1.0.5 by TreeOS Site 0 downloads 42 files 24,206 lines 792.6 KB published 38d ago
treeos ext install treeos-base
View changelog

Manifest

Provides

  • tools

Requires

  • services: websocket, llm
  • models: Node, User, Note, Contribution

Optional

  • extensions: html-rendering, navigation
SHA256: 63b01dee7350313c4b050b31b1a1fe1d268236845cb3a5bd4ce56b0695c7a44d

Dependents

2 packages depend on this

PackageTypeRelationship
tree-orchestrator v1.0.5extensionneeds
treeos v1.0.1osstandalone

Source Code

1// treeos/app/app.js - Dashboard page
2import express from "express";
3import { sendError, ERR } from "../../../seed/protocol.js";
4import User from "../../../seed/models/user.js";
5import LlmConnection from "../../../seed/models/llmConnection.js";
6import authenticateLite from "../../html-rendering/authenticateLite.js";
7import { notFoundPage } from "../../html-rendering/notFoundPage.js";
8import {
9  dashboardCSS,
10  dashboardHTML,
11  dashboardJS,
12} from "./sessionManagerPartial.js";
13import { getLandUrl, getLandIdentity } from "../../../canopy/identity.js";
14import { isHtmlEnabled } from "../../html-rendering/config.js";
15import { esc } from "../../html-rendering/html/utils.js";
16
17const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18
19const router = express.Router();
20
21/**
22 * GET /dashboard
23 * Authenticated iframe shell with integrated chat
24 */
25router.get("/dashboard", authenticateLite, async (req, res) => {
26  try {
27    if (!isHtmlEnabled()) {
28      return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, "Server-rendered HTML is disabled. Use the SPA frontend.");
29    }
30    if (!req.userId) {
31      return res.redirect("/login");
32    }
33
34    const user = await User.findById(req.userId).select(
35      "username metadata llmDefault",
36    );
37
38    if (!user) {
39      return notFoundPage(req, res, "This user doesn't exist.");
40    }
41
42    const userMetaModule = await import("../../../seed/tree/userMetadata.js");
43    const nav = userMetaModule.getUserMeta(user, "nav");
44    const userRoots = Array.isArray(nav.roots) ? nav.roots : [];
45
46    // Redirect to setup if user needs LLM (unless they skipped recently).
47    // No tree is fine. Sprout creates trees from conversation.
48    const setupSkipped = req.cookies?.setupSkipped === "1";
49    if (!setupSkipped) {
50      const hasMainLlm = !!user.llmDefault;
51      if (!hasMainLlm) {
52        const connCount = await LlmConnection.countDocuments({ userId: req.userId });
53        if (connCount === 0) {
54          return res.redirect("/setup");
55        }
56      }
57    }
58
59    const { getUserMeta } = await import("../../../seed/tree/userMetadata.js");
60    const htmlShareToken = getUserMeta(user, "html")?.shareToken || "";
61    const { username } = user;
62    const hasLlm =
63      !!user.llmDefault ||
64      (await LlmConnection.countDocuments({ userId: req.userId })) > 0;
65
66    const landName = getLandIdentity()?.name || "TreeOS";
67
68    return res.send(`<!DOCTYPE html>
69<html lang="en">
70<head>
71  <meta charset="UTF-8" />
72  <title>${landName}</title>
73  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
74  <meta name="theme-color" content="#0d1117" />
75  <link rel="icon" href="/tree.png" />
76  <link rel="canonical" href="${getLandUrl()}/app" />
77  <meta name="robots" content="noindex, nofollow" />
78  <meta name="description" content="${landName}. Powered by TreeOS." />
79  <meta property="og:title" content="${landName}" />
80  <meta property="og:description" content="${landName}. Powered by TreeOS." />
81  <meta property="og:url" content="${getLandUrl()}/app" />
82  <meta property="og:type" content="website" />
83  <meta property="og:site_name" content="${landName}" />
84  <meta property="og:image" content="${getLandUrl()}/tree.png" />
85  <link rel="preconnect" href="https://fonts.googleapis.com">
86  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
87  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
88  <style>
89    :root {
90      /* Nightfall theme */
91      --bg:           #0d1117;
92      --bg-elevated:  #161b24;
93      --bg-hover:     #1c222e;
94      --border:       #232a38;
95      --border-strong:#2f3849;
96
97      --text-primary:   #e6e8eb;
98      --text-secondary: #c4c8d0;
99      --text-muted:     #9ba1ad;
100      --text-dim:       #5d6371;
101
102      --accent:      #7dd385;
103      --accent-glow: rgba(125, 211, 133, 0.5);
104      --error:       #c97e6a;
105
106      /* Legacy aliases (some code still references these) */
107      --glass-rgb:          22, 27, 36;
108      --glass-alpha:        1;
109      --glass-blur:         0px;
110      --glass-border:       #232a38;
111      --glass-border-light: #232a38;
112      --glass-highlight:    #2f3849;
113
114      --header-height: 56px;
115      --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
116      --mobile-input-height: 70px;
117      --min-panel-width: 280px;
118    }
119
120
121    * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
122    html, body { height: 100%; width: 100%; overflow: hidden; font-family: 'DM Sans', -apple-system, sans-serif; color: var(--text-primary); background: var(--bg); }
123
124    .app-bg { position: fixed; inset: 0; background: var(--bg); z-index: -2; }
125
126    .app-container { display: flex; height: 100%; width: 100%; padding: 0px; gap: 0px; }
127    .glass-panel {
128      background: var(--bg-elevated);
129      border-radius: 0;
130      border: none;
131      box-shadow: none;
132    }
133
134    .chat-panel { width: 400px; min-width: 0; height: 100%; display: flex; flex-direction: column; z-index: 10; flex-shrink: 0; position: relative; }
135    .chat-header { height: var(--header-height); padding: 0 16px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--glass-border-light); flex-shrink: 0; position: relative; z-index: 1; }
136    .chat-header a { text-decoration: none; color: inherit; }
137    .chat-title { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
138    .tree-icon { font-size: 28px; filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.3)); animation: grow 4.5s infinite ease-in-out; }
139    @keyframes grow { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }
140    .chat-title h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.02em; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); }
141    
142    .chat-header-controls { display: flex; align-items: center; gap: 0; margin-left: auto; }
143    .chat-header-buttons { display: flex; align-items: center; gap: 6px; margin-right: 12px; }
144    .chat-header-right { display: flex; align-items: center; gap: 0; }
145
146    .status-badge { display: flex; align-items: center; gap: 8px; padding: 6px 14px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border-radius: 100px; border: 1px solid var(--glass-border-light); font-size: 12px; font-weight: 600; }
147    .status-badge .status-text { display: inline; }
148    .status-dot { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 12px var(--accent-glow); animation: pulse 2s ease-in-out infinite; flex-shrink: 0; }
149    .status-dot.connected { background: var(--accent); }
150    .status-dot.disconnected { background: var(--error); animation: none; }
151    .status-dot.connecting { background: #f59e0b; }
152    @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(1.15); } }
153    
154    /* Compact mode for narrow panels */
155    .chat-panel:not(.collapsed) { container-type: inline-size; }
156    @container (max-width: 420px) {
157      #desktopOpenTabBtn { display: none; }
158    }
159    @container (max-width: 360px) {
160      .status-badge .status-text { display: none; }
161      .status-badge { padding: 6px; min-width: 20px; justify-content: center; }
162    }
163    @container (max-width: 320px) {
164      .chat-title h1 { display: none; }
165    }
166    
167    /* Wide panel mode - constrain content when panel is very wide */
168    @container (min-width: 750px) {
169      .chat-messages {
170        width: 100%;
171        max-width: 720px;
172        margin-left: auto;
173        margin-right: auto;
174        padding-left: 24px;
175        padding-right: 24px;
176      }
177      .chat-input-area {
178        width: 100%;
179        max-width: 760px;
180        margin-left: auto;
181        margin-right: auto;
182      }
183      .mode-bar {
184        width: 100%;
185        max-width: 760px;
186        margin-left: auto;
187        margin-right: auto;
188      }
189    }
190    @container (min-width: 950px) {
191      .chat-messages {
192        max-width: 840px;
193      }
194      .chat-input-area {
195        max-width: 880px;
196      }
197      .mode-bar {
198        max-width: 880px;
199      }
200    }
201    @container (min-width: 1150px) {
202      .chat-messages {
203        max-width: 920px;
204        padding-left: 32px;
205        padding-right: 32px;
206      }
207      .chat-input-area {
208        max-width: 960px;
209      }
210      .mode-bar {
211        max-width: 960px;
212      }
213    }
214/* Orchestrator step / system messages: hidden by default, toggle to show */
215.message.orchestrator-step,
216.chat-message.system {
217  display: none;
218}
219body.show-bg-messages .message.orchestrator-step,
220body.show-bg-messages .chat-message.system {
221  display: flex;
222}
223/* Mode picker is an advanced override. Sprout + the routing index pick the
224   right mode automatically. Hide the picker unless the user opts into the
225   advanced/system view (same toggle as background messages). */
226.mode-bar,
227.mobile-mode-bar {
228  display: none !important;
229}
230body.show-bg-messages .mode-bar {
231  display: flex !important;
232}
233body.show-bg-messages .mobile-mode-bar {
234  display: flex !important;
235}
236.message.orchestrator-step .message-content {
237  background: rgba(255, 255, 255, 0.06);
238  border: 1px dashed rgba(255, 255, 255, 0.15);
239  border-radius: 12px;
240  font-size: 12px;
241  color: var(--text-muted);
242  padding: 10px 14px;
243  font-family: 'JetBrains Mono', monospace;
244  max-width: 95%;
245}
246.message.orchestrator-step .message-avatar {
247  width: 28px;
248  height: 28px;
249  font-size: 12px;
250  border-radius: 8px;
251  background: rgba(255, 255, 255, 0.06);
252  border-color: rgba(255, 255, 255, 0.1);
253}
254.message.orchestrator-step .step-mode {
255  color: var(--accent);
256  font-weight: 600;
257  font-size: 11px;
258  text-transform: uppercase;
259  letter-spacing: 0.5px;
260  margin-bottom: 4px;
261  display: block;
262}
263.message.orchestrator-step .step-body {
264  white-space: pre-wrap;
265  word-break: break-word;
266  max-height: 200px;
267  overflow-y: auto;
268  display: block;
269}
270.message.orchestrator-step .step-body::-webkit-scrollbar { width: 4px; }
271.message.orchestrator-step .step-body::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 2px; }
272    /* Clear chat button in header */
273    .clear-chat-btn {
274      width: 30px;
275      height: 30px;
276      display: flex;
277      align-items: center;
278      justify-content: center;
279      background: rgba(255, 255, 255, 0.1);
280      border: 1px solid var(--glass-border-light);
281      border-radius: 8px;
282      color: var(--text-muted);
283      cursor: pointer;
284      transition: all var(--transition-fast);
285      flex-shrink: 0;
286    }
287    #clearChatBtn {
288      margin-left: 8px;
289    }
290    .clear-chat-btn:hover {
291      background: rgba(255, 255, 255, 0.2);
292      color: var(--text-primary);
293    }
294    .clear-chat-btn:active {
295      transform: scale(0.93);
296    }
297    .clear-chat-btn svg { width: 14px; height: 14px; }
298    .clear-chat-btn.llm-glow {
299      animation: llmGlow 0.6s ease-in-out 3;
300      border-color: var(--accent);
301      will-change: opacity;
302    }
303    @keyframes llmGlow {
304      0%, 100% { opacity: 1; }
305      50% { opacity: 0.4; background: rgba(16, 185, 129, 0.25); }
306    }
307
308    /* Active root name - inline after Tree in header */
309    .root-name-inline {
310      font-size: 13px;
311      font-weight: 400;
312      color: var(--text-muted);
313      white-space: nowrap;
314      overflow: hidden;
315      text-overflow: ellipsis;
316      flex: 1;
317      min-width: 0;
318      opacity: 0;
319      cursor: default;
320      transition: opacity 0.3s ease;
321    }
322    .root-name-inline.visible {
323      opacity: 1;
324    }
325    .root-name-inline::before {
326      content: ' / ';
327      color: var(--glass-border-light);
328    }
329    .root-name-inline.fade-in {
330      animation: rootNameFade 0.5s ease;
331    }
332    @keyframes rootNameFade {
333      0% { opacity: 0; transform: translateY(-4px); }
334      100% { opacity: 1; transform: translateY(0); }
335    }
336
337    /* Mobile root path - no prefix slash, styled as path */
338    .mobile-root-path {
339      font-size: 15px;
340      font-weight: 500;
341    }
342    .mobile-root-path::before {
343      content: '/';
344      color: var(--text-muted);
345    }
346
347    /* ================================================================
348       RECENT ROOTS DROPDOWN (Top-left overlay, doesn't push content)
349       ================================================================ */
350    .recent-roots-dropdown {
351      position: absolute;
352      top: calc(var(--header-height) + 6px);
353      left: 16px;
354      z-index: 50;
355    }
356    .recent-roots-dropdown.hidden {
357      display: none;
358    }
359    
360    .recent-roots-trigger {
361      width: 28px;
362      height: 28px;
363      display: flex;
364      align-items: center;
365      justify-content: center;
366      background: rgba(255, 255, 255, 0.12);
367      border: 1px solid var(--glass-border-light);
368      border-radius: 8px;
369      cursor: pointer;
370      transition: all var(--transition-fast);
371      color: var(--text-muted);
372    }
373    .recent-roots-trigger:hover {
374      background: rgba(255, 255, 255, 0.2);
375      color: var(--text-primary);
376    }
377    .recent-roots-trigger:active {
378      transform: scale(0.94);
379    }
380    .recent-roots-trigger svg {
381      width: 14px;
382      height: 14px;
383      transition: transform 0.2s ease;
384    }
385    .recent-roots-dropdown.open .recent-roots-trigger svg {
386      transform: rotate(180deg);
387    }
388    .recent-roots-dropdown.open .recent-roots-trigger {
389      background: rgba(255, 255, 255, 0.2);
390      color: var(--text-primary);
391    }
392    
393    .recent-roots-menu {
394      display: none;
395      position: absolute;
396      top: calc(100% + 6px);
397      left: 0;
398      min-width: 160px;
399      max-width: 200px;
400      background: rgba(var(--glass-rgb), 0.92);
401      backdrop-filter: blur(var(--glass-blur)) saturate(140%);
402      -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
403      border: 1px solid var(--glass-border);
404      border-radius: 12px;
405      padding: 6px;
406      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
407      animation: recentMenuIn 0.15s ease-out;
408    }
409    @keyframes recentMenuIn {
410      from { opacity: 0; transform: translateY(-4px); }
411      to { opacity: 1; transform: translateY(0); }
412    }
413    .recent-roots-dropdown.open .recent-roots-menu {
414      display: block;
415    }
416    
417    .recent-roots-menu-header {
418      padding: 6px 10px 8px;
419      font-size: 10px;
420      font-weight: 600;
421      text-transform: uppercase;
422      letter-spacing: 0.5px;
423      color: var(--text-muted);
424      border-bottom: 1px solid var(--glass-border-light);
425      margin-bottom: 4px;
426    }
427    
428    .recent-root-item {
429      display: flex;
430      align-items: center;
431      gap: 8px;
432      padding: 8px 10px;
433      border-radius: 8px;
434      cursor: pointer;
435      transition: all var(--transition-fast);
436      font-size: 12px;
437      font-weight: 500;
438      color: var(--text-secondary);
439      border: none;
440      background: none;
441      width: 100%;
442      text-align: left;
443    }
444    .recent-root-item:hover {
445      background: rgba(255, 255, 255, 0.12);
446      color: var(--text-primary);
447    }
448    .recent-root-item:active {
449      background: rgba(255, 255, 255, 0.18);
450      transform: scale(0.98);
451    }
452    .recent-root-item.active {
453      background: rgba(16, 185, 129, 0.15);
454      color: var(--text-primary);
455      border-left: 2px solid var(--accent);
456      padding-left: 8px;
457    }
458    .recent-root-name {
459      flex: 1;
460      min-width: 0;
461      overflow: hidden;
462      text-overflow: ellipsis;
463      white-space: nowrap;
464    }
465
466    .chat-messages { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 24px 20px; display: flex; flex-direction: column; gap: 16px; position: relative; z-index: 1; }
467    .chat-messages::-webkit-scrollbar { width: 6px; }
468    .chat-messages::-webkit-scrollbar-track { background: transparent; }
469    .chat-messages::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 3px; }
470
471    .welcome-message { text-align: center; padding: 40px 20px; }
472    .welcome-message.disconnected { opacity: 0.7; }
473    .welcome-message.disconnected .welcome-icon { filter: grayscale(0.5) drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); animation: none; }
474    .welcome-icon { font-size: 64px; margin-bottom: 20px; display: inline-block; filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); animation: floatIcon 3s ease-in-out infinite; }
475    @keyframes floatIcon { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
476    .welcome-message h2 { font-size: 24px; font-weight: 600; margin-bottom: 12px; }
477    .welcome-message p { font-size: 15px; color: var(--text-secondary); line-height: 1.6; margin-bottom: 8px; }
478
479    .message { display: flex; gap: 12px; animation: messageIn 0.3s ease-out; min-width: 0; max-width: 100%; }
480    @keyframes messageIn { from { opacity: 0; transform: translateY(10px); } }
481    .message.user { flex-direction: row-reverse; }
482    .message-avatar { width: 36px; height: 36px; border-radius: 12px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
483    .message.user .message-avatar { background: rgba(125, 211, 133, 0.18); border-color: rgba(125, 211, 133, 0.4); }
484    .message-content { max-width: 85%; min-width: 0; padding: 14px 18px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 18px; font-size: 14px; line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
485    .message.user .message-content { background: rgba(125, 211, 133, 0.08); border-color: rgba(125, 211, 133, 0.25); border-radius: 18px 18px 6px 18px; }
486    .message.assistant .message-content { border-radius: 18px 18px 18px 6px; }
487    .message.error .message-content { background: rgba(239, 68, 68, 0.3); border-color: rgba(239, 68, 68, 0.5); }
488
489    /* Carried messages from previous mode - dimmed */
490    .message.carried { opacity: 0.4; pointer-events: none; }
491    .message.carried .message-content { border-style: dashed; }
492
493    /* Mode bar locked while AI is responding */
494    .mode-bar.locked .mode-current {
495      opacity: 0.4;
496      pointer-events: none;
497      cursor: not-allowed;
498    }
499    .mobile-mode-btn.locked {
500      opacity: 0.4;
501      pointer-events: none;
502    }
503
504    /* Send button in stop mode */
505    .send-btn.stop-mode {
506      background: rgba(239, 68, 68, 0.7);
507      box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4);
508    }
509    .send-btn.stop-mode:hover:not(:disabled) {
510      background: rgba(239, 68, 68, 0.9);
511      box-shadow: 0 6px 25px rgba(239, 68, 68, 0.5);
512    }
513
514    /* Message content formatting */
515    .message-content p { margin: 0 0 10px 0; word-break: break-word; }
516    .message-content p:last-child { margin-bottom: 0; }
517    .message-content h1, .message-content h2, .message-content h3, .message-content h4 {
518      margin: 14px 0 8px 0;
519      font-weight: 600;
520      line-height: 1.3;
521    }
522    .message-content h1:first-child, .message-content h2:first-child, 
523    .message-content h3:first-child, .message-content h4:first-child { margin-top: 0; }
524    .message-content h1 { font-size: 17px; }
525    .message-content h2 { font-size: 16px; }
526    .message-content h3 { font-size: 15px; }
527    .message-content h4 { font-size: 14px; color: var(--text-secondary); }
528    .message-content ul, .message-content ol {
529      margin: 8px 0;
530      padding-left: 0;
531      list-style: none;
532    }
533    .message-content li {
534      margin: 4px 0;
535      padding: 6px 10px;
536      background: rgba(255, 255, 255, 0.06);
537      border-radius: 8px;
538      line-height: 1.4;
539      word-break: break-word;
540    }
541    .message-content li .list-num {
542      color: var(--accent);
543      font-weight: 600;
544      margin-right: 6px;
545    }
546    .message-content strong, .message-content b {
547      font-weight: 600;
548      color: #fff;
549    }
550    .message-content em, .message-content i {
551      font-style: italic;
552      color: var(--text-secondary);
553    }
554    .message-content code {
555      background: rgba(0, 0, 0, 0.3);
556      padding: 2px 6px;
557      border-radius: 4px;
558      font-family: 'JetBrains Mono', monospace;
559      font-size: 11px;
560      word-break: break-all;
561    }
562    .message-content pre {
563      background: rgba(0, 0, 0, 0.3);
564      padding: 12px;
565      border-radius: 8px;
566      overflow-x: auto;
567      margin: 10px 0;
568      max-width: 100%;
569    }
570    .message-content pre code {
571      background: none;
572      padding: 0;
573      word-break: normal;
574      white-space: pre-wrap;
575    }
576    .message-content blockquote {
577      border-left: 3px solid var(--accent);
578      padding-left: 12px;
579      margin: 10px 0;
580      color: var(--text-secondary);
581      font-style: italic;
582    }
583    .message-content hr {
584      border: none;
585      border-top: 1px solid var(--glass-border-light);
586      margin: 14px 0;
587    }
588    .message-content a {
589      color: var(--accent);
590      text-decoration: underline;
591      text-underline-offset: 2px;
592    }
593    .message-content a:hover {
594      text-decoration: none;
595    }
596
597    /* Menu items - numbered/bulleted options */
598    .message-content .menu-item {
599      display: flex;
600      align-items: flex-start;
601      gap: 10px;
602      padding: 10px 12px;
603      margin: 6px 0;
604      background: rgba(255, 255, 255, 0.08);
605      border-radius: 10px;
606      border: 1px solid rgba(255, 255, 255, 0.06);
607      transition: all 0.15s ease;
608    }
609    .message-content .menu-item.clickable {
610      cursor: pointer;
611      user-select: none;
612    }
613    .message-content .menu-item.clickable:hover {
614      background: rgba(255, 255, 255, 0.15);
615      border-color: rgba(16, 185, 129, 0.3);
616      transform: translateX(4px);
617    }
618    .message-content .menu-item.clickable:active {
619      transform: translateX(4px) scale(0.98);
620      background: rgba(16, 185, 129, 0.2);
621    }
622    .message-content .menu-item:first-of-type {
623      margin-top: 8px;
624    }
625    .message-content .menu-number {
626      display: flex;
627      align-items: center;
628      justify-content: center;
629      min-width: 26px;
630      max-width: 26px;
631      height: 26px;
632      background: linear-gradient(135deg, var(--accent) 0%, #059669 100%);
633      border-radius: 8px;
634      font-size: 12px;
635      font-weight: 600;
636      flex-shrink: 0;
637      box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
638      transition: all 0.15s ease;
639    }
640    .message-content .menu-item.clickable:hover .menu-number {
641      transform: scale(1.1);
642      box-shadow: 0 4px 12px rgba(16, 185, 129, 0.5);
643    }
644    .message-content .menu-text {
645      flex: 1;
646      min-width: 0;
647      padding-top: 2px;
648      word-break: break-word;
649      overflow-wrap: break-word;
650    }
651    .message-content .menu-text strong {
652      display: block;
653      margin-bottom: 2px;
654      word-break: break-word;
655    }
656    .message-content .menu-item.clicking {
657      animation: menuClick 0.3s ease;
658    }
659    @keyframes menuClick {
660      0% { background: rgba(16, 185, 129, 0.3); }
661      100% { background: rgba(255, 255, 255, 0.08); }
662    }
663
664    .typing-indicator { display: flex; gap: 4px; padding: 14px 18px; }
665    .typing-dot { width: 8px; height: 8px; background: #ffffff; border-radius: 50%; animation: typing 1.4s infinite; }
666    .typing-dot:nth-child(2) { animation-delay: 0.2s; }
667    .typing-dot:nth-child(3) { animation-delay: 0.4s; }
668    @keyframes typing { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-8px); } }
669
670    .chat-input-area { padding: 16px 20px 20px; border-top: 1px solid var(--glass-border-light); position: relative; z-index: 1; }
671    .input-container { display: flex; align-items: flex-end; gap: 12px; padding: 14px 18px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); border-radius: 18px; transition: all var(--transition-fast); }
672    .input-container:focus-within { background: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.4); box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.1); }
673    .chat-input { flex: 1; min-width: 0; background: transparent; border: none; outline: none; font-family: inherit; font-size: 15px; color: var(--text-primary); resize: none; max-height: 120px; line-height: 1.5; }
674    .chat-input::placeholder { color: var(--text-muted); }
675    .chat-input:disabled { opacity: 0.5; cursor: not-allowed; }
676    .send-btn { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background: var(--accent); border: none; border-radius: 12px; color: white; cursor: pointer; transition: all var(--transition-fast); flex-shrink: 0; box-shadow: 0 4px 15px var(--accent-glow); }
677    .send-btn:hover:not(:disabled) { transform: scale(1.08); box-shadow: 0 6px 25px var(--accent-glow); }
678    .send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
679    .send-btn svg { width: 20px; height: 20px; }
680
681    .viewport-panel { flex: 1; height: 100%; display: flex; flex-direction: column; min-width: 0; position: relative; }
682
683    .iframe-container {
684      flex: 1;
685      position: relative;
686      overflow: hidden;
687      border-radius: 0;
688      margin: 0;
689    }
690
691    iframe {
692      width: 100%;
693      height: 100%;
694      border: none;
695      display: block;
696      background: transparent;
697      border-radius: 0;
698    }
699
700    .loading-overlay { position: absolute; inset: 0; background: rgba(var(--glass-rgb), 0.8); backdrop-filter: blur(10px); display: flex; align-items: center; justify-content: center; opacity: 0; pointer-events: none; transition: opacity var(--transition-fast); z-index: 5; border-radius: 0; }
701    .loading-overlay.visible { opacity: 1; pointer-events: auto; }
702    .spinner-ring { width: 44px; height: 44px; border: 3px solid rgba(255, 255, 255, 0.2); border-top-color: white; border-radius: 50%; animation: spin 0.8s linear infinite; }
703    @keyframes spin { to { transform: rotate(360deg); } }
704    .loading-text { font-size: 14px; font-weight: 500; color: var(--text-secondary); margin-top: 16px; }
705
706    .navigator-indicator {
707      display: none;
708      align-items: center;
709      justify-content: flex-end;
710      gap: 4px;
711      padding: 2px 8px 4px;
712    }
713    .navigator-indicator.active { display: flex; }
714    .navigator-indicator.desktop-only { }
715    .navigator-indicator.mobile-only {
716      position: fixed;
717      top: 8px;
718      right: 8px;
719      z-index: 200;
720      padding: 0;
721    }
722    @media (max-width: 768px) {
723      .navigator-indicator.desktop-only { display: none !important; }
724    }
725    @media (min-width: 769px) {
726      .navigator-indicator.mobile-only { display: none !important; }
727    }
728    /* ── Dashboard toggle (chat panel) ─────────────────────────────── */
729    .chat-dashboard-btn {
730      display: flex;
731      align-items: center;
732      justify-content: flex-end;
733      gap: 4px;
734      padding: 2px 8px 4px;
735    }
736    .chat-dashboard-badge {
737      display: flex;
738      align-items: center;
739      gap: 5px;
740      padding: 3px 8px;
741      background: rgba(59, 130, 246, 0.12);
742      border: 1px solid rgba(59, 130, 246, 0.25);
743      border-radius: 6px;
744      cursor: pointer;
745      transition: all 0.15s;
746      font-size: 10px;
747      color: rgba(147, 197, 253, 0.9);
748      white-space: nowrap;
749    }
750    .chat-dashboard-badge:hover { background: rgba(59, 130, 246, 0.22); color: #93c5fd; }
751    .chat-dashboard-badge.active { background: rgba(16,185,129,0.15); border-color: rgba(16,185,129,0.35); color: var(--accent); }
752    .chat-dashboard-badge svg { width: 12px; height: 12px; flex-shrink: 0; }
753    .chat-dashboard-badge .dash-btn-label {
754      max-width: 0;
755      overflow: hidden;
756      transition: max-width 0.25s ease, opacity 0.2s;
757      opacity: 0;
758    }
759    .chat-dashboard-badge:hover .dash-btn-label { max-width: 80px; opacity: 1; }
760    @media (max-width: 768px) {
761      .chat-dashboard-btn { display: none; }
762    }
763    .navigator-badge {
764      display: flex;
765      flex-direction: row-reverse;
766      align-items: center;
767      gap: 4px;
768      padding: 3px 6px;
769      background: rgba(var(--glass-rgb), 0.4);
770      border: 1px solid var(--glass-border);
771      border-radius: 6px;
772      cursor: default;
773      transition: background 0.2s;
774    }
775    .navigator-badge:hover { background: rgba(var(--glass-rgb), 0.7); }
776    .navigator-badge .nav-icon {
777      width: 14px;
778      height: 14px;
779      color: var(--accent);
780      flex-shrink: 0;
781    }
782    .navigator-badge .nav-label {
783      font-size: 10px;
784      color: var(--text-secondary);
785      white-space: nowrap;
786      max-width: 0;
787      overflow: hidden;
788      transition: max-width 0.3s ease, opacity 0.2s;
789      opacity: 0;
790    }
791    .navigator-badge .nav-close-icon {
792      width: 12px;
793      height: 12px;
794      max-width: 0;
795      overflow: hidden;
796      opacity: 0;
797      flex-shrink: 0;
798      transition: max-width 0.3s ease, opacity 0.2s;
799    }
800    /* Reveal: just expand to show label (no red, no X) */
801    .navigator-badge.reveal .nav-label { max-width: 160px; opacity: 1; }
802    /* Hover: expand all, turn red, show X */
803    .navigator-badge:hover .nav-label { max-width: 160px; opacity: 1; }
804    .navigator-badge:hover .nav-close-icon { max-width: 16px; opacity: 1; }
805    .navigator-badge:hover {
806      background: rgba(239, 68, 68, 0.15);
807      border-color: rgba(239, 68, 68, 0.3);
808      cursor: pointer;
809    }
810    .navigator-badge:hover .nav-icon { color: #ef4444; }
811    .navigator-badge:hover .nav-label { color: #ef4444; }
812    .navigator-badge:hover .nav-close-icon { color: #ef4444; }
813
814    /* Welcome/background message toggle */
815    .welcome-toggle {
816      display: flex;
817      align-items: center;
818      justify-content: flex-end;
819      padding: 2px 8px 4px;
820    }
821    .welcome-toggle-btn {
822      display: flex;
823      align-items: center;
824      gap: 5px;
825      padding: 3px 8px;
826      background: rgba(255,255,255,0.06);
827      border: 1px solid rgba(255,255,255,0.1);
828      border-radius: 6px;
829      cursor: pointer;
830      transition: all 0.15s;
831      font-size: 10px;
832      color: var(--text-muted);
833      white-space: nowrap;
834    }
835    .welcome-toggle-btn:hover { background: rgba(255,255,255,0.1); color: var(--text-secondary); }
836    .welcome-toggle-btn.active { background: rgba(16,185,129,0.12); border-color: rgba(16,185,129,0.25); color: var(--accent); }
837    .welcome-toggle-btn svg { width: 12px; height: 12px; flex-shrink: 0; }
838    @media (max-width: 768px) {
839      .welcome-toggle { display: none; }
840    }
841
842    .panel-divider { width: 16px; height: 100%; display: flex; align-items: center; justify-content: center; cursor: col-resize; position: relative; z-index: 20; flex-shrink: 0; }
843    .divider-handle { width: 6px; height: 80px; background: rgba(var(--glass-rgb), 0.5); backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: 4px; transition: all var(--transition-fast); }
844    .panel-divider:hover .divider-handle { background: rgba(var(--glass-rgb), 0.7); width: 8px; }
845    .chat-header,
846    .chat-input-area {
847      border-bottom: none;
848      border-top: none;
849    }
850    .expand-buttons { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; gap: 8px; opacity: 0; pointer-events: none; transition: opacity var(--transition-fast); }
851    .panel-divider:hover .expand-buttons { opacity: 1; pointer-events: auto; }
852    .panel-divider:hover .divider-handle { opacity: 0; }
853    .expand-btn { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: rgba(var(--glass-rgb), 0.8); backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: 8px; color: var(--text-secondary); cursor: pointer; transition: all var(--transition-fast); }
854    .expand-btn:hover { background: rgba(255, 255, 255, 0.25); color: var(--text-primary); transform: scale(1.1); }
855    .expand-btn svg { width: 16px; height: 16px; }
856
857    /* Collapsed chat tab - visible by default on mobile */
858    .mobile-chat-tab {
859      display: none;
860      position: fixed;
861      bottom: calc(100px + env(safe-area-inset-bottom, 0px));
862      right: 0;
863      z-index: 150;
864      width: 32px;
865      height: 56px;
866      background: rgba(255, 255, 255, 0.12);
867      backdrop-filter: blur(12px);
868      -webkit-backdrop-filter: blur(12px);
869      border: 1px solid rgba(255, 255, 255, 0.15);
870      border-right: none;
871      border-radius: 12px 0 0 12px;
872      align-items: center;
873      justify-content: center;
874      cursor: pointer;
875      transition: all 0.2s ease;
876      box-shadow: -2px 0 12px rgba(0, 0, 0, 0.1);
877      font-size: 18px;
878    }
879    .mobile-chat-tab:active {
880      width: 38px;
881      background: rgba(255, 255, 255, 0.2);
882    }
883
884    /* Mobile connection status indicator - inside sheet header */
885    .mobile-status-indicator {
886      display: none;
887      width: 6px;
888      height: 6px;
889      border-radius: 50%;
890      flex-shrink: 0;
891    }
892    .mobile-status-indicator.connected {
893      background: var(--accent);
894      box-shadow: 0 0 6px var(--accent-glow);
895      animation: pulse 2s ease-in-out infinite;
896    }
897    .mobile-status-indicator.disconnected {
898      background: var(--error);
899      animation: none;
900    }
901    .mobile-status-indicator.connecting {
902      background: #f59e0b;
903      animation: pulse 1s ease-in-out infinite;
904    }
905
906    /* Tree icon with status dot wrapper */
907    .mobile-tree-icon-wrapper {
908      position: relative;
909      display: flex;
910      align-items: center;
911      justify-content: center;
912      text-decoration: none;
913      transition: transform 0.15s ease;
914    }
915    .mobile-tree-icon-wrapper:active {
916      transform: scale(0.92);
917    }
918    .mobile-tree-icon-wrapper .mobile-status-indicator {
919      display: block;
920      position: absolute;
921      top: 0;
922      left: 0;
923    }
924    .mobile-tree-icon-wrapper .tree-icon {
925      font-size: 24px;
926      text-shadow: none;
927      filter: none;
928    }
929
930    /* Mobile header action buttons */
931    .mobile-header-actions {
932      display: flex;
933      align-items: center;
934      gap: 2px;
935    }
936    .mobile-header-actions .mobile-close-btn {
937      margin-left: 10px;
938    }
939    .mobile-header-actions .clear-chat-btn {
940      background: rgba(255, 255, 255, 0.08);
941      border-color: rgba(255, 255, 255, 0.12);
942    }
943    .mobile-header-actions .clear-chat-btn:active {
944      background: rgba(255, 255, 255, 0.15);
945    }
946    .mobile-header-actions .mobile-dash-btn {
947      background: rgba(59, 130, 246, 0.15);
948      border-color: rgba(59, 130, 246, 0.3);
949      color: rgba(147, 197, 253, 0.9);
950    }
951    .mobile-header-actions .mobile-dash-btn:active {
952      background: rgba(59, 130, 246, 0.28);
953    }
954    .mobile-header-actions .mobile-dash-btn.active {
955      background: rgba(16, 185, 129, 0.2);
956      border-color: rgba(16, 185, 129, 0.35);
957      color: var(--accent);
958    }
959
960    .mobile-chat-sheet {
961      display: none;
962      position: fixed;
963      bottom: 0;
964      left: 0;
965      right: 0;
966      height: 85vh;
967      max-height: calc(100vh - 40px);
968      z-index: 200;
969      background: rgba(var(--glass-rgb), 0.22);
970      backdrop-filter: blur(12px);
971      -webkit-backdrop-filter: blur(12px);
972      border-top-left-radius: 24px;
973      border-top-right-radius: 24px;
974      border: 1px solid rgba(255, 255, 255, 0.18);
975      border-bottom: none;
976      box-shadow: 0 -15px 50px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.25);
977      transform: translateY(100%);
978      flex-direction: column;
979      will-change: transform;
980    }
981    .mobile-chat-sheet.open { 
982      transform: translateY(0); 
983      transition: transform 0.4s cubic-bezier(0.32, 0.72, 0, 1);
984    }
985    .mobile-chat-sheet.peeked {
986      transform: translateY(calc(100% - 90px));
987      transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
988    }
989    .mobile-chat-sheet.peeked .mobile-chat-messages,
990    .mobile-chat-sheet.peeked .mobile-chat-input-area,
991    .mobile-chat-sheet.peeked .mobile-recent-roots,
992    .mobile-chat-sheet.peeked .mobile-mode-bar {
993      display: none;
994    }
995    .mobile-chat-sheet.closing {
996      transform: translateY(100%);
997      transition: transform 0.3s cubic-bezier(0.4, 0, 1, 1);
998    }
999    .mobile-chat-sheet.dragging { 
1000      transition: none !important; 
1001    }
1002
1003    .mobile-sheet-header {
1004      padding: 12px 16px;
1005      display: flex;
1006      flex-direction: column;
1007      align-items: center;
1008      border-bottom: 1px solid rgba(255, 255, 255, 0.1);
1009      flex-shrink: 0;
1010      cursor: grab;
1011      touch-action: none;
1012      background: rgba(255, 255, 255, 0.03);
1013      user-select: none;
1014      position: relative;
1015    }
1016    .mobile-sheet-header h1,
1017    .mobile-sheet-header .root-name-inline {
1018      text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
1019    }
1020    .mobile-sheet-header:active { cursor: grabbing; }
1021    .mobile-sheet-header .drag-handle { 
1022      width: 36px; 
1023      height: 4px; 
1024      background: rgba(255, 255, 255, 0.2); 
1025      border-radius: 2px; 
1026      margin-bottom: 10px;
1027    }
1028    .mobile-sheet-title-row { width: 100%; display: flex; align-items: center; justify-content: space-between; }
1029    .mobile-sheet-title { display: flex; align-items: center; gap: 10px; min-width: 0; overflow: hidden; }
1030    .mobile-sheet-title .tree-icon { font-size: 24px; }
1031    .mobile-sheet-title h1 { font-size: 17px; font-weight: 600; }
1032    .mobile-close-btn { 
1033      width: 32px; 
1034      height: 32px; 
1035      display: flex; 
1036      align-items: center; 
1037      justify-content: center; 
1038      background: rgba(255, 255, 255, 0.08); 
1039      border: 1px solid rgba(255, 255, 255, 0.12); 
1040      border-radius: 50%; 
1041      color: var(--text-primary); 
1042      cursor: pointer;
1043      transition: all 0.15s ease;
1044    }
1045    .mobile-close-btn:active {
1046      background: rgba(255, 255, 255, 0.15);
1047      transform: scale(0.95);
1048    }
1049    .mobile-close-btn svg { width: 16px; height: 16px; }
1050
1051    .mobile-chat-messages {
1052      flex: 1;
1053      overflow-y: auto;
1054      padding: 16px;
1055      display: flex;
1056      flex-direction: column;
1057      gap: 12px;
1058      background: transparent;
1059      -webkit-overflow-scrolling: touch;
1060    }
1061    
1062    /* Glass-printed text style for mobile */
1063    .mobile-chat-messages .message-content {
1064      background: rgba(255, 255, 255, 0.1);
1065      border: 1px solid rgba(255, 255, 255, 0.12);
1066      color: #fff;
1067      text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
1068    }
1069    .mobile-chat-messages .message.user .message-content {
1070      background: rgba(255, 255, 255, 0.14);
1071      border-color: rgba(255, 255, 255, 0.18);
1072    }
1073    .mobile-chat-messages .message-avatar {
1074      background: rgba(255, 255, 255, 0.1);
1075      border-color: rgba(255, 255, 255, 0.12);
1076    }
1077    .mobile-chat-messages .welcome-message {
1078      text-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
1079    }
1080
1081    .mobile-chat-input-area {
1082      padding: 14px 16px;
1083      padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px));
1084      border-top: 1px solid rgba(255, 255, 255, 0.1);
1085      flex-shrink: 0;
1086      background: transparent;
1087    }
1088    .mobile-chat-input-area .input-container { 
1089      padding: 14px 18px; 
1090      border-radius: 26px;
1091      background: rgba(255, 255, 255, 0.12);
1092      backdrop-filter: blur(6px);
1093      border: 1px solid rgba(255, 255, 255, 0.12);
1094      min-height: 52px;
1095    }
1096    .mobile-chat-input-area .input-container:focus-within {
1097      background: rgba(255, 255, 255, 0.16);
1098      border-color: rgba(255, 255, 255, 0.25);
1099    }
1100    .mobile-chat-input-area .chat-input { font-size: 16px; }
1101    .mobile-chat-input-area .send-btn { 
1102      width: 42px; 
1103      height: 42px; 
1104      border-radius: 14px;
1105      box-shadow: none;
1106    }
1107    .mobile-chat-input-area .send-btn:hover:not(:disabled) {
1108      box-shadow: none;
1109    }
1110
1111    .mobile-backdrop {
1112      display: none;
1113      position: fixed;
1114      inset: 0;
1115      background: rgba(0, 0, 0, 0.1);
1116      z-index: 190;
1117      opacity: 0;
1118      transition: opacity 0.3s ease;
1119      pointer-events: none;
1120    }
1121    .mobile-backdrop.visible { opacity: 1; pointer-events: auto; }
1122
1123    /* Mobile recent roots dropdown */
1124    .mobile-recent-roots {
1125      display: none;
1126      width: 100%;
1127      margin-top: 6px;
1128    }
1129    .mobile-recent-roots.visible {
1130      display: block;
1131    }
1132    .mobile-recent-roots-toggle {
1133      display: flex;
1134      align-items: center;
1135      justify-content: center;
1136      gap: 4px;
1137      padding: 4px 10px;
1138      background: rgba(255, 255, 255, 0.08);
1139      border: 1px solid rgba(255, 255, 255, 0.1);
1140      border-radius: 14px;
1141      font-size: 11px;
1142      font-weight: 500;
1143      color: var(--text-muted);
1144      cursor: pointer;
1145      margin: 0 auto;
1146      transition: all var(--transition-fast);
1147    }
1148    .mobile-recent-roots-toggle:active {
1149      background: rgba(255, 255, 255, 0.15);
1150      transform: scale(0.97);
1151    }
1152    .mobile-recent-roots-toggle svg {
1153      width: 10px;
1154      height: 10px;
1155      transition: transform 0.2s ease;
1156    }
1157    .mobile-recent-roots.expanded .mobile-recent-roots-toggle svg {
1158      transform: rotate(180deg);
1159    }
1160    .mobile-recent-roots-list {
1161      display: none;
1162      flex-direction: row;
1163      flex-wrap: wrap;
1164      gap: 4px;
1165      margin-top: 6px;
1166      padding: 0 4px;
1167      justify-content: center;
1168    }
1169    .mobile-recent-roots.expanded .mobile-recent-roots-list {
1170      display: flex;
1171    }
1172    .mobile-recent-roots-list .recent-root-item {
1173      background: rgba(255, 255, 255, 0.06);
1174      font-size: 11px;
1175      padding: 5px 10px;
1176      border-radius: 12px;
1177      width: auto;
1178      flex: 0 0 auto;
1179    }
1180
1181    @media (max-width: 768px) {
1182      .app-container { padding: 0; gap: 0; flex-direction: column; }
1183      .chat-panel { display: none !important; }
1184      .viewport-panel { width: 100% !important; height: 100%; }
1185      .viewport-panel.glass-panel { border-radius: 0; }
1186      .iframe-container { border-radius: 0; margin: 0; flex: 1; }
1187      iframe, .loading-overlay { border-radius: 0; }
1188      .panel-divider { display: none; }
1189      .mobile-chat-sheet, .mobile-backdrop { display: block; }
1190      .mobile-chat-sheet { display: flex; }
1191      .mobile-chat-tab { display: flex; }
1192      .mobile-chat-tab.hidden { display: none; }
1193      
1194      .message-content {
1195        max-width: 90%;
1196        padding: 12px 14px;
1197        font-size: 14px;
1198      }
1199      .message-content .menu-item {
1200        padding: 8px 10px;
1201        gap: 8px;
1202      }
1203      .message-content .menu-number {
1204        min-width: 24px;
1205        max-width: 24px;
1206        height: 24px;
1207        font-size: 11px;
1208      }
1209      .message-content .menu-text {
1210        font-size: 13px;
1211      }
1212      .message-content code {
1213        font-size: 10px;
1214      }
1215      .message-content pre {
1216        padding: 10px;
1217        font-size: 11px;
1218      }
1219    }
1220
1221    .app-container.dragging { user-select: none; cursor: col-resize; }
1222    .app-container.dragging iframe { pointer-events: none; }
1223    .chat-panel.collapsed, .viewport-panel.collapsed { width: 0 !important; min-width: 0 !important; opacity: 0; pointer-events: none; padding: 0; border: none; overflow: hidden; }
1224    .chat-panel, .viewport-panel { transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
1225    .app-container.dragging .chat-panel, .app-container.dragging .viewport-panel { transition: none; }
1226
1227    /* ================================================================
1228       Mode bar styles
1229       ================================================================ */
1230    .mode-bar {
1231      display: flex;
1232      align-items: center;
1233      gap: 6px;
1234      padding: 6px 12px;
1235      border-top: 1px solid var(--glass-border-light);
1236      flex-shrink: 0;
1237      position: relative;
1238      z-index: 12;
1239      min-height: 40px;
1240    }
1241
1242    .mode-current {
1243      display: flex;
1244      align-items: center;
1245      gap: 8px;
1246      padding: 5px 12px 5px 8px;
1247      background: rgba(255, 255, 255, 0.15);
1248      backdrop-filter: blur(10px);
1249      border: 1px solid var(--glass-border-light);
1250      border-radius: 10px;
1251      cursor: pointer;
1252      user-select: none;
1253      transition: all var(--transition-fast);
1254      position: relative;
1255      font-size: 13px;
1256      font-weight: 600;
1257      color: var(--text-primary);
1258    }
1259    .mode-current:hover {
1260      background: rgba(255, 255, 255, 0.22);
1261      border-color: rgba(255, 255, 255, 0.3);
1262    }
1263    .mode-current:active {
1264      transform: scale(0.97);
1265    }
1266    .mode-current-emoji {
1267      font-size: 16px;
1268      line-height: 1;
1269    }
1270    .mode-current-label {
1271      white-space: nowrap;
1272    }
1273    .mode-current-chevron {
1274      width: 12px;
1275      height: 12px;
1276      color: var(--text-muted);
1277      transition: transform var(--transition-fast);
1278      flex-shrink: 0;
1279    }
1280    .mode-bar.open .mode-current-chevron {
1281      transform: rotate(180deg);
1282    }
1283
1284    .mode-dropdown {
1285      display: none;
1286      position: absolute;
1287      bottom: calc(100% + 8px);
1288      left: 0;
1289      min-width: 180px;
1290      max-width: 280px;
1291      max-height: 60vh;
1292      overflow-y: auto;
1293      background: rgba(var(--glass-rgb), 0.85);
1294      backdrop-filter: blur(var(--glass-blur)) saturate(140%);
1295      -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
1296      border: 1px solid var(--glass-border);
1297      border-radius: 14px;
1298      padding: 6px;
1299      z-index: 100;
1300      box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.3), inset 0 1px 0 var(--glass-highlight);
1301      animation: dropdownIn 0.15s ease-out;
1302    }
1303    @keyframes dropdownIn {
1304      from { opacity: 0; transform: translateY(6px); }
1305      to { opacity: 1; transform: translateY(0); }
1306    }
1307    .mode-bar.open .mode-dropdown {
1308      display: block;
1309    }
1310
1311    .mode-option {
1312      display: flex;
1313      align-items: center;
1314      gap: 10px;
1315      padding: 8px 12px;
1316      border-radius: 10px;
1317      cursor: pointer;
1318      transition: all var(--transition-fast);
1319      font-size: 13px;
1320      font-weight: 500;
1321      color: var(--text-secondary);
1322      border: none;
1323      background: none;
1324      width: 100%;
1325      text-align: left;
1326    }
1327    .mode-option:hover {
1328      background: rgba(255, 255, 255, 0.15);
1329      color: var(--text-primary);
1330    }
1331    .mode-option:active {
1332      background: rgba(255, 255, 255, 0.2);
1333      transform: scale(0.97);
1334    }
1335    .mode-option.active {
1336      background: rgba(16, 185, 129, 0.2);
1337      color: var(--text-primary);
1338      font-weight: 600;
1339      border: 1px solid rgba(16, 185, 129, 0.3);
1340    }
1341    .mode-option-emoji {
1342      font-size: 16px;
1343      width: 22px;
1344      text-align: center;
1345      flex-shrink: 0;
1346    }
1347
1348    /* Mode alert toast */
1349    .mode-alert {
1350      position: fixed;
1351      bottom: 165px;
1352      left: 10px;
1353      z-index: 9999;
1354      display: flex;
1355      align-items: center;
1356      gap: 6px;
1357      padding: 7px 14px;
1358      background: rgba(var(--glass-rgb), 0.85);
1359      backdrop-filter: blur(var(--glass-blur));
1360      -webkit-backdrop-filter: blur(var(--glass-blur));
1361      border: 1px solid var(--glass-border);
1362      border-radius: 10px;
1363      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
1364      font-size: 12px;
1365      font-weight: 600;
1366      color: var(--text-primary);
1367      pointer-events: none;
1368      opacity: 0;
1369      transform: translateY(10px);
1370      transition: opacity 0.25s ease, transform 0.25s ease;
1371    }
1372    .mode-alert.visible {
1373      opacity: 1;
1374      transform: translateY(0);
1375    }
1376    .mode-alert-emoji {
1377      font-size: 14px;
1378    }
1379
1380    /* Mobile mode bar (inside sheet header) */
1381    .mobile-mode-bar {
1382      display: flex;
1383      gap: 4px;
1384      margin-top: 10px;
1385      width: 100%;
1386      overflow-x: auto;
1387      -webkit-overflow-scrolling: touch;
1388      scrollbar-width: none;
1389      padding-bottom: 2px;
1390      scroll-behavior: smooth;
1391    }
1392    .mobile-mode-bar::-webkit-scrollbar { display: none; }
1393
1394    .mobile-mode-btn {
1395      display: flex;
1396      align-items: center;
1397      gap: 6px;
1398      padding: 6px 12px;
1399      background: rgba(255, 255, 255, 0.08);
1400      border: 1px solid rgba(255, 255, 255, 0.1);
1401      border-radius: 20px;
1402      font-size: 12px;
1403      font-weight: 600;
1404      color: var(--text-primary);
1405      cursor: pointer;
1406      white-space: nowrap;
1407      flex-shrink: 0;
1408      transition: all var(--transition-fast);
1409      text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
1410    }
1411    .mobile-mode-btn:active {
1412      transform: scale(0.95);
1413    }
1414    .mobile-mode-btn.active {
1415      background: rgba(16, 185, 129, 0.2);
1416      border-color: rgba(16, 185, 129, 0.3);
1417      color: var(--text-primary);
1418    }
1419    .mobile-mode-btn-emoji {
1420      font-size: 14px;
1421    }
1422
1423    @media (max-width: 768px) {
1424      .mode-alert {
1425        top: 10px;
1426        bottom: auto;
1427        left: 50%;
1428        transform: translateX(-50%) translateY(-10px);
1429      }
1430      .mode-alert.visible {
1431        transform: translateX(-50%) translateY(0);
1432      }
1433    }
1434    ${dashboardCSS()}
1435  </style>
1436</head>
1437<body>
1438  <div class="app-bg"></div>
1439
1440  <!-- Mode alert toast -->
1441  <div class="mode-alert" id="modeAlert">
1442    <span class="mode-alert-emoji" id="modeAlertEmoji"></span>
1443    <span id="modeAlertText"></span>
1444  </div>
1445
1446  <!-- Navigator indicator (mobile: fixed top-right) -->
1447  <div class="navigator-indicator mobile-only" id="navigatorIndicatorMobile">
1448    <div class="navigator-badge" id="navigatorBadgeMobile" title="Detach session navigator">
1449      <svg class="nav-close-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1450      <span class="nav-label" id="navigatorLabelMobile">session</span>
1451      <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
1452    </div>
1453  </div>
1454
1455  <div class="app-container">
1456    <!-- Chat Panel -->
1457    <div class="chat-panel glass-panel" id="chatPanel">
1458      <div class="chat-header">
1459        <a href="/app" class="tree-home-link">
1460          <div class="chat-title">
1461            <span class="tree-icon">🌳</span>
1462            <h1>${landName}</h1>
1463          </div>
1464        </a>
1465        <span class="root-name-inline" id="rootNameLabel" title=""></span>
1466
1467        <div class="chat-header-controls">
1468          <div class="chat-header-buttons">
1469            <button class="clear-chat-btn" id="desktopHomeBtn" title="Home">
1470              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
1471            </button>
1472            <button class="clear-chat-btn" id="desktopRefreshBtn" title="Refresh">
1473              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
1474            </button>
1475            <button class="clear-chat-btn" id="desktopOpenTabBtn" title="Open in new tab">
1476              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
1477            </button>
1478            <button class="clear-chat-btn" id="desktopCustomAiBtn" title="LLM Connections">🤖</button>
1479          </div>
1480          <div class="chat-header-right">
1481            <div class="status-badge">
1482              <span class="status-dot connecting" id="statusDot"></span>
1483              <span class="status-text" id="statusText">Connecting...</span>
1484            </div>
1485            <button class="clear-chat-btn" id="clearChatBtn" title="Clear conversation">
1486              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
1487            </button>
1488          </div>
1489        </div>
1490      </div>
1491
1492      <!-- Session manager toggle (desktop: row above navigator) -->
1493      <div class="chat-dashboard-btn" id="desktopDashboardRow">
1494        <div class="chat-dashboard-badge" id="desktopDashboardBtn" title="Session Manager">
1495          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
1496          <span class="dash-btn-label">Sessions</span>
1497        </div>
1498      </div>
1499
1500      <!-- Navigator indicator (desktop: row below session manager) -->
1501      <div class="navigator-indicator desktop-only" id="navigatorIndicatorDesktop">
1502        <div class="navigator-badge" id="navigatorBadgeDesktop" title="Detach session navigator">
1503          <svg class="nav-close-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1504          <span class="nav-label" id="navigatorLabelDesktop">session</span>
1505          <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
1506        </div>
1507      </div>
1508
1509      <!-- Background messages toggle -->
1510      <div class="welcome-toggle">
1511        <button class="welcome-toggle-btn" id="bgMsgToggleBtn" title="Show background system messages">
1512          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
1513          <span>System</span>
1514        </button>
1515      </div>
1516
1517      <!-- Recent Roots Dropdown (absolute positioned, top-left overlay) -->
1518      <div class="recent-roots-dropdown hidden" id="recentRootsDropdown">
1519        <div class="recent-roots-trigger" id="recentRootsTrigger">
1520          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg>
1521        </div>
1522        <div class="recent-roots-menu" id="recentRootsMenu">
1523          <div class="recent-roots-menu-header">Recent Trees</div>
1524          <div id="recentRootsList"></div>
1525        </div>
1526      </div>
1527
1528      <div class="chat-messages" id="chatMessages">
1529        <div class="welcome-message">
1530          <div class="welcome-icon">🌳</div>
1531          <h2>Welcome</h2>
1532          <p>Just type. Say hello, ask a question, or tell it something. Natural language works.</p>
1533          <p style="margin-top:8px;font-size:13px;color:var(--text-tertiary);">You can also connect via CLI: <code style="background:rgba(255,255,255,0.06);padding:2px 6px;border-radius:4px;">npm i -g treeos</code></p>
1534        </div>
1535      </div>
1536
1537      <!-- Desktop mode bar (above input) -->
1538      <div class="mode-bar" id="modeBar">
1539        <div class="mode-current" id="modeCurrent">
1540          <span class="mode-current-emoji" id="modeCurrentEmoji">🏠</span>
1541          <span class="mode-current-label" id="modeCurrentLabel">Home</span>
1542          <svg class="mode-current-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 15l6-6 6 6"/></svg>
1543          <div class="mode-dropdown" id="modeDropdown"></div>
1544        </div>
1545      </div>
1546
1547      <div class="chat-input-area">
1548        <div class="input-container">
1549          <textarea class="chat-input" id="chatInput" placeholder="Message Tree..." rows="1"></textarea>
1550          <button class="send-btn" id="sendBtn" disabled>
1551            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1552              <path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
1553            </svg>
1554          </button>
1555        </div>
1556      </div>
1557    </div>
1558
1559    <!-- Divider -->
1560    <div class="panel-divider" id="panelDivider">
1561      <div class="divider-handle"></div>
1562      <div class="expand-buttons">
1563        <button class="expand-btn" id="expandChatBtn" title="Expand chat">
1564          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 5l7 7-7 7"/><path d="M5 5l7 7-7 7"/></svg>
1565        </button>
1566        <button class="expand-btn" id="resetPanelsBtn" title="Reset">
1567          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="18" rx="1"/><rect x="14" y="3" width="7" height="18" rx="1"/></svg>
1568        </button>
1569        <button class="expand-btn" id="expandViewportBtn" title="Expand viewport">
1570          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 19l-7-7 7-7"/><path d="M19 19l-7-7 7-7"/></svg>
1571        </button>
1572      </div>
1573    </div>
1574
1575    <!-- Viewport Panel -->
1576    <div class="viewport-panel glass-panel" id="viewportPanel">
1577      ${dashboardHTML()}
1578      <div class="iframe-container" id="iframeContainer">
1579        <div class="loading-overlay" id="loadingOverlay">
1580          <div class="loading-spinner">
1581            <div class="spinner-ring"></div>
1582            <span class="loading-text">Loading...</span>
1583          </div>
1584        </div>
1585        <iframe id="viewport" src="${req.query.rootId && UUID_RE.test(req.query.rootId) ? `/api/v1/root/${req.query.rootId}?html&token=${encodeURIComponent(htmlShareToken)}&inApp=1` : `/api/v1/user/${req.userId}?html&token=${encodeURIComponent(htmlShareToken)}&inApp=1`}" sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-downloads allow-top-navigation-by-user-activation allow-top-navigation"></iframe>
1586      </div>
1587    </div>
1588  </div>
1589
1590  <!-- Collapsed chat tab -->
1591  <div class="mobile-chat-tab" id="mobileChatTab">🌳</div>
1592
1593  <div class="mobile-backdrop" id="mobileBackdrop"></div>
1594
1595  <div class="mobile-chat-sheet" id="mobileChatSheet">
1596    <div class="mobile-sheet-header" id="mobileSheetHeader">
1597      <div class="drag-handle"></div>
1598      <div class="mobile-sheet-title-row">
1599        <div class="mobile-sheet-title">
1600          <a href="/app" class="mobile-tree-icon-wrapper" title="Back to ${landName}">
1601            <div class="mobile-status-indicator connecting" id="mobileStatusIndicator"></div>
1602            <span class="tree-icon">🌳</span>
1603          </a>
1604          <span class="root-name-inline mobile-root-path" id="mobileRootNameLabel" title=""></span>
1605        </div>
1606        <div class="mobile-header-actions">
1607          <button class="clear-chat-btn mobile-dash-btn" id="mobileDashboardBtn" title="Session Manager">
1608            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
1609          </button>
1610          <button class="clear-chat-btn" id="mobileHomeBtn" title="Home">
1611            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
1612          </button>
1613          <button class="clear-chat-btn" id="mobileRefreshBtn" title="Refresh">
1614            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
1615          </button>
1616          <button class="clear-chat-btn" id="mobileClearChatBtn" title="Clear conversation">
1617            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
1618          </button>
1619          <button class="clear-chat-btn" id="mobileCustomAiBtn" title="LLM Connections">🤖</button>
1620          <button class="mobile-close-btn" id="mobileCloseBtn">
1621            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
1622          </button>
1623        </div>
1624      </div>
1625      <!-- Mobile mode bar (horizontal pill row) -->
1626      <div class="mobile-mode-bar" id="mobileModeBar"></div>
1627      <!-- Mobile recent roots dropdown -->
1628      <div class="mobile-recent-roots" id="mobileRecentRoots">
1629        <div class="mobile-recent-roots-toggle" id="mobileRecentRootsToggle">
1630          <span>Recent Trees</span>
1631          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg>
1632        </div>
1633        <div class="mobile-recent-roots-list" id="mobileRecentRootsList"></div>
1634      </div>
1635    </div>
1636    <div class="mobile-chat-messages" id="mobileChatMessages">
1637      <div class="welcome-message">
1638        <div class="welcome-icon">🌳</div>
1639        <h2>Welcome</h2>
1640        <p>Just type. Say hello, ask a question, or tell it something.</p>
1641      </div>
1642    </div>
1643    <div class="mobile-chat-input-area">
1644      <div class="input-container">
1645        <textarea class="chat-input" id="mobileSheetInput" placeholder="Message Tree..." rows="1"></textarea>
1646        <button class="send-btn" id="mobileSheetSendBtn" disabled>
1647          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
1648        </button>
1649      </div>
1650    </div>
1651  </div>
1652
1653  <script src="/socket.io/socket.io.js"></script>
1654  <script>
1655    // Config from server
1656    const CONFIG = {
1657      userId: "${esc(req.userId)}",
1658      username: ${JSON.stringify(username || req.userId || "")},
1659      htmlShareToken: "${esc(htmlShareToken)}",
1660      homeUrl: "/api/v1/user/${esc(req.userId)}?html&token=${encodeURIComponent(htmlShareToken)}&inApp=1",
1661      hasLlm: ${!!hasLlm},
1662      landName: ${JSON.stringify(landName || "")},
1663    };
1664
1665    // Elements
1666    const $ = (id) => document.getElementById(id);
1667    const chatMessages = $("chatMessages");
1668    const chatInput = $("chatInput");
1669    const sendBtn = $("sendBtn");
1670    const statusDot = $("statusDot");
1671    const statusText = $("statusText");
1672    const mobileStatusIndicator = $("mobileStatusIndicator");
1673    const iframe = $("viewport");
1674    const loadingOverlay = $("loadingOverlay");
1675    const mobileChatMessages = $("mobileChatMessages");
1676    const mobileSheetInput = $("mobileSheetInput");
1677    const mobileSheetSendBtn = $("mobileSheetSendBtn");
1678    const mobileChatSheet = $("mobileChatSheet");
1679    const mobileBackdrop = $("mobileBackdrop");
1680    const mobileSheetHeader = $("mobileSheetHeader");
1681    const mobileChatTab = $("mobileChatTab");
1682
1683    // Recent roots elements
1684    const recentRootsDropdown = $("recentRootsDropdown");
1685    const recentRootsTrigger = $("recentRootsTrigger");
1686    const recentRootsList = $("recentRootsList");
1687    const mobileRecentRoots = $("mobileRecentRoots");
1688    const mobileRecentRootsToggle = $("mobileRecentRootsToggle");
1689    const mobileRecentRootsList = $("mobileRecentRootsList");
1690
1691    // State
1692    let isConnected = false;
1693    let isRegistered = false;
1694    let isSending = false;
1695    let currentIframeUrl = CONFIG.homeUrl;
1696
1697    // Mobile sheet state: 'closed' | 'peeked' | 'open'
1698    let mobileSheetState = 'closed';
1699
1700    // Mode state
1701    let currentModeKey = null;
1702    let availableModes = [];
1703    let modeBarOpen = false;
1704    let requestGeneration = 0;
1705
1706    // Recent roots state
1707    let recentRoots = [];
1708    let recentRootsOpen = false;
1709    let mobileRecentRootsExpanded = false;
1710// Build iframe URL — always injects inApp, token, and rootId when available
1711function buildIframeUrl(raw) {
1712  try {
1713    const base = raw.startsWith('http') ? raw : new URL(raw, window.location.origin).href;
1714    const u = new URL(base);
1715    if (!u.searchParams.has('inApp'))  u.searchParams.set('inApp', '1');
1716    if (!u.searchParams.has('token'))  u.searchParams.set('token', CONFIG.htmlShareToken);
1717    const rootId = getCurrentRootId();
1718    if (rootId && !u.pathname.includes('/root/')) {
1719      u.searchParams.set('rootId', rootId);
1720    } else {
1721      u.searchParams.delete('rootId');
1722    }
1723    return u.pathname + u.search;
1724  } catch (e) {
1725    return raw;
1726  }
1727}
1728    // Socket setup
1729    const socket = io({ transports: ["websocket", "polling"], withCredentials: true });
1730
1731    socket.on("connect", () => {
1732      console.log("[socket] connected:", socket.id);
1733      isConnected = true;
1734      socket.emit("ready");
1735      updateStatus("connecting");
1736      socket.emit("register", { username: CONFIG.username });
1737    });
1738
1739    socket.on("registered", ({ success, error }) => {
1740      if (success) {
1741        isRegistered = true;
1742        updateStatus("connected");
1743        console.log("[socket] registered for chat");
1744        let currentUrl = "";
1745        try { currentUrl = iframe.contentWindow?.location?.pathname + iframe.contentWindow?.location?.search; } catch(e) {}
1746        if (!currentUrl) {
1747          try { const u = new URL(iframe.src); currentUrl = u.pathname + u.search; } catch(e) {}
1748        }
1749        if (!currentUrl) {
1750          currentUrl = currentIframeUrl || "";
1751        }
1752        socket.emit("getAvailableModes", { url: currentUrl });
1753        socket.emit("getRecentRoots");
1754        if (currentUrl) detectIframeUrlChange();
1755      } else {
1756        console.error("[socket] registration failed:", error);
1757        updateStatus("connected");
1758        addMessage("Chat registration failed: " + (error || "Unknown error") + ". You can still browse your tree.", "error");
1759      }
1760    });
1761
1762    socket.on("chatResponse", ({ answer, generation }) => {
1763      if (generation !== undefined && generation < requestGeneration) {
1764        console.log("[socket] dropping stale response, gen:", generation, "current:", requestGeneration);
1765        return;
1766      }
1767      removeTypingIndicator();
1768      addMessage(answer, "assistant");
1769      isSending = false;
1770      updateSendButtons();
1771      lockModeBar(false);
1772      // Refresh iframe content after AI response (data may have changed)
1773      try { iframe.contentWindow?.location.reload(); } catch(e) {}
1774    });
1775
1776    socket.on("chatError", ({ error, generation }) => {
1777      if (generation !== undefined && generation < requestGeneration) return;
1778      removeTypingIndicator();
1779      addMessage("Error: " + error, "error");
1780      isSending = false;
1781      updateSendButtons();
1782      lockModeBar(false);
1783
1784      // Glow the LLM button after a delay so they read the error first
1785      if (error && (error.includes("/setup") || error.includes("LLM connection"))) {
1786        setTimeout(function() {
1787          var glowBtns = [$("desktopCustomAiBtn"), $("mobileCustomAiBtn")];
1788          glowBtns.forEach(function(btn) {
1789            if (!btn) return;
1790            btn.classList.remove("llm-glow");
1791            void btn.offsetWidth;
1792            btn.classList.add("llm-glow");
1793            btn.addEventListener("animationend", function() { btn.classList.remove("llm-glow"); }, { once: true });
1794          });
1795        }, 2500);
1796      }
1797    });
1798
1799    // Session killed from session manager while chat was in-flight
1800    socket.on("chatCancelled", () => {
1801      if (isSending) {
1802        removeTypingIndicator();
1803        isSending = false;
1804        lockModeBar(false);
1805        updateSendButtons();
1806      }
1807    });
1808
1809socket.on("navigate", ({ url, replace }) => {
1810    console.log("[socket] navigate:", url);
1811    loadingOverlay.classList.add("visible");
1812    currentIframeUrl = url;
1813    let navUrl = buildIframeUrl(url);
1814    if (replace) {
1815      iframe.contentWindow?.location.replace(navUrl);
1816    } else {
1817      iframe.src = navUrl;
1818    }
1819  });
1820
1821    // ── Navigator session indicator ──────────────────────────────────
1822    const navIndicators = [
1823      document.getElementById("navigatorIndicatorDesktop"),
1824      document.getElementById("navigatorIndicatorMobile"),
1825    ];
1826    const navBadges = [
1827      document.getElementById("navigatorBadgeDesktop"),
1828      document.getElementById("navigatorBadgeMobile"),
1829    ];
1830    const navLabels = [
1831      document.getElementById("navigatorLabelDesktop"),
1832      document.getElementById("navigatorLabelMobile"),
1833    ];
1834
1835    const sessionTypeLabels = {
1836      "websocket-chat": "chat",
1837      "api-tree-chat": "api chat",
1838      "api-tree-place": "api place",
1839      "raw-idea-orchestrate": "raw idea",
1840      "raw-idea-chat": "raw idea chat",
1841      "understanding-orchestrate": "understand",
1842      "scheduled-raw-idea": "scheduled",
1843    };
1844
1845    let navFlashTimer = null;
1846    let currentNavSessionId = null;
1847    socket.on("navigatorSession", (data) => {
1848      if (data && data.sessionId) {
1849        const label = sessionTypeLabels[data.type] || data.type || "session";
1850        navLabels.forEach(el => { if (el) el.textContent = label; });
1851        navIndicators.forEach(el => { if (el) el.classList.add("active"); });
1852        // Only reveal when navigator actually changes (new session or added from nothing)
1853        if (data.sessionId !== currentNavSessionId) {
1854          currentNavSessionId = data.sessionId;
1855          navBadges.forEach(el => { if (el) el.classList.add("reveal"); });
1856          if (navFlashTimer) clearTimeout(navFlashTimer);
1857          navFlashTimer = setTimeout(() => {
1858            navBadges.forEach(el => { if (el) el.classList.remove("reveal"); });
1859          }, 3000);
1860        }
1861      } else {
1862        currentNavSessionId = null;
1863        navIndicators.forEach(el => { if (el) el.classList.remove("active"); });
1864        navBadges.forEach(el => { if (el) el.classList.remove("reveal"); });
1865        if (navFlashTimer) clearTimeout(navFlashTimer);
1866      }
1867    });
1868
1869    document.getElementById("navigatorBadgeDesktop").addEventListener("click", () => {
1870      socket.emit("detachNavigator");
1871    });
1872
1873    // Mobile: first tap expands, second tap (when expanded) detaches
1874    let mobileNavExpanded = false;
1875    let mobileNavCollapseTimer = null;
1876    document.getElementById("navigatorBadgeMobile").addEventListener("click", () => {
1877      const badge = document.getElementById("navigatorBadgeMobile");
1878      if (mobileNavExpanded) {
1879        // Already expanded — detach
1880        mobileNavExpanded = false;
1881        if (mobileNavCollapseTimer) clearTimeout(mobileNavCollapseTimer);
1882        badge.classList.remove("reveal");
1883        socket.emit("detachNavigator");
1884      } else {
1885        // First tap — expand to show session name
1886        mobileNavExpanded = true;
1887        badge.classList.add("reveal");
1888        if (mobileNavCollapseTimer) clearTimeout(mobileNavCollapseTimer);
1889        mobileNavCollapseTimer = setTimeout(() => {
1890          mobileNavExpanded = false;
1891          badge.classList.remove("reveal");
1892        }, 4000);
1893      }
1894    });
1895
1896    socket.on("reload", () => {
1897      loadingOverlay.classList.add("visible");
1898      iframe.contentWindow?.location.reload();
1899    });
1900
1901    // Live dashboard updates: when extension data changes in the background,
1902    // refresh the iframe if it's showing that tree's dashboard.
1903    socket.on("dashboardUpdate", ({ rootId: updatedRootId }) => {
1904      if (!updatedRootId || isSending) return;
1905      const currentRoot = getCurrentRootId();
1906      if (currentRoot === updatedRootId) {
1907        try { iframe.contentWindow?.location.reload(); } catch(e) {}
1908      }
1909    });
1910
1911    socket.on("disconnect", () => {
1912      isConnected = false;
1913      isRegistered = false;
1914      updateStatus("disconnected");
1915      navIndicators.forEach(el => { if (el) el.classList.remove("active"); });
1916
1917      [chatMessages, mobileChatMessages].forEach(container => {
1918        container.innerHTML = '<div class="welcome-message disconnected"><div class="welcome-icon">🌳</div><h2>Disconnected</h2><p>You have been disconnected from ' + CONFIG.landName + '. Please refresh the whole website to reconnect.</p></div>';
1919      });
1920    });
1921
1922    // ================================================================
1923    // Recent Roots
1924    // ================================================================
1925
1926    socket.on("recentRoots", ({ roots }) => {
1927      console.log("[socket] recent roots:", roots);
1928      recentRoots = roots || [];
1929      renderRecentRoots();
1930    });
1931var _initParams = new URLSearchParams(window.location.search);
1932let activeRootId = _initParams.get("rootId") || null;
1933if (activeRootId) window.history.replaceState({}, "", "/dashboard");
1934
1935   function getCurrentRootId() {
1936  if (activeRootId) return activeRootId;
1937  // Fallback: try to extract from URL
1938  const ID = '(?:[a-f0-9]{24}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})';
1939  const rootMatch = currentIframeUrl.match(new RegExp('(?:/api/v1)?/root/(' + ID + ')', 'i'));
1940  return rootMatch ? rootMatch[1] : null;
1941}
1942
1943    function truncateName(str, maxLen = 18) {
1944      if (!str) return '';
1945      return str.length > maxLen ? str.slice(0, maxLen) + '…' : str;
1946    }
1947
1948    function renderRecentRoots() {
1949      const currentRootId = getCurrentRootId();
1950
1951      // Hide if no roots
1952      if (recentRoots.length === 0) {
1953        recentRootsDropdown.classList.add("hidden");
1954        mobileRecentRoots.classList.remove("visible");
1955        return;
1956      }
1957
1958      // Show dropdown trigger
1959      recentRootsDropdown.classList.remove("hidden");
1960      mobileRecentRoots.classList.add("visible");
1961      mobileRecentRoots.classList.toggle("expanded", mobileRecentRootsExpanded);
1962
1963      // Render list HTML (truncated names, no emoji)
1964      const listHtml = recentRoots.map(root => {
1965        const isActive = root.rootId === currentRootId;
1966        return \`
1967          <button class="recent-root-item\${isActive ? ' active' : ''}" data-root-id="\${root.rootId}">
1968            <span class="recent-root-name">\${escapeHtml(truncateName(root.name))}</span>
1969          </button>
1970        \`;
1971      }).join('');
1972
1973      recentRootsList.innerHTML = listHtml;
1974      mobileRecentRootsList.innerHTML = listHtml;
1975
1976      // Add click handlers
1977      [recentRootsList, mobileRecentRootsList].forEach(list => {
1978        list.querySelectorAll('.recent-root-item').forEach(item => {
1979          item.addEventListener('click', (e) => {
1980            e.preventDefault();
1981            e.stopPropagation();
1982            const rootId = item.dataset.rootId;
1983            if (rootId) {
1984              navigateToRoot(rootId);
1985              closeRecentRoots();
1986              // On mobile, just collapse recent trees, not the whole sheet
1987              if (window.innerWidth <= 768) {
1988                mobileRecentRootsExpanded = false;
1989                mobileRecentRoots.classList.remove("expanded");
1990              }
1991            }
1992          });
1993        });
1994      });
1995    }
1996
1997  function navigateToRoot(rootId) {
1998    activeRootId = rootId;
1999
2000  const url = '/api/v1/root/' + rootId + '?html&token=' + CONFIG.htmlShareToken + '&inApp=1';
2001  loadingOverlay.classList.add("visible");
2002  iframe.src = url;
2003  currentIframeUrl = '/api/v1/root/' + rootId;
2004}
2005
2006    function closeRecentRoots() {
2007      recentRootsOpen = false;
2008      recentRootsDropdown.classList.remove("open");
2009    }
2010
2011    function toggleRecentRoots() {
2012      recentRootsOpen = !recentRootsOpen;
2013      recentRootsDropdown.classList.toggle("open", recentRootsOpen);
2014    }
2015
2016    // Desktop trigger click
2017    recentRootsTrigger.addEventListener("click", (e) => {
2018      e.stopPropagation();
2019      toggleRecentRoots();
2020    });
2021
2022    // Mobile toggle
2023    mobileRecentRootsToggle.addEventListener("click", (e) => {
2024      e.preventDefault();
2025      e.stopPropagation();
2026      e.stopImmediatePropagation();
2027      mobileRecentRootsExpanded = !mobileRecentRootsExpanded;
2028      mobileRecentRoots.classList.toggle("expanded", mobileRecentRootsExpanded);
2029    });
2030
2031    // Close recent roots when clicking outside
2032    document.addEventListener("click", (e) => {
2033      if (recentRootsOpen && !recentRootsDropdown.contains(e.target)) {
2034        closeRecentRoots();
2035      }
2036      if (modeBarOpen && !$("modeBar").contains(e.target)) {
2037        closeModeBar();
2038      }
2039    });
2040
2041    // Close recent roots when focusing chat input
2042    chatInput.addEventListener("focus", () => {
2043      closeRecentRoots();
2044    });
2045
2046    // Close mobile recent roots when focusing input
2047    mobileSheetInput.addEventListener("focus", () => {
2048      mobileRecentRootsExpanded = false;
2049      mobileRecentRoots.classList.remove("expanded");
2050    });
2051
2052    // ================================================================
2053    // Mode switching socket events
2054    // ================================================================
2055
2056    socket.on("modeSwitched", ({ modeKey, emoji, label, alert, carriedMessages, silent }) => {
2057      console.log("[mode] switched to:", modeKey, silent ? "(silent)" : "", "carried:", carriedMessages?.length || 0);
2058      currentModeKey = modeKey;
2059      $("modeCurrentEmoji").textContent = emoji;
2060      $("modeCurrentLabel").textContent = label;
2061      const bigMode = modeKey.split(":")[0];
2062      if (availableModes.length && availableModes[0].key.startsWith(bigMode + ":")) {
2063        renderModeDropdown();
2064        renderMobileModeBar();
2065      }
2066      if (!silent) {
2067        if (isSending) {
2068          isSending = false;
2069          removeTypingIndicator();
2070          lockModeBar(false);
2071          updateSendButtons();
2072        }
2073        clearChatUI(carriedMessages || [], modeKey, emoji);
2074        showModeAlert(emoji, label);
2075      }
2076    });
2077
2078   socket.on("availableModes", ({ bigMode, modes, currentMode, rootName, rootId }) => {
2079  console.log("[mode] available:", bigMode, modes, "root:", rootName, rootId);
2080  availableModes = modes || [];
2081  if (currentMode) currentModeKey = currentMode;
2082
2083  // Sync activeRootId from server — this is the source of truth
2084  if (rootId) {
2085    activeRootId = rootId;
2086  } else if (bigMode === 'home') {
2087    activeRootId = null;
2088  }
2089
2090  const active = availableModes.find(m => m.key === currentModeKey);
2091  if (active) {
2092    $("modeCurrentEmoji").textContent = active.emoji;
2093    $("modeCurrentLabel").textContent = active.label;
2094  }
2095  renderModeDropdown();
2096  renderMobileModeBar();
2097
2098  // Visibility is controlled by CSS (gated behind body.show-bg-messages).
2099  // The mode picker is an advanced override; sprout + the routing index
2100  // pick the right mode automatically.
2101  updateRootName(rootName);
2102});
2103
2104    socket.on("conversationCleared", () => {
2105      console.log("[socket] conversation manually cleared");
2106      clearChatUI([], currentModeKey);
2107    });
2108
2109    // ================================================================
2110    // Mode bar logic (desktop)
2111    // ================================================================
2112
2113    function renderModeDropdown() {
2114      const dropdown = $("modeDropdown");
2115      dropdown.innerHTML = "";
2116      availableModes.forEach(mode => {
2117        const btn = document.createElement("button");
2118        btn.className = "mode-option" + (mode.key === currentModeKey ? " active" : "");
2119        btn.innerHTML = '<span class="mode-option-emoji">' + mode.emoji + '</span><span>' + mode.label + '</span>';
2120        btn.addEventListener("click", (e) => {
2121          e.stopPropagation();
2122          if (mode.key !== currentModeKey) {
2123            socket.emit("switchMode", { modeKey: mode.key });
2124          }
2125          closeModeBar();
2126        });
2127        dropdown.appendChild(btn);
2128      });
2129    }
2130
2131    function toggleModeBar() {
2132      modeBarOpen = !modeBarOpen;
2133      $("modeBar").classList.toggle("open", modeBarOpen);
2134    }
2135
2136    function closeModeBar() {
2137      modeBarOpen = false;
2138      $("modeBar").classList.remove("open");
2139    }
2140
2141    $("modeCurrent").addEventListener("click", (e) => {
2142      e.stopPropagation();
2143      toggleModeBar();
2144    });
2145
2146    // Prevent clicks inside dropdown from toggling mode bar
2147    $("modeDropdown").addEventListener("click", (e) => {
2148      e.stopPropagation();
2149    });
2150
2151    // ================================================================
2152    // Lock/unlock mode bar while AI is responding
2153    // ================================================================
2154
2155    const SEND_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>';
2156    const STOP_SVG = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
2157
2158    function lockModeBar(locked) {
2159      $("modeBar").classList.toggle("locked", locked);
2160      document.querySelectorAll(".mobile-mode-btn").forEach(btn => {
2161        btn.classList.toggle("locked", locked);
2162      });
2163      [sendBtn, mobileSheetSendBtn].forEach(btn => {
2164        btn.classList.toggle("stop-mode", locked);
2165        btn.innerHTML = locked ? STOP_SVG : SEND_SVG;
2166        if (locked) btn.disabled = false;
2167      });
2168    }
2169
2170    function cancelRequest() {
2171      if (!isSending) return;
2172      requestGeneration++;
2173      isSending = false;
2174      removeTypingIndicator();
2175      lockModeBar(false);
2176      updateSendButtons();
2177      socket.emit("cancelRequest");
2178    }
2179
2180    function updateRootName(name) {
2181      ["rootNameLabel", "mobileRootNameLabel"].forEach(id => {
2182        const el = $(id);
2183        if (name) {
2184          const changed = el.textContent !== name;
2185          el.textContent = name;
2186          el.title = name;
2187          el.classList.add("visible");
2188          if (changed) {
2189            el.classList.remove("fade-in");
2190            void el.offsetWidth;
2191            el.classList.add("fade-in");
2192          }
2193        } else {
2194          el.classList.remove("visible", "fade-in");
2195          el.textContent = "";
2196          el.title = "";
2197        }
2198      });
2199    }
2200
2201    // ================================================================
2202    // Mobile mode bar (horizontal pills in sheet header)
2203    // ================================================================
2204
2205    function renderMobileModeBar() {
2206      const bar = $("mobileModeBar");
2207      bar.innerHTML = "";
2208      availableModes.forEach(mode => {
2209        const btn = document.createElement("button");
2210        btn.className = "mobile-mode-btn" + (mode.key === currentModeKey ? " active" : "");
2211        btn.dataset.modeKey = mode.key;
2212        btn.innerHTML = '<span class="mobile-mode-btn-emoji">' + mode.emoji + '</span><span>' + mode.label + '</span>';
2213        btn.addEventListener("click", (e) => {
2214          e.stopPropagation();
2215          if (mode.key !== currentModeKey) {
2216            socket.emit("switchMode", { modeKey: mode.key });
2217          }
2218        });
2219        bar.appendChild(btn);
2220      });
2221      scrollToActiveMode();
2222    }
2223
2224    function scrollToActiveMode() {
2225      const bar = $("mobileModeBar");
2226      const activeBtn = bar.querySelector(".mobile-mode-btn.active");
2227      if (activeBtn) {
2228        const barRect = bar.getBoundingClientRect();
2229        const btnRect = activeBtn.getBoundingClientRect();
2230        const scrollLeft = activeBtn.offsetLeft - (barRect.width / 2) + (btnRect.width / 2);
2231        bar.scrollTo({ left: Math.max(0, scrollLeft), behavior: "smooth" });
2232      }
2233    }
2234
2235    // ================================================================
2236    // Mode alert toast
2237    // ================================================================
2238
2239    let modeAlertTimer = null;
2240    function showModeAlert(emoji, label) {
2241     //handled behind scenes
2242    }
2243
2244    // ================================================================
2245    // Clear chat UI helper
2246    // ================================================================
2247
2248    function clearChatUI(carriedMessages, modeKey, emoji) {
2249      const valid = (carriedMessages || []).filter(m => m.content && m.content.trim());
2250      const activeMode = availableModes.find(m => m.key === modeKey);
2251      const welcome = {
2252        icon: emoji || activeMode?.emoji || "🌳",
2253        title: activeMode?.label || (modeKey === "home:default" ? "Welcome to " + CONFIG.landName : "Ready"),
2254        desc: "",
2255      };
2256
2257      [chatMessages, mobileChatMessages].forEach(container => {
2258        container.innerHTML = '';
2259
2260        if (valid.length > 0) {
2261          valid.forEach(msg => {
2262            const el = document.createElement("div");
2263            el.className = "message " + msg.role + " carried";
2264            const formattedContent = msg.role === "assistant" ? formatMessageContent(msg.content) : escapeHtml(msg.content);
2265            el.innerHTML =
2266              '<div class="message-avatar">' + (msg.role === "user" ? "👤" : "🌳") + '</div>' +
2267              '<div class="message-content">' + formattedContent + '</div>';
2268            container.appendChild(el);
2269          });
2270          container.scrollTop = container.scrollHeight;
2271        } else {
2272          container.innerHTML = '<div class="welcome-message"><div class="welcome-icon">' + welcome.icon + '</div><h2>' + welcome.title + '</h2><p>' + welcome.desc + '</p></div>';
2273        }
2274      });
2275    }
2276
2277    // ================================================================
2278    // iframe URL change detection
2279    // ================================================================
2280
2281    let lastEmittedUrl = "";
2282    function detectIframeUrlChange() {
2283      let path = "";
2284
2285      try {
2286        const loc = iframe.contentWindow?.location;
2287        if (loc) path = loc.pathname + loc.search;
2288      } catch (e) {}
2289
2290      if (!path) {
2291        try { const u = new URL(iframe.src); path = u.pathname + u.search; } catch(e) {}
2292      }
2293
2294      if (!path) {
2295        path = currentIframeUrl || "";
2296      }
2297
2298      if (path && path !== lastEmittedUrl) {
2299        lastEmittedUrl = path;
2300        currentIframeUrl = path;
2301        const ID = '(?:[a-f0-9]{24}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})';
2302        let rootId = null;
2303        let nodeId = null;
2304        const rootMatch = path.match(new RegExp('(?:/api/v1)?/root/(' + ID + ')(?:[?/]|$)', 'i'));
2305        const extDashMatch = path.match(new RegExp('(?:/api/v1)?/root/(' + ID + ')/[a-z]', 'i'));
2306        const bareMatch = path.match(new RegExp('(?:/api/v1)?/(' + ID + ')(?:[?/]|$)', 'i'));
2307
2308        // Extension dashboards (/root/:id/fitness, /root/:id/food, etc.)
2309        // Chat bar is now in the app shell, not in the iframe.
2310        // Emit urlChanged so the server switches to the right tree session.
2311        if (extDashMatch) {
2312          rootId = extDashMatch[1];
2313          activeRootId = rootId;
2314          if (isRegistered) {
2315            socket.emit("urlChanged", { url: path, rootId, nodeId: null });
2316          }
2317        } else {
2318          if (rootMatch) rootId = rootMatch[1];
2319          else if (bareMatch) nodeId = bareMatch[1];
2320
2321          if (isRegistered) {
2322            socket.emit("urlChanged", { url: path, rootId, nodeId });
2323          }
2324          if (rootMatch) activeRootId = rootId;
2325        }
2326        // Re-render recent roots to update active state
2327        renderRecentRoots();
2328      }
2329    }
2330
2331    // Status
2332    function updateStatus(status) {
2333      statusDot.className = "status-dot " + status;
2334      mobileStatusIndicator.className = "mobile-status-indicator " + status;
2335      statusText.textContent = status === "connected" ? "Connected" : status === "connecting" ? "Connecting..." : "Disconnected";
2336      isConnected = status === "connected";
2337    }
2338
2339    // Format message content with markdown-like parsing
2340    function formatMessageContent(text) {
2341      if (!text) return '';
2342      
2343      let html = text;
2344      
2345      html = html.replace(/&nbsp;/g, ' ');
2346      html = html.replace(/&amp;/g, '&');
2347      html = html.replace(/&lt;/g, '<');
2348      html = html.replace(/&gt;/g, '>');
2349      html = html.replace(/\\u00A0/g, ' ');
2350      html = html.replace(/–/g, '-');
2351      html = html.replace(/—/g, '--');
2352      
2353      html = html.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2354      
2355      const tableRegex = /^\\|(.+)\\|\\s*\\n\\|[-:\\s|]+\\|\\s*\\n((?:\\|.+\\|\\s*\\n?)+)/gm;
2356      html = html.replace(tableRegex, (match, headerRow, bodyRows) => {
2357        const rows = bodyRows.trim().split('\\n').map(row => 
2358          row.split('|').map(cell => cell.trim()).filter(cell => cell)
2359        );
2360        let items = '';
2361        rows.forEach(row => {
2362          if (row.length >= 2) {
2363            const num = row[0];
2364            const name = row[1];
2365            if (/^\\d{1,2}$/.test(num)) {
2366              items += '<div class="menu-item clickable" data-action="' + num + '" data-name="' + name.replace(/"/g, '&quot;') + '">' +
2367                '<span class="menu-number">' + num + '</span>' +
2368                '<span class="menu-text">' + name + '</span></div>';
2369            } else {
2370              items += '<div class="menu-item">' +
2371                '<span class="menu-number">•</span>' +
2372                '<span class="menu-text">' + name + '</span></div>';
2373            }
2374          }
2375        });
2376        return items;
2377      });
2378      
2379      html = html.replace(/^\\|\\s*(\\d{1,2})\\s*\\|\\s*(.+?)\\s*\\|\\s*$/gm, (match, num, name) => {
2380        return '<div class="menu-item clickable" data-action="' + num + '" data-name="' + name.replace(/"/g, '&quot;') + '">' +
2381          '<span class="menu-number">' + num + '</span>' +
2382          '<span class="menu-text">' + name + '</span></div>';
2383      });
2384      
2385      html = html.replace(/^\\|\\s*#\\s*\\|.*\\|\\s*$/gm, '');
2386      html = html.replace(/^\\|[-:\\s|]+\\|\\s*$/gm, '');
2387      
2388      html = html.replace(/\`\`\`([\\s\\S]*?)\`\`\`/g, '<pre><code>$1</code></pre>');
2389      html = html.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
2390      
2391      html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');
2392      html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
2393      
2394      html = html.replace(/(?<![\\w\\*])\\*([^\\*]+)\\*(?![\\w\\*])/g, '<em>$1</em>');
2395      
2396      html = html.replace(/^####\\s*(.+)$/gm, '<h4>$1</h4>');
2397      html = html.replace(/^###\\s*(.+)$/gm, '<h3>$1</h3>');
2398      html = html.replace(/^##\\s*(.+)$/gm, '<h2>$1</h2>');
2399      html = html.replace(/^#\\s*(.+)$/gm, '<h1>$1</h1>');
2400      
2401      html = html.replace(/^-{3,}$/gm, '<hr>');
2402      html = html.replace(/^\\*{3,}$/gm, '<hr>');
2403      
2404      html = html.replace(/^&gt;\\s*(.+)$/gm, '<blockquote>$1</blockquote>');
2405      
2406      html = html.replace(/^([1-9]️⃣)\\s*<strong>(.+?)<\\/strong>(.*)$/gm, (m, emoji, title, rest) => {
2407        const num = emoji.match(/[1-9]/)?.[0] || '1';
2408        return '<div class="menu-item clickable" data-action="' + num + '" data-name="' + title.replace(/"/g, '&quot;') + '">' +
2409          '<span class="menu-number">' + num + '</span>' +
2410          '<span class="menu-text"><strong>' + title + '</strong>' + rest + '</span></div>';
2411      });
2412      html = html.replace(/^([1-9]️⃣)\\s*(.+)$/gm, (m, emoji, text) => {
2413        const num = emoji.match(/[1-9]/)?.[0] || '1';
2414        return '<div class="menu-item clickable" data-action="' + num + '" data-name="' + text.replace(/"/g, '&quot;') + '">' +
2415          '<span class="menu-number">' + num + '</span>' +
2416          '<span class="menu-text">' + text + '</span></div>';
2417      });
2418      
2419      html = html.replace(/^([1-9]|1[0-9]|20)\\.\\s*<strong>(.+?)<\\/strong>(.*)$/gm, (m, num, title, rest) => {
2420        return '<div class="menu-item clickable" data-action="' + num + '" data-name="' + title.replace(/"/g, '&quot;') + '">' +
2421          '<span class="menu-number">' + num + '</span>' +
2422          '<span class="menu-text"><strong>' + title + '</strong>' + rest + '</span></div>';
2423      });
2424      
2425      html = html.replace(/^[-–•]\\s*<strong>(.+?)<\\/strong>(.*)$/gm, 
2426        '<div class="menu-item"><span class="menu-number">•</span><span class="menu-text"><strong>$1</strong>$2</span></div>');
2427      
2428      html = html.replace(/^[-–•]\\s+([^<].*)$/gm, '<li>$1</li>');
2429      
2430      html = html.replace(/^(\\d+)\\.\\s+([^<*].*)$/gm, '<li><span class="list-num">$1.</span> $2</li>');
2431      
2432      let inList = false;
2433      const lines = html.split('\\n');
2434      const processed = [];
2435      for (let i = 0; i < lines.length; i++) {
2436        const line = lines[i];
2437        const isListItem = line.trim().startsWith('<li>');
2438        if (isListItem && !inList) { processed.push('<ul>'); inList = true; }
2439        else if (!isListItem && inList) { processed.push('</ul>'); inList = false; }
2440        processed.push(line);
2441      }
2442      if (inList) processed.push('</ul>');
2443      html = processed.join('\\n');
2444      
2445      html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank">$1</a>');
2446      
2447      const blocks = html.split(/\\n\\n+/);
2448      html = blocks.map(block => {
2449        const trimmed = block.trim();
2450        if (!trimmed) return '';
2451        if (trimmed.match(/^<(h[1-4]|ul|ol|pre|blockquote|hr|div|table)/)) return trimmed;
2452        const withBreaks = trimmed.split('\\n').map(l => l.trim()).filter(l => l).join('<br>');
2453        return '<p>' + withBreaks + '</p>';
2454      }).filter(b => b).join('');
2455      
2456      html = html.replace(/<p><\\/p>/g, '');
2457      html = html.replace(/<p>(<div|<ul|<ol|<h[1-4]|<hr|<pre|<blockquote|<table)/g, '$1');
2458      html = html.replace(/(<\\/div>|<\\/ul>|<\\/ol>|<\\/h[1-4]>|<\\/pre>|<\\/blockquote>|<\\/table>)<\\/p>/g, '$1');
2459      html = html.replace(/<br>(<div|<\\/div>)/g, '$1');
2460      html = html.replace(/(<div[^>]*>)<br>/g, '$1');
2461      
2462      return html;
2463    }
2464
2465    // Messages
2466    function addMessage(content, role) {
2467      [chatMessages, mobileChatMessages].forEach(container => {
2468        const welcome = container.querySelector(".welcome-message");
2469        if (welcome) welcome.remove();
2470
2471        const msg = document.createElement("div");
2472        msg.className = "message " + role;
2473        
2474        const formattedContent = role === "assistant" ? formatMessageContent(content) : escapeHtml(content);
2475        
2476        msg.innerHTML = \`
2477          <div class="message-avatar">\${role === "user" ? "👤" : "🌳"}</div>
2478          <div class="message-content">\${formattedContent}</div>
2479        \`;
2480        
2481        if (role === "assistant") {
2482          msg.querySelectorAll('.menu-item.clickable').forEach(item => {
2483            item.addEventListener('click', () => handleMenuItemClick(item));
2484          });
2485        }
2486        
2487        container.appendChild(msg);
2488        container.scrollTop = container.scrollHeight;
2489      });
2490    }
2491
2492    function handleMenuItemClick(item) {
2493      const action = item.dataset.action;
2494      const name = item.dataset.name;
2495      
2496      if (!action || isSending) return;
2497      
2498      item.classList.add('clicking');
2499      setTimeout(() => item.classList.remove('clicking'), 300);
2500      
2501      sendChatMessage(action);
2502    }
2503
2504    function addTypingIndicator() {
2505      [chatMessages, mobileChatMessages].forEach(container => {
2506        if (container.querySelector(".typing-indicator")) return;
2507        const typing = document.createElement("div");
2508        typing.className = "message assistant";
2509        typing.innerHTML = \`
2510          <div class="message-avatar">🌳</div>
2511          <div class="typing-indicator">
2512            <div class="typing-dot"></div>
2513            <div class="typing-dot"></div>
2514            <div class="typing-dot"></div>
2515          </div>
2516        \`;
2517        container.appendChild(typing);
2518        container.scrollTop = container.scrollHeight;
2519      });
2520    }
2521
2522    function removeTypingIndicator() {
2523      document.querySelectorAll(".typing-indicator").forEach(el => el.closest(".message")?.remove());
2524    }
2525
2526    function escapeHtml(text) {
2527      const div = document.createElement("div");
2528      div.textContent = text;
2529      return div.innerHTML;
2530    }
2531
2532    // Send message. During processing, messages still send (stream extension
2533    // accumulates them for mid-flight injection). Input stays enabled.
2534    function sendChatMessage(message) {
2535      if (!message.trim() || !isRegistered) return;
2536
2537      addMessage(message, "user");
2538      if (!isSending) {
2539        addTypingIndicator();
2540        isSending = true;
2541        lockModeBar(true);
2542      }
2543      requestGeneration++;
2544      const thisGen = requestGeneration;
2545      updateSendButtons();
2546
2547      socket.emit("chat", { message, username: CONFIG.username, generation: thisGen, mode: currentModeKey?.split(":").pop()?.split("-")[0] || "chat" });
2548    }
2549
2550    function updateSendButtons() {
2551      const desktopText = chatInput.value.trim();
2552      const mobileSheetText = mobileSheetInput.value.trim();
2553
2554      // When AI is working: button is Stop (red) unless user has typed text, then it's Send
2555      if (isSending && !desktopText) {
2556        sendBtn.disabled = false;
2557        sendBtn.classList.add("stop-mode");
2558      } else {
2559        sendBtn.disabled = !(desktopText && isRegistered);
2560        sendBtn.classList.remove("stop-mode");
2561      }
2562
2563      if (isSending && !mobileSheetText) {
2564        mobileSheetSendBtn.disabled = false;
2565        mobileSheetSendBtn.classList.add("stop-mode");
2566      } else {
2567        mobileSheetSendBtn.disabled = !(mobileSheetText && isRegistered);
2568        mobileSheetSendBtn.classList.remove("stop-mode");
2569      }
2570
2571      // Input always enabled so user can type corrections mid-flight
2572      chatInput.disabled = false;
2573      mobileSheetInput.disabled = false;
2574    }
2575
2576    // Input handlers - Desktop
2577    chatInput.addEventListener("input", () => {
2578      updateSendButtons();
2579      chatInput.style.height = "auto";
2580      chatInput.style.height = Math.min(chatInput.scrollHeight, 120) + "px";
2581    });
2582
2583    chatInput.addEventListener("keydown", (e) => {
2584      if (e.key === "Enter" && !e.shiftKey) {
2585        e.preventDefault();
2586        const msg = chatInput.value.trim();
2587        if (msg && isRegistered) {
2588          sendChatMessage(msg);
2589          chatInput.value = "";
2590          chatInput.style.height = "auto";
2591          updateSendButtons();
2592        }
2593      }
2594    });
2595
2596    // Input change: if user starts typing while AI is responding,
2597    // switch button from Stop back to Send
2598    chatInput.addEventListener("input", () => updateSendButtons());
2599    mobileSheetInput.addEventListener("input", () => updateSendButtons());
2600
2601    sendBtn.addEventListener("click", () => {
2602      const msg = chatInput.value.trim();
2603      if (msg && isRegistered) {
2604        // Text in input: send it (even during processing, stream accumulates)
2605        sendChatMessage(msg);
2606        chatInput.value = "";
2607        chatInput.style.height = "auto";
2608        updateSendButtons();
2609      } else if (isSending) {
2610        // No text, AI is working: stop
2611        cancelRequest();
2612      }
2613    });
2614
2615    // Mobile handlers
2616    let sheetDragStartY = 0;
2617    let isDraggingSheet = false;
2618    let sheetHeight = 0;
2619    let currentDragY = 0;
2620    let dragStartState = 'closed';
2621
2622    function setMobileSheetState(newState, force = false) {
2623      // Don't re-run if already in this state (unless forced)
2624      if (mobileSheetState === newState && !force) return;
2625      
2626      mobileSheetState = newState;
2627      mobileChatSheet.classList.remove("open", "peeked", "closing");
2628      
2629      if (newState === 'open') {
2630        mobileChatSheet.classList.add("open");
2631        mobileBackdrop.classList.add("visible");
2632        mobileChatTab.classList.add("hidden");
2633        setTimeout(() => {
2634          mobileSheetInput.focus();
2635          updateSendButtons();
2636          scrollToActiveMode();
2637        }, 350);
2638      } else if (newState === 'peeked') {
2639        mobileChatSheet.classList.add("peeked");
2640        mobileBackdrop.classList.remove("visible");
2641        mobileChatTab.classList.add("hidden");
2642        mobileSheetInput.blur();
2643      } else {
2644        // closed - go to side tab
2645        mobileChatSheet.classList.add("closing");
2646        mobileBackdrop.classList.remove("visible");
2647        mobileSheetInput.blur();
2648        setTimeout(() => {
2649          mobileChatSheet.classList.remove("closing");
2650          mobileChatTab.classList.remove("hidden");
2651        }, 300);
2652      }
2653    }
2654
2655    function openMobileSheetFull() {
2656      setMobileSheetState('open');
2657    }
2658
2659    function peekMobileSheet() {
2660      setMobileSheetState('peeked');
2661    }
2662
2663    function closeMobileSheet() {
2664      setMobileSheetState('closed');
2665    }
2666
2667    // Side tab opens to full
2668    mobileChatTab.addEventListener("click", (e) => {
2669      e.preventDefault();
2670      e.stopPropagation();
2671      openMobileSheetFull();
2672    });
2673
2674    // Header tap in peeked state opens full (ignore button clicks)
2675    mobileSheetHeader.addEventListener("click", (e) => {
2676      if (mobileSheetState === 'peeked' && !isDraggingSheet && !e.target.closest("button")) {
2677        e.preventDefault();
2678        e.stopPropagation();
2679        openMobileSheetFull();
2680      }
2681    });
2682
2683    mobileSheetInput.addEventListener("input", () => {
2684      updateSendButtons();
2685      mobileSheetInput.style.height = "auto";
2686      mobileSheetInput.style.height = Math.min(mobileSheetInput.scrollHeight, 120) + "px";
2687    });
2688
2689    mobileSheetInput.addEventListener("keydown", (e) => {
2690      if (e.key === "Enter" && !e.shiftKey) {
2691        e.preventDefault();
2692        const msg = mobileSheetInput.value.trim();
2693        if (msg && isRegistered && !isSending) {
2694          sendChatMessage(msg);
2695          mobileSheetInput.value = "";
2696          mobileSheetInput.style.height = "auto";
2697          updateSendButtons();
2698        }
2699      }
2700    });
2701
2702    mobileSheetSendBtn.addEventListener("click", () => {
2703      const msg = mobileSheetInput.value.trim();
2704      if (msg && isRegistered) {
2705        sendChatMessage(msg);
2706        mobileSheetInput.value = "";
2707        mobileSheetInput.style.height = "auto";
2708        updateSendButtons();
2709      } else if (isSending) {
2710        cancelRequest();
2711      }
2712    });
2713
2714    // X button always closes to side tab
2715    $("mobileCloseBtn").addEventListener("click", (e) => {
2716      e.stopPropagation();
2717      e.preventDefault();
2718      closeMobileSheet();
2719    });
2720    // Backdrop click closes to side tab
2721    mobileBackdrop.addEventListener("click", closeMobileSheet);
2722
2723    // Sheet drag handling
2724    function handleSheetDragStart(e) {
2725      if (mobileSheetState === 'closed') return;
2726      
2727      const touch = e.touches ? e.touches[0] : e;
2728      sheetDragStartY = touch.clientY;
2729      sheetHeight = mobileChatSheet.offsetHeight;
2730      currentDragY = 0;
2731      isDraggingSheet = true;
2732      dragStartState = mobileSheetState;
2733      mobileChatSheet.classList.add("dragging");
2734    }
2735
2736    function handleSheetDragMove(e) {
2737      if (!isDraggingSheet) return;
2738      
2739      const touch = e.touches ? e.touches[0] : e;
2740      const deltaY = touch.clientY - sheetDragStartY;
2741      
2742      // Calculate base offset based on current state
2743      let baseOffset = 0;
2744      if (dragStartState === 'peeked') {
2745        baseOffset = sheetHeight - 90; // peeked position
2746      }
2747      
2748      if (dragStartState === 'open') {
2749        // Dragging down from open
2750        if (deltaY > 0) {
2751          currentDragY = deltaY;
2752          mobileChatSheet.style.transform = \`translateY(\${deltaY}px)\`;
2753          const progress = Math.min(deltaY / (sheetHeight * 0.5), 1);
2754          mobileBackdrop.style.opacity = String(1 - progress);
2755        } else if (deltaY < 0) {
2756          // Allow slight overdrag up
2757          currentDragY = deltaY;
2758          mobileChatSheet.style.transform = \`translateY(\${Math.max(deltaY, -20)}px)\`;
2759        }
2760      } else if (dragStartState === 'peeked') {
2761        // Dragging from peeked state
2762        currentDragY = deltaY;
2763        const newOffset = Math.max(0, Math.min(baseOffset + deltaY, sheetHeight));
2764        mobileChatSheet.style.transform = \`translateY(\${newOffset}px)\`;
2765        
2766        // Show backdrop when dragging up
2767        if (deltaY < 0) {
2768          const progress = Math.min(Math.abs(deltaY) / (sheetHeight - 90), 1);
2769          mobileBackdrop.style.opacity = String(progress);
2770          mobileBackdrop.classList.add("visible");
2771        }
2772      }
2773    }
2774
2775    function handleSheetDragEnd(e) {
2776      if (!isDraggingSheet) return;
2777      
2778      isDraggingSheet = false;
2779      mobileChatSheet.classList.remove("dragging");
2780      mobileChatSheet.style.transform = "";
2781      mobileBackdrop.style.opacity = "";
2782      
2783      const peekThreshold = sheetHeight - 200; // pixels from top to trigger peek
2784      
2785      if (dragStartState === 'open') {
2786        // From open: drag down far = peek, drag down very far = still peek (not close)
2787        if (currentDragY > peekThreshold) {
2788          peekMobileSheet();
2789        } else if (currentDragY > 50) {
2790          peekMobileSheet();
2791        } else {
2792          setMobileSheetState('open');
2793        }
2794      } else if (dragStartState === 'peeked') {
2795        // From peeked: drag up = open, drag down = stay peeked
2796        if (currentDragY < -50) {
2797          openMobileSheetFull();
2798        } else {
2799          setMobileSheetState('peeked');
2800        }
2801      }
2802      
2803      currentDragY = 0;
2804    }
2805
2806    mobileSheetHeader.addEventListener("touchstart", handleSheetDragStart, { passive: true });
2807    mobileSheetHeader.addEventListener("touchmove", handleSheetDragMove, { passive: true });
2808    mobileSheetHeader.addEventListener("touchend", handleSheetDragEnd, { passive: true });
2809    mobileSheetHeader.addEventListener("touchcancel", handleSheetDragEnd, { passive: true });
2810
2811    let mouseIsDown = false;
2812    mobileSheetHeader.addEventListener("mousedown", (e) => {
2813      mouseIsDown = true;
2814      handleSheetDragStart(e);
2815    });
2816    document.addEventListener("mousemove", (e) => {
2817      if (mouseIsDown && isDraggingSheet) handleSheetDragMove(e);
2818    });
2819    document.addEventListener("mouseup", (e) => {
2820      if (mouseIsDown) {
2821        mouseIsDown = false;
2822        if (isDraggingSheet) handleSheetDragEnd(e);
2823      }
2824    });
2825
2826    // Start in peeked state on mobile
2827    if (window.innerWidth <= 768) {
2828      setTimeout(() => setMobileSheetState('peeked'), 100);
2829    }
2830
2831    // Panel resizing (desktop)
2832    const appContainer = document.querySelector(".app-container");
2833    const chatPanel = $("chatPanel");
2834    const viewportPanel = $("viewportPanel");
2835    const panelDivider = $("panelDivider");
2836    let isDragging = false, dragStartX = 0, dragStartWidth = 0, currentChatWidth = 0;
2837    const MIN_PANEL = 280, DIVIDER = 16, PADDING = 32;
2838
2839    function getAvailable() { return appContainer.clientWidth - PADDING - DIVIDER; }
2840
2841    function setChatWidth(w) {
2842      const avail = getAvailable();
2843      let clamped = Math.max(0, Math.min(w, avail));
2844      if (clamped > 0 && clamped < MIN_PANEL) clamped = 0;
2845      if (avail - clamped > 0 && avail - clamped < MIN_PANEL) clamped = avail;
2846      currentChatWidth = clamped;
2847      chatPanel.style.width = clamped + "px";
2848      chatPanel.classList.toggle("collapsed", clamped === 0);
2849      viewportPanel.classList.toggle("collapsed", avail - clamped === 0);
2850    }
2851
2852    setChatWidth(getAvailable() / 2.5);
2853
2854    // Resize handling: keep panel widths sane AND keep mobile sheet state in
2855    // sync with the CSS breakpoint. Without the breakpoint sync, the mobile
2856    // backdrop's .visible class can survive a desktop->mobile resize and end
2857    // up blocking all clicks on the iframe (full-viewport pointer-events:auto
2858    // overlay). The .open class on the sheet would also leave it slid up over
2859    // the viewport. Reset to peeked when entering mobile, closed when leaving.
2860    const mobileMql = window.matchMedia("(max-width: 768px)");
2861    function onBreakpointChange(e) {
2862      if (e.matches) {
2863        // Just entered mobile. Make sure the sheet starts in peeked state and
2864        // the backdrop is hidden so the iframe is interactive.
2865        setMobileSheetState('peeked', true);
2866      } else {
2867        // Just left mobile. Clear all mobile state so the desktop layout has
2868        // no leftover transforms or backdrop overlays.
2869        setMobileSheetState('closed', true);
2870      }
2871    }
2872    if (typeof mobileMql.addEventListener === "function") {
2873      mobileMql.addEventListener("change", onBreakpointChange);
2874    } else if (typeof mobileMql.addListener === "function") {
2875      // Older Safari
2876      mobileMql.addListener(onBreakpointChange);
2877    }
2878
2879    window.addEventListener("resize", () => setChatWidth(currentChatWidth));
2880
2881    panelDivider.addEventListener("mousedown", (e) => {
2882      isDragging = true;
2883      dragStartX = e.clientX;
2884      dragStartWidth = currentChatWidth;
2885      appContainer.classList.add("dragging");
2886      e.preventDefault();
2887    });
2888
2889    document.addEventListener("mousemove", (e) => {
2890      if (!isDragging) return;
2891      setChatWidth(dragStartWidth + (e.clientX - dragStartX));
2892    });
2893
2894    document.addEventListener("mouseup", () => {
2895      if (isDragging) {
2896        isDragging = false;
2897        appContainer.classList.remove("dragging");
2898      }
2899    });
2900
2901    $("expandChatBtn").addEventListener("click", () => setChatWidth(getAvailable()));
2902    $("expandViewportBtn").addEventListener("click", () => setChatWidth(0));
2903    $("resetPanelsBtn").addEventListener("click", () => setChatWidth(getAvailable() / 2));
2904
2905    // Clear chat buttons
2906   function handleClearChat() {
2907      if (!isRegistered) return;
2908      if (isSending) cancelRequest();
2909      socket.emit("clearConversation");
2910      clearChatUI([], currentModeKey);
2911      // Navigate iframe back to tree root
2912      const rootId = getCurrentRootId();
2913      if (rootId) {
2914        navigateToRoot(rootId);
2915      } else {
2916        goHome();
2917      }
2918    }
2919
2920    $("clearChatBtn").addEventListener("click", handleClearChat);
2921    $("mobileClearChatBtn").addEventListener("click", (e) => {
2922      e.stopPropagation();
2923      e.preventDefault();
2924      handleClearChat();
2925    });
2926
2927    function getCurrentIframeUrl() {
2928      let url = "";
2929      try {
2930        url = iframe.contentWindow?.location?.href;
2931      } catch (e) {}
2932      if (!url) {
2933        try { url = iframe.src; } catch(e) {}
2934      }
2935      if (!url) {
2936        url = window.location.origin + currentIframeUrl;
2937      }
2938      try {
2939        const u = new URL(url, window.location.origin);
2940        u.searchParams.delete('inApp');
2941        return u.href;
2942      } catch(e) {
2943        return url.replace(/[&?]inApp=1/g, '');
2944      }
2945    }
2946
2947function goHome() {
2948  activeRootId = null;
2949
2950  // Close dashboard if open
2951  if (window.TreeApp && window.TreeApp.closeDashboard) window.TreeApp.closeDashboard();
2952
2953  loadingOverlay.classList.add("visible");
2954  currentIframeUrl = CONFIG.homeUrl;
2955  iframe.src = CONFIG.homeUrl; // home doesn't need rootId
2956}
2957
2958    // Background messages toggle (defaults off)
2959    $("bgMsgToggleBtn").addEventListener("click", () => {
2960      const on = document.body.classList.toggle("show-bg-messages");
2961      $("bgMsgToggleBtn").classList.toggle("active", on);
2962      try { localStorage.setItem("treeos:bgMessages", on ? "1" : "0"); } catch {}
2963    });
2964    // Restore preference
2965    try { if (localStorage.getItem("treeos:bgMessages") === "1") { document.body.classList.add("show-bg-messages"); $("bgMsgToggleBtn").classList.add("active"); } } catch {}
2966
2967    $("desktopHomeBtn").addEventListener("click", goHome);
2968    $("mobileHomeBtn").addEventListener("click", (e) => {
2969      e.stopPropagation();
2970      e.preventDefault();
2971      goHome();
2972      // Always reset to peeked/dragged-down mode on mobile (use timeout to ensure it happens last)
2973      setTimeout(() => setMobileSheetState('peeked', true), 10);
2974    });
2975
2976    function doRefresh() {
2977      loadingOverlay.classList.add("visible");
2978      iframe.contentWindow?.location.reload();
2979    }
2980
2981    $("desktopRefreshBtn").addEventListener("click", doRefresh);
2982    $("mobileRefreshBtn").addEventListener("click", (e) => {
2983      e.stopPropagation();
2984      e.preventDefault();
2985      doRefresh();
2986    });
2987
2988    function openInNewTab() {
2989      const url = getCurrentIframeUrl();
2990      window.open(url, '_blank');
2991    }
2992
2993    $("desktopOpenTabBtn").addEventListener("click", openInNewTab);
2994
2995    // LLM Connections button — go to /setup if no LLM, else energy page
2996    function goCustomAi() {
2997      if (!CONFIG.hasLlm) {
2998        window.location.href = "/setup";
2999        return;
3000      }
3001      const url = buildIframeUrl('/api/v1/user/' + CONFIG.userId + '/energy?html');
3002      loadingOverlay.classList.add("visible");
3003      currentIframeUrl = url;
3004      iframe.src = url;
3005      // Scroll iframe to bottom once loaded
3006      const onLoad = () => {
3007        iframe.removeEventListener('load', onLoad);
3008        try { iframe.contentWindow.scrollTo(0, iframe.contentDocument.body.scrollHeight); } catch(e) {}
3009      };
3010      iframe.addEventListener('load', onLoad);
3011    }
3012
3013    $("desktopCustomAiBtn").addEventListener("click", goCustomAi);
3014    $("mobileCustomAiBtn").addEventListener("click", (e) => {
3015      e.stopPropagation();
3016      e.preventDefault();
3017      if (mobileSheetState === 'open') {
3018        // In full chat mode on mobile — slide it down to peeked
3019        setTimeout(() => setMobileSheetState('peeked', true), 10);
3020      }
3021      // If already peeked/closed, keep it down (don't open)
3022      goCustomAi();
3023    });
3024
3025    // Iframe
3026   iframe.addEventListener("load", () => {
3027  loadingOverlay.classList.remove("visible");
3028  try {
3029    const loc = iframe.contentWindow?.location;
3030    if (loc) {
3031      currentIframeUrl = loc.pathname + loc.search;
3032    }
3033  } catch (e) {}
3034  detectIframeUrlChange();
3035  injectIframeParamForwarding();
3036});
3037
3038function injectIframeParamForwarding() {
3039  try {
3040    const doc = iframe.contentDocument || iframe.contentWindow?.document;
3041    if (!doc) return;
3042
3043    // Skip if already injected
3044    if (doc._paramForwardingInjected) return;
3045    doc._paramForwardingInjected = true;
3046
3047    // Intercept all clicks on links
3048    doc.addEventListener('click', (e) => {
3049      const anchor = e.target.closest('a');
3050      if (!anchor || !anchor.href) return;
3051
3052      try {
3053        const url = new URL(anchor.href);
3054
3055        // Only rewrite same-origin links
3056        if (url.origin !== window.location.origin) return;
3057
3058        // Add inApp
3059        if (!url.searchParams.has('inApp')) {
3060          url.searchParams.set('inApp', '1');
3061        }
3062
3063        // Add token
3064        if (!url.searchParams.has('token')) {
3065          url.searchParams.set('token', CONFIG.htmlShareToken);
3066        }
3067
3068        // Add rootId if we have one and it's not already a /root/ URL
3069        const rootId = getCurrentRootId();
3070        if (rootId && !url.pathname.includes('/root/')) {
3071          url.searchParams.set('rootId', rootId);
3072        }
3073
3074        anchor.href = url.pathname + url.search;
3075      } catch (err) {
3076        // ignore malformed URLs
3077      }
3078    }, true); // capture phase to run before default
3079
3080    // Also intercept form submissions
3081    doc.addEventListener('submit', (e) => {
3082      const form = e.target;
3083      if (!form || !form.action) return;
3084      try {
3085        const url = new URL(form.action, window.location.origin);
3086        if (url.origin !== window.location.origin) return;
3087
3088        // Inject hidden fields
3089        ['inApp', 'token', 'rootId'].forEach(key => {
3090          if (form.querySelector('input[name="' + key + '"]')) return;
3091          let val;
3092          if (key === 'inApp') val = '1';
3093          else if (key === 'token') val = CONFIG.htmlShareToken;
3094          else if (key === 'rootId') val = getCurrentRootId();
3095          if (!val) return;
3096          const input = doc.createElement('input');
3097          input.type = 'hidden';
3098          input.name = key;
3099          input.value = val;
3100          form.appendChild(input);
3101        });
3102      } catch (err) {}
3103    }, true);
3104
3105    // Intercept programmatic navigation (window.location assignments)
3106    const iframeWindow = iframe.contentWindow;
3107    if (iframeWindow) {
3108      const origPushState = iframeWindow.history.pushState?.bind(iframeWindow.history);
3109      const origReplaceState = iframeWindow.history.replaceState?.bind(iframeWindow.history);
3110
3111      function patchUrl(urlArg) {
3112        if (!urlArg || typeof urlArg !== 'string') return urlArg;
3113        try {
3114          const u = new URL(urlArg, window.location.origin);
3115          if (u.origin !== window.location.origin) return urlArg;
3116          if (!u.searchParams.has('inApp')) u.searchParams.set('inApp', '1');
3117          if (!u.searchParams.has('token')) u.searchParams.set('token', CONFIG.htmlShareToken);
3118          const rootId = getCurrentRootId();
3119          if (rootId && !u.pathname.includes('/root/')) u.searchParams.set('rootId', rootId);
3120          return u.pathname + u.search;
3121        } catch (e) { return urlArg; }
3122      }
3123
3124      if (origPushState) {
3125        iframeWindow.history.pushState = function(state, title, url) {
3126          return origPushState(state, title, patchUrl(url));
3127        };
3128      }
3129      if (origReplaceState) {
3130        iframeWindow.history.replaceState = function(state, title, url) {
3131          return origReplaceState(state, title, patchUrl(url));
3132        };
3133      }
3134    }
3135  } catch (e) {
3136    // Cross-origin or sandbox restriction — can't inject
3137    console.warn('[iframe] param forwarding injection failed:', e.message);
3138  }
3139}
3140
3141    // Socket events
3142    socket.on("treeChanged", ({ nodeId, changeType, details }) => {
3143      console.log("[socket] tree changed:", changeType, nodeId);
3144      loadingOverlay.classList.add("visible");
3145      iframe.contentWindow?.location.reload();
3146    });
3147
3148    socket.on("toolResult", ({ tool, args, success, error }) => {
3149      console.log("[socket] tool:", tool, success ? "✓" : "✗", error || "");
3150    });
3151
3152    // Stream extension: message was accumulated for mid-flight injection
3153    socket.on("messageQueued", ({ message, status }) => {
3154      console.log("[stream] queued:", message?.slice(0, 60), status);
3155      // Show a subtle indicator that the message was received
3156      const indicator = document.createElement("div");
3157      indicator.className = "chat-message system";
3158      indicator.style.cssText = "font-size:0.75rem;color:rgba(255,255,255,0.3);text-align:center;padding:4px;";
3159      indicator.textContent = status || "will be incorporated";
3160      const container = document.getElementById("chatMessages");
3161      if (container) {
3162        container.appendChild(indicator);
3163        container.scrollTop = container.scrollHeight;
3164      }
3165    });
3166socket.on("executionStatus", ({ phase, text }) => {
3167  if (!text || phase === "done") return;
3168  console.log("[status]", phase, text);
3169  // Optionally show as a subtle inline status
3170  addOrchestratorStep("status:" + phase, text);
3171});
3172socket.on("orchestratorStep", ({ modeKey, result, timestamp }) => {
3173  console.log("[orchestrator]", modeKey, result);
3174  addOrchestratorStep(modeKey, result);
3175});
3176
3177function addOrchestratorStep(modeKey, result) {
3178  // Truncate long results for display
3179  let displayResult = result;
3180 // if (displayResult.length > 500) {
3181   // displayResult = displayResult.slice(0, 500) + "\\n… (truncated)";
3182  //}
3183
3184  const MODE_EMOJIS = {
3185    "intent": "🎯",
3186    "tree:navigate": "🧭",
3187    "tree:get-context": "📖",
3188    "tree:structure": "🏗️",
3189    "tree:edit": "✏️",
3190    "tree:notes": "📝",
3191    "tree:respond": "💬",
3192  };
3193
3194  const emoji = MODE_EMOJIS[modeKey] || "⚙️";
3195  const label = modeKey.replace("tree:", "");
3196
3197  [chatMessages, mobileChatMessages].forEach(container => {
3198    // Remove welcome if present
3199    const welcome = container.querySelector(".welcome-message");
3200    if (welcome) welcome.remove();
3201
3202    // Insert before the typing indicator if it exists
3203    const typing = container.querySelector(".typing-indicator")?.closest(".message");
3204
3205    const msg = document.createElement("div");
3206    msg.className = "message orchestrator-step";
3207    msg.innerHTML =
3208      '<div class="message-avatar">' + emoji + '</div>' +
3209      '<div class="message-content">' +
3210        '<span class="step-mode">' + escapeHtml(label) + '</span>' +
3211        '<span class="step-body">' + escapeHtml(displayResult) + '</span>' +
3212      '</div>';
3213
3214    if (typing) {
3215      container.insertBefore(msg, typing);
3216    } else {
3217      container.appendChild(msg);
3218    }
3219    container.scrollTop = container.scrollHeight;
3220  });
3221}
3222    // API
3223    window.TreeApp = {
3224      sendMessage: sendChatMessage,
3225      addMessage,
3226  navigate: (url) => { 
3227  loadingOverlay.classList.add("visible"); 
3228  currentIframeUrl = url;
3229  iframe.src = buildIframeUrl(url);
3230},
3231      goHome: () => { loadingOverlay.classList.add("visible"); iframe.src = CONFIG.homeUrl; currentIframeUrl = CONFIG.homeUrl; },
3232      isConnected: () => isConnected,
3233      isRegistered: () => isRegistered,
3234      notifyNodeUpdated: (nodeId, changes) => { if (isRegistered) socket.emit("nodeUpdated", { nodeId, changes }); },
3235      notifyNodeNavigated: (nodeId, nodeName) => { if (isRegistered) socket.emit("nodeNavigated", { nodeId, nodeName }); },
3236      notifyNodeSelected: (nodeId, nodeName) => { if (isRegistered) socket.emit("nodeSelected", { nodeId, nodeName }); },
3237      notifyNodeCreated: (nodeId, nodeName, parentId) => { if (isRegistered) socket.emit("nodeCreated", { nodeId, nodeName, parentId }); },
3238      notifyNodeDeleted: (nodeId, nodeName) => { if (isRegistered) socket.emit("nodeDeleted", { nodeId, nodeName }); },
3239      notifyNoteCreated: (nodeId, noteContent) => { if (isRegistered) socket.emit("noteCreated", { nodeId, noteContent }); },
3240      clearConversation: () => { if (isRegistered) socket.emit("clearConversation"); },
3241      switchMode: (modeKey) => { if (isRegistered) socket.emit("switchMode", { modeKey }); },
3242      getCurrentMode: () => currentModeKey,
3243      getAvailableModes: () => availableModes,
3244      getRecentRoots: () => recentRoots,
3245      navigateToRoot: navigateToRoot
3246    };
3247
3248    ${dashboardJS()}
3249  </script>
3250</body>
3251</html>`);
3252  } catch (err) {
3253    console.error(err);
3254    res.status(500).send("Failed to load app");
3255  }
3256});
3257
3258export default router;
3259
1// routesURL/chat.js
2// Simple chat-only interface for tree conversations.
3// No iframe, no tree view — just pick a tree and talk.
4
5import express from "express";
6import { sendOk, sendError, ERR, DELETED } from "../../../seed/protocol.js";
7import User from "../../../seed/models/user.js";
8import Node from "../../../seed/models/node.js";
9import LlmConnection from "../../../seed/models/llmConnection.js";
10import authenticateLite from "../../html-rendering/authenticateLite.js";
11import { getExtension } from "../../loader.js";
12import { notFoundPage } from "../../html-rendering/notFoundPage.js";
13import { getLandUrl, getLandIdentity } from "../../../canopy/identity.js";
14import { isHtmlEnabled } from "../../html-rendering/config.js";
15
16const router = express.Router();
17
18function escapeHtml(str) {
19  return str
20    .replace(/&/g, "&amp;")
21    .replace(/</g, "&lt;")
22    .replace(/>/g, "&gt;")
23    .replace(/"/g, "&quot;")
24    .replace(/'/g, "&#039;");
25}
26
27router.get("/chat", authenticateLite, async (req, res) => {
28  try {
29    if (!isHtmlEnabled()) {
30      return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, "Server-rendered HTML is disabled. Use the SPA frontend.");
31    }
32    if (!req.userId) {
33      return res.redirect("/login");
34    }
35
36    const user = await User.findById(req.userId).select(
37      "username metadata llmDefault",
38    );
39    if (!user) {
40      return notFoundPage(req, res, "This user doesn't exist.");
41    }
42
43    const { getUserMeta } = await import("../../../seed/tree/userMetadata.js");
44    const nav = getUserMeta(user, "nav");
45    const userRoots = Array.isArray(nav.roots) ? nav.roots : [];
46
47    // Redirect to setup if user needs LLM (unless they skipped recently).
48    // No tree is fine. Sprout creates trees from conversation.
49    const setupSkipped = req.cookies?.setupSkipped === "1";
50    if (!setupSkipped) {
51      const hasMainLlm = !!user.llmDefault;
52      if (!hasMainLlm) {
53        const connCount = await LlmConnection.countDocuments({ userId: req.userId });
54        if (connCount === 0) {
55          return res.redirect("/setup");
56        }
57      }
58    }
59
60    const { username } = user;
61
62    // Load user's trees
63    const rootIds = userRoots.map(String);
64    let trees = [];
65    if (rootIds.length > 0) {
66      trees = await Node.find({ _id: { $in: rootIds }, parent: { $ne: DELETED } })
67        .select("_id name children")
68        .lean();
69    }
70
71    const treesJSON = JSON.stringify(
72      trees.map((t) => ({
73        id: t._id,
74        name: t.name,
75        childCount: t.children?.length || 0,
76      })),
77    );
78
79    // Load app data for hotbar (life domains)
80    let appsJSON = "[]";
81    try {
82      const life = getExtension("life");
83      if (life?.exports?.findLifeRoot && life?.exports?.getDomainNodes) {
84        const lifeRootId = await life.exports.findLifeRoot(req.userId);
85        if (lifeRootId) {
86          const domains = await life.exports.getDomainNodes(lifeRootId);
87          const APP_META = {
88            food: { emoji: "🍎", name: "Food", path: "food" },
89            fitness: { emoji: "💪", name: "Fitness", path: "fitness" },
90            recovery: { emoji: "🌿", name: "Recovery", path: "recovery" },
91            study: { emoji: "📚", name: "Study", path: "study" },
92            kb: { emoji: "📖", name: "KB", path: "kb" },
93            relationships: { emoji: "👥", name: "Relationships", path: "relationships" },
94            finance: { emoji: "💰", name: "Finance", path: "finance" },
95            investor: { emoji: "📈", name: "Investor", path: "investor" },
96            "market-researcher": { emoji: "🔬", name: "Research", path: "market-researcher" },
97          };
98          const apps = [];
99          for (const [key, info] of Object.entries(domains)) {
100            const meta = APP_META[key];
101            if (meta) apps.push({ key, id: info.id, name: info.name, emoji: meta.emoji, label: meta.name, path: meta.path, treeRootId: lifeRootId });
102          }
103          appsJSON = JSON.stringify(apps);
104        }
105      }
106    } catch {}
107
108    const landName = getLandIdentity()?.name || "TreeOS";
109
110    return res.send(`<!DOCTYPE html>
111<html lang="en">
112<head>
113  <meta charset="UTF-8" />
114  <title>Chat - ${landName}</title>
115  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
116  <meta name="theme-color" content="#0d1117" />
117  <link rel="icon" href="/tree.png" />
118  <link rel="canonical" href="${getLandUrl()}/chat" />
119  <meta name="robots" content="noindex, nofollow" />
120  <meta name="description" content="Chat with your knowledge trees on ${landName}." />
121  <meta property="og:title" content="Chat - ${landName}" />
122  <meta property="og:description" content="Chat with your knowledge trees on ${landName}." />
123  <meta property="og:url" content="${getLandUrl()}/chat" />
124  <meta property="og:type" content="website" />
125  <meta property="og:site_name" content="${landName}" />
126  <meta property="og:image" content="${getLandUrl()}/tree.png" />
127  <link rel="preconnect" href="https://fonts.googleapis.com">
128  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
129  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
130  <style>
131    :root {
132      /* Nightfall theme */
133      --bg:           #0d1117;
134      --bg-elevated:  #161b24;
135      --bg-hover:     #1c222e;
136      --border:       #232a38;
137      --border-strong:#2f3849;
138
139      --text-primary:   #e6e8eb;
140      --text-secondary: #c4c8d0;
141      --text-muted:     #9ba1ad;
142
143      --accent:      #7dd385;
144      --accent-glow: rgba(125, 211, 133, 0.5);
145      --error:       #c97e6a;
146
147      /* Legacy aliases */
148      --glass-rgb:          22, 27, 36;
149      --glass-alpha:        1;
150      --glass-blur:         0px;
151      --glass-border:       #232a38;
152      --glass-border-light: #232a38;
153      --glass-highlight:    #2f3849;
154
155      --header-height: 56px;
156      --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
157    }
158
159    * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
160    html, body { height: 100%; width: 100%; overflow: hidden; font-family: 'DM Sans', -apple-system, sans-serif; color: var(--text-primary); }
161    html { background: var(--bg); }
162    body { background: var(--bg); }
163
164    /* Layout */
165    .container {
166      height: 100%; width: 100%;
167      display: flex; flex-direction: column;
168      max-width: 800px; margin: 0 auto;
169    }
170
171    /* Header */
172    .chat-header {
173      height: var(--header-height); padding: 0 20px;
174      display: flex; align-items: center; justify-content: space-between;
175      border-bottom: 1px solid var(--glass-border-light); flex-shrink: 0;
176    }
177    .chat-title { display: flex; align-items: center; gap: 12px; }
178    .tree-icon { font-size: 28px; filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.3)); animation: grow 4.5s infinite ease-in-out; }
179    @keyframes grow { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }
180    .chat-title h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.02em; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); }
181
182    .header-right { display: flex; align-items: center; gap: 10px; }
183    .back-row {
184      display: none; padding: 8px 20px 0;
185      border-bottom: none; flex-shrink: 0;
186    }
187    .back-row.visible { display: flex; }
188    .back-btn {
189      display: flex; align-items: center; gap: 6px;
190      font-size: 12px; color: var(--text-muted);
191      background: rgba(255,255,255,0.1); border-radius: 8px;
192      padding: 6px 12px; border: 1px solid var(--glass-border-light);
193      cursor: pointer; transition: all var(--transition-fast);
194      font-family: inherit;
195    }
196    .back-btn:hover { background: rgba(255,255,255,0.18); color: var(--text-primary); }
197    .back-btn svg { width: 12px; height: 12px; }
198
199    .status-badge { display: flex; align-items: center; gap: 8px; padding: 6px 14px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border-radius: 100px; border: 1px solid var(--glass-border-light); font-size: 12px; font-weight: 600; }
200    .status-badge .status-text { display: inline; }
201    .status-dot { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 12px var(--accent-glow); animation: pulse 2s ease-in-out infinite; flex-shrink: 0; }
202    .status-dot.connected { background: var(--accent); }
203    .status-dot.disconnected { background: var(--error); animation: none; }
204    .status-dot.connecting { background: #f59e0b; }
205    @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(1.15); } }
206
207    .advanced-btn {
208      font-size: 12px; color: var(--text-muted);
209      background: rgba(255,255,255,0.1); border-radius: 8px;
210      padding: 6px 14px; border: 1px solid var(--glass-border-light);
211      cursor: pointer; text-decoration: none; transition: all var(--transition-fast);
212      font-family: inherit;
213    }
214    .advanced-btn:hover { background: rgba(255,255,255,0.18); color: var(--text-primary); }
215
216    /* Root name inline */
217    .root-name-inline {
218      font-size: 13px; font-weight: 400; color: var(--text-muted);
219      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
220      max-width: 200px; opacity: 0; transition: opacity 0.3s ease;
221    }
222    .root-name-inline.visible { opacity: 1; }
223    .root-name-inline::before { content: ' / '; color: var(--glass-border-light); }
224
225    /* Tree picker */
226    .tree-picker {
227      flex: 1; display: flex; flex-direction: column;
228      align-items: center;
229      padding: 32px 20px 40px; gap: 24px;
230      overflow-y: auto; min-height: 0;
231    }
232    .tree-picker-title { font-size: 24px; font-weight: 600; margin-bottom: 4px; flex-shrink: 0; }
233    .tree-picker-sub { color: var(--text-muted); font-size: 15px; text-align: center; flex-shrink: 0; }
234    .tree-list { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 420px; }
235    .tree-item {
236      background: rgba(var(--glass-rgb), var(--glass-alpha));
237      backdrop-filter: blur(var(--glass-blur)) saturate(140%);
238      -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
239      border: 1px solid var(--glass-border-light);
240      border-radius: 16px; padding: 18px 22px;
241      cursor: pointer; transition: all var(--transition-fast);
242      display: flex; align-items: center; justify-content: space-between;
243      animation: fadeInUp 0.3s ease-out backwards;
244    }
245    .tree-item:hover { background: rgba(var(--glass-rgb), 0.42); transform: translateY(-2px); box-shadow: 0 8px 32px rgba(0,0,0,0.15); }
246    .tree-item:active { transform: translateY(0) scale(0.98); }
247    .tree-item-left { display: flex; align-items: center; gap: 14px; }
248    .tree-item-icon { font-size: 22px; }
249    .tree-item-name { font-size: 15px; font-weight: 500; }
250    .tree-item-meta { font-size: 12px; color: var(--text-muted); }
251    @keyframes fadeInUp { from { opacity: 0; transform: translateY(16px); } }
252    ${trees.map((_, i) => `.tree-item:nth-child(${i + 1}) { animation-delay: ${i * 0.06}s; }`).join("\n    ")}
253
254    .empty-state {
255      background: rgba(var(--glass-rgb), var(--glass-alpha));
256      backdrop-filter: blur(var(--glass-blur)) saturate(140%);
257      border: 1px solid var(--glass-border-light);
258      border-radius: 20px; padding: 48px 32px;
259      text-align: center; max-width: 400px;
260    }
261    .empty-state .empty-icon { font-size: 48px; margin-bottom: 16px; display: block; filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); }
262    .empty-state h2 { font-size: 20px; margin-bottom: 8px; }
263    .empty-state p { color: var(--text-muted); font-size: 14px; margin-bottom: 20px; line-height: 1.5; }
264    /* Create tree form */
265    .create-tree-form {
266      display: flex; gap: 8px; width: 100%; max-width: 420px; margin-top: 8px;
267      flex-shrink: 0; padding-bottom: 8px;
268    }
269    .create-tree-form input {
270      flex: 1; padding: 14px 18px; font-size: 15px;
271      background: rgba(var(--glass-rgb), 0.25);
272      border: 1px solid var(--glass-border-light);
273      border-radius: 14px; color: var(--text-primary);
274      transition: all 0.2s; outline: none;
275    }
276    .create-tree-form input::placeholder { color: var(--text-muted); }
277    .create-tree-form input:focus {
278      border-color: var(--accent); background: rgba(var(--glass-rgb), 0.35);
279      box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
280    }
281    .create-tree-form button {
282      padding: 14px 18px; font-size: 20px; line-height: 1;
283      background: rgba(var(--glass-rgb), 0.3);
284      border: 1px solid var(--glass-border-light);
285      border-radius: 14px; color: var(--text-primary);
286      cursor: pointer; transition: all 0.2s;
287    }
288    .create-tree-form button:hover {
289      background: var(--accent); border-color: var(--accent);
290      box-shadow: 0 4px 15px var(--accent-glow);
291    }
292    .create-tree-form button:disabled { opacity: 0.4; cursor: not-allowed; }
293
294    /* Chat area */
295    .chat-area { flex: 1; display: none; flex-direction: column; overflow: hidden; }
296    .chat-area.active { display: flex; }
297
298    /* Messages — matches app.js */
299    .chat-messages { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 24px 20px; display: flex; flex-direction: column; gap: 16px; }
300    .chat-messages::-webkit-scrollbar { width: 4px; }
301    .chat-messages::-webkit-scrollbar-track { background: transparent; margin: 8px 0; }
302    .chat-messages::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.12); border-radius: 4px; }
303    .chat-messages::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.25); }
304    .chat-messages { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.12) transparent; }
305
306    .message { display: flex; gap: 12px; animation: messageIn 0.3s ease-out; min-width: 0; max-width: 100%; }
307    @keyframes messageIn { from { opacity: 0; transform: translateY(10px); } }
308    .message.user { flex-direction: row-reverse; }
309    .message-avatar { width: 36px; height: 36px; border-radius: 12px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
310    .message.user .message-avatar { background: linear-gradient(135deg, rgba(99, 102, 241, 0.6) 0%, rgba(139, 92, 246, 0.6) 100%); }
311    .message-content { max-width: 85%; min-width: 0; padding: 14px 18px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); border-radius: 18px; font-size: 14px; line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
312    .message.user .message-content { background: linear-gradient(135deg, rgba(99, 102, 241, 0.5) 0%, rgba(139, 92, 246, 0.5) 100%); border-radius: 18px 18px 6px 18px; }
313    .message.assistant .message-content { border-radius: 18px 18px 18px 6px; }
314    .message.error .message-content { background: rgba(239, 68, 68, 0.3); border-color: rgba(239, 68, 68, 0.5); }
315
316    /* Message content formatting — matches app.js */
317    .message-content p { margin: 0 0 10px 0; word-break: break-word; }
318    .message-content p:last-child { margin-bottom: 0; }
319    .message-content h1, .message-content h2, .message-content h3, .message-content h4 { margin: 14px 0 8px 0; font-weight: 600; line-height: 1.3; }
320    .message-content h1:first-child, .message-content h2:first-child, .message-content h3:first-child, .message-content h4:first-child { margin-top: 0; }
321    .message-content h1 { font-size: 17px; }
322    .message-content h2 { font-size: 16px; }
323    .message-content h3 { font-size: 15px; }
324    .message-content h4 { font-size: 14px; color: var(--text-secondary); }
325    .message-content ul, .message-content ol { margin: 8px 0; padding-left: 0; list-style: none; }
326    .message-content li { margin: 4px 0; padding: 6px 10px; background: rgba(255, 255, 255, 0.06); border-radius: 8px; line-height: 1.4; word-break: break-word; }
327    .message-content li .list-num { color: var(--accent); font-weight: 600; margin-right: 6px; }
328    .message-content strong, .message-content b { font-weight: 600; color: #fff; }
329    .message-content em, .message-content i { font-style: italic; color: var(--text-secondary); }
330    .message-content code { background: rgba(0, 0, 0, 0.3); padding: 2px 6px; border-radius: 4px; font-family: 'JetBrains Mono', monospace; font-size: 11px; word-break: break-all; }
331    .message-content pre { background: rgba(0, 0, 0, 0.3); padding: 12px; border-radius: 8px; overflow-x: auto; margin: 10px 0; max-width: 100%; }
332    .message-content pre code { background: none; padding: 0; word-break: normal; white-space: pre-wrap; }
333    .message-content blockquote { border-left: 3px solid var(--accent); padding-left: 12px; margin: 10px 0; color: var(--text-secondary); font-style: italic; }
334    .message-content hr { border: none; border-top: 1px solid var(--glass-border-light); margin: 14px 0; }
335    .message-content a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
336    .message-content a:hover { text-decoration: none; }
337
338    /* Menu items */
339    .message-content .menu-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; margin: 6px 0; background: rgba(255, 255, 255, 0.08); border-radius: 10px; border: 1px solid rgba(255, 255, 255, 0.06); transition: all 0.15s ease; }
340    .message-content .menu-item.clickable { cursor: pointer; user-select: none; }
341    .message-content .menu-item.clickable:hover { background: rgba(255, 255, 255, 0.15); border-color: rgba(16, 185, 129, 0.3); transform: translateX(4px); }
342    .message-content .menu-item.clickable:active { transform: translateX(4px) scale(0.98); background: rgba(16, 185, 129, 0.2); }
343    .message-content .menu-item:first-of-type { margin-top: 8px; }
344    .message-content .menu-number { display: flex; align-items: center; justify-content: center; min-width: 26px; max-width: 26px; height: 26px; background: linear-gradient(135deg, var(--accent) 0%, #059669 100%); border-radius: 8px; font-size: 12px; font-weight: 600; flex-shrink: 0; box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); transition: all 0.15s ease; }
345    .message-content .menu-item.clickable:hover .menu-number { transform: scale(1.1); box-shadow: 0 4px 12px rgba(16, 185, 129, 0.5); }
346    .message-content .menu-text { flex: 1; min-width: 0; padding-top: 2px; word-break: break-word; overflow-wrap: break-word; }
347    .message-content .menu-text strong { display: block; margin-bottom: 2px; word-break: break-word; }
348
349    /* Typing indicator — matches app.js */
350    .typing-indicator { display: flex; gap: 4px; padding: 14px 18px; }
351    .typing-dot { width: 8px; height: 8px; background: rgba(255, 255, 255, 0.6); border-radius: 50%; animation: typing 1.4s infinite; }
352    .typing-dot:nth-child(2) { animation-delay: 0.2s; }
353    .typing-dot:nth-child(3) { animation-delay: 0.4s; }
354    @keyframes typing { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-8px); } }
355
356    /* Input — matches app.js */
357    .chat-input-area { padding: 16px 20px 20px; border-top: 1px solid var(--glass-border-light); }
358    .input-container { display: flex; align-items: flex-end; gap: 12px; padding: 14px 18px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); border-radius: 18px; transition: all var(--transition-fast); }
359    .input-container:focus-within { background: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.4); box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.1); }
360    .chat-input { flex: 1; min-width: 0; background: transparent; border: none; outline: none; font-family: inherit; font-size: 15px; color: var(--text-primary); resize: none; max-height: 120px; line-height: 1.5; overflow-y: auto; }
361    .chat-input::-webkit-scrollbar { width: 4px; }
362    .chat-input::-webkit-scrollbar-track { background: transparent; }
363    .chat-input::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
364    .chat-input::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); }
365    .chat-area.empty .chat-input { max-height: 40vh; }
366    .chat-input::placeholder { color: var(--text-muted); }
367    .chat-input:disabled { opacity: 0.5; cursor: not-allowed; }
368    .send-btn { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background: var(--accent); border: none; border-radius: 12px; color: white; cursor: pointer; transition: all var(--transition-fast); flex-shrink: 0; box-shadow: 0 4px 15px var(--accent-glow); }
369    .send-btn:hover:not(:disabled) { transform: scale(1.08); box-shadow: 0 6px 25px var(--accent-glow); }
370    .send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
371    .send-btn svg { width: 20px; height: 20px; }
372    .send-btn.stop-mode { background: rgba(239, 68, 68, 0.7); box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4); }
373    .send-btn.stop-mode:hover:not(:disabled) { background: rgba(239, 68, 68, 0.9); box-shadow: 0 6px 25px rgba(239, 68, 68, 0.5); }
374
375    /* Mode toggle */
376    .mode-toggle { display: flex; gap: 4px; padding: 0 2px 10px; }
377    .mode-btn { padding: 4px 14px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.06); color: var(--text-muted); font-size: 12px; font-weight: 500; cursor: pointer; transition: all var(--transition-fast); font-family: inherit; }
378    .mode-btn:hover { background: rgba(255,255,255,0.12); color: var(--text-secondary); }
379    .mode-btn.active { background: rgba(255,255,255,0.18); color: var(--text-primary); border-color: rgba(255,255,255,0.25); }
380    .mode-btn.active[data-mode="chat"] { background: var(--accent); border-color: var(--accent); color: #fff; }
381    .mode-btn.active[data-mode="place"] { background: rgba(72,187,120,0.4); border-color: rgba(72,187,120,0.5); color: #fff; }
382    .mode-btn.active[data-mode="query"] { background: rgba(115,111,230,0.4); border-color: rgba(115,111,230,0.5); color: #fff; }
383    .mode-hint { font-size: 11px; color: var(--text-muted); padding: 0 4px 6px; opacity: 0.7; }
384
385    /* Place result message */
386    .place-result { font-size: 13px; color: var(--text-muted); padding: 8px 14px; background: rgba(72,187,120,0.08); border-radius: 12px; border: 1px solid rgba(72,187,120,0.15); margin: 4px 0; }
387
388    /* Empty state — input pinned to vertical center, welcome above it */
389    .chat-area.empty { position: relative; }
390    .chat-area.empty .chat-input-area { position: absolute; top: 40%; left: 50%; transform: translate(-50%, 0); border-top: none; max-width: 600px; width: calc(100% - 40px); }
391    .chat-area.empty .chat-messages { position: absolute; top: 40%; left: 0; right: 0; transform: translateY(-100%); display: flex; flex-direction: column; align-items: center; overflow: visible; flex: none; }
392
393    /* Welcome message */
394    .welcome-message { text-align: center; padding: 40px 20px; }
395    .welcome-icon { font-size: 64px; margin-bottom: 20px; display: inline-block; filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); animation: floatIcon 3s ease-in-out infinite; }
396    @keyframes floatIcon { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
397    .chat-area.empty .welcome-message { padding: 8px 20px; }
398    .chat-area.empty .welcome-icon { font-size: 48px; margin-bottom: 12px; }
399    .chat-area.empty .welcome-message h2 { font-size: 18px; margin-bottom: 6px; }
400    .chat-area.empty .welcome-message p { font-size: 13px; }
401    .welcome-message h2 { font-size: 24px; font-weight: 600; margin-bottom: 12px; }
402    .welcome-message p { font-size: 15px; color: var(--text-secondary); line-height: 1.6; }
403    .welcome-message.disconnected { opacity: 0.7; }
404    .welcome-message.disconnected .welcome-icon { filter: grayscale(0.5) drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); animation: none; }
405
406    /* Notifications panel */
407    .clear-chat-btn {
408      background: rgba(255,255,255,0.1); border: 1px solid var(--glass-border-light);
409      border-radius: 8px; padding: 6px 8px; cursor: pointer;
410      color: var(--text-muted); transition: all var(--transition-fast);
411      display: none; align-items: center; justify-content: center;
412    }
413    .clear-chat-btn.visible { display: flex; }
414    .clear-chat-btn:hover { background: rgba(255,255,255,0.2); color: var(--text-primary); }
415    .clear-chat-btn:active { transform: scale(0.93); }
416    .clear-chat-btn svg { width: 14px; height: 14px; }
417    .notif-btn {
418      font-size: 12px; color: var(--text-muted);
419      background: rgba(255,255,255,0.1); border-radius: 8px;
420      padding: 6px 14px; border: 1px solid var(--glass-border-light);
421      cursor: pointer; transition: all var(--transition-fast);
422      font-family: inherit; position: relative;
423      display: flex; align-items: center; gap: 6px;
424    }
425    .notif-btn:hover { background: rgba(255,255,255,0.18); color: var(--text-primary); }
426    .notif-btn-icon { display: none; font-size: 14px; line-height: 1; }
427    .notif-btn .notif-dot {
428      position: absolute; top: -3px; right: -3px;
429      width: 8px; height: 8px; border-radius: 50%;
430      background: var(--accent); box-shadow: 0 0 8px var(--accent-glow);
431      display: none;
432    }
433    .notif-btn .notif-dot.has-notifs { display: block; }
434
435    .notif-overlay {
436      position: fixed; inset: 0; background: rgba(0,0,0,0.4);
437      z-index: 9998; display: none;
438    }
439    .notif-overlay.open { display: block; }
440
441    .notif-panel {
442      position: fixed; top: 0; right: -400px; bottom: 0;
443      width: 380px; max-width: 90vw;
444      background: var(--bg-elevated);
445      z-index: 9999;
446      display: flex; flex-direction: column;
447      transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
448      box-shadow: -8px 0 32px rgba(0,0,0,0.3);
449    }
450    .notif-panel.open { right: 0; }
451
452    .notif-panel-header {
453      padding: 20px; display: flex; align-items: center;
454      justify-content: space-between; flex-shrink: 0;
455      border-bottom: 1px solid var(--glass-border-light);
456    }
457    .notif-panel-header h2 { font-size: 18px; font-weight: 600; color: white; }
458    .notif-close {
459      width: 32px; height: 32px; border-radius: 8px;
460      background: rgba(255,255,255,0.1); border: 1px solid var(--glass-border-light);
461      color: white; cursor: pointer; font-size: 16px; display: flex;
462      align-items: center; justify-content: center; transition: all var(--transition-fast);
463    }
464    .notif-close:hover { background: rgba(255,255,255,0.2); }
465
466    .notif-list {
467      flex: 1; overflow-y: auto; padding: 16px;
468      display: flex; flex-direction: column; gap: 12px;
469    }
470    .notif-list::-webkit-scrollbar { width: 6px; }
471    .notif-list::-webkit-scrollbar-track { background: transparent; }
472    .notif-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
473
474    .notif-item {
475      background: rgba(var(--glass-rgb), var(--glass-alpha));
476      backdrop-filter: blur(var(--glass-blur)) saturate(140%);
477      -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
478      border: 1px solid var(--glass-border-light);
479      border-radius: 14px; padding: 16px;
480      animation: fadeInUp 0.3s ease-out backwards;
481      transition: all var(--transition-fast);
482    }
483    .notif-item:hover {
484      background: rgba(var(--glass-rgb), 0.42);
485      transform: translateY(-1px);
486    }
487    .notif-item.type-thought { border-left: 3px solid #9b64dc; }
488    .notif-item.type-summary { border-left: 3px solid #6464d2; }
489
490    .notif-item-header {
491      display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
492    }
493    .notif-item-icon { font-size: 18px; flex-shrink: 0; }
494    .notif-item-title {
495      font-size: 14px; font-weight: 600; color: white;
496      flex: 1; line-height: 1.3;
497    }
498    .notif-item-badge {
499      font-size: 9px; font-weight: 700; text-transform: uppercase;
500      letter-spacing: 0.5px; padding: 2px 7px; border-radius: 6px;
501      background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.6);
502      border: 1px solid rgba(255,255,255,0.1); flex-shrink: 0;
503    }
504    .notif-item-content {
505      font-size: 13px; color: rgba(255,255,255,0.85);
506      line-height: 1.55; white-space: pre-wrap; word-break: break-word;
507    }
508    .notif-item-time {
509      font-size: 11px; color: rgba(255,255,255,0.45);
510      margin-top: 8px;
511    }
512    .notif-empty {
513      text-align: center; color: rgba(255,255,255,0.5);
514      font-size: 14px; padding: 40px 20px;
515    }
516    .notif-empty-icon { font-size: 40px; margin-bottom: 12px; display: block; }
517    .notif-loading { text-align: center; color: rgba(255,255,255,0.5); padding: 40px 20px; font-size: 14px; }
518
519    /* Notification tabs */
520    .notif-tabs {
521      display: flex; gap: 0; flex-shrink: 0;
522      border-bottom: 1px solid var(--glass-border-light);
523    }
524    .notif-tab {
525      flex: 1; padding: 10px 0; text-align: center;
526      font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.5);
527      background: none; border: none; border-bottom: 2px solid transparent;
528      cursor: pointer; font-family: inherit; transition: all var(--transition-fast);
529      position: relative;
530    }
531    .notif-tab:hover { color: rgba(255,255,255,0.8); }
532    .notif-tab.active { color: white; border-bottom-color: white; }
533    .notif-tab .tab-dot {
534      display: none; width: 6px; height: 6px; border-radius: 50%;
535      background: var(--accent); position: absolute; top: 8px; right: calc(50% - 30px);
536    }
537    .notif-tab .tab-dot.visible { display: block; }
538
539    /* Invite items */
540    .invite-item {
541      background: rgba(var(--glass-rgb), var(--glass-alpha));
542      backdrop-filter: blur(var(--glass-blur)) saturate(140%);
543      border: 1px solid var(--glass-border-light);
544      border-radius: 14px; padding: 16px;
545      border-left: 3px solid #48bb78;
546      animation: fadeInUp 0.3s ease-out backwards;
547    }
548    .invite-item-text { font-size: 13px; color: rgba(255,255,255,0.9); line-height: 1.5; margin-bottom: 10px; }
549    .invite-item-text strong { color: white; }
550    .invite-item-actions { display: flex; gap: 8px; }
551    .invite-item-actions button {
552      flex: 1; padding: 8px; border-radius: 8px; border: none;
553      font-family: inherit; font-size: 12px; font-weight: 600;
554      cursor: pointer; transition: all var(--transition-fast);
555    }
556    .invite-accept { background: var(--accent); color: white; }
557    .invite-accept:hover { filter: brightness(1.1); }
558    .invite-decline { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border: 1px solid var(--glass-border-light) !important; }
559    .invite-decline:hover { background: rgba(255,255,255,0.18); color: white; }
560
561    /* Members section */
562    .members-section { margin-top: 4px; }
563    .members-section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: rgba(255,255,255,0.4); margin-bottom: 8px; }
564    .member-item {
565      display: flex; align-items: center; justify-content: space-between;
566      padding: 10px 14px; border-radius: 10px;
567      background: rgba(var(--glass-rgb), var(--glass-alpha));
568      border: 1px solid var(--glass-border-light); margin-bottom: 8px;
569    }
570    .member-name { font-size: 13px; color: white; font-weight: 500; }
571    .member-role { font-size: 11px; color: rgba(255,255,255,0.45); margin-left: 6px; }
572    .member-actions { display: flex; gap: 6px; }
573    .member-actions button {
574      padding: 4px 10px; border-radius: 6px; border: 1px solid var(--glass-border-light);
575      background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.6);
576      font-family: inherit; font-size: 11px; cursor: pointer;
577      transition: all var(--transition-fast);
578    }
579    .member-actions button:hover { background: rgba(255,255,255,0.15); color: white; }
580    .member-actions .btn-danger:hover { background: rgba(239,68,68,0.3); color: #fca5a5; border-color: rgba(239,68,68,0.4); }
581
582    .invite-form {
583      display: flex; gap: 8px; margin-top: 12px;
584    }
585    .invite-form input {
586      flex: 1; padding: 8px 12px; border-radius: 8px;
587      background: rgba(255,255,255,0.08); border: 1px solid var(--glass-border-light);
588      color: white; font-family: inherit; font-size: 13px; outline: none;
589    }
590    .invite-form input::placeholder { color: rgba(255,255,255,0.35); }
591    .invite-form input:focus { border-color: var(--glass-border); }
592    .invite-form button {
593      padding: 8px 16px; border-radius: 8px; border: none;
594      background: var(--accent); color: white; font-family: inherit;
595      font-size: 12px; font-weight: 600; cursor: pointer;
596      transition: all var(--transition-fast);
597    }
598    .invite-form button:hover { filter: brightness(1.1); }
599    .invite-form button:disabled { opacity: 0.5; cursor: not-allowed; }
600
601    .invite-status {
602      font-size: 12px; margin-top: 6px; padding: 6px 10px;
603      border-radius: 6px; display: none;
604    }
605    .invite-status.error { display: block; background: rgba(239,68,68,0.15); color: #fca5a5; }
606    .invite-status.success { display: block; background: rgba(16,185,129,0.15); color: #6ee7b7; }
607
608    /* Dream time config */
609    .dream-config {
610      background: rgba(var(--glass-rgb), var(--glass-alpha));
611      border: 1px solid var(--glass-border-light);
612      border-radius: 14px; padding: 14px 16px; margin-bottom: 12px;
613    }
614    .dream-config-label { font-size: 12px; color: rgba(255,255,255,0.5); margin-bottom: 6px; }
615    .dream-config-row { display: flex; align-items: center; gap: 8px; }
616    .dream-config-row input[type="time"] {
617      padding: 6px 10px; border-radius: 8px;
618      background: rgba(255,255,255,0.08); border: 1px solid var(--glass-border-light);
619      color: white; font-family: inherit; font-size: 13px; outline: none;
620      color-scheme: dark;
621    }
622    .dream-config-row input[type="time"]:focus { border-color: var(--glass-border); }
623    .dream-config-row button {
624      padding: 6px 12px; border-radius: 8px; border: none;
625      font-family: inherit; font-size: 12px; font-weight: 600;
626      cursor: pointer; transition: all var(--transition-fast);
627    }
628    .dream-config-save { background: var(--accent); color: white; }
629    .dream-config-save:hover { filter: brightness(1.1); }
630    .dream-config-off {
631      background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6);
632      border: 1px solid var(--glass-border-light) !important;
633    }
634    .dream-config-off:hover { background: rgba(255,255,255,0.18); color: white; }
635    .dream-config-status { font-size: 11px; color: rgba(255,255,255,0.5); margin-top: 6px; }
636    .dream-config-hint { font-size: 12px; color: rgba(255,255,255,0.45); line-height: 1.4; }
637
638    .notif-panel-footer {
639      padding: 16px; border-top: 1px solid var(--glass-border-light); flex-shrink: 0;
640    }
641    .logout-btn {
642      width: 100%; padding: 10px; border-radius: 10px;
643      background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3);
644      color: #fca5a5; font-family: inherit; font-size: 13px;
645      cursor: pointer; transition: all var(--transition-fast);
646    }
647    .logout-btn:hover { background: rgba(239,68,68,0.3); color: #fecaca; }
648
649    @media (max-width: 600px) {
650      .container { max-width: 100%; }
651      .chat-header { padding: 0 12px; }
652      .header-right { gap: 6px; }
653      .chat-input-area { padding: 12px 16px 16px; }
654
655      /* Collapse status badge to dot only */
656      .status-badge .status-text { display: none; }
657      .status-badge { padding: 6px; min-width: 20px; justify-content: center; }
658
659      /* Collapse notifications button to icon only */
660      .notif-btn-label { display: none; }
661      .notif-btn-icon { display: inline; }
662      .notif-btn { padding: 6px 8px; }
663
664      /* Shrink other buttons */
665      .advanced-btn { padding: 6px 10px; font-size: 11px; }
666      .back-btn { padding: 4px 8px; font-size: 11px; }
667      .back-btn svg { width: 10px; height: 10px; }
668
669      /* Hide title text, keep icon */
670      .chat-title h1 { display: none; }
671
672      .notif-panel { width: 100%; max-width: 100%; right: -100%; }
673      .notif-panel.open { right: 0; }
674
675      /* Empty state — mobile: push to top */
676      .chat-area.empty { overflow: visible; }
677      .chat-area.empty .chat-messages { position: static; flex: 0; padding-top: 0; transform: none; overflow: visible; }
678      .chat-area.empty .chat-input-area { position: static; transform: none; width: 100%; max-width: 100%; margin: 0; }
679    }
680
681    /* App dashboard panel (slide-out drawer from right) */
682    .app-panel {
683      position: fixed; top: 0; right: -500px; bottom: 0;
684      width: 480px; max-width: 95vw;
685      background: var(--bg-elevated);
686      z-index: 9997;
687      display: flex; flex-direction: column;
688      transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
689      box-shadow: -8px 0 32px rgba(0,0,0,0.3);
690    }
691    .app-panel.visible { right: 0; }
692    .app-panel iframe {
693      flex: 1; width: 100%; border: none;
694      background: transparent;
695    }
696    .app-panel-header {
697      display: flex; align-items: center; justify-content: space-between;
698      padding: 14px 20px; flex-shrink: 0;
699      border-bottom: 1px solid var(--glass-border-light);
700    }
701    .app-panel-title {
702      font-size: 15px; font-weight: 600; color: white;
703      display: flex; align-items: center; gap: 8px;
704    }
705    .app-panel-close {
706      width: 32px; height: 32px; border-radius: 8px;
707      background: rgba(255,255,255,0.1); border: 1px solid var(--glass-border-light);
708      color: white; cursor: pointer; font-size: 16px; display: flex;
709      align-items: center; justify-content: center; transition: all var(--transition-fast);
710    }
711    .app-panel-close:hover { background: rgba(255,255,255,0.2); }
712
713    /* App hotbar */
714    .app-hotbar {
715      display: none; flex-shrink: 0;
716      border-top: 1px solid var(--glass-border-light);
717      padding: 6px 12px;
718      overflow-x: auto;
719    }
720    .app-hotbar.visible { display: flex; }
721    .app-hotbar-inner {
722      display: flex; gap: 2px; margin: 0 auto;
723      justify-content: center;
724    }
725    .app-hotbar-item {
726      display: flex; flex-direction: column; align-items: center; gap: 1px;
727      padding: 4px 10px; border-radius: 8px;
728      background: none; border: 1px solid transparent;
729      color: var(--text-muted); font-size: 9px;
730      cursor: pointer; transition: all var(--transition-fast);
731      font-family: inherit; min-width: 48px;
732    }
733    .app-hotbar-item:hover { background: rgba(255,255,255,0.06); color: var(--text-secondary); }
734    .app-hotbar-item.active {
735      background: rgba(var(--glass-rgb), 0.3);
736      border-color: var(--glass-border-light);
737      color: white;
738    }
739    .app-hotbar-emoji { font-size: 18px; }
740  </style>
741</head>
742<body>
743  <div class="container">
744    <div class="chat-header">
745      <div class="chat-title">
746        <a href="/app" style="text-decoration:none;display:flex;align-items:center;gap:12px;color:inherit;">
747        <span class="tree-icon">🌳</span>
748        <h1>Tree</h1>
749        </a>
750        <span class="root-name-inline" id="rootName"></span>
751      </div>
752      <div class="header-right">
753        <div class="status-badge">
754          <div class="status-dot connecting" id="statusDot"></div>
755          <span class="status-text" id="statusText">Connecting</span>
756        </div>
757        <button class="clear-chat-btn" id="clearChatBtn" title="Clear conversation">
758          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
759        </button>
760        <button class="notif-btn" id="notifBtn" onclick="toggleNotifs()">
761          <span class="notif-dot" id="notifDot"></span>
762          <span class="notif-btn-icon">☰</span>
763          <span class="notif-btn-label">Menu</span>
764        </button>
765        <a href="/dashboard" class="advanced-btn" id="advancedLink">Advanced</a>
766      </div>
767    </div>
768    <div class="back-row" id="backRow">
769      <button class="back-btn" onclick="backToTrees()">
770        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
771        Back
772      </button>
773    </div>
774
775    <!-- Menu panel -->
776    <div class="notif-overlay" id="notifOverlay" onclick="toggleNotifs()"></div>
777    <div class="notif-panel" id="notifPanel">
778      <div class="notif-panel-header">
779        <h2>Menu</h2>
780        <button class="notif-close" onclick="toggleNotifs()">&#x2715;</button>
781      </div>
782      <div class="notif-tabs">
783        ${getExtension("dreams") ? `<button class="notif-tab active" id="tabDreams" onclick="switchTab('dreams')">Dreams<span class="tab-dot" id="dreamsDot"></span></button>` : ""}
784        ${getExtension("team") ? `<button class="notif-tab${getExtension("dreams") ? "" : " active"}" id="tabInvites" onclick="switchTab('invites')">Invites<span class="tab-dot" id="invitesDot"></span></button>` : ""}
785      </div>
786      ${getExtension("dreams") ? `<div class="notif-list" id="notifList"><div class="notif-loading">Loading...</div></div>` : ""}
787      ${getExtension("team") ? `<div class="notif-list" id="invitesList"${getExtension("dreams") ? ' style="display:none"' : ""}><div class="notif-loading">Loading...</div></div>` : ""}
788      <div class="notif-panel-footer">
789        <button class="logout-btn" onclick="doLogout()">Log out</button>
790      </div>
791    </div>
792
793    <div class="tree-picker" id="treePicker">
794
795
796      ${
797        trees.length > 0
798          ? `<h2 class="tree-picker-title">Your Trees</h2>
799            <p class="tree-picker-sub">Pick a tree to continue</p>
800            <div class="tree-list" id="treeList">
801              ${trees
802                .map(
803                  (t) => `
804                <div class="tree-item" onclick="selectTree('${t._id}', '${escapeHtml(t.name)}')">
805                  <span class="tree-item-icon">🌳</span>
806                  <span class="tree-item-name">${escapeHtml(t.name)}</span>
807                </div>`,
808                )
809                .join("")}
810            </div>`
811          : ""
812      }
813
814      <!-- Custom tree: below the fold -->
815      <div style="margin-top:${trees.length > 0 ? "24px" : "16px"};padding-top:16px;border-top:1px solid rgba(255,255,255,0.06);">
816        <p style="color:rgba(255,255,255,0.3);font-size:0.75rem;margin-bottom:8px;">Custom tree (advanced)</p>
817        <form class="create-tree-form" id="createTreeForm" onsubmit="createTree(event)">
818          <input type="text" id="newTreeName" placeholder="Tree name..." autocomplete="off" />
819          <button type="submit" title="Create tree">+</button>
820        </form>
821      </div>
822    </div>
823
824    <div class="app-panel" id="appPanel">
825      <div class="app-panel-header">
826        <span class="app-panel-title" id="appPanelTitle"></span>
827        <button class="app-panel-close" onclick="closeAppPanel()">x</button>
828      </div>
829      <iframe id="appPanelFrame" sandbox="allow-same-origin allow-scripts allow-forms"></iframe>
830    </div>
831    <div class="chat-area empty" id="chatArea">
832      <div class="chat-messages" id="messages">
833        <div class="welcome-message" id="welcomeMsg">
834          <div class="welcome-icon">🌳</div>
835          <h2>Start chatting</h2>
836          <p>Just type. Natural language works. Say hello, log food, ask a question, or tell it something new.</p>
837          <p style="margin-top:8px;font-size:13px;color:var(--text-tertiary);">Connect via CLI too: <code style="background:rgba(255,255,255,0.06);padding:2px 6px;border-radius:4px;">npm i -g treeos</code> . <a href="/cli" style="color:inherit;text-decoration:underline;" target="_blank">Reference</a></p>
838        </div>
839      </div>
840      <div class="chat-input-area">
841        <div class="mode-toggle" id="modeToggle">
842          <button class="mode-btn active" data-mode="chat">Chat</button>
843          <button class="mode-btn" data-mode="place">Place</button>
844          <button class="mode-btn" data-mode="query">Query</button>
845        </div>
846        <div class="input-container">
847          <textarea class="chat-input" id="chatInput" placeholder="Say something..." rows="1"></textarea>
848          <button class="send-btn" id="sendBtn" disabled>
849            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
850          </button>
851        </div>
852      </div>
853      <div class="app-hotbar" id="appHotbar"><div class="app-hotbar-inner" id="appHotbarInner"></div></div>
854    </div>
855  </div>
856
857  <script src="/socket.io/socket.io.js"></script>
858  <script>
859    const CONFIG = {
860      username: "${escapeHtml(username)}",
861      userId: "${req.userId}",
862      trees: ${treesJSON},
863      apps: ${appsJSON},
864      landName: "${landName.replace(/"/g, '\\"')}",
865    };
866
867    // State
868    let activeRootId = null;
869    let isConnected = false;
870    let isRegistered = false;
871    let isSending = false;
872    let requestGeneration = 0;
873    let chatMode = "chat";
874
875    // Mode toggle
876    const modeToggle = document.getElementById("modeToggle");
877    const modePlaceholders = { chat: "Full conversation. Places content and responds.", place: "Places content onto your tree but doesn't respond.", query: "Talk to your tree without it making any changes." };
878    modeToggle.addEventListener("click", function(e) {
879      var btn = e.target.closest(".mode-btn");
880      if (!btn || isSending) return;
881      chatMode = btn.dataset.mode;
882      modeToggle.querySelectorAll(".mode-btn").forEach(function(b) { b.classList.remove("active"); });
883      btn.classList.add("active");
884      document.getElementById("chatInput").placeholder = modePlaceholders[chatMode] || "Say something...";
885    });
886
887    // Elements
888    const statusDot = document.getElementById("statusDot");
889    const statusText = document.getElementById("statusText");
890    const treePicker = document.getElementById("treePicker");
891    const chatArea = document.getElementById("chatArea");
892    const chatMessages = document.getElementById("messages");
893    const chatInput = document.getElementById("chatInput");
894    const sendBtn = document.getElementById("sendBtn");
895    const backRow = document.getElementById("backRow");
896    const rootName = document.getElementById("rootName");
897    const advancedLink = document.getElementById("advancedLink");
898
899    function escapeHtml(s) {
900      const d = document.createElement("div");
901      d.textContent = s;
902      return d.innerHTML;
903    }
904
905    // ── Markdown formatting — matches app.js ──────────────────────────
906    function formatMessageContent(text) {
907      if (!text) return '';
908      let html = text;
909
910      html = html.replace(/&nbsp;/g, ' ');
911      html = html.replace(/&amp;/g, '&');
912      html = html.replace(/&lt;/g, '<');
913      html = html.replace(/&gt;/g, '>');
914      html = html.replace(/\\u00A0/g, ' ');
915
916      html = html.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
917
918      // Code blocks
919      html = html.replace(/\`\`\`([\\s\\S]*?)\`\`\`/g, '<pre><code>$1</code></pre>');
920      html = html.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
921
922      // Bold / italic
923      html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');
924      html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
925      html = html.replace(/(?<![\\w\\*])\\*([^\\*]+)\\*(?![\\w\\*])/g, '<em>$1</em>');
926
927      // Headings
928      html = html.replace(/^####\\s*(.+)$/gm, '<h4>$1</h4>');
929      html = html.replace(/^###\\s*(.+)$/gm, '<h3>$1</h3>');
930      html = html.replace(/^##\\s*(.+)$/gm, '<h2>$1</h2>');
931      html = html.replace(/^#\\s*(.+)$/gm, '<h1>$1</h1>');
932
933      // HR
934      html = html.replace(/^-{3,}$/gm, '<hr>');
935      html = html.replace(/^\\*{3,}$/gm, '<hr>');
936
937      // Blockquote
938      html = html.replace(/^&gt;\\s*(.+)$/gm, '<blockquote>$1</blockquote>');
939
940      // Numbered menu items with bold title
941      html = html.replace(/^([1-9]|1[0-9]|20)\\.\\s*<strong>(.+?)<\\/strong>(.*)$/gm, function(m, num, title, rest) {
942        return '<div class="menu-item clickable" data-action="' + num + '" data-name="' + title.replace(/"/g, '&quot;') + '">' +
943          '<span class="menu-number">' + num + '</span>' +
944          '<span class="menu-text"><strong>' + title + '</strong>' + rest + '</span></div>';
945      });
946
947      // Bullet items with bold title
948      html = html.replace(/^[-\\u2013\\u2022]\\s*<strong>(.+?)<\\/strong>(.*)$/gm,
949        '<div class="menu-item"><span class="menu-number">\\u2022</span><span class="menu-text"><strong>$1</strong>$2</span></div>');
950
951      // Plain bullet items
952      html = html.replace(/^[-\\u2013\\u2022]\\s+([^<].*)$/gm, '<li>$1</li>');
953
954      // Numbered list items
955      html = html.replace(/^(\\d+)\\.\\s+([^<*].*)$/gm, '<li><span class="list-num">$1.</span> $2</li>');
956
957      // Wrap consecutive li in ul
958      let inList = false;
959      const lines = html.split('\\n');
960      const processed = [];
961      for (let i = 0; i < lines.length; i++) {
962        const line = lines[i];
963        const isListItem = line.trim().startsWith('<li>');
964        if (isListItem && !inList) { processed.push('<ul>'); inList = true; }
965        else if (!isListItem && inList) { processed.push('</ul>'); inList = false; }
966        processed.push(line);
967      }
968      if (inList) processed.push('</ul>');
969      html = processed.join('\\n');
970
971      // Links
972      html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank">$1</a>');
973
974      // Paragraphs
975      const blocks = html.split(/\\n\\n+/);
976      html = blocks.map(function(block) {
977        const trimmed = block.trim();
978        if (!trimmed) return '';
979        if (trimmed.match(/^<(h[1-4]|ul|ol|pre|blockquote|hr|div|table)/)) return trimmed;
980        const withBreaks = trimmed.split('\\n').map(function(l) { return l.trim(); }).filter(function(l) { return l; }).join('<br>');
981        return '<p>' + withBreaks + '</p>';
982      }).filter(function(b) { return b; }).join('');
983
984      // Clean up
985      html = html.replace(/<p><\\/p>/g, '');
986      html = html.replace(/<p>(<div|<ul|<ol|<h[1-4]|<hr|<pre|<blockquote)/g, '$1');
987      html = html.replace(/(<\\/div>|<\\/ul>|<\\/ol>|<\\/h[1-4]>|<\\/pre>|<\\/blockquote>)<\\/p>/g, '$1');
988      html = html.replace(/<br>(<div|<\\/div>)/g, '$1');
989      html = html.replace(/(<div[^>]*>)<br>/g, '$1');
990
991      return html;
992    }
993
994    // ── Socket ────────────────────────────────────────────────────────
995    const socket = io({ transports: ["websocket", "polling"], withCredentials: true });
996
997    socket.on("connect", () => {
998      isConnected = true;
999      statusDot.className = "status-dot connecting";
1000      statusText.textContent = "Connecting";
1001      socket.emit("ready");
1002      socket.emit("register", { username: CONFIG.username });
1003    });
1004
1005    socket.on("registered", ({ success }) => {
1006      if (success) {
1007        isRegistered = true;
1008        statusDot.className = "status-dot connected";
1009        statusText.textContent = "Connected";
1010
1011        // Clear disconnected message on reconnect
1012        const disc = chatMessages.querySelector(".welcome-message.disconnected");
1013        if (disc) {
1014          disc.remove();
1015          chatMessages.innerHTML = '<div class="welcome-message" id="welcomeMsg"><div class="welcome-icon">🌳</div><h2>Start chatting</h2><p>Just type. Natural language works.</p></div>';
1016          chatArea.classList.add("empty");
1017        }
1018
1019        updateSendBtn();
1020        if (activeRootId) {
1021          socket.emit("setActiveRoot", { rootId: activeRootId });
1022          socket.emit("urlChanged", { url: "/api/v1/root/" + activeRootId, rootId: activeRootId });
1023        }
1024      }
1025    });
1026
1027    socket.on("chatResponse", ({ answer, generation, targetNodeId }) => {
1028      if (generation !== undefined && generation < requestGeneration) return;
1029      removeTyping();
1030      addMessage(answer, "assistant");
1031      isSending = false;
1032      updateSendBtn();
1033
1034      // Surface app dashboard if the response routed to an extension node
1035      if (targetNodeId && targetNodeId !== activeRootId) {
1036        surfaceApp(targetNodeId);
1037      }
1038    });
1039
1040    socket.on("placeResult", ({ stepSummaries, targetPath, generation }) => {
1041      if (generation !== undefined && generation < requestGeneration) return;
1042      var el = document.getElementById("placeStatus");
1043      var summary = (stepSummaries && stepSummaries.length > 0)
1044        ? "Placed on: " + (targetPath || stepSummaries.map(function(s) { return s.summary || s; }).join(", "))
1045        : "Nothing to place for that message.";
1046      if (el) {
1047        el.querySelector(".place-result").textContent = summary;
1048      } else {
1049        addMessage(summary, "place-status");
1050      }
1051      isSending = false;
1052      updateSendBtn();
1053    });
1054
1055    socket.on("chatError", ({ error, generation }) => {
1056      if (generation !== undefined && generation < requestGeneration) return;
1057      removeTyping();
1058      addMessage("Error: " + error, "error");
1059      isSending = false;
1060      updateSendBtn();
1061    });
1062
1063    socket.on("chatCancelled", () => {
1064      if (isSending) {
1065        removeTyping();
1066        isSending = false;
1067        updateSendBtn();
1068      }
1069    });
1070
1071    socket.on("disconnect", () => {
1072      isConnected = false;
1073      isRegistered = false;
1074      isSending = false;
1075      statusDot.className = "status-dot disconnected";
1076      statusText.textContent = "Disconnected";
1077      updateSendBtn();
1078
1079      chatMessages.innerHTML = '<div class="welcome-message disconnected"><div class="welcome-icon">🌳</div><h2>Disconnected</h2><p>You have been disconnected from ' + CONFIG.landName + '. Please refresh the page to reconnect.</p></div>';
1080    });
1081
1082    // Ignore navigate events — no iframe
1083    socket.on("navigate", () => {});
1084
1085    // ── Create tree ─────────────────────────────────────────────────
1086    async function createTree(e) {
1087      e.preventDefault();
1088      const input = e.target.querySelector("input[type=text]");
1089      const name = input.value.trim();
1090      if (!name) return;
1091
1092      const btn = e.target.querySelector("button");
1093      btn.disabled = true;
1094
1095      try {
1096        const res = await fetch("/api/v1/user/" + CONFIG.userId + "/createRoot", {
1097          method: "POST",
1098          headers: { "Content-Type": "application/json" },
1099          credentials: "include",
1100          body: JSON.stringify({ name }),
1101        });
1102        const data = await res.json();
1103        if (!res.ok || data.status === "error") throw new Error((data.error && data.error.message) || data.error || "Failed");
1104
1105        // Add to tree list (create it if empty state)
1106        let treeList = document.getElementById("treeList");
1107        if (!treeList) {
1108          // Was empty state — rebuild picker content
1109          const emptyState = treePicker.querySelector(".empty-state");
1110          if (emptyState) emptyState.remove();
1111
1112          const title = document.createElement("h2");
1113          title.className = "tree-picker-title";
1114          title.textContent = "Your Trees";
1115
1116          const sub = document.createElement("p");
1117          sub.className = "tree-picker-sub";
1118          sub.textContent = "Pick a tree to start chatting";
1119
1120          treeList = document.createElement("div");
1121          treeList.className = "tree-list";
1122          treeList.id = "treeList";
1123
1124          const form = document.getElementById("createTreeForm");
1125          treePicker.insertBefore(treeList, form);
1126          treePicker.insertBefore(sub, treeList);
1127          treePicker.insertBefore(title, sub);
1128        }
1129
1130        const item = document.createElement("div");
1131        item.className = "tree-item";
1132        const rootId = data.data?.rootId || data.rootId;
1133        item.onclick = () => selectTree(rootId, name);
1134        item.innerHTML = \`
1135          <span class="tree-item-icon">🌳</span>
1136          <span class="tree-item-name">\${escapeHtml(name)}</span>\`;
1137        item.style.animation = "fadeInUp 0.3s ease-out";
1138        treeList.appendChild(item);
1139
1140        input.value = "";
1141      } catch (err) {
1142        console.error("Create tree error:", err);
1143        alert("Failed to create tree: " + err.message);
1144      } finally {
1145        btn.disabled = false;
1146      }
1147    }
1148
1149    // ── Tree selection ────────────────────────────────────────────────
1150    function selectTree(rootId, name) {
1151      activeRootId = rootId;
1152      advancedLink.href = "/dashboard?rootId=" + rootId;
1153      treePicker.style.display = "none";
1154      chatArea.classList.add("active");
1155      rootName.textContent = name;
1156      rootName.classList.add("visible");
1157      backRow.classList.add("visible");
1158
1159      // Reset chat
1160      const welcome = chatMessages.querySelector(".welcome-message");
1161      if (welcome) welcome.style.display = "";
1162      chatMessages.querySelectorAll(".message, .typing-indicator").forEach(el => el.remove());
1163      chatArea.classList.add("empty");
1164
1165      // Tell server about this root
1166      socket.emit("setActiveRoot", { rootId });
1167      socket.emit("urlChanged", { url: "/api/v1/root/" + rootId, rootId });
1168
1169      // Build app hotbar for this tree
1170      buildHotbar(rootId);
1171
1172      // Refresh menu panel for this tree
1173      dreamsLoaded = false;
1174      invitesLoaded = false;
1175      if (notifOpen) {
1176        if (activeTab === "dreams") fetchDreams();
1177        if (activeTab === "invites") fetchInvites();
1178      }
1179
1180      updateSendBtn();
1181    }
1182
1183    function backToTrees() {
1184      // Cancel any in-flight request
1185      if (isSending) {
1186        requestGeneration++;
1187        socket.emit("cancelRequest");
1188        removeTyping();
1189      }
1190
1191      activeRootId = null;
1192      advancedLink.href = "/dashboard";
1193      treePicker.style.display = "";
1194      chatArea.classList.remove("active");
1195      rootName.classList.remove("visible");
1196      backRow.classList.remove("visible");
1197      document.getElementById("clearChatBtn").classList.remove("visible");
1198      isSending = false;
1199      updateSendBtn();
1200
1201      // Tell server we're going home so it properly exits tree mode
1202      socket.emit("urlChanged", { url: "/api/v1/user/" + CONFIG.userId });
1203      socket.emit("clearConversation");
1204      dreamsLoaded = false;
1205      invitesLoaded = false;
1206      if (notifOpen) {
1207        if (activeTab === "dreams") fetchDreams();
1208        if (activeTab === "invites") fetchInvites();
1209      }
1210    }
1211
1212    // ── Messages ──────────────────────────────────────────────────────
1213    function addMessage(content, role) {
1214      const welcome = chatMessages.querySelector(".welcome-message");
1215      if (welcome) {
1216        welcome.remove();
1217        document.getElementById("chatArea").classList.remove("empty");
1218        document.getElementById("clearChatBtn").classList.add("visible");
1219      }
1220
1221      const msg = document.createElement("div");
1222      if (role === "place-status") {
1223        msg.className = "message assistant";
1224        msg.id = "placeStatus";
1225        msg.innerHTML = '<div class="message-avatar">\\ud83c\\udf33</div><div class="message-content"><div class="place-result">' + escapeHtml(content) + '</div></div>';
1226        chatMessages.appendChild(msg);
1227        chatMessages.scrollTop = chatMessages.scrollHeight;
1228        return;
1229      }
1230
1231      msg.className = "message " + role;
1232
1233      const formattedContent = role === "assistant" ? formatMessageContent(content) : escapeHtml(content);
1234
1235      msg.innerHTML =
1236        '<div class="message-avatar">' + (role === "user" ? "\\ud83d\\udc64" : "\\ud83c\\udf33") + '</div>' +
1237        '<div class="message-content">' + formattedContent + '</div>';
1238
1239      // Clickable menu items
1240      if (role === "assistant") {
1241        msg.querySelectorAll(".menu-item.clickable").forEach(function(item) {
1242          item.addEventListener("click", function() {
1243            const name = item.dataset.name;
1244            if (name && !isSending) {
1245              chatInput.value = name;
1246              sendMessage();
1247            }
1248          });
1249        });
1250      }
1251
1252      chatMessages.appendChild(msg);
1253      chatMessages.scrollTop = chatMessages.scrollHeight;
1254    }
1255
1256    function addTyping() {
1257      removeTyping();
1258      const msg = document.createElement("div");
1259      msg.className = "message assistant";
1260      msg.id = "typingIndicator";
1261      msg.innerHTML =
1262        '<div class="message-avatar">\\ud83c\\udf33</div>' +
1263        '<div class="message-content typing-indicator"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>';
1264      chatMessages.appendChild(msg);
1265      chatMessages.scrollTop = chatMessages.scrollHeight;
1266    }
1267
1268    function removeTyping() {
1269      const el = document.getElementById("typingIndicator");
1270      if (el) el.remove();
1271    }
1272
1273    // ── App Dashboard Panel ──────────────────────────────────────────
1274
1275    const APP_META = {
1276      food: { emoji: "🍎", name: "Food", path: "food" },
1277      fitness: { emoji: "💪", name: "Fitness", path: "fitness" },
1278      recovery: { emoji: "🌿", name: "Recovery", path: "recovery" },
1279      study: { emoji: "📚", name: "Study", path: "study" },
1280      kb: { emoji: "📖", name: "KB", path: "kb" },
1281      relationships: { emoji: "👥", name: "Relationships", path: "relationships" },
1282      finance: { emoji: "💰", name: "Finance", path: "finance" },
1283      investor: { emoji: "📈", name: "Investor", path: "investor" },
1284      "market-researcher": { emoji: "🔬", name: "Research", path: "market-researcher" },
1285    };
1286
1287    let currentAppNodeId = null;
1288    let hotbarBuilt = false;
1289
1290    function surfaceApp(nodeId) {
1291      const apps = CONFIG.apps || [];
1292      const match = apps.find(a => a.id === nodeId);
1293      if (match) {
1294        showAppPanel(nodeId, match.key);
1295        highlightHotbarItem(match.key);
1296      }
1297    }
1298
1299    function showAppPanel(nodeId, appKey) {
1300      // Navigate the session to this app's node
1301      if (typeof socket !== "undefined" && socket.connected) {
1302        socket.emit("urlChanged", { url: "/api/v1/node/" + nodeId, nodeId });
1303      }
1304
1305      const panel = document.getElementById("appPanel");
1306      const frame = document.getElementById("appPanelFrame");
1307      const title = document.getElementById("appPanelTitle");
1308      const meta = APP_META[appKey];
1309      if (!meta) return;
1310
1311      currentAppNodeId = nodeId;
1312      title.innerHTML = meta.emoji + " " + meta.name;
1313      frame.src = "/api/v1/root/" + nodeId + "/" + meta.path + "?html&inApp=1";
1314
1315      // Update header to show active app
1316      const rn = document.getElementById("rootName");
1317      if (rn) rn.textContent = meta.name;
1318      panel.classList.add("visible");
1319
1320    }
1321
1322    function closeAppPanel() {
1323      document.getElementById("appPanel").classList.remove("visible");
1324      document.getElementById("appPanelFrame").src = "";
1325      currentAppNodeId = null;
1326      highlightHotbarItem(null);
1327      // Restore header to tree name
1328      const tree = CONFIG.trees.find(t => t.id === activeRootId);
1329      const rn = document.getElementById("rootName");
1330      if (rn && tree) rn.textContent = tree.name;
1331    }
1332
1333    function buildHotbar(treeId) {
1334      if (hotbarBuilt) return;
1335      const apps = CONFIG.apps || [];
1336      if (apps.length === 0) return;
1337
1338      // Check if this tree is the Life tree (apps are under it)
1339      const isLifeTree = apps.some(a => a.treeRootId === treeId);
1340      if (!isLifeTree) return;
1341
1342      const hotbar = document.getElementById("appHotbar");
1343      const inner = document.getElementById("appHotbarInner");
1344      inner.innerHTML = apps.map(a =>
1345        '<button class="app-hotbar-item" data-app="' + a.key + '" data-node-id="' + a.id + '" onclick="showAppPanel(\\'' + a.id + '\\',\\'' + a.key + '\\');highlightHotbarItem(\\'' + a.key + '\\')">' +
1346          '<span class="app-hotbar-emoji">' + a.emoji + '</span>' +
1347          '<span>' + a.label + '</span>' +
1348        '</button>'
1349      ).join("");
1350      hotbar.classList.add("visible");
1351      hotbarBuilt = true;
1352    }
1353
1354    function highlightHotbarItem(appKey) {
1355      document.querySelectorAll(".app-hotbar-item").forEach(el => {
1356        el.classList.toggle("active", el.dataset.app === appKey);
1357      });
1358    }
1359
1360    // ── Send ──────────────────────────────────────────────────────────
1361    function sendMessage() {
1362      if (isSending) {
1363        requestGeneration++;
1364        socket.emit("cancelRequest");
1365        removeTyping();
1366        addMessage("Stopped", "error");
1367        isSending = false;
1368        updateSendBtn();
1369        return;
1370      }
1371
1372      const text = chatInput.value.trim();
1373      if (!text || !isRegistered || !activeRootId) return;
1374
1375      chatInput.value = "";
1376      chatInput.style.height = "auto";
1377      addMessage(text, "user");
1378      if (chatMode === "place") {
1379        addMessage("Placing...", "place-status");
1380      } else {
1381        addTyping();
1382      }
1383      isSending = true;
1384      requestGeneration++;
1385      updateSendBtn();
1386      socket.emit("chat", { message: text, username: CONFIG.username, generation: requestGeneration, mode: chatMode });
1387    }
1388
1389    function updateSendBtn() {
1390      const hasText = chatInput.value.trim().length > 0;
1391      if (isSending) {
1392        sendBtn.classList.add("stop-mode");
1393        sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
1394        sendBtn.disabled = !(isConnected && isRegistered);
1395        chatInput.disabled = true;
1396      } else {
1397        sendBtn.classList.remove("stop-mode");
1398        sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>';
1399        sendBtn.disabled = !(hasText && isRegistered && activeRootId);
1400        chatInput.disabled = false;
1401      }
1402    }
1403
1404    // ── Input handlers ────────────────────────────────────────────────
1405    chatInput.addEventListener("input", () => {
1406      const maxH = chatArea.classList.contains("empty") ? window.innerHeight * 0.4 : 120;
1407      chatInput.style.height = "auto";
1408      chatInput.style.height = Math.min(chatInput.scrollHeight, maxH) + "px";
1409      updateSendBtn();
1410    });
1411
1412    chatInput.addEventListener("keydown", (e) => {
1413      if (e.key === "Enter" && !e.shiftKey) {
1414        e.preventDefault();
1415        sendMessage();
1416        // On mobile, blur to dismiss keyboard so user can see the response
1417        if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
1418          chatInput.blur();
1419        }
1420      }
1421    });
1422
1423    sendBtn.addEventListener("click", sendMessage);
1424
1425    document.getElementById("clearChatBtn").addEventListener("click", () => {
1426      if (!isRegistered) return;
1427      if (isSending) {
1428        socket.emit("cancelRequest");
1429        removeTyping();
1430        isSending = false;
1431      }
1432      socket.emit("clearConversation");
1433      chatMessages.innerHTML = '<div class="welcome-message" id="welcomeMsg"><div class="welcome-icon">🌳</div><h2>Start chatting</h2><p>Just type. Natural language works.</p></div>';
1434      chatArea.classList.add("empty");
1435      document.getElementById("clearChatBtn").classList.remove("visible");
1436      updateSendBtn();
1437    });
1438
1439    // ── Notifications + Invites ────────────────────────────────────────
1440    const notifPanel = document.getElementById("notifPanel");
1441    const notifOverlay = document.getElementById("notifOverlay");
1442    const notifList = document.getElementById("notifList");
1443    const invitesList = document.getElementById("invitesList");
1444    const notifDot = document.getElementById("notifDot");
1445    let notifOpen = false;
1446    let dreamsLoaded = false;
1447    let invitesLoaded = false;
1448    let activeTab = notifList ? "dreams" : invitesList ? "invites" : "dreams";
1449
1450    async function doLogout() {
1451      try {
1452        await fetch("/api/v1/logout", { method: "POST", credentials: "include" });
1453        window.location.href = "/login";
1454      } catch(e) {
1455        alert("Logout failed");
1456      }
1457    }
1458
1459    function toggleNotifs() {
1460      notifOpen = !notifOpen;
1461      notifPanel.classList.toggle("open", notifOpen);
1462      notifOverlay.classList.toggle("open", notifOpen);
1463      if (notifOpen) {
1464        if (activeTab === "dreams" && !dreamsLoaded && notifList) fetchDreams();
1465        if (activeTab === "invites" && !invitesLoaded && invitesList) fetchInvites();
1466      }
1467    }
1468
1469    function switchTab(tab) {
1470      activeTab = tab;
1471      var tabDreams = document.getElementById("tabDreams");
1472      var tabInvites = document.getElementById("tabInvites");
1473      if (tabDreams) tabDreams.classList.toggle("active", tab === "dreams");
1474      if (tabInvites) tabInvites.classList.toggle("active", tab === "invites");
1475      if (notifList) notifList.style.display = tab === "dreams" ? "" : "none";
1476      if (invitesList) invitesList.style.display = tab === "invites" ? "" : "none";
1477      if (tab === "dreams" && !dreamsLoaded) fetchDreams();
1478      if (tab === "invites" && !invitesLoaded) fetchInvites();
1479    }
1480
1481    async function fetchDreams() {
1482      notifList.innerHTML = '<div class="notif-loading">Loading...</div>';
1483      dreamsLoaded = false;
1484      try {
1485        var dreamUrl = "/chat/notifications" + (activeRootId ? "?rootId=" + activeRootId : "");
1486        var res = await fetch(dreamUrl, { credentials: "include" });
1487        var data = await res.json();
1488        if (!res.ok || data.status === "error") throw new Error((data.error && data.error.message) || data.error || "Failed");
1489        var inner = data.data || data;
1490
1491        dreamsLoaded = true;
1492        var notifs = inner.notifications || [];
1493        var html = "";
1494
1495        // Dream time config (only when inside a tree and user is owner)
1496        if (activeRootId && inner.isOwner) {
1497          if (inner.metadata?.dreams?.dreamTime) {
1498            html += '<div class="dream-config">' +
1499              '<div class="dream-config-label">Dream schedule</div>' +
1500              '<div class="dream-config-row">' +
1501                '<input type="time" id="dreamTimeInput" value="' + escapeHtml(inner.metadata?.dreams?.dreamTime) + '" />' +
1502                '<button class="dream-config-save" onclick="saveDreamTime()">Save</button>' +
1503                '<button class="dream-config-off" onclick="disableDreamTime()">Turn Off</button>' +
1504              '</div>' +
1505              '<div class="dream-config-status" id="dreamStatus"></div>' +
1506            '</div>';
1507          } else {
1508            html += '<div class="dream-config">' +
1509              '<div class="dream-config-hint">Dreams are off for this tree. Set a time to enable nightly dreams. Your tree will reflect, reorganize, and share thoughts with you.</div>' +
1510              '<div class="dream-config-row" style="margin-top:8px">' +
1511                '<input type="time" id="dreamTimeInput" value="" />' +
1512                '<button class="dream-config-save" onclick="saveDreamTime()">Enable</button>' +
1513              '</div>' +
1514              '<div class="dream-config-status" id="dreamStatus"></div>' +
1515            '</div>';
1516          }
1517        }
1518
1519        if (notifs.length === 0) {
1520          html += '<div class="notif-empty"><span class="notif-empty-icon">\\ud83d\\udd14</span>' +
1521            (activeRootId ? 'No dreams from this tree yet' : 'No dream notifications from the last 7 days') +
1522          '</div>';
1523          notifList.innerHTML = html;
1524          return;
1525        }
1526
1527        document.getElementById("dreamsDot").classList.add("visible");
1528        notifDot.classList.add("has-notifs");
1529        html += notifs.map(function(n, i) {
1530          var isThought = n.type === "dream-thought";
1531          var icon = isThought ? "\\ud83d\\udcad" : "\\ud83d\\udccb";
1532          var badge = isThought ? "Thought" : "Summary";
1533          var cls = isThought ? "type-thought" : "type-summary";
1534          var date = new Date(n.createdAt).toLocaleDateString(undefined, {
1535            month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
1536          });
1537          return '<div class="notif-item ' + cls + '" style="animation-delay:' + (i * 0.04) + 's">' +
1538            '<div class="notif-item-header">' +
1539              '<span class="notif-item-icon">' + icon + '</span>' +
1540              '<span class="notif-item-title">' + escapeHtml(n.title) + '</span>' +
1541              '<span class="notif-item-badge">' + badge + '</span>' +
1542            '</div>' +
1543            '<div class="notif-item-content">' + escapeHtml(n.content) + '</div>' +
1544            '<div class="notif-item-time">' + date + '</div>' +
1545          '</div>';
1546        }).join("");
1547        notifList.innerHTML = html;
1548      } catch (err) {
1549        console.error("Dreams error:", err);
1550        notifList.innerHTML = '<div class="notif-empty">Failed to load notifications</div>';
1551      }
1552    }
1553
1554    async function saveDreamTime() {
1555      var input = document.getElementById("dreamTimeInput");
1556      var status = document.getElementById("dreamStatus");
1557      if (!input.value) { status.textContent = "Pick a time first"; return; }
1558      try {
1559        var res = await fetch("/api/v1/root/" + activeRootId + "/dream-time", {
1560          method: "POST",
1561          headers: { "Content-Type": "application/json" },
1562          credentials: "include",
1563          body: JSON.stringify({ dreamTime: input.value }),
1564        });
1565        var data = await res.json();
1566        if (!res.ok) throw new Error((data.error && data.error.message) || data.error || "Failed");
1567        status.textContent = "Dreams set for " + input.value;
1568        dreamsLoaded = false;
1569        fetchDreams();
1570      } catch (err) {
1571        status.textContent = err.message;
1572      }
1573    }
1574
1575    async function disableDreamTime() {
1576      var status = document.getElementById("dreamStatus");
1577      try {
1578        var res = await fetch("/api/v1/root/" + activeRootId + "/dream-time", {
1579          method: "POST",
1580          headers: { "Content-Type": "application/json" },
1581          credentials: "include",
1582          body: JSON.stringify({ dreamTime: null }),
1583        });
1584        var data = await res.json();
1585        if (!res.ok) throw new Error((data.error && data.error.message) || data.error || "Failed");
1586        status.textContent = "Dreams disabled";
1587        dreamsLoaded = false;
1588        fetchDreams();
1589      } catch (err) {
1590        status.textContent = err.message;
1591      }
1592    }
1593
1594    async function fetchInvites() {
1595      invitesList.innerHTML = '<div class="notif-loading">Loading...</div>';
1596      invitesLoaded = false;
1597      try {
1598        var invUrl = "/chat/invites" + (activeRootId ? "?rootId=" + activeRootId : "");
1599        var res = await fetch(invUrl, { credentials: "include" });
1600        var data = await res.json();
1601        if (!res.ok || data.status === "error") throw new Error((data.error && data.error.message) || data.error || "Failed");
1602        var invInner = data.data || data;
1603
1604        invitesLoaded = true;
1605        var html = "";
1606
1607        // Pending invites section
1608        var invites = invInner.invites || [];
1609        if (invites.length > 0) {
1610          document.getElementById("invitesDot").classList.add("visible");
1611          notifDot.classList.add("has-notifs");
1612          html += invites.map(function(inv, i) {
1613            return '<div class="invite-item" style="animation-delay:' + (i * 0.04) + 's">' +
1614              '<div class="invite-item-text"><strong>' + escapeHtml(inv.from) + '</strong> invited you to <strong>' + escapeHtml(inv.treeName) + (inv.isRemote && inv.homeLand ? ' on ' + escapeHtml(inv.homeLand) : '') + '</strong></div>' +
1615              '<div class="invite-item-actions">' +
1616                '<button class="invite-accept" onclick="respondInvite(\\'' + inv.id + '\\', true, this)">Accept</button>' +
1617                '<button class="invite-decline" onclick="respondInvite(\\'' + inv.id + '\\', false, this)">Decline</button>' +
1618              '</div>' +
1619            '</div>';
1620          }).join("");
1621        }
1622
1623        // Members section (only when inside a tree)
1624        if (activeRootId && invInner.members) {
1625          var members = invInner.members;
1626          html += '<div class="members-section">';
1627          html += '<div class="members-section-title">Members</div>';
1628
1629          // Owner
1630          if (members.owner) {
1631            html += '<div class="member-item">' +
1632              '<div><span class="member-name">' + escapeHtml(members.owner.username) + '</span><span class="member-role">Owner</span></div>' +
1633            '</div>';
1634          }
1635
1636          // Contributors
1637          (members.contributors || []).forEach(function(c) {
1638            var isSelf = c._id === CONFIG.userId;
1639            var isOwner = members.isOwner;
1640            var actions = '';
1641            if (isOwner || isSelf) {
1642              var label = isSelf ? "Leave" : "Remove";
1643              var cls = isSelf ? "btn-danger" : "btn-danger";
1644              actions = '<div class="member-actions">';
1645              if (isOwner && !isSelf) {
1646                actions += '<button onclick="transferOwner(\\'' + c._id + '\\', this)">Transfer</button>';
1647              }
1648              actions += '<button class="' + cls + '" onclick="removeMember(\\'' + c._id + '\\', \\'' + label + '\\', this)">' + label + '</button>';
1649              actions += '</div>';
1650            }
1651            html += '<div class="member-item">' +
1652              '<div><span class="member-name">' + escapeHtml(c.username) + '</span></div>' +
1653              actions +
1654            '</div>';
1655          });
1656
1657          // Invite form (owner or contributor)
1658          if (members.isOwner || members.contributors.some(function(c) { return c._id === userId; })) {
1659            html += '<form class="invite-form" onsubmit="sendInvite(event)">' +
1660              '<input type="text" id="inviteUsername" placeholder="username or user@other.land.com" />' +
1661              '<button type="submit">Invite</button>' +
1662            '</form>' +
1663            '<div class="invite-status" id="inviteStatus"></div>';
1664          }
1665          html += '</div>';
1666        }
1667
1668        if (!html) {
1669          html = '<div class="notif-empty"><span class="notif-empty-icon">\\ud83d\\udcec</span>No pending invites</div>';
1670        }
1671
1672        invitesList.innerHTML = html;
1673      } catch (err) {
1674        console.error("Invites error:", err);
1675        invitesList.innerHTML = '<div class="notif-empty">Failed to load invites</div>';
1676      }
1677    }
1678
1679    async function respondInvite(inviteId, accept, btn) {
1680      var item = btn.closest(".invite-item");
1681      item.style.opacity = "0.5";
1682      item.style.pointerEvents = "none";
1683      try {
1684        var res = await fetch("/chat/invites/" + inviteId, {
1685          method: "POST",
1686          headers: { "Content-Type": "application/json" },
1687          credentials: "include",
1688          body: JSON.stringify({ accept: accept }),
1689        });
1690        var data = await res.json();
1691        if (!res.ok) throw new Error((data.error && data.error.message) || data.error || "Failed");
1692        item.remove();
1693        // Refresh tree list if accepted
1694        if (accept) {
1695          location.reload();
1696        }
1697      } catch (err) {
1698        item.style.opacity = "1";
1699        item.style.pointerEvents = "";
1700        alert(err.message);
1701      }
1702    }
1703
1704    async function sendInvite(e) {
1705      e.preventDefault();
1706      var input = document.getElementById("inviteUsername");
1707      var status = document.getElementById("inviteStatus");
1708      var username = input.value.trim();
1709      if (!username) return;
1710
1711      status.className = "invite-status";
1712      status.textContent = "";
1713
1714      try {
1715        var res = await fetch("/api/v1/root/" + activeRootId + "/invite", {
1716          method: "POST",
1717          headers: { "Content-Type": "application/json" },
1718          credentials: "include",
1719          body: JSON.stringify({ userReceiving: username }),
1720        });
1721        var data = await res.json();
1722        if (!res.ok) throw new Error((data.error && data.error.message) || data.error || "Failed");
1723
1724        status.textContent = "Invite sent!";
1725        status.className = "invite-status success";
1726        input.value = "";
1727      } catch (err) {
1728        status.textContent = err.message;
1729        status.className = "invite-status error";
1730      }
1731    }
1732
1733    async function removeMember(userId, label, btn) {
1734      if (!confirm("Are you sure you want to " + label.toLowerCase() + "?")) return;
1735      btn.disabled = true;
1736      try {
1737        var res = await fetch("/api/v1/root/" + activeRootId + "/remove-user", {
1738          method: "POST",
1739          headers: { "Content-Type": "application/json" },
1740          credentials: "include",
1741          body: JSON.stringify({ userReceiving: userId }),
1742        });
1743        var data = await res.json();
1744        if (!res.ok) throw new Error((data.error && data.error.message) || data.error || "Failed");
1745        if (userId === CONFIG.userId) {
1746          location.reload();
1747        } else {
1748          invitesLoaded = false;
1749          fetchInvites();
1750        }
1751      } catch (err) {
1752        btn.disabled = false;
1753        alert(err.message);
1754      }
1755    }
1756
1757    async function transferOwner(userId, btn) {
1758      if (!confirm("Transfer ownership? This cannot be undone.")) return;
1759      btn.disabled = true;
1760      try {
1761        var res = await fetch("/api/v1/root/" + activeRootId + "/transfer-owner", {
1762          method: "POST",
1763          headers: { "Content-Type": "application/json" },
1764          credentials: "include",
1765          body: JSON.stringify({ userReceiving: userId }),
1766        });
1767        var data = await res.json();
1768        if (!res.ok) throw new Error((data.error && data.error.message) || data.error || "Failed");
1769        invitesLoaded = false;
1770        fetchInvites();
1771      } catch (err) {
1772        btn.disabled = false;
1773        alert(err.message);
1774      }
1775    }
1776
1777    // Check for notifications + invites on load
1778    fetch("/chat/notifications", { credentials: "include" })
1779      .then(function(r) { return r.json(); })
1780      .then(function(d) {
1781        var di = d.data || d;
1782        if (di.notifications && di.notifications.length > 0) {
1783          notifDot.classList.add("has-notifs");
1784          document.getElementById("dreamsDot").classList.add("visible");
1785        }
1786      })
1787      .catch(function() {});
1788
1789    fetch("/chat/invites", { credentials: "include" })
1790      .then(function(r) { return r.json(); })
1791      .then(function(d) {
1792        if (d.invites && d.invites.length > 0) {
1793          notifDot.classList.add("has-notifs");
1794          document.getElementById("invitesDot").classList.add("visible");
1795        }
1796      })
1797      .catch(function() {});
1798  </script>
1799</body>
1800</html>`);
1801  } catch (err) {
1802    console.error("Error rendering /chat:", err);
1803    return res.status(500).send("Internal server error");
1804  }
1805});
1806
1807router.get("/chat/notifications", authenticateLite, async (req, res) => {
1808  try {
1809    if (!req.userId)
1810      return sendError(res, 401, ERR.UNAUTHORIZED, "Not authenticated");
1811    const rootId = req.query.rootId;
1812    const notifExt = getExtension("notifications");
1813    const getNotifications = notifExt?.exports?.getNotifications;
1814    const { notifications, total } = getNotifications
1815      ? await getNotifications({ userId: req.userId, rootId, limit: 50, sinceDays: 7 })
1816      : { notifications: [], total: 0 };
1817
1818    // Include dream config when viewing a specific tree
1819    let dreamTime = null;
1820    let isOwner = false;
1821    if (rootId) {
1822      const rootNode = await Node.findById(rootId)
1823        .select("metadata rootOwner")
1824        .lean();
1825      if (rootNode) {
1826        dreamTime = rootNode.metadata?.dreams?.dreamTime || null;
1827        isOwner = rootNode.rootOwner?.toString() === req.userId.toString();
1828      }
1829    }
1830
1831    return sendOk(res, { notifications, total, dreamTime, isOwner });
1832  } catch (err) {
1833    console.error("Chat notifications error:", err);
1834    return sendError(res, 500, ERR.INTERNAL, err.message);
1835  }
1836});
1837
1838// ── Invites + Members API for chat panel ──────────────────────────────
1839router.get("/chat/invites", authenticateLite, async (req, res) => {
1840  try {
1841    if (!req.userId)
1842      return sendError(res, 401, ERR.UNAUTHORIZED, "Not authenticated");
1843
1844    // Pending invites for this user (from team extension)
1845    const teamExt = getExtension("team")?.exports || {};
1846    const invites = teamExt.getPendingInvitesForUser
1847      ? await teamExt.getPendingInvitesForUser(req.userId)
1848      : [];
1849    const inviteList = invites.map((inv) => ({
1850      id: inv._id,
1851      from: inv.userInviting?.username
1852        ? (inv.userInviting.isRemote && inv.userInviting.homeLand
1853          ? inv.userInviting.username + "@" + inv.userInviting.homeLand
1854          : inv.userInviting.username)
1855        : "Unknown",
1856      isRemote: inv.userInviting?.isRemote || false,
1857      homeLand: inv.userInviting?.homeLand || null,
1858      treeName: inv.rootId?.name || "Unknown tree",
1859      rootId: inv.rootId?._id || inv.rootId,
1860    }));
1861
1862    // Members (only if rootId query param provided = user is in a tree)
1863    let members = null;
1864    const rootId = req.query.rootId;
1865    if (rootId) {
1866      const rootNode = await Node.findById(rootId)
1867        .populate("rootOwner", "username _id")
1868        .populate("contributors", "username _id")
1869        .select("rootOwner contributors")
1870        .lean();
1871      if (rootNode) {
1872        members = {
1873          owner: rootNode.rootOwner || null,
1874          contributors: rootNode.contributors || [],
1875          isOwner:
1876            rootNode.rootOwner?._id?.toString() === req.userId.toString(),
1877        };
1878      }
1879    }
1880
1881    return sendOk(res, { invites: inviteList, members });
1882  } catch (err) {
1883    console.error("Chat invites error:", err);
1884    return sendError(res, 500, ERR.INTERNAL, err.message);
1885  }
1886});
1887
1888router.post("/chat/invites/:inviteId", authenticateLite, async (req, res) => {
1889  try {
1890    if (!req.userId)
1891      return sendError(res, 401, ERR.UNAUTHORIZED, "Not authenticated");
1892    const { accept } = req.body;
1893    const teamExt = getExtension("team")?.exports || {};
1894    if (!teamExt.respondToInvite) {
1895      return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, "Team extension not installed");
1896    }
1897    await teamExt.respondToInvite({
1898      inviteId: req.params.inviteId,
1899      userId: req.userId,
1900      acceptInvite: accept === true || accept === "true",
1901    });
1902    return sendOk(res);
1903  } catch (err) {
1904    return sendError(res, 400, ERR.INVALID_INPUT, err.message);
1905  }
1906});
1907
1908export default router;
1909
1// routesURL/sessionManagerPartial.js
2// Exports CSS, HTML, and JS strings for the session manager view
3// embedded inside app.js viewport panel.
4
5export function dashboardCSS() {
6  return `
7    /* ── Dashboard view ─────────────────────────────────────────────── */
8    .dashboard-view {
9      display: none;
10      width: 100%;
11      height: 100%;
12      flex-direction: column;
13      overflow: hidden;
14    }
15    .dashboard-view.active { display: flex; }
16    .dashboard-view.disconnected { position: relative; pointer-events: none; }
17    .dashboard-view.disconnected::after {
18      content: "Disconnected";
19      position: absolute;
20      inset: 0;
21      display: flex;
22      align-items: center;
23      justify-content: center;
24      background: rgba(0, 0, 0, 0.5);
25      color: var(--text-muted);
26      font-size: 14px;
27      font-weight: 500;
28      letter-spacing: 0.5px;
29      z-index: 100;
30    }
31    .iframe-container.hidden { display: none; }
32
33    .dashboard-layout {
34      display: flex;
35      flex: 1;
36      overflow: hidden;
37    }
38
39    /* ── Main area ───────────────────────────────────────────────────── */
40    .dash-tree-view {
41      flex: 1;
42      display: flex;
43      flex-direction: column;
44      padding: 16px;
45      position: relative;
46      min-height: 0;
47      overflow: hidden;
48    }
49    #dashForestView {
50      flex: 1;
51      overflow: auto;
52      min-height: 0;
53    }
54    #dashTreeContent {
55      flex: 1;
56      display: flex;
57      flex-direction: column;
58      min-height: 0;
59    }
60    #dashTreeCanvas {
61      flex: 1;
62      min-height: 0;
63    }
64    .dash-tree-header {
65      display: flex;
66      align-items: center;
67      gap: 10px;
68      margin-bottom: 12px;
69      padding-bottom: 8px;
70      border-bottom: 1px solid rgba(255,255,255,0.06);
71    }
72    .dash-tree-title {
73      font-size: 14px;
74      font-weight: 600;
75      color: var(--text-secondary);
76    }
77    .dash-back-btn {
78      padding: 4px 10px;
79      border-radius: 6px;
80      border: 1px solid rgba(255,255,255,0.1);
81      background: rgba(255,255,255,0.06);
82      color: var(--text-secondary);
83      font-size: 11px;
84      cursor: pointer;
85      transition: all 0.15s;
86      white-space: nowrap;
87    }
88    .dash-back-btn:hover { background: rgba(255,255,255,0.15); color: var(--text-primary); }
89
90    .dash-close-btn {
91      margin-left: auto;
92      width: 28px; height: 28px;
93      display: flex; align-items: center; justify-content: center;
94      border-radius: 8px;
95      border: 1px solid rgba(255,255,255,0.1);
96      background: rgba(255,255,255,0.06);
97      color: var(--text-muted);
98      font-size: 16px; line-height: 1;
99      cursor: pointer;
100      transition: all 0.15s;
101      flex-shrink: 0;
102    }
103    .dash-close-btn:hover { background: rgba(255,255,255,0.15); color: var(--text-primary); }
104    .dash-close-btn:active { transform: scale(0.93); }
105
106    /* ── Raw idea processing strip ──────────────────────────────────── */
107    .raw-idea-space {
108      margin-bottom: 14px;
109      flex-shrink: 0;
110    }
111    .raw-idea-label {
112      font-size: 10px;
113      font-weight: 600;
114      text-transform: uppercase;
115      color: var(--text-muted);
116      margin-bottom: 6px;
117      letter-spacing: 0.5px;
118    }
119    .raw-idea-list {
120      display: flex;
121      gap: 8px;
122      overflow-x: auto;
123      padding-bottom: 4px;
124    }
125    .raw-idea-card {
126      display: flex;
127      align-items: center;
128      gap: 8px;
129      padding: 6px 12px;
130      border-radius: 8px;
131      background: rgba(251,191,36,0.1);
132      border: 1px solid rgba(251,191,36,0.2);
133      flex-shrink: 0;
134      cursor: pointer;
135      transition: all 0.15s;
136      max-width: 200px;
137    }
138    .raw-idea-card:hover { background: rgba(251,191,36,0.18); }
139    .raw-idea-pulse {
140      width: 8px;
141      height: 8px;
142      border-radius: 50%;
143      background: rgba(251,191,36,0.8);
144      flex-shrink: 0;
145      animation: rawPulse 1.5s ease-in-out infinite;
146    }
147    @keyframes rawPulse {
148      0%, 100% { opacity: 0.4; transform: scale(0.9); }
149      50% { opacity: 1; transform: scale(1.1); }
150    }
151    .raw-idea-desc {
152      font-size: 11px;
153      color: var(--text-secondary);
154      overflow: hidden;
155      text-overflow: ellipsis;
156      white-space: nowrap;
157    }
158
159    /* ── Forest view (grid of root trees) ──────────────────────────── */
160    .dash-forest {
161      display: grid;
162      grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
163      gap: 12px;
164      padding: 4px 0;
165    }
166    .dash-root-card {
167      display: flex;
168      flex-direction: column;
169      align-items: center;
170      gap: 6px;
171      padding: 16px 10px 12px;
172      border-radius: 10px;
173      background: rgba(255,255,255,0.05);
174      border: 1px solid rgba(255,255,255,0.08);
175      cursor: pointer;
176      transition: all 0.15s;
177      position: relative;
178      text-align: center;
179    }
180    .dash-root-card:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.15); }
181    .dash-root-card.has-sessions { border-color: rgba(16,185,129,0.3); }
182    .dash-root-icon {
183      font-size: 28px;
184      line-height: 1;
185    }
186    .dash-root-name {
187      font-size: 12px;
188      font-weight: 500;
189      color: var(--text-secondary);
190      overflow: hidden;
191      text-overflow: ellipsis;
192      white-space: nowrap;
193      max-width: 100%;
194    }
195    .dash-root-info {
196      font-size: 9px;
197      color: var(--text-muted);
198    }
199    .dash-root-badge {
200      position: absolute;
201      top: 6px;
202      right: 6px;
203      background: var(--accent);
204      color: #000;
205      font-size: 9px;
206      font-weight: 700;
207      width: 18px;
208      height: 18px;
209      border-radius: 50%;
210      display: flex;
211      align-items: center;
212      justify-content: center;
213    }
214    .dash-forest-empty {
215      grid-column: 1 / -1;
216      text-align: center;
217      padding: 40px 16px;
218      color: var(--text-muted);
219      font-size: 13px;
220    }
221    .dash-forest-empty-icon { font-size: 40px; opacity: 0.4; margin-bottom: 8px; }
222
223    /* ── Visual tree (SVG) ───────────────────────────────────────────── */
224    .vtree-container {
225      width: 100%;
226      height: 100%;
227      display: flex;
228      align-items: center;
229      justify-content: center;
230      overflow: auto;
231    }
232    .vtree-svg {
233      width: 100%;
234      height: 100%;
235      max-width: 100%;
236      max-height: 100%;
237    }
238    .vtree-node { cursor: pointer; }
239    .vtree-node:hover circle.vtree-main { filter: brightness(1.4); }
240    .vtree-highlight-ring.active {
241      stroke: var(--accent) !important;
242      stroke-width: 2.5;
243      filter: drop-shadow(0 0 8px rgba(16,185,129,0.6));
244    }
245    .vtree-tooltip {
246      position: absolute;
247      background: rgba(0,0,0,0.88);
248      color: #fff;
249      padding: 6px 10px;
250      border-radius: 6px;
251      font-size: 11px;
252      pointer-events: none;
253      z-index: 40;
254      max-width: 220px;
255      white-space: nowrap;
256      display: none;
257      box-shadow: 0 2px 8px rgba(0,0,0,0.4);
258    }
259    .vtree-tooltip.visible { display: block; }
260    .vtree-tooltip-name { font-weight: 600; }
261    .vtree-tooltip-status { opacity: 0.65; margin-left: 6px; font-size: 10px; }
262    .vtree-badge-dot {
263      stroke: none;
264      filter: drop-shadow(0 0 3px rgba(16,185,129,0.5));
265    }
266
267    /* ── Session sidebar ────────────────────────────────────────────── */
268    .session-sidebar {
269      width: 280px;
270      flex-shrink: 0;
271      border-left: 1px solid var(--glass-border-light);
272      display: flex;
273      flex-direction: column;
274      overflow: hidden;
275    }
276    .session-sidebar-header {
277      padding: 12px 16px;
278      border-bottom: 1px solid var(--glass-border-light);
279      display: flex;
280      align-items: center;
281      justify-content: space-between;
282      flex-shrink: 0;
283    }
284    .session-sidebar-header h3 {
285      font-size: 14px;
286      font-weight: 600;
287    }
288    .session-count-badge {
289      background: rgba(255,255,255,0.15);
290      padding: 2px 8px;
291      border-radius: 100px;
292      font-size: 11px;
293      font-weight: 600;
294    }
295    .session-list {
296      flex: 1;
297      overflow-y: auto;
298      padding: 8px;
299    }
300
301    /* ── Session cards ──────────────────────────────────────────────── */
302    .session-card {
303      padding: 10px 12px;
304      border-radius: 8px;
305      background: rgba(255,255,255,0.06);
306      border: 1px solid rgba(255,255,255,0.08);
307      margin-bottom: 6px;
308      transition: all 0.15s;
309      cursor: pointer;
310    }
311    .session-card:hover { background: rgba(255,255,255,0.1); }
312    .session-card.tracked {
313      border-color: var(--accent);
314      background: rgba(16, 185, 129, 0.1);
315    }
316    .session-card-header {
317      display: flex;
318      align-items: center;
319      gap: 8px;
320      margin-bottom: 6px;
321    }
322    .session-type-icon { font-size: 16px; }
323    .session-desc {
324      font-size: 12px;
325      font-weight: 500;
326      color: var(--text-secondary);
327      flex: 1;
328      overflow: hidden;
329      text-overflow: ellipsis;
330      white-space: nowrap;
331    }
332    .session-stop-btn {
333      background: none;
334      border: 1px solid rgba(239, 68, 68, 0.3);
335      color: #ef4444;
336      font-size: 10px;
337      font-weight: 600;
338      line-height: 1;
339      cursor: pointer;
340      padding: 3px 8px;
341      border-radius: 4px;
342      transition: all 0.15s;
343      flex-shrink: 0;
344      opacity: 0;
345    }
346    .session-card:hover .session-stop-btn { opacity: 1; }
347    .session-stop-btn:hover {
348      color: #fff;
349      background: rgba(239, 68, 68, 0.4);
350    }
351    .session-meta-info {
352      font-size: 10px;
353      color: var(--text-muted);
354      margin-bottom: 6px;
355    }
356    .session-actions {
357      display: flex;
358      gap: 4px;
359    }
360    .session-btn {
361      padding: 3px 8px;
362      border-radius: 4px;
363      border: 1px solid rgba(255,255,255,0.1);
364      background: rgba(255,255,255,0.06);
365      color: var(--text-secondary);
366      font-size: 10px;
367      cursor: pointer;
368      transition: all 0.15s;
369    }
370    .session-btn:hover { background: rgba(255,255,255,0.15); color: var(--text-primary); }
371    .session-btn.active { background: rgba(16,185,129,0.2); border-color: var(--accent); color: var(--accent); }
372
373    /* ── Chat slide-out panel ───────────────────────────────────────── */
374    .dashboard-chat-panel {
375      position: absolute;
376      top: 0;
377      right: 0;
378      width: 360px;
379      height: 100%;
380      background: rgba(var(--glass-rgb), 0.95);
381      backdrop-filter: blur(var(--glass-blur));
382      border-left: 1px solid var(--glass-border);
383      transform: translateX(100%);
384      transition: transform 0.25s ease;
385      z-index: 30;
386      display: flex;
387      flex-direction: column;
388      overflow: hidden;
389    }
390    .dashboard-chat-panel.open { transform: translateX(0); }
391    .dash-chat-header {
392      padding: 12px 16px;
393      border-bottom: 1px solid var(--glass-border-light);
394      display: flex;
395      flex-direction: column;
396      align-items: flex-start;
397      flex-shrink: 0;
398    }
399    .dash-chat-close {
400      background: none;
401      border: none;
402      color: var(--text-muted);
403      cursor: pointer;
404      padding: 4px;
405      font-size: 18px;
406    }
407    .dash-chat-close:hover { color: var(--text-primary); }
408    .dash-chat-kill {
409      color: #ef4444;
410      font-size: 11px;
411      font-weight: 600;
412    }
413    .dash-chat-kill:hover { color: #f87171 !important; }
414    .dash-chat-body {
415      flex: 1;
416      overflow-y: auto;
417      padding: 12px 16px;
418    }
419    .chat-message-item {
420      margin-bottom: 12px;
421      padding: 8px 10px;
422      border-radius: 8px;
423      background: rgba(255,255,255,0.05);
424      font-size: 12px;
425      color: var(--text-secondary);
426      line-height: 1.5;
427    }
428    .chat-message-role {
429      font-size: 10px;
430      font-weight: 600;
431      color: var(--text-muted);
432      margin-bottom: 4px;
433      text-transform: uppercase;
434    }
435
436    /* (dashboard toggle buttons are now in app.js chat panel) */
437
438    /* ── Mobile sessions overlay ────────────────────────────────────── */
439    .mobile-sessions-pill {
440      display: none;
441      z-index: 25;
442      align-items: center;
443      align-self: flex-start;
444      flex-shrink: 0;
445      gap: 5px;
446      padding: 4px 10px;
447      margin-bottom: 8px;
448      border-radius: 20px;
449      background: rgba(var(--glass-rgb), 0.8);
450      backdrop-filter: blur(10px);
451      border: 1px solid var(--glass-border);
452      color: var(--text-secondary);
453      cursor: pointer;
454      font-size: 12px;
455      font-weight: 500;
456      transition: all 0.15s;
457    }
458    .mobile-sessions-pill:hover { background: rgba(var(--glass-rgb), 0.95); }
459    .mobile-sessions-pill.has-activity { border-color: rgba(16,185,129,0.4); }
460    .mobile-sessions-pill .pill-count {
461      background: var(--accent);
462      color: #000;
463      font-size: 9px;
464      font-weight: 700;
465      min-width: 16px;
466      height: 16px;
467      border-radius: 50%;
468      display: flex;
469      align-items: center;
470      justify-content: center;
471      padding: 0 3px;
472    }
473    .mobile-sessions-pill .pill-label {
474      font-size: 11px;
475    }
476    .mobile-sessions-overlay {
477      display: none;
478      position: absolute;
479      top: 52px;
480      right: 8px;
481      left: 8px;
482      max-height: 70%;
483      background: rgba(var(--glass-rgb), 0.95);
484      backdrop-filter: blur(var(--glass-blur));
485      border: 1px solid var(--glass-border);
486      border-radius: 12px;
487      z-index: 35;
488      flex-direction: column;
489      overflow: hidden;
490      box-shadow: 0 8px 32px rgba(0,0,0,0.4);
491    }
492    .mobile-sessions-overlay.open { display: flex; }
493    .mobile-overlay-header {
494      padding: 10px 14px;
495      border-bottom: 1px solid var(--glass-border-light);
496      display: flex;
497      align-items: center;
498      justify-content: space-between;
499      flex-shrink: 0;
500    }
501    .mobile-overlay-header h3 { font-size: 13px; font-weight: 600; margin: 0; }
502    .mobile-overlay-close {
503      background: none;
504      border: none;
505      color: var(--text-muted);
506      cursor: pointer;
507      font-size: 18px;
508      padding: 0 2px;
509      line-height: 1;
510    }
511    .mobile-overlay-close:hover { color: var(--text-primary); }
512    .mobile-overlay-body {
513      flex: 1;
514      overflow-y: auto;
515      padding: 8px 10px 12px;
516    }
517    .mobile-sessions-scrim {
518      display: none;
519      position: absolute;
520      inset: 0;
521      z-index: 34;
522      background: rgba(0,0,0,0.3);
523    }
524    .mobile-sessions-scrim.open { display: block; }
525
526    /* ── Mobile adjustments ─────────────────────────────────────────── */
527    @media (max-width: 768px) {
528      .dashboard-layout { flex-direction: column; }
529      .session-sidebar { display: none; }
530      .dashboard-chat-panel { width: 100%; }
531      .mobile-sessions-pill { display: flex; }
532      .dash-forest { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; }
533    }
534  `;
535}
536
537export function dashboardHTML() {
538  return `
539    <div class="dashboard-view" id="dashboardView">
540      <div class="dashboard-layout">
541        <div class="dash-tree-view" id="dashTreeView">
542
543          <!-- Mobile sessions (inline row, not floating) -->
544          <button class="mobile-sessions-pill" id="mobileSessionsPill">
545            <span class="pill-label">Sessions</span>
546            <span class="pill-count" id="mobileSessionsPillCount">0</span>
547          </button>
548          <div class="mobile-sessions-scrim" id="mobileSessionsScrim"></div>
549          <div class="mobile-sessions-overlay" id="mobileSessionsOverlay">
550            <div class="mobile-overlay-header">
551              <h3>Sessions <span class="session-count-badge" id="dashSessionCountMobile">0</span></h3>
552              <button class="mobile-overlay-close" id="mobileSessionsClose">&times;</button>
553            </div>
554            <div class="mobile-overlay-body" id="mobileSessionsList"></div>
555          </div>
556
557          <!-- Raw ideas being processed (visible when any exist) -->
558          <div class="raw-idea-space" id="rawIdeaSpace" style="display:none">
559            <div class="raw-idea-label">Processing</div>
560            <div class="raw-idea-list" id="rawIdeaList"></div>
561          </div>
562
563          <!-- Forest view — all root trees (default) -->
564          <div id="dashForestView">
565            <div class="dash-tree-header">
566              <span class="dash-tree-title">Your Trees</span>
567              <button class="dash-close-btn" id="dashCloseBtn1" title="Close dashboard">&times;</button>
568            </div>
569            <div class="dash-forest" id="dashForestGrid"></div>
570          </div>
571
572          <!-- Single tree view (shown when a root is selected) -->
573          <div id="dashTreeContent" style="display:none">
574            <div class="dash-tree-header">
575              <button class="dash-back-btn" id="dashBackBtn">&larr; All Trees</button>
576              <span class="dash-tree-title" id="dashTreeTitle">Tree</span>
577              <button class="dash-close-btn" id="dashCloseBtn2" title="Close dashboard">&times;</button>
578            </div>
579            <div id="dashTreeCanvas"></div>
580          </div>
581
582          <div class="vtree-tooltip" id="vtreeTooltip">
583            <span class="vtree-tooltip-name" id="vtreeTooltipName"></span>
584            <span class="vtree-tooltip-status" id="vtreeTooltipStatus"></span>
585          </div>
586        </div>
587        <div class="session-sidebar" id="sessionSidebar">
588          <div class="session-sidebar-header">
589            <h3>Sessions</h3>
590            <span class="session-count-badge" id="dashSessionCount">0</span>
591          </div>
592          <div class="session-list" id="dashSessionList"></div>
593        </div>
594      </div>
595      <div class="dashboard-chat-panel" id="dashChatPanel">
596        <div class="dash-chat-header">
597          <span id="dashChatTitle" style="font-size:13px;font-weight:600">Messages</span>
598          <div class="dash-chat-controls" style="display:flex;gap:6px;align-items:center;margin-top:8px">
599            <button class="dash-chat-close" id="dashChatClose" title="Back">&larr;</button>
600            <button class="dash-chat-close" id="dashChatRefresh" title="Refresh">&#x21BB;</button>
601            <button class="dash-chat-close dash-chat-kill" id="dashChatKill" title="Kill session">Kill</button>
602          </div>
603        </div>
604        <div class="dash-chat-body" id="dashChatBody"></div>
605      </div>
606    </div>
607  `;
608}
609
610export function dashboardJS() {
611  return `
612    // ══════════════════════════════════════════════════════════════════
613    // SESSION DASHBOARD
614    // ══════════════════════════════════════════════════════════════════
615
616    (function() {
617      var dashboardActive = false;
618      var dashMode = "forest";       // "forest" or "tree"
619      var dashSessions = [];
620      var dashRoots = [];
621      var dashTrackedSessionId = null;
622      var dashTrackedNavRootId = null;  // last rootId we auto-navigated to for tracked session
623      var dashCurrentRootId = null;
624      var dashTreeData = null;
625      var dashSelfSessionId = null;
626      var dashActiveNavigatorId = null;
627
628      var desktopDashboardBtn = document.getElementById("desktopDashboardBtn");
629      var iframeContainer = document.getElementById("iframeContainer");
630      var dashboardView = document.getElementById("dashboardView");
631      var dashForestView = document.getElementById("dashForestView");
632      var dashForestGrid = document.getElementById("dashForestGrid");
633      var dashTreeContent = document.getElementById("dashTreeContent");
634      var dashTreeCanvas = document.getElementById("dashTreeCanvas");
635      var dashTreeTitle = document.getElementById("dashTreeTitle");
636      var dashBackBtn = document.getElementById("dashBackBtn");
637      var rawIdeaSpace = document.getElementById("rawIdeaSpace");
638      var rawIdeaList = document.getElementById("rawIdeaList");
639      var dashSessionList = document.getElementById("dashSessionList");
640      var dashSessionCount = document.getElementById("dashSessionCount");
641      var dashChatPanel = document.getElementById("dashChatPanel");
642      var dashChatBody = document.getElementById("dashChatBody");
643      var dashChatTitle = document.getElementById("dashChatTitle");
644      var vtreeTooltip = document.getElementById("vtreeTooltip");
645      var vtreeTooltipName = document.getElementById("vtreeTooltipName");
646      var vtreeTooltipStatus = document.getElementById("vtreeTooltipStatus");
647      var dashTreeView = document.getElementById("dashTreeView");
648      var mobileSessionsPill = document.getElementById("mobileSessionsPill");
649      var mobileSessionsPillCount = document.getElementById("mobileSessionsPillCount");
650      var mobileSessionsOverlay = document.getElementById("mobileSessionsOverlay");
651      var mobileSessionsScrim = document.getElementById("mobileSessionsScrim");
652      var mobileSessionsClose = document.getElementById("mobileSessionsClose");
653      var mobileSessionsList = document.getElementById("mobileSessionsList");
654      var dashSessionCountMobile = document.getElementById("dashSessionCountMobile");
655      var mobileDashboardBtn = document.getElementById("mobileDashboardBtn");
656
657      // ── Disconnected state ──────────────────────────────────────
658      socket.on("disconnect", function() {
659        if (dashboardView) dashboardView.classList.add("disconnected");
660      });
661      socket.on("connect", function() {
662        if (dashboardView) dashboardView.classList.remove("disconnected");
663      });
664
665      var DASH_SESSION_ICONS = {
666        "websocket-chat": "\\u{1F4AC}",
667        "api-tree-chat": "\\u{1F333}",
668        "api-tree-place": "\\u{1F4CC}",
669        "raw-idea-orchestrate": "\\u{1F4A1}",
670        "raw-idea-chat": "\\u{1F4A1}",
671        "understanding-orchestrate": "\\u{1F9E0}",
672        "scheduled-raw-idea": "\\u{23F0}"
673      };
674
675      function dashEscape(str) {
676        return String(str || "").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
677      }
678      function dashTimeAgo(ts) {
679        if (!ts) return "";
680        var diff = Date.now() - ts;
681        if (diff < 60000) return "just now";
682        if (diff < 3600000) return Math.floor(diff / 60000) + "m ago";
683        return Math.floor(diff / 3600000) + "h ago";
684      }
685      function dashTruncate(str, len) {
686        if (!str) return "";
687        return str.length > len ? str.slice(0, len) + "..." : str;
688      }
689
690      // ── Toggle ────────────────────────────────────────────────────
691      function toggleDashboard() {
692        dashboardActive = !dashboardActive;
693        if (desktopDashboardBtn) desktopDashboardBtn.classList.toggle("active", dashboardActive);
694        if (mobileDashboardBtn) mobileDashboardBtn.classList.toggle("active", dashboardActive);
695        iframeContainer.classList.toggle("hidden", dashboardActive);
696        dashboardView.classList.toggle("active", dashboardActive);
697        if (dashboardActive) {
698          socket.emit("getDashboardSessions");
699          socket.emit("getDashboardRoots");
700        }
701      }
702
703      if (desktopDashboardBtn) desktopDashboardBtn.addEventListener("click", toggleDashboard);
704      if (mobileDashboardBtn) mobileDashboardBtn.addEventListener("click", function(e) {
705        e.stopPropagation();
706        toggleDashboard();
707      });
708
709      // Close buttons inside the dashboard view
710      var dashCloseBtn1 = document.getElementById("dashCloseBtn1");
711      var dashCloseBtn2 = document.getElementById("dashCloseBtn2");
712      if (dashCloseBtn1) dashCloseBtn1.addEventListener("click", function() { if (dashboardActive) toggleDashboard(); });
713      if (dashCloseBtn2) dashCloseBtn2.addEventListener("click", function() { if (dashboardActive) toggleDashboard(); });
714
715      // Expose closeDashboard so app.js goHome() can dismiss it
716      if (window.TreeApp) {
717        window.TreeApp.closeDashboard = function() {
718          if (dashboardActive) toggleDashboard();
719        };
720      }
721
722      // ── Mode switching ──────────────────────────────────────────
723      function enterTreeMode(rootId) {
724        dashMode = "tree";
725        dashCurrentRootId = rootId;
726        dashTreeData = null;
727        dashForestView.style.display = "none";
728        dashTreeContent.style.display = "";
729        dashTreeCanvas.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-muted)">Loading tree...</div>';
730        dashTreeTitle.textContent = "Loading...";
731        socket.emit("getDashboardTree", { rootId: rootId });
732        renderDashSessions();
733      }
734
735      function exitTreeMode() {
736        dashMode = "forest";
737        dashCurrentRootId = null;
738        dashTreeData = null;
739        dashForestView.style.display = "";
740        dashTreeContent.style.display = "none";
741        renderForest();
742        renderDashSessions();
743      }
744
745      dashBackBtn.addEventListener("click", exitTreeMode);
746
747      // ── Roots (forest) ──────────────────────────────────────────
748      socket.on("dashboardRoots", function(data) {
749        if (!data) return;
750        dashRoots = data.roots || [];
751        if (dashMode === "forest") renderForest();
752      });
753
754      function renderForest() {
755        if (dashRoots.length === 0) {
756          dashForestGrid.innerHTML = '<div class="dash-forest-empty">'
757            + '<div class="dash-forest-empty-icon">\\u{1F331}</div>'
758            + '<p>No trees yet</p></div>';
759          return;
760        }
761
762        var html = "";
763        for (var i = 0; i < dashRoots.length; i++) {
764          var r = dashRoots[i];
765          // Count sessions on this root
766          var count = 0;
767          for (var j = 0; j < dashSessions.length; j++) {
768            if (dashSessions[j].meta && dashSessions[j].meta.rootId === r.id) count++;
769          }
770          var sizeLabel = r.childCount === 0 ? "seedling" : r.childCount <= 3 ? "sapling" : r.childCount <= 10 ? "growing" : "mature";
771          var treeIcon = r.childCount === 0 ? "\\u{1F331}" : r.childCount <= 3 ? "\\u{1F33F}" : r.childCount <= 10 ? "\\u{1F333}" : "\\u{1F332}";
772
773          html += '<div class="dash-root-card' + (count > 0 ? " has-sessions" : "") + '" data-root-id="' + r.id + '">'
774            + (count > 0 ? '<span class="dash-root-badge">' + count + '</span>' : '')
775            + '<div class="dash-root-icon">' + treeIcon + '</div>'
776            + '<div class="dash-root-name">' + dashEscape(r.name) + '</div>'
777            + '<div class="dash-root-info">' + sizeLabel + '</div>'
778            + '</div>';
779        }
780        dashForestGrid.innerHTML = html;
781      }
782
783      // Click root card → enter tree mode
784      dashForestGrid.addEventListener("click", function(e) {
785        var card = e.target.closest("[data-root-id]");
786        if (!card) return;
787        enterTreeMode(card.getAttribute("data-root-id"));
788      });
789
790      // ── Raw ideas ───────────────────────────────────────────────
791      function renderRawIdeas() {
792        var rawSessions = [];
793        for (var i = 0; i < dashSessions.length; i++) {
794          var s = dashSessions[i];
795          var isRaw = s.type === "raw-idea-orchestrate" || s.type === "raw-idea-chat" || s.type === "scheduled-raw-idea";
796          var noTree = !s.meta || !s.meta.rootId;
797          if (isRaw && noTree) rawSessions.push(s);
798        }
799
800        if (rawSessions.length === 0) {
801          rawIdeaSpace.style.display = "none";
802          return;
803        }
804        rawIdeaSpace.style.display = "";
805
806        var html = "";
807        for (var i = 0; i < rawSessions.length; i++) {
808          var s = rawSessions[i];
809          var desc = dashEscape(s.description || "Raw idea");
810          html += '<div class="raw-idea-card" data-raw-sid="' + s.sessionId + '">'
811            + '<span class="raw-idea-pulse"></span>'
812            + '<span class="raw-idea-desc">' + desc + '</span>'
813            + '</div>';
814        }
815        rawIdeaList.innerHTML = html;
816      }
817
818      // Click raw idea → track it (auto-follow when it gets placed)
819      rawIdeaList.addEventListener("click", function(e) {
820        var card = e.target.closest("[data-raw-sid]");
821        if (!card) return;
822        var sid = card.getAttribute("data-raw-sid");
823        dashTrackedSessionId = sid;
824        renderDashSessions();
825      });
826
827      // ── Sessions ────────────────────────────────────────────────
828      socket.on("dashboardSessions", function(data) {
829        if (!data) return;
830        dashSessions = data.sessions || [];
831        if (data.selfSessionId) dashSelfSessionId = data.selfSessionId;
832        dashActiveNavigatorId = data.activeNavigatorId || null;
833
834        renderRawIdeas();
835        renderDashSessions();
836
837        // Auto-follow tracked session — only navigate when rootId first appears or changes
838        if (dashTrackedSessionId) {
839          var tracked = null;
840          for (var i = 0; i < dashSessions.length; i++) {
841            if (dashSessions[i].sessionId === dashTrackedSessionId) { tracked = dashSessions[i]; break; }
842          }
843          if (!tracked) {
844            dashTrackedSessionId = null;
845            dashTrackedNavRootId = null;
846            renderDashSessions();
847          } else if (tracked.meta && tracked.meta.rootId && tracked.meta.rootId !== dashTrackedNavRootId) {
848            // Session got a NEW rootId — close dashboard; server handles navigation via emitNavigate
849            dashTrackedNavRootId = tracked.meta.rootId;
850            if (dashboardActive) toggleDashboard();
851          }
852        }
853
854        // Update forest badges if in forest mode
855        if (dashMode === "forest") renderForest();
856        // Update tree highlights if in tree mode
857        if (dashMode === "tree") updateDashHighlights();
858      });
859
860      function renderDashSessions() {
861        // Filter sessions based on mode
862        var filtered;
863        if (dashMode === "tree" && dashCurrentRootId) {
864          filtered = [];
865          for (var i = 0; i < dashSessions.length; i++) {
866            if (dashSessions[i].meta && dashSessions[i].meta.rootId === dashCurrentRootId) {
867              filtered.push(dashSessions[i]);
868            }
869          }
870        } else {
871          filtered = dashSessions;
872        }
873
874        dashSessionCount.textContent = filtered.length;
875
876        if (filtered.length === 0) {
877          var emptyMsg = dashMode === "tree"
878            ? "No sessions on this tree"
879            : "No active sessions";
880          dashSessionList.innerHTML = '<div style="padding:16px;text-align:center;color:var(--text-muted);font-size:12px">' + emptyMsg + '</div>';
881          syncMobileSessions("", 0);
882          return;
883        }
884
885        var html = "";
886        for (var i = 0; i < filtered.length; i++) {
887          var s = filtered[i];
888          var isTracked = s.sessionId === dashTrackedSessionId;
889          var icon = DASH_SESSION_ICONS[s.type] || "\\u{1F527}";
890          var desc = dashEscape(s.description || s.type);
891          var hasRoot = s.meta && s.meta.rootId;
892          var ago = dashTimeAgo(s.lastActivity);
893
894          // Resolve location label: tree name or "Home"
895          var locationLabel;
896          if (hasRoot) {
897            var rootName = null;
898            for (var k = 0; k < dashRoots.length; k++) {
899              if (dashRoots[k].id === s.meta.rootId) { rootName = dashRoots[k].name; break; }
900            }
901            locationLabel = "Tree: " + dashEscape(rootName || s.meta.rootId.slice(0, 8));
902          } else {
903            locationLabel = "Home";
904          }
905
906          // Follow button logic: follow sets navigator, detach clears it
907          var trackBtn = "";
908          if (isTracked) {
909            trackBtn = '<button class="session-btn active" data-action="track" data-sid="' + s.sessionId + '">\\u{1F4CD} Detach</button>';
910          } else {
911            trackBtn = '<button class="session-btn" data-action="track" data-sid="' + s.sessionId + '">\\u{1F3AF} Follow</button>';
912          }
913
914          html += '<div class="session-card ' + (isTracked ? "tracked" : "") + '" data-sid="' + s.sessionId + '"'
915            + (hasRoot ? ' data-root="' + s.meta.rootId + '"' : '') + '>'
916            + '<div class="session-card-header">'
917            + '<span class="session-type-icon">' + icon + '</span>'
918            + '<span class="session-desc">' + desc + '</span>'
919            + '<button class="session-stop-btn" data-action="stop" data-sid="' + s.sessionId + '" title="Kill session">Kill</button>'
920            + '</div>'
921            + '<div class="session-meta-info">' + locationLabel + ' \\u00B7 ' + ago + '</div>'
922            + '<div class="session-actions">'
923            + trackBtn
924            + '<button class="session-btn" data-action="chat" data-sid="' + s.sessionId + '">\\u{1F4AC} Messages</button>'
925            + '</div>'
926            + '</div>';
927        }
928        dashSessionList.innerHTML = html;
929        syncMobileSessions(html, filtered.length);
930      }
931
932      function syncMobileSessions(html, count) {
933        var n = count !== undefined ? count : dashSessions.length;
934        if (mobileSessionsList) mobileSessionsList.innerHTML = html || dashSessionList.innerHTML;
935        if (mobileSessionsPillCount) mobileSessionsPillCount.textContent = n;
936        if (dashSessionCountMobile) dashSessionCountMobile.textContent = n;
937        if (mobileSessionsPill) mobileSessionsPill.classList.toggle("has-activity", n > 1);
938      }
939
940      // Event delegation for session cards + buttons
941      function handleSessionClick(e) {
942        // Button actions first
943        var btn = e.target.closest("[data-action]");
944        if (btn) {
945          e.stopPropagation();
946          var action = btn.getAttribute("data-action");
947          var sid = btn.getAttribute("data-sid");
948          if (action === "track") toggleTrack(sid);
949          else if (action === "chat") dashViewChat(sid);
950          else if (action === "stop") stopSession(sid);
951          return;
952        }
953        // Click on card body → navigate to session's tree
954        var card = e.target.closest("[data-sid]");
955        if (!card) return;
956        var rootId = card.getAttribute("data-root");
957        if (rootId) {
958          enterTreeMode(rootId);
959        }
960      }
961
962      dashSessionList.addEventListener("click", handleSessionClick);
963
964      function toggleTrack(sessionId) {
965        if (dashTrackedSessionId === sessionId) {
966          dashTrackedSessionId = null;
967          dashTrackedNavRootId = null;
968          socket.emit("detachNavigator");
969        } else {
970          dashTrackedSessionId = sessionId;
971          dashTrackedNavRootId = null;
972          socket.emit("attachNavigator", { sessionId: sessionId });
973          var s = null;
974          for (var i = 0; i < dashSessions.length; i++) {
975            if (dashSessions[i].sessionId === sessionId) { s = dashSessions[i]; break; }
976          }
977          if (s && s.meta && s.meta.rootId) {
978            // Close dashboard; server handles navigation via emitNavigate
979            dashTrackedNavRootId = s.meta.rootId;
980            if (dashboardActive) toggleDashboard();
981          }
982          // If no rootId (Home), stay on dashboard
983        }
984        renderDashSessions();
985      }
986
987      function stopSession(sessionId) {
988        if (!confirm("Stop this session?")) return;
989        // If stopping the tracked session, detach first
990        if (dashTrackedSessionId === sessionId) {
991          dashTrackedSessionId = null;
992          dashTrackedNavRootId = null;
993          socket.emit("detachNavigator");
994        }
995        socket.emit("stopSession", { sessionId: sessionId });
996      }
997
998      // ── Tree loading ──────────────────────────────────────────────
999      socket.on("dashboardTreeData", function(data) {
1000        if (!data || data.rootId !== dashCurrentRootId) return;
1001        if (data.error) {
1002          dashTreeCanvas.innerHTML = '<div style="color:var(--error);padding:16px">' + dashEscape(data.error.message || data.error) + '</div>';
1003          return;
1004        }
1005        dashTreeData = data.tree;
1006        renderDashTree();
1007        updateDashHighlights();
1008      });
1009
1010      // ── Visual tree helpers ───────────────────────────────────────
1011      function vtreeCount(node) {
1012        var c = 1;
1013        if (node.children) for (var i = 0; i < node.children.length; i++) c += vtreeCount(node.children[i]);
1014        return c;
1015      }
1016      function vtreeMaxDepth(node, d) {
1017        d = d || 0;
1018        if (!node.children || !node.children.length) return d;
1019        var mx = d;
1020        for (var i = 0; i < node.children.length; i++) {
1021          var cd = vtreeMaxDepth(node.children[i], d + 1);
1022          if (cd > mx) mx = cd;
1023        }
1024        return mx;
1025      }
1026      function vtreeWidth(node) {
1027        if (!node.children || !node.children.length) return 1;
1028        var w = 0;
1029        for (var i = 0; i < node.children.length; i++) w += vtreeWidth(node.children[i]);
1030        return w;
1031      }
1032
1033      function buildVisualTree(treeData) {
1034        var total = vtreeCount(treeData);
1035        var maxD = vtreeMaxDepth(treeData);
1036
1037        var nodeR, fontSize, branchBase;
1038        if (total <= 5) { nodeR = 22; fontSize = 11; branchBase = 6; }
1039        else if (total <= 15) { nodeR = 15; fontSize = 10; branchBase = 4.5; }
1040        else if (total <= 40) { nodeR = 11; fontSize = 9; branchBase = 3; }
1041        else { nodeR = 7; fontSize = 0; branchBase = 2; }
1042
1043        var hSpace = total <= 5 ? 100 : total <= 15 ? 65 : total <= 40 ? 45 : 30;
1044        var vSpace = total <= 5 ? 110 : total <= 15 ? 80 : total <= 40 ? 58 : 44;
1045
1046        var nodes = [];
1047        function place(node, depth, xL, xR, pid) {
1048          var x = (xL + xR) / 2;
1049          var isLeaf = !node.children || !node.children.length;
1050          nodes.push({ id: node.id, name: node.name, status: node.status || "active", prestige: 0 || 0, x: x, depth: depth, pid: pid, isLeaf: isLeaf });
1051          if (!isLeaf) {
1052            var tw = vtreeWidth(node);
1053            var cur = xL;
1054            for (var i = 0; i < node.children.length; i++) {
1055              var cw = vtreeWidth(node.children[i]);
1056              var cR = cur + (xR - xL) * (cw / tw);
1057              place(node.children[i], depth + 1, cur, cR, node.id);
1058              cur = cR;
1059            }
1060          }
1061        }
1062        var treeW = vtreeWidth(treeData);
1063        place(treeData, 0, 0, treeW, null);
1064
1065        var pad = nodeR * 3 + 15;
1066        var trunkH = total <= 5 ? 40 : 25;
1067        var svgW = treeW * hSpace + pad * 2;
1068        var svgH = (maxD + 1) * vSpace + pad * 2 + trunkH;
1069
1070        for (var i = 0; i < nodes.length; i++) {
1071          nodes[i].sx = nodes[i].x * hSpace + pad;
1072          nodes[i].sy = pad + (maxD - nodes[i].depth) * vSpace;
1073        }
1074
1075        var rootN = nodes[0];
1076        var groundY = rootN.sy + trunkH + 8;
1077
1078        var s = '<svg class="vtree-svg" viewBox="0 0 ' + svgW + ' ' + svgH + '" preserveAspectRatio="xMidYMid meet">';
1079        s += '<ellipse cx="' + (svgW / 2) + '" cy="' + groundY + '" rx="' + Math.min(svgW * 0.35, 120) + '" ry="6" fill="rgba(139,90,43,0.12)"/>';
1080        s += '<line x1="' + rootN.sx + '" y1="' + rootN.sy + '" x2="' + rootN.sx + '" y2="' + (rootN.sy + trunkH) + '"'
1081          + ' stroke="rgba(139,90,43,0.55)" stroke-width="' + (branchBase * 1.8) + '" stroke-linecap="round"/>';
1082
1083        for (var i = 0; i < nodes.length; i++) {
1084          var n = nodes[i];
1085          if (n.pid === null) continue;
1086          var par = null;
1087          for (var j = 0; j < nodes.length; j++) { if (nodes[j].id === n.pid) { par = nodes[j]; break; } }
1088          if (!par) continue;
1089          var bw = Math.max(1, branchBase - n.depth * 0.6);
1090          var cpY = (par.sy + n.sy) / 2;
1091          s += '<path class="vtree-branch" d="M' + par.sx + ',' + par.sy + ' C' + par.sx + ',' + cpY + ' ' + n.sx + ',' + cpY + ' ' + n.sx + ',' + n.sy + '"'
1092            + ' fill="none" stroke="rgba(139,90,43,0.3)" stroke-width="' + bw + '" stroke-linecap="round"/>';
1093        }
1094
1095        for (var i = 0; i < nodes.length; i++) {
1096          var n = nodes[i];
1097          var fill;
1098          if (n.status === "trimmed") fill = "rgba(120,120,120,0.45)";
1099          else if (n.status === "completed") fill = "rgba(234,179,8,0.65)";
1100          else if (n.isLeaf) fill = "rgba(34,197,94,0.75)";
1101          else fill = "rgba(16,185,129,0.55)";
1102          var r = (n.pid === null) ? nodeR * 1.2 : nodeR;
1103
1104          s += '<g class="vtree-node" data-node-id="' + n.id + '" data-name="' + dashEscape(n.name) + '" data-status="' + n.status + '" data-prestige="' + n.prestige + '">';
1105          s += '<circle class="vtree-highlight-ring" cx="' + n.sx + '" cy="' + n.sy + '" r="' + (r + 5) + '" fill="none" stroke="transparent" stroke-width="2"/>';
1106          s += '<circle class="vtree-main" cx="' + n.sx + '" cy="' + n.sy + '" r="' + r + '" fill="' + fill + '" stroke="rgba(255,255,255,0.15)" stroke-width="1"/>';
1107          if (fontSize > 0) {
1108            var lbl = n.name.length > 14 ? n.name.slice(0, 12) + ".." : n.name;
1109            s += '<text x="' + n.sx + '" y="' + (n.sy - r - 6) + '" text-anchor="middle" fill="var(--text-secondary)" font-size="' + fontSize + '" style="pointer-events:none">' + dashEscape(lbl) + '</text>';
1110          }
1111          s += '</g>';
1112        }
1113
1114        s += '</svg>';
1115        return s;
1116      }
1117
1118      function renderDashTree() {
1119        if (!dashTreeData) return;
1120        dashTreeTitle.textContent = dashEscape(dashTreeData.name);
1121        dashTreeCanvas.innerHTML = '<div class="vtree-container">' + buildVisualTree(dashTreeData) + '</div>';
1122      }
1123
1124      // ── Tooltip ─────────────────────────────────────────────────────
1125      dashTreeCanvas.addEventListener("mouseover", function(e) {
1126        var g = e.target.closest(".vtree-node");
1127        if (!g) return;
1128        vtreeTooltipName.textContent = g.getAttribute("data-name");
1129        vtreeTooltipStatus.textContent = g.getAttribute("data-status");
1130        vtreeTooltip.classList.add("visible");
1131      });
1132      dashTreeCanvas.addEventListener("mousemove", function(e) {
1133        if (!vtreeTooltip.classList.contains("visible")) return;
1134        var rect = dashTreeView.getBoundingClientRect();
1135        vtreeTooltip.style.left = (e.clientX - rect.left + 14) + "px";
1136        vtreeTooltip.style.top = (e.clientY - rect.top - 32) + "px";
1137      });
1138      dashTreeCanvas.addEventListener("mouseout", function(e) {
1139        if (e.target.closest(".vtree-node")) vtreeTooltip.classList.remove("visible");
1140      });
1141
1142      // ── Click tree node → navigate iframe ──────────────────────────
1143      dashTreeCanvas.addEventListener("click", function(e) {
1144        var g = e.target.closest(".vtree-node");
1145        if (!g) return;
1146        var nodeId = g.getAttribute("data-node-id");
1147        var prestige = g.getAttribute("data-prestige") || "0";
1148        if (!nodeId) return;
1149        // Switch back to iframe and navigate to this node
1150        if (dashboardActive) toggleDashboard();
1151        if (window.TreeApp && window.TreeApp.navigate) {
1152          window.TreeApp.navigate("/api/v1/node/" + nodeId + "/" + prestige + "?html");
1153        }
1154      });
1155
1156      // ── Node highlighting (SVG) ────────────────────────────────────
1157      function updateDashHighlights() {
1158        var rings = document.querySelectorAll(".vtree-highlight-ring.active");
1159        for (var i = 0; i < rings.length; i++) rings[i].classList.remove("active");
1160        var dots = document.querySelectorAll(".vtree-badge-dot");
1161        for (var i = 0; i < dots.length; i++) dots[i].remove();
1162
1163        for (var j = 0; j < dashSessions.length; j++) {
1164          var s = dashSessions[j];
1165          if (!s.meta || !s.meta.nodeId) continue;
1166          var g = document.querySelector('.vtree-node[data-node-id="' + s.meta.nodeId + '"]');
1167          if (!g) continue;
1168          var ring = g.querySelector(".vtree-highlight-ring");
1169          if (ring) ring.classList.add("active");
1170          var main = g.querySelector(".vtree-main");
1171          if (main) {
1172            var cx = parseFloat(main.getAttribute("cx"));
1173            var cy = parseFloat(main.getAttribute("cy"));
1174            var r = parseFloat(main.getAttribute("r"));
1175            var badge = document.createElementNS("http://www.w3.org/2000/svg", "circle");
1176            badge.setAttribute("class", "vtree-badge-dot");
1177            badge.setAttribute("cx", String(cx + r + 4));
1178            badge.setAttribute("cy", String(cy - r + 2));
1179            badge.setAttribute("r", "4");
1180            badge.setAttribute("fill", "rgba(16,185,129,0.9)");
1181            g.appendChild(badge);
1182          }
1183        }
1184      }
1185
1186      // ── Tree change live updates ──────────────────────────────────
1187      socket.on("dashboardTreeChanged", function(data) {
1188        if (dashCurrentRootId) {
1189          socket.emit("getDashboardTree", { rootId: dashCurrentRootId });
1190        }
1191      });
1192
1193      // ── Messages panel ─────────────────────────────────────────────
1194      var dashChatCurrentSid = null;
1195
1196      document.getElementById("dashChatClose").addEventListener("click", function() {
1197        dashChatPanel.classList.remove("open");
1198        dashChatCurrentSid = null;
1199      });
1200
1201      document.getElementById("dashChatRefresh").addEventListener("click", function() {
1202        if (!dashChatCurrentSid) return;
1203        dashChatBody.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-muted)">Loading...</div>';
1204        socket.emit("getDashboardChats", { sessionId: dashChatCurrentSid });
1205      });
1206
1207      document.getElementById("dashChatKill").addEventListener("click", function() {
1208        if (!dashChatCurrentSid) return;
1209        stopSession(dashChatCurrentSid);
1210        dashChatPanel.classList.remove("open");
1211        dashChatCurrentSid = null;
1212      });
1213
1214      function dashViewChat(sessionId) {
1215        dashChatCurrentSid = sessionId;
1216        dashChatPanel.classList.add("open");
1217        dashChatBody.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-muted)">Loading...</div>';
1218        dashChatTitle.textContent = "Messages \\u2014 " + sessionId.slice(0, 8);
1219        socket.emit("getDashboardChats", { sessionId: sessionId });
1220      }
1221
1222      socket.on("dashboardChats", function(data) {
1223        if (!data) return;
1224        if (data.error) {
1225          dashChatBody.innerHTML = '<div style="color:var(--error)">Error loading chats</div>';
1226          return;
1227        }
1228        var chats = data.chats;
1229        if (!chats || chats.length === 0) {
1230          dashChatBody.innerHTML = '<div style="color:var(--text-muted);padding:16px;text-align:center">No chat records for this session</div>';
1231          return;
1232        }
1233        var html = "";
1234        for (var i = 0; i < chats.length; i++) {
1235          var c = chats[i];
1236          var source = (c.startMessage && c.startMessage.source) || "user";
1237          var path = (c.aiContext && c.aiContext.zone && c.aiContext.mode) ? c.aiContext.zone + ":" + c.aiContext.mode : "?";
1238          var userMsg = dashEscape(dashTruncate((c.startMessage && c.startMessage.content) || "", 300));
1239          var aiMsg = (c.endMessage && c.endMessage.content)
1240            ? '<div style="margin-top:6px;border-top:1px solid rgba(255,255,255,0.06);padding-top:6px">' + dashEscape(dashTruncate(c.endMessage.content, 500)) + '</div>'
1241            : '';
1242          html += '<div class="chat-message-item">'
1243            + '<div class="chat-message-role">' + dashEscape(source) + ' \\u2192 ' + dashEscape(path) + '</div>'
1244            + '<div>' + userMsg + '</div>'
1245            + aiMsg
1246            + '</div>';
1247        }
1248        dashChatBody.innerHTML = html;
1249      });
1250
1251      // ── Mobile sessions ──────────────────────────────────────────
1252      function closeMobileOverlay() {
1253        if (mobileSessionsOverlay) mobileSessionsOverlay.classList.remove("open");
1254        if (mobileSessionsScrim) mobileSessionsScrim.classList.remove("open");
1255      }
1256      function openMobileOverlay() {
1257        if (mobileSessionsOverlay) mobileSessionsOverlay.classList.add("open");
1258        if (mobileSessionsScrim) mobileSessionsScrim.classList.add("open");
1259      }
1260
1261      if (mobileSessionsPill) {
1262        mobileSessionsPill.addEventListener("click", function() {
1263          var isOpen = mobileSessionsOverlay && mobileSessionsOverlay.classList.contains("open");
1264          if (isOpen) closeMobileOverlay();
1265          else openMobileOverlay();
1266        });
1267      }
1268      if (mobileSessionsClose) {
1269        mobileSessionsClose.addEventListener("click", closeMobileOverlay);
1270      }
1271      if (mobileSessionsScrim) {
1272        mobileSessionsScrim.addEventListener("click", closeMobileOverlay);
1273      }
1274      if (mobileSessionsList) {
1275        mobileSessionsList.addEventListener("click", function(e) {
1276          var btn = e.target.closest("[data-action]");
1277          if (btn) {
1278            var action = btn.getAttribute("data-action");
1279            var sid = btn.getAttribute("data-sid");
1280            if (action === "track") toggleTrack(sid);
1281            else if (action === "chat") dashViewChat(sid);
1282            else if (action === "stop") { stopSession(sid); }
1283            closeMobileOverlay();
1284            return;
1285          }
1286          var card = e.target.closest("[data-sid]");
1287          if (card) {
1288            var rootId = card.getAttribute("data-root");
1289            if (rootId) enterTreeMode(rootId);
1290            closeMobileOverlay();
1291          }
1292        });
1293      }
1294
1295    })();
1296  `;
1297}
1298
1// routesURL/setup.js
2// First-time onboarding: connect LLM, then go to chat.
3// Sprout handles tree creation from conversation. No domain picker.
4
5import log from "../../../seed/log.js";
6import { sendError, ERR } from "../../../seed/protocol.js";
7import express from "express";
8import authenticateLite from "../../html-rendering/authenticateLite.js";
9import User from "../../../seed/models/user.js";
10import LlmConnection from "../../../seed/models/llmConnection.js";
11import { renderSetup } from "./setupPage.js";
12import { isHtmlEnabled } from "../../html-rendering/config.js";
13
14const router = express.Router();
15
16router.get("/setup", authenticateLite, async (req, res) => {
17  try {
18    if (!isHtmlEnabled()) {
19      return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, "Server-rendered HTML is disabled. Use the SPA frontend.");
20    }
21
22    if (!req.userId) {
23      return res.redirect("/login?redirect=/setup");
24    }
25
26    const user = await User.findById(req.userId)
27      .select("username metadata llmDefault")
28      .lean();
29    if (!user) {
30      return res.redirect("/login?redirect=/setup");
31    }
32
33    const connCount = await LlmConnection.countDocuments({ userId: req.userId });
34    const hasMainLlm = !!(user.llmDefault);
35    const needsLlm = !hasMainLlm && connCount === 0;
36
37    // LLM connected, go to chat. Sprout handles everything from there.
38    if (!needsLlm) {
39      return res.redirect("/chat");
40    }
41
42    return res.send(renderSetup({ userId: req.userId, username: user.username }));
43  } catch (err) {
44    log.error("HTML", "Setup page error:", err.message);
45    return res.status(500).send("Something went wrong");
46  }
47});
48
49export default router;
50
1/* ─────────────────────────────────────────────── */
2/* HTML renderer for setup / onboarding page       */
3/* LLM connection only. Sprout handles the rest.   */
4/* ─────────────────────────────────────────────── */
5
6export function renderSetup({ userId, username }) {
7  return `<!DOCTYPE html>
8<html lang="en">
9<head>
10  <meta charset="UTF-8" />
11  <title>Setup - TreeOS</title>
12  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
13  <meta name="theme-color" content="#0d1117" />
14  <link rel="preconnect" href="https://fonts.googleapis.com">
15  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
16  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
17  <style>
18    :root {
19      /* Nightfall theme */
20      --bg:           #0d1117;
21      --bg-elevated:  #161b24;
22      --bg-hover:     #1c222e;
23      --border:       #232a38;
24      --border-strong:#2f3849;
25
26      --text-primary:   #e6e8eb;
27      --text-secondary: #c4c8d0;
28      --text-muted:     #9ba1ad;
29
30      --accent:      #7dd385;
31      --accent-glow: rgba(125, 211, 133, 0.5);
32      --error:       #c97e6a;
33
34      /* Legacy aliases */
35      --glass-rgb:          22, 27, 36;
36      --glass-alpha:        1;
37      --glass-blur:         0px;
38      --glass-border:       #232a38;
39      --glass-border-light: #232a38;
40      --glass-highlight:    #2f3849;
41
42      --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
43    }
44
45    * { box-sizing: border-box; margin: 0; padding: 0; }
46    html { background: var(--bg); }
47    body { min-height: 100vh; min-height: 100dvh; width: 100%; font-family: 'DM Sans', -apple-system, sans-serif; color: var(--text-primary); background: var(--bg); position: relative; overflow-x: hidden; overflow-y: auto; }
48
49    .container {
50      max-width: 620px; margin: 0 auto; padding: 40px 20px 60px;
51      display: flex; flex-direction: column; gap: 24px;
52      min-height: 100vh;
53    }
54
55    .header {
56      text-align: center; padding: 20px 0 10px;
57    }
58    .header .tree-icon { font-size: 48px; display: block; margin-bottom: 12px; filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.3)); }
59    .header h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 6px; }
60    .header p { font-size: 14px; color: var(--text-muted); line-height: 1.5; }
61
62    .glass-card {
63      background: rgba(var(--glass-rgb), var(--glass-alpha));
64      backdrop-filter: blur(var(--glass-blur));
65      -webkit-backdrop-filter: blur(var(--glass-blur));
66      border: 1px solid var(--glass-border);
67      border-radius: 16px;
68      padding: 24px;
69      animation: fadeUp 0.5s ease both;
70    }
71    @keyframes fadeUp {
72      from { opacity: 0; transform: translateY(16px); }
73      to { opacity: 1; transform: translateY(0); }
74    }
75    .glass-card h2 { font-size: 17px; font-weight: 600; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
76    .glass-card .sub { font-size: 13px; color: var(--text-muted); line-height: 1.5; margin-bottom: 16px; }
77
78    .field-row { margin-bottom: 16px; text-align: left; }
79    .field-label {
80      display: block; font-size: 14px; font-weight: 600; color: white;
81      margin-bottom: 8px; text-shadow: 0 1px 3px rgba(0,0,0,0.2); letter-spacing: -0.2px;
82    }
83    .field-input {
84      width: 100%; padding: 14px 18px;
85      background: rgba(255,255,255,0.15); border: 2px solid rgba(255,255,255,0.3);
86      border-radius: 12px; color: white; font-family: inherit; font-size: 16px; font-weight: 500;
87      outline: none; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
88      backdrop-filter: blur(20px) saturate(150%); -webkit-backdrop-filter: blur(20px) saturate(150%);
89      box-shadow: 0 4px 20px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.25);
90    }
91    .field-input:focus {
92      border-color: rgba(255,255,255,0.6); background: rgba(255,255,255,0.25);
93      backdrop-filter: blur(25px) saturate(160%); -webkit-backdrop-filter: blur(25px) saturate(160%);
94      box-shadow: 0 0 0 4px rgba(255,255,255,0.15), 0 8px 30px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.4);
95      transform: translateY(-2px);
96    }
97    .field-input::placeholder { color: rgba(255,255,255,0.5); font-weight: 400; }
98
99    .btn-primary {
100      width: 100%; padding: 16px; margin-top: 8px;
101      border-radius: 980px; border: 1px solid rgba(255,255,255,0.3);
102      background: rgba(255,255,255,0.25); backdrop-filter: blur(10px);
103      color: white; font-family: inherit; font-size: 16px; font-weight: 600;
104      cursor: pointer; transition: all 0.3s; letter-spacing: -0.2px;
105      box-shadow: 0 4px 12px rgba(0,0,0,0.12);
106      position: relative; overflow: hidden;
107    }
108    .btn-primary::before {
109      content: ''; position: absolute; inset: -40%;
110      background: radial-gradient(120% 60% at 0% 0%, rgba(255,255,255,0.35), transparent 60%);
111      opacity: 0; transform: translateX(-30%) translateY(-10%);
112      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
113      pointer-events: none;
114    }
115    .btn-primary:hover::before { opacity: 1; transform: translateX(30%) translateY(10%); }
116    .btn-primary:hover { background: rgba(255,255,255,0.35); transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.18); }
117    .btn-primary:active { transform: translateY(0); }
118    .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
119    .btn-primary:disabled::before { display: none; }
120
121    .btn-skip {
122      display: block; width: 100%; text-align: center; padding: 12px;
123      color: white; font-size: 15px; font-weight: 600; font-family: inherit;
124      background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.3);
125      border-radius: 980px; cursor: pointer; transition: all 0.3s;
126      text-decoration: none; box-shadow: 0 2px 8px rgba(0,0,0,0.08);
127    }
128    .btn-skip:hover { background: rgba(255,255,255,0.25); color: white; }
129
130    .status-msg {
131      padding: 12px 16px; border-radius: 10px; font-size: 14px; font-weight: 600;
132      margin-top: 8px; display: none; text-align: left;
133    }
134    .status-msg.error { display: block; background: rgba(239,68,68,0.3); backdrop-filter: blur(10px); border: 1px solid rgba(239,68,68,0.4); color: white; }
135    .status-msg.success { display: block; background: rgba(16,185,129,0.3); backdrop-filter: blur(10px); border: 1px solid rgba(16,185,129,0.4); color: white; }
136
137    .video-wrap {
138      position: relative; width: 100%; padding-top: 56.25%;
139      border-radius: 12px; overflow: hidden; margin-bottom: 16px;
140      background: rgba(0,0,0,0.2);
141    }
142    .video-wrap iframe {
143      position: absolute; inset: 0; width: 100%; height: 100%; border: none;
144    }
145
146    .done-card { text-align: center; padding: 40px 24px; }
147    .done-card .seed-anim {
148      font-size: 48px; display: inline-block; margin-bottom: 12px;
149    }
150    .done-card .seed-anim.shake {
151      animation: seedShake 0.4s ease-in-out 3;
152    }
153    .done-card .seed-anim.burst {
154      animation: seedBurst 0.4s ease-out forwards;
155    }
156    .done-card .seed-anim.tree {
157      animation: treeAppear 0.5s ease-out forwards;
158    }
159    @keyframes seedShake {
160      0%, 100% { transform: rotate(0deg) scale(1); }
161      25% { transform: rotate(-12deg) scale(1.05); }
162      75% { transform: rotate(12deg) scale(1.05); }
163    }
164    @keyframes seedBurst {
165      0% { transform: scale(1); opacity: 1; filter: brightness(1); }
166      50% { transform: scale(1.6); opacity: 0.8; filter: brightness(2.5); }
167      100% { transform: scale(0); opacity: 0; filter: brightness(3); }
168    }
169    @keyframes treeAppear {
170      0% { transform: scale(0); opacity: 0; }
171      60% { transform: scale(1.2); opacity: 1; }
172      100% { transform: scale(1); opacity: 1; }
173    }
174
175    .screen-flash {
176      position: fixed; inset: -50px; background: rgba(255,255,255,0.9); opacity: 0; z-index: 9999;
177      pointer-events: none; animation: screenFlash 0.6s ease-out forwards;
178    }
179    @keyframes screenFlash {
180      0% { opacity: 0; }
181      15% { opacity: 0.85; }
182      100% { opacity: 0; }
183    }
184
185    .skip-note { font-size: 12px; color: var(--text-muted); text-align: center; margin-top: 4px; line-height: 1.4; }
186
187    .hidden { display: none !important; }
188  </style>
189</head>
190<body>
191  <div class="container">
192
193    <div class="header">
194      <span class="tree-icon">🌳</span>
195      <h1>Welcome${username ? ", " + username : ""}!</h1>
196      <p>Connect your LLM and start talking. Your tree will grow from the conversation.</p>
197    </div>
198
199    <!-- Step 1: Connect LLM -->
200    <div class="glass-card" id="stepLlm">
201      <h2>Connect Your LLM</h2>
202      <div class="sub">
203        TreeOS uses AI to help you build and organize your knowledge. You'll need to connect your own LLM provider
204        using any OpenAI-compatible API endpoint. We recommend <strong>OpenRouter</strong> for the easiest setup.
205        It gives you access to hundreds of models with one API key.
206      </div>
207
208      <div class="video-wrap">
209        <iframe src="https://www.youtube-nocookie.com/embed/_cXGZXdiVgw" allowfullscreen></iframe>
210      </div>
211
212      <div class="field-row">
213        <label class="field-label">Label</label>
214        <input type="text" class="field-input" id="llmName" placeholder="e.g. OpenRouter, Groq" />
215      </div>
216      <div class="field-row">
217        <label class="field-label">Endpoint URL</label>
218        <input type="text" class="field-input" id="llmBaseUrl" placeholder="https://openrouter.ai/api/v1/chat/completions" />
219      </div>
220      <div class="field-row">
221        <label class="field-label">API Key <span style="opacity:0.5;font-weight:400;">- not required for Ollama/local models</span></label>
222        <input type="password" class="field-input" id="llmApiKey" placeholder="sk-or-... (leave blank for local)" />
223      </div>
224      <div class="field-row">
225        <label class="field-label">Model</label>
226        <input type="text" class="field-input" id="llmModel" placeholder="e.g. openai/gpt-4o-mini" />
227      </div>
228      <div id="llmStatus" class="status-msg"></div>
229      <button class="btn-primary" id="llmSubmit" onclick="submitLlm()">Connect</button>
230    </div>
231
232    <!-- Done state -->
233    <div class="glass-card done-card hidden" id="stepDone">
234      <div class="seed-anim" id="seedEmoji">&#127793;</div>
235      <h2 style="justify-content:center;" id="doneTitle" class="hidden">Planting your seed...</h2>
236      <div class="sub" style="text-align:center;" id="doneSub" class="hidden"></div>
237    </div>
238
239    <!-- Skip -->
240    <a class="btn-skip" href="#" id="skipBtn" onclick="skipSetup(); return false;">Skip for now</a>
241    <div class="skip-note" id="skipNote">You can still browse trees others have invited you to if they have their own LLM connected, but you won't be able to talk to your own trees.</div>
242
243  </div>
244
245  <script>
246    var CONFIG = {
247      userId: "${userId}",
248    };
249
250    function showStatus(id, msg, type) {
251      var el = document.getElementById(id);
252      el.textContent = msg;
253      el.className = "status-msg " + type;
254    }
255    function clearStatus(id) {
256      var el = document.getElementById(id);
257      el.className = "status-msg";
258      el.textContent = "";
259    }
260
261    async function submitLlm() {
262      var name = document.getElementById("llmName").value.trim();
263      var baseUrl = document.getElementById("llmBaseUrl").value.trim();
264      var apiKey = document.getElementById("llmApiKey").value.trim();
265      var model = document.getElementById("llmModel").value.trim();
266
267      if (!name || !baseUrl || !model) {
268        showStatus("llmStatus", "Name, URL, and model are required.", "error");
269        return;
270      }
271      if (!apiKey) apiKey = "none";
272
273      var btn = document.getElementById("llmSubmit");
274      btn.disabled = true;
275      btn.textContent = "Connecting...";
276      clearStatus("llmStatus");
277
278      try {
279        // Create connection
280        var createRes = await fetch("/api/v1/user/" + CONFIG.userId + "/custom-llm", {
281          method: "POST",
282          headers: { "Content-Type": "application/json" },
283          credentials: "include",
284          body: JSON.stringify({ name: name, baseUrl: baseUrl, apiKey: apiKey, model: model }),
285        });
286        var createData = await createRes.json();
287
288        if (!createRes.ok || createData.status === "error") {
289          throw new Error((createData.error && createData.error.message) || createData.error || "Failed to create connection");
290        }
291
292        // Set as default
293        var connId = (createData.data && createData.data.connection && createData.data.connection._id) || (createData.connection && createData.connection._id);
294        var assignRes = await fetch("/api/v1/user/" + CONFIG.userId + "/llm-assign", {
295          method: "POST",
296          headers: { "Content-Type": "application/json" },
297          credentials: "include",
298          body: JSON.stringify({ slot: "main", connectionId: connId }),
299        });
300        var assignData = await assignRes.json();
301        if (!assignRes.ok || assignData.status === "error") {
302          throw new Error((assignData.error && assignData.error.message) || assignData.error || "Failed to set as default");
303        }
304
305        showStatus("llmStatus", "Connected!", "success");
306        setTimeout(function() { finish(); }, 600);
307
308      } catch (err) {
309        showStatus("llmStatus", err.message, "error");
310        btn.disabled = false;
311        btn.textContent = "Connect";
312      }
313    }
314
315    function skipSetup() {
316      document.cookie = "setupSkipped=1;path=/;max-age=" + (12 * 60 * 60) + ";secure;samesite=none";
317      window.location.href = "/chat";
318    }
319
320    function finish() {
321      document.getElementById("stepLlm").style.display = "none";
322      document.getElementById("stepDone").classList.remove("hidden");
323      document.getElementById("skipBtn").style.display = "none";
324      document.getElementById("skipNote").style.display = "none";
325
326      var seed = document.getElementById("seedEmoji");
327      var title = document.getElementById("doneTitle");
328      var sub = document.getElementById("doneSub");
329      title.classList.remove("hidden");
330      // Phase 1: seed shakes
331      seed.classList.add("shake");
332      setTimeout(function() {
333        // Phase 2: burst flash
334        seed.classList.remove("shake");
335        seed.classList.add("burst");
336        setTimeout(function() {
337          // Phase 3: flash + swap to tree
338          var flash = document.createElement("div");
339          flash.className = "screen-flash";
340          document.body.appendChild(flash);
341          flash.addEventListener("animationend", function() { flash.remove(); });
342          seed.innerHTML = "&#127795;";
343          seed.classList.remove("burst");
344          seed.classList.add("tree");
345          title.textContent = "You're ready.";
346          sub.textContent = "Just start talking. Your tree will grow.";
347          sub.classList.remove("hidden");
348          setTimeout(function() {
349            window.location.href = "/chat";
350          }, 1500);
351        }, 400);
352      }, 1200);
353    }
354  </script>
355</body>
356</html>`;
357}
358
1// treeos/handlers.js
2// MCP tool handlers for the treeos extension.
3// Extracted from the monolithic MCP server and adapted for the extension system.
4
5import log from "../../seed/log.js";
6import { getExtension } from "../loader.js";
7import { z } from "zod";
8import { getTreeForAi, getNodeForAi } from "../../seed/tree/treeData.js";
9import {
10  createNode,
11  createNodeBranch,
12  deleteNodeBranch,
13  updateParentRelationship,
14  editNodeName,
15  editNodeType,
16} from "../../seed/tree/treeManagement.js";
17import {
18  createNote,
19  editNote,
20  getNotes,
21  deleteNoteAndFile,
22  transferNote,
23  getAllNotesByUser,
24  searchNotesByUser,
25} from "../../seed/tree/notes.js";
26import { editStatus } from "../../seed/tree/statuses.js";
27import {
28  getContributions,
29  getContributionsByUser,
30} from "../../seed/tree/contributions.js";
31import {
32  getActiveLeafExecutionFrontier,
33  getNavigationContext,
34  getContextForAi,
35} from "../../seed/tree/treeFetch.js";
36import { DELETED } from "../../seed/protocol.js";
37
38// Models wired from init via setModels
39let Node = null;
40let User = null;
41let _getAvailableCommands = null;
42export function setModels(models) { Node = models.Node; User = models.User; }
43export function setCommandResolver(fn) { _getAvailableCommands = fn; }
44
45// ── Helpers ────────────────────────────────────────────────────────────────
46
47/**
48 * Resolve prestige/version to a concrete number.
49 * If a valid number is provided, use it. Otherwise look up the node's
50 * current version from metadata (default 0).
51 */
52async function resolvePrestige({ nodeId, prestige }) {
53  if (typeof prestige === "number" && prestige >= 0) {
54    return prestige;
55  }
56  const node = await Node.findById(nodeId).select("metadata").lean();
57  if (node) {
58    const meta = node.metadata instanceof Map
59      ? Object.fromEntries(node.metadata)
60      : (node.metadata || {});
61    return meta.prestige?.current || meta.version?.current || 0;
62  }
63  return 0;
64}
65
66function text(str) {
67  return { content: [{ type: "text", text: str }] };
68}
69
70function json(data) {
71  return text(JSON.stringify(data, null, 2));
72}
73
74function error(msg) {
75  return { content: [{ type: "text", text: msg }], isError: true };
76}
77
78// ── TimeWindow shared schema fields ────────────────────────────────────────
79
80const TimeWindowSchema = {
81  startDate: z
82    .string()
83    .optional()
84    .describe("ISO date/time. Include items created on or after this time."),
85  endDate: z
86    .string()
87    .optional()
88    .describe("ISO date/time. Include items created on or before this time."),
89};
90
91// ── NodeSchema for create-new-node-branch ──────────────────────────────────
92
93const NodeSchema = z.lazy(() =>
94  z.object({
95    name: z.string().describe("Node name."),
96    schedule: z
97      .string()
98      .nullable()
99      .optional()
100      .describe("Optional scheduling date/time (in ISO 8601 format)."),
101    reeffectTime: z
102      .number()
103      .nullable()
104      .optional()
105      .describe("Reeffect time in hours."),
106    values: z
107      .record(z.number())
108      .nullable()
109      .optional()
110      .describe("Numeric key-value pairs for node values."),
111    goals: z
112      .record(z.number())
113      .nullable()
114      .optional()
115      .describe("Goal key-value pairs for the node."),
116    note: z
117      .string()
118      .nullable()
119      .optional()
120      .describe("Optional note for new node made on creation."),
121    type: z
122      .string()
123      .nullable()
124      .optional()
125      .describe(
126        "Optional semantic type. Core types: goal, plan, task, knowledge, resource, identity. Custom types are valid.",
127      ),
128    children: z
129      .array(z.any())
130      .nullable()
131      .optional()
132      .describe("List of child nodes."),
133  }),
134);
135
136// ── Tool definitions with handlers ─────────────────────────────────────────
137
138export function buildTools() {
139  return [
140    // ────────────────────────────────────────────────────────────────────────
141    // READ tools
142    // ────────────────────────────────────────────────────────────────────────
143
144    {
145      name: "get-tree",
146      description:
147        "Fetch a branching tree outline (structure only). READ-ONLY.",
148      schema: {
149        nodeId: z.string().describe("Root node ID to fetch the tree from."),
150        filters: z
151          .object({
152            status: z
153              .union([
154                z.array(z.enum(["active", "trimmed", "completed"])),
155                z.enum(["active", "trimmed", "completed"]),
156              ])
157              .optional()
158              .describe(
159                "Statuses to include. ALWAYS prefer array form. Example: ['active'] or ['active','completed']",
160              ),
161          })
162          .optional()
163          .describe(
164            "Optional filters. If omitted, defaults to ['active', 'completed'].",
165          ),
166      },
167      annotations: {
168        readOnlyHint: true,
169        destructiveHint: false,
170        idempotentHint: true,
171        openWorldHint: false,
172      },
173      handler: async ({ nodeId, filters }) => {
174        let status;
175        if (Array.isArray(filters?.status)) {
176          status = filters.status;
177        } else if (typeof filters?.status === "string") {
178          status = [filters.status];
179        } else {
180          status = ["active", "completed"];
181        }
182        status = [...new Set(status)].filter(
183          (s) => s === "active" || s === "trimmed" || s === "completed",
184        );
185        if (status.length === 0) {
186          status = ["active", "completed"];
187        }
188        const mergedFilter = {
189          active: status.includes("active"),
190          trimmed: status.includes("trimmed"),
191          completed: status.includes("completed"),
192        };
193        const treeData = await getTreeForAi(nodeId, mergedFilter);
194        if (treeData == null) {
195          return error(
196            JSON.stringify({ error: "Tree not found", nodeId }, null, 2),
197          );
198        }
199
200        // Append active extension CLI commands for this tree so the AI
201        // can give specific directions ("fitness 'pushups 20'" not "note ...")
202        if (_getAvailableCommands) {
203          try {
204            const cmds = await _getAvailableCommands(nodeId);
205            if (cmds?.length > 0) {
206              // treeData is a JSON string from getTreeForAi. Parse, add commands, pass object to json().
207              const parsed = JSON.parse(treeData);
208              parsed.availableCommands = cmds;
209              return json(parsed);
210            }
211          } catch (cmdErr) {
212            log.warn("TreeOS", `get-tree commands failed: ${cmdErr.message}`);
213          }
214        }
215
216        return json(treeData);
217      },
218    },
219
220    {
221      name: "get-node",
222      description:
223        "Fetch detailed information for a specific node. READ-ONLY.",
224      schema: {
225        nodeId: z.string().describe("Node ID to fetch."),
226      },
227      annotations: {
228        readOnlyHint: true,
229        destructiveHint: false,
230        idempotentHint: true,
231        openWorldHint: false,
232      },
233      handler: async ({ nodeId }) => {
234        const nodeData = await getNodeForAi(nodeId);
235        if (nodeData == null) {
236          return error(
237            JSON.stringify({ error: "Node not found", nodeId }, null, 2),
238          );
239        }
240        return json(nodeData);
241      },
242    },
243
244    {
245      name: "get-node-notes",
246      description:
247        "Retrieves notes for a node.",
248      schema: {
249        nodeId: z
250          .string()
251          .describe("The ID of the node to fetch notes for."),
252        limit: z
253          .number()
254          .optional()
255          .describe(
256            "Optional limit for the number of most recent notes",
257          ),
258        ...TimeWindowSchema,
259      },
260      annotations: {
261        readOnlyHint: true,
262        destructiveHint: false,
263        idempotentHint: true,
264        openWorldHint: false,
265      },
266      handler: async ({ nodeId, limit, startDate, endDate }) => {
267        try {
268          const result = await getNotes({
269            nodeId,
270            limit,
271            startDate,
272            endDate,
273          });
274          return json(result);
275        } catch (err) {
276          return text(`Failed to fetch notes: ${err.message}`);
277        }
278      },
279    },
280
281    {
282      name: "get-node-contributions",
283      description:
284        "Fetches contributions for a node.",
285      schema: {
286        nodeId: z
287          .string()
288          .describe("The ID of the node to fetch contributions for."),
289        limit: z
290          .number()
291          .optional()
292          .describe(
293            "Optional limit for number of most recent contributions.",
294          ),
295        ...TimeWindowSchema,
296      },
297      annotations: {
298        readOnlyHint: true,
299        destructiveHint: false,
300        idempotentHint: true,
301        openWorldHint: false,
302      },
303      handler: async ({ nodeId, limit, startDate, endDate }) => {
304        if (typeof limit === "number" && limit > 30) {
305          limit = 30;
306        }
307        try {
308          const result = await getContributions({
309            nodeId,
310            limit,
311            startDate,
312            endDate,
313          });
314          return json(result);
315        } catch (err) {
316          return text(
317            `Failed to fetch contributions: ${err.message}`,
318          );
319        }
320      },
321    },
322
323    {
324      name: "get-unsearched-notes-by-user",
325      description:
326        "Fetches all notes written by a specific user (optionally limited to the most recent N). Recommend limit 10 or less. Use get-searched-notes-by-user if looking for specifics.",
327      schema: {
328        userId: z.string().describe("Injected by server. Ignore."),
329        chatId: z
330          .string()
331          .nullable()
332          .optional()
333          .describe("Injected by server. Ignore."),
334        sessionId: z
335          .string()
336          .nullable()
337          .optional()
338          .describe("Injected by server. Ignore."),
339        limit: z
340          .number()
341          .optional()
342          .describe(
343            "Optional limit: number of most recent notes to return.",
344          ),
345        ...TimeWindowSchema,
346      },
347      annotations: {
348        readOnlyHint: true,
349        destructiveHint: false,
350        idempotentHint: true,
351        openWorldHint: false,
352      },
353      handler: async ({ userId, limit, startDate, endDate }) => {
354        if (typeof limit === "number" && limit > 20) {
355          limit = 20;
356        }
357        try {
358          const result = await getAllNotesByUser(
359            userId,
360            limit,
361            startDate,
362            endDate,
363          );
364          const trimmedNotes = result.notes.slice(0, 20);
365          return json(trimmedNotes);
366        } catch (err) {
367          return text(
368            `Failed to fetch user notes: ${err.message}`,
369          );
370        }
371      },
372    },
373
374    {
375      name: "get-searched-notes-by-user",
376      description: "Search text notes by a user based on text matching.",
377      schema: {
378        userId: z.string().describe("Injected by server. Ignore."),
379        chatId: z
380          .string()
381          .nullable()
382          .optional()
383          .describe("Injected by server. Ignore."),
384        sessionId: z
385          .string()
386          .nullable()
387          .optional()
388          .describe("Injected by server. Ignore."),
389        query: z.string().describe("Search query string."),
390        limit: z
391          .number()
392          .optional()
393          .describe("Optional limit for returned notes."),
394        ...TimeWindowSchema,
395      },
396      annotations: {
397        readOnlyHint: true,
398        destructiveHint: false,
399        idempotentHint: true,
400        openWorldHint: false,
401      },
402      handler: async ({ userId, query, limit, startDate, endDate }) => {
403        try {
404          if (typeof limit === "number" && limit > 40) {
405            limit = 40;
406          }
407          const result = await searchNotesByUser({
408            userId,
409            query,
410            limit,
411            startDate,
412            endDate,
413          });
414          return json(result);
415        } catch (err) {
416          return error(`Search failed: ${err.message}`);
417        }
418      },
419    },
420
421    {
422      name: "get-all-tags-for-user",
423      description:
424        "Fetches all notes where a specific user was tagged (optionally limited to the most recent N). May be referenced as mail.",
425      schema: {
426        userId: z.string().describe("Injected by server. Ignore."),
427        chatId: z
428          .string()
429          .nullable()
430          .optional()
431          .describe("Injected by server. Ignore."),
432        sessionId: z
433          .string()
434          .nullable()
435          .optional()
436          .describe("Injected by server. Ignore."),
437        limit: z
438          .number()
439          .optional()
440          .describe(
441            "Optional limit: number of most recent tagged notes.",
442          ),
443        ...TimeWindowSchema,
444      },
445      annotations: {
446        readOnlyHint: true,
447        destructiveHint: false,
448        idempotentHint: true,
449        openWorldHint: false,
450      },
451      handler: async ({ userId, limit, startDate, endDate }) => {
452        if (typeof limit === "number" && limit > 20) {
453          limit = 20;
454        }
455        try {
456          const { getAllTagsForUser } = await import(
457            "../../extensions/team/tags.js"
458          );
459          const Note = (await import("../../seed/models/note.js")).default;
460          const result = await getAllTagsForUser(
461            userId,
462            limit,
463            startDate,
464            endDate,
465            Note,
466          );
467          return json(result);
468        } catch (err) {
469          return text(
470            `Failed to fetch tagged notes: ${err.message}`,
471          );
472        }
473      },
474    },
475
476    {
477      name: "get-contributions-by-user",
478      description:
479        "Fetches contributions made by a specific user (optionally limited).",
480      schema: {
481        userId: z
482          .string()
483          .describe(
484            "The ID of the user to fetch contributions for.",
485          ),
486        limit: z
487          .number()
488          .optional()
489          .describe(
490            "Optional limit for number of most recent contributions.",
491          ),
492        ...TimeWindowSchema,
493      },
494      annotations: {
495        readOnlyHint: true,
496        destructiveHint: false,
497        idempotentHint: true,
498        openWorldHint: false,
499      },
500      handler: async ({ userId, limit, startDate, endDate }) => {
501        if (typeof limit === "number" && limit > 30) {
502          limit = 30;
503        }
504        try {
505          const result = await getContributionsByUser(
506            userId,
507            limit,
508            startDate,
509            endDate,
510          );
511          return json(result);
512        } catch (err) {
513          return text(
514            `Failed to fetch user contributions: ${err.message}`,
515          );
516        }
517      },
518    },
519
520    {
521      name: "get-root-nodes",
522      description:
523        "Fetches all root nodes (roots, trees) owned by a user. READ-ONLY.",
524      schema: {
525        userId: z.string().describe("Injected by server. Ignore."),
526      },
527      annotations: {
528        readOnlyHint: true,
529        destructiveHint: false,
530        idempotentHint: true,
531        openWorldHint: false,
532      },
533      handler: async ({ userId }) => {
534        try {
535          // Roots live in metadata.nav.roots, managed by the navigation extension.
536          const nav = getExtension("navigation");
537          if (nav?.exports?.getUserRootsWithNames) {
538            const roots = await nav.exports.getUserRootsWithNames(userId);
539            return json(roots);
540          }
541          // Fallback: query nodes directly by rootOwner (works without navigation extension)
542          const roots = await Node.find({ rootOwner: userId })
543            .select("_id name status type dateCreated visibility")
544            .lean();
545          return json(roots);
546        } catch (err) {
547          return error(
548            `Failed to fetch root nodes: ${err.message}`,
549          );
550        }
551      },
552    },
553
554    {
555      name: "get-active-leaf-execution-frontier",
556      description: "Get the next executable leaf node for focused work. Starts from the given node, not necessarily the tree root. Pass the current position to find leaves within that branch.",
557      schema: {
558        rootNodeId: z
559          .string()
560          .describe("Node to start from. Use current position for branch-scoped work, or tree root for whole-tree scan."),
561      },
562      annotations: {
563        readOnlyHint: true,
564        destructiveHint: false,
565        idempotentHint: false,
566        openWorldHint: false,
567      },
568      handler: async ({ rootNodeId }) => {
569        const frontier =
570          await getActiveLeafExecutionFrontier(rootNodeId);
571
572        if (!frontier.leaves?.length) {
573          return json({ done: true });
574        }
575
576        const primary = frontier.leaves.find((l) => l.next);
577        if (!primary) {
578          return json({
579            error: "Frontier returned no primary leaf.",
580          });
581        }
582
583        const MAX_ALTERNATES = 4;
584        const alternates = [];
585        const byDepth = new Map();
586        for (const leaf of frontier.leaves) {
587          if (leaf.next) continue;
588          if (!byDepth.has(leaf.depth)) {
589            byDepth.set(leaf.depth, []);
590          }
591          byDepth.get(leaf.depth).push(leaf);
592        }
593        const candidateDepths = [
594          primary.depth,
595          primary.depth - 1,
596          primary.depth + 1,
597        ];
598        for (const depth of candidateDepths) {
599          const group = byDepth.get(depth);
600          if (!group) continue;
601          for (const leaf of group) {
602            if (alternates.length >= MAX_ALTERNATES) break;
603            alternates.push({
604              nodeId: leaf.nodeId,
605              name: leaf.name,
606              path: leaf.path,
607              depth: leaf.depth,
608              versionPrestige: leaf.versionPrestige,
609              versionStatus: leaf.versionStatus,
610            });
611          }
612          if (alternates.length >= MAX_ALTERNATES) break;
613        }
614
615        return json({
616          primary: {
617            nodeId: primary.nodeId,
618            name: primary.name,
619            path: primary.path,
620            depth: primary.depth,
621            versionPrestige: primary.versionPrestige,
622            versionStatus: primary.versionStatus,
623          },
624          alternates,
625          execution: {
626            status: "active",
627            isLeaf: true,
628          },
629          instructions:
630            "You are in BE mode.\n\nThis is where we are right now.\n\nStay with this step.\nHelp the user move it forward.\nHandle all system updates quietly.\n\nWhen the work here feels complete,\npause and ask if it's ready to move on.",
631        });
632      },
633    },
634
635    {
636      name: "navigate-tree",
637      description:
638        "Returns structural context for tree navigation. Optionally searches by name or shows deeper children.",
639      schema: {
640        nodeId: z.string().describe("Node ID to inspect from."),
641        search: z
642          .string()
643          .optional()
644          .describe(
645            "Search node names across the tree. Returns up to 10 matches with paths.",
646          ),
647      },
648      annotations: {
649        readOnlyHint: true,
650        destructiveHint: false,
651        idempotentHint: true,
652        openWorldHint: false,
653      },
654      handler: async ({ nodeId, search }) => {
655        try {
656          const context = await getNavigationContext(nodeId, {
657            search,
658          });
659          return json(context);
660        } catch (err) {
661          return error(
662            `Failed to load navigation context: ${err.message}`,
663          );
664        }
665      },
666    },
667
668    {
669      name: "get-tree-context",
670      description:
671        "Reads node data with configurable scope. Returns current version, notes, and optionally siblings, parent chain, scripts.",
672      schema: {
673        nodeId: z.string().describe("Node ID to read."),
674        includeNotes: z
675          .boolean()
676          .optional()
677          .describe(
678            "Include notes for current version. Default true.",
679          ),
680        includeSiblings: z
681          .boolean()
682          .optional()
683          .describe("Include sibling node names. Default false."),
684        includeParentChain: z
685          .boolean()
686          .optional()
687          .describe(
688            "Include full path from root. Default false.",
689          ),
690        includeChildren: z
691          .boolean()
692          .optional()
693          .describe("Include children names. Default true."),
694      },
695      annotations: {
696        readOnlyHint: true,
697        destructiveHint: false,
698        idempotentHint: true,
699        openWorldHint: false,
700      },
701      handler: async ({ nodeId, ...flags }) => {
702        try {
703          const context = await getContextForAi(nodeId, flags);
704          return json(context);
705        } catch (err) {
706          return error(
707            `Failed to load context: ${err.message}`,
708          );
709        }
710      },
711    },
712
713    // ────────────────────────────────────────────────────────────────────────
714    // WRITE tools
715    // ────────────────────────────────────────────────────────────────────────
716
717    {
718      name: "edit-node-or-branch-status",
719      description:
720        "Calls editStatus() to update a node's status (optionally recursively).",
721      schema: {
722        nodeId: z
723          .string()
724          .describe(
725            "The unique ID of the node whose status will be edited.",
726          ),
727        status: z
728          .enum(["active", "trimmed", "completed"])
729          .describe("The new status to set for the node."),
730        prestige: z
731          .number()
732          .describe(
733            "Prestige version number of the node to modify.",
734          ),
735        isInherited: z
736          .boolean()
737          .describe(
738            "If true, propagate the status to child nodes recursively. Typically true unless otherwise specified.",
739          ),
740        userId: z
741          .string()
742          .describe(
743            "ID of the user making the status edit (for contribution logging).",
744          ),
745        chatId: z
746          .string()
747          .nullable()
748          .optional()
749          .describe("Injected by server. Ignore."),
750        sessionId: z
751          .string()
752          .nullable()
753          .optional()
754          .describe("Injected by server. Ignore."),
755      },
756      annotations: {
757        readOnlyHint: false,
758        destructiveHint: false,
759        idempotentHint: true,
760        openWorldHint: false,
761      },
762      handler: async ({
763        nodeId,
764        status,
765        prestige,
766        isInherited,
767        userId,
768        chatId,
769        sessionId,
770      }) => {
771        try {
772          const result = await editStatus({
773            nodeId,
774            status,
775            isInherited,
776            userId,
777            wasAi: true,
778            chatId,
779            sessionId,
780          });
781          return json(result);
782        } catch (err) {
783          return text(
784            `Failed to update status: ${err.message}`,
785          );
786        }
787      },
788    },
789
790    {
791      name: "create-node-note",
792      description:
793        "Creates a new text note for a node. Please confirm exact wording of content and do not add anything unless asked.",
794      schema: {
795        content: z
796          .string()
797          .describe("The text content of the note."),
798        userId: z.string().describe("Injected by server. Ignore."),
799        chatId: z
800          .string()
801          .nullable()
802          .optional()
803          .describe("Injected by server. Ignore."),
804        sessionId: z
805          .string()
806          .nullable()
807          .optional()
808          .describe("Injected by server. Ignore."),
809        nodeId: z
810          .string()
811          .describe("The ID of the node the note belongs to."),
812      },
813      annotations: {
814        readOnlyHint: false,
815        destructiveHint: false,
816        idempotentHint: false,
817        openWorldHint: false,
818      },
819      handler: async ({
820        content,
821        userId,
822        nodeId,
823        chatId,
824        sessionId,
825      }) => {
826        try {
827          const result = await createNote({
828            contentType: "text",
829            content,
830            userId,
831            nodeId,
832            wasAi: true,
833            chatId,
834            sessionId,
835            metadata: { treeos: { isReflection: true } },
836          });
837          return json(result);
838        } catch (err) {
839          return text(
840            `Failed to create note: ${err.message}`,
841          );
842        }
843      },
844    },
845
846    {
847      name: "edit-node-note",
848      description:
849        "Edit an existing text note. Replaces all content by default. Specify lineStart/lineEnd to replace a specific range, or lineStart alone to insert.",
850      schema: {
851        nodeId: z
852          .string()
853          .describe(
854            "The unique ID of the node whose note will be edited.",
855          ),
856        prestige: z
857          .number()
858          .describe(
859            "Prestige version number of the node to modify.",
860          ),
861        noteId: z
862          .string()
863          .describe("The ID of the note to edit."),
864        content: z
865          .string()
866          .describe(
867            "New content. Replaces entire note or the specified line range.",
868          ),
869        lineStart: z
870          .number()
871          .optional()
872          .describe(
873            "Start line (0-indexed). With lineEnd: replaces range. Alone: inserts at line.",
874          ),
875        lineEnd: z
876          .number()
877          .optional()
878          .describe(
879            "End line (0-indexed, exclusive). Lines [lineStart, lineEnd) are replaced.",
880          ),
881        userId: z
882          .string()
883          .describe(
884            "ID of the user making the edit (for contribution logging).",
885          ),
886        chatId: z
887          .string()
888          .nullable()
889          .optional()
890          .describe("Injected by server. Ignore."),
891        sessionId: z
892          .string()
893          .nullable()
894          .optional()
895          .describe("Injected by server. Ignore."),
896      },
897      annotations: {
898        readOnlyHint: false,
899        destructiveHint: false,
900        idempotentHint: false,
901        openWorldHint: false,
902      },
903      handler: async ({
904        noteId,
905        content,
906        lineStart,
907        lineEnd,
908        userId,
909        chatId,
910        sessionId,
911      }) => {
912        try {
913          const result = await editNote({
914            noteId,
915            content,
916            userId,
917            lineStart: lineStart ?? null,
918            lineEnd: lineEnd ?? null,
919            wasAi: true,
920            chatId,
921            sessionId,
922          });
923          return json(result);
924        } catch (err) {
925          return error(
926            `Failed to edit note: ${err.message}`,
927          );
928        }
929      },
930    },
931
932    {
933      name: "transfer-node-note",
934      description:
935        "Transfers a note from its current node to a different node in the same tree. Logs contributions on both source and target nodes.",
936      schema: {
937        noteId: z
938          .string()
939          .describe("The ID of the note to transfer."),
940        targetNodeId: z
941          .string()
942          .describe("The destination node ID."),
943        prestige: z
944          .number()
945          .optional()
946          .describe(
947            "Target version (defaults to latest).",
948          ),
949        userId: z.string().describe("Injected by server. Ignore."),
950        chatId: z
951          .string()
952          .nullable()
953          .optional()
954          .describe("Injected by server. Ignore."),
955        sessionId: z
956          .string()
957          .nullable()
958          .optional()
959          .describe("Injected by server. Ignore."),
960      },
961      annotations: {
962        readOnlyHint: false,
963        destructiveHint: false,
964        idempotentHint: false,
965        openWorldHint: false,
966      },
967      handler: async ({
968        noteId,
969        targetNodeId,
970        prestige,
971        userId,
972        chatId,
973        sessionId,
974      }) => {
975        try {
976          const result = await transferNote({
977            noteId,
978            targetNodeId,
979            userId,
980            prestige: prestige ?? null,
981            wasAi: true,
982            chatId,
983            sessionId,
984          });
985          return json(result);
986        } catch (err) {
987          return text(
988            `Failed to transfer note: ${err.message}`,
989          );
990        }
991      },
992    },
993
994    {
995      name: "delete-node-note",
996      description: "Deletes a text note by its ID.",
997      schema: {
998        noteId: z
999          .string()
1000          .describe("The ID of the note to delete."),
1001        userId: z.string().describe("Injected by server. Ignore."),
1002        chatId: z
1003          .string()
1004          .nullable()
1005          .optional()
1006          .describe("Injected by server. Ignore."),
1007        sessionId: z
1008          .string()
1009          .nullable()
1010          .optional()
1011          .describe("Injected by server. Ignore."),
1012        nodeId: z
1013          .string()
1014          .describe("The ID of the node the note belongs to."),
1015        prestige: z
1016          .number()
1017          .describe("The prestige version of the node"),
1018      },
1019      annotations: {
1020        readOnlyHint: false,
1021        destructiveHint: true,
1022        idempotentHint: false,
1023        openWorldHint: false,
1024      },
1025      handler: async ({ noteId, userId, chatId, sessionId }) => {
1026        try {
1027          const result = await deleteNoteAndFile({
1028            noteId,
1029            userId,
1030            wasAi: true,
1031            chatId,
1032            sessionId,
1033          });
1034          return json(result);
1035        } catch (err) {
1036          return text(
1037            `Failed to delete note: ${err.message}`,
1038          );
1039        }
1040      },
1041    },
1042
1043    {
1044      name: "create-new-node",
1045      description: "Create a single new node under an existing parent.",
1046      schema: {
1047        name: z.string().describe("Name of the new node."),
1048        parentId: z
1049          .string()
1050          .describe("ID of the parent node."),
1051        userId: z.string().describe("Injected by server. Ignore."),
1052        chatId: z
1053          .string()
1054          .nullable()
1055          .optional()
1056          .describe("Injected by server. Ignore."),
1057        sessionId: z
1058          .string()
1059          .nullable()
1060          .optional()
1061          .describe("Injected by server. Ignore."),
1062        note: z
1063          .string()
1064          .nullable()
1065          .optional()
1066          .describe("Optional initial note"),
1067        type: z
1068          .string()
1069          .nullable()
1070          .optional()
1071          .describe(
1072            "Optional semantic type. Core types: goal, plan, task, knowledge, resource, identity. Custom types valid.",
1073          ),
1074      },
1075      annotations: {
1076        readOnlyHint: false,
1077        destructiveHint: false,
1078        idempotentHint: false,
1079        openWorldHint: false,
1080      },
1081      handler: async ({
1082        name,
1083        parentId,
1084        userId,
1085        note,
1086        type,
1087        chatId,
1088        sessionId,
1089      }) => {
1090        try {
1091          const newNode = await createNode({
1092            name,
1093            parentId,
1094            userId,
1095            type: type ?? null,
1096            note: note ?? null,
1097            wasAi: true,
1098            chatId: chatId ?? null,
1099            sessionId: sessionId ?? null,
1100          });
1101          return json(newNode);
1102        } catch (err) {
1103          return error(
1104            `Failed to create node: ${err.message}`,
1105          );
1106        }
1107      },
1108    },
1109
1110    {
1111      name: "create-tree",
1112      description: "Creates a new tree by creating a root node.",
1113      schema: {
1114        name: z
1115          .string()
1116          .describe("Name of the new tree (root node)."),
1117        note: z
1118          .string()
1119          .nullable()
1120          .optional()
1121          .describe("Optional note for the root node."),
1122        type: z
1123          .string()
1124          .nullable()
1125          .optional()
1126          .describe(
1127            "Optional semantic type. Core types: goal, plan, task, knowledge, resource, identity. Custom types are valid.",
1128          ),
1129        userId: z.string().describe("Injected by server. Ignore."),
1130        chatId: z
1131          .string()
1132          .nullable()
1133          .optional()
1134          .describe("Injected by server. Ignore."),
1135        sessionId: z
1136          .string()
1137          .nullable()
1138          .optional()
1139          .describe("Injected by server. Ignore."),
1140      },
1141      annotations: {
1142        readOnlyHint: false,
1143        destructiveHint: false,
1144        idempotentHint: false,
1145        openWorldHint: false,
1146      },
1147      handler: async ({
1148        name,
1149        note,
1150        type,
1151        userId,
1152        chatId,
1153        sessionId,
1154        rootId,
1155      }) => {
1156        if (rootId) {
1157          return text("create-tree can only be used from home (~). Use create-new-node to add nodes inside a tree.");
1158        }
1159        try {
1160          const rootNode = await createNode({
1161            name,
1162            isRoot: true,
1163            userId,
1164            type: type ?? null,
1165            note: note ?? null,
1166            wasAi: true,
1167            chatId: chatId ?? null,
1168            sessionId: sessionId ?? null,
1169          });
1170          return json(rootNode);
1171        } catch (err) {
1172          return error(
1173            `Failed to create tree: ${err.message}`,
1174          );
1175        }
1176      },
1177    },
1178
1179    {
1180      name: "create-new-node-branch",
1181      description:
1182        "Used to create new node branch off a current node to extend its structure.",
1183      schema: {
1184        nodeData: NodeSchema.describe(
1185          "JSON structure of the node branch to create.",
1186        ),
1187        parentId: z
1188          .string()
1189          .describe(
1190            "Parent node ID for the root of this subtree. Required.",
1191          ),
1192        userId: z.string().describe("Injected by server. Ignore."),
1193        chatId: z
1194          .string()
1195          .nullable()
1196          .optional()
1197          .describe("Injected by server. Ignore."),
1198        sessionId: z
1199          .string()
1200          .nullable()
1201          .optional()
1202          .describe("Injected by server. Ignore."),
1203      },
1204      annotations: {
1205        readOnlyHint: false,
1206        destructiveHint: false,
1207        idempotentHint: false,
1208        openWorldHint: false,
1209      },
1210      handler: async ({
1211        nodeData,
1212        parentId,
1213        userId,
1214        chatId,
1215        sessionId,
1216      }) => {
1217        try {
1218          const { rootId, rootName, totalCreated } =
1219            await createNodeBranch(
1220              nodeData,
1221              parentId,
1222              userId,
1223              true, // wasAi
1224              chatId,
1225              sessionId,
1226            );
1227          return text(
1228            `Successfully created a new node branch!\n\n` +
1229              `Root Node: "${rootName}"\n` +
1230              `Root ID: ${rootId}\n` +
1231              `Total Nodes Created: ${totalCreated}`,
1232          );
1233        } catch (err) {
1234          return text(
1235            `Failed to create recursive nodes: ${err.message}`,
1236          );
1237        }
1238      },
1239    },
1240
1241    {
1242      name: "delete-node-branch",
1243      description:
1244        "Used to retire (delete) a node branch and detach it from its parent.",
1245      schema: {
1246        nodeId: z
1247          .string()
1248          .describe("ID of the node branch to delete."),
1249        userId: z.string().describe("Injected by server. Ignore."),
1250        chatId: z
1251          .string()
1252          .nullable()
1253          .optional()
1254          .describe("Injected by server. Ignore."),
1255        sessionId: z
1256          .string()
1257          .nullable()
1258          .optional()
1259          .describe("Injected by server. Ignore."),
1260      },
1261      annotations: {
1262        readOnlyHint: false,
1263        destructiveHint: true,
1264        idempotentHint: false,
1265        openWorldHint: false,
1266      },
1267      handler: async ({ nodeId, userId, chatId, sessionId }) => {
1268        try {
1269          const deletedNode = await deleteNodeBranch(
1270            nodeId,
1271            userId,
1272            true, // wasAi
1273            chatId,
1274            sessionId,
1275          );
1276          return text(
1277            `Node branch retired successfully.\n\n` +
1278              `Node ID: ${deletedNode._id.toString()}\n` +
1279              `Previous Parent: ${deletedNode.parent === DELETED ? "N/A" : deletedNode.parent}`,
1280          );
1281        } catch (err) {
1282          return text(
1283            `Failed to delete node branch: ${err.message}`,
1284          );
1285        }
1286      },
1287    },
1288
1289    {
1290      name: "edit-node-name",
1291      description:
1292        "Renames an existing node and logs the name change.",
1293      schema: {
1294        nodeId: z
1295          .string()
1296          .describe("The ID of the node being renamed."),
1297        newName: z
1298          .string()
1299          .describe("The new name to assign to the node."),
1300        userId: z.string().describe("Injected by server. Ignore."),
1301        chatId: z
1302          .string()
1303          .nullable()
1304          .optional()
1305          .describe("Injected by server. Ignore."),
1306        sessionId: z
1307          .string()
1308          .nullable()
1309          .optional()
1310          .describe("Injected by server. Ignore."),
1311      },
1312      annotations: {
1313        readOnlyHint: false,
1314        destructiveHint: false,
1315        idempotentHint: true,
1316        openWorldHint: false,
1317      },
1318      handler: async ({
1319        nodeId,
1320        newName,
1321        userId,
1322        chatId,
1323        sessionId,
1324      }) => {
1325        try {
1326          const { oldName, newName: updatedName } =
1327            await editNodeName({
1328              nodeId,
1329              newName,
1330              userId,
1331              wasAi: true,
1332              chatId,
1333              sessionId,
1334            });
1335          return text(
1336            `Node: ${nodeId} was renamed successfully from "${oldName}" to "${updatedName}".`,
1337          );
1338        } catch (err) {
1339          return text(
1340            `Failed to rename node: ${err.message}`,
1341          );
1342        }
1343      },
1344    },
1345
1346    {
1347      name: "edit-node-type",
1348      description: "Set or clear a node's semantic type.",
1349      schema: {
1350        nodeId: z
1351          .string()
1352          .describe("The ID of the node to update."),
1353        newType: z
1354          .string()
1355          .nullable()
1356          .describe(
1357            "Type label or null to clear. Core types: goal, plan, task, knowledge, resource, identity. Custom types are valid.",
1358          ),
1359        userId: z.string().describe("Injected by server. Ignore."),
1360        chatId: z
1361          .string()
1362          .nullable()
1363          .optional()
1364          .describe("Injected by server. Ignore."),
1365        sessionId: z
1366          .string()
1367          .nullable()
1368          .optional()
1369          .describe("Injected by server. Ignore."),
1370      },
1371      annotations: {
1372        readOnlyHint: false,
1373        destructiveHint: false,
1374        idempotentHint: true,
1375        openWorldHint: false,
1376      },
1377      handler: async ({
1378        nodeId,
1379        newType,
1380        userId,
1381        chatId,
1382        sessionId,
1383      }) => {
1384        try {
1385          const { oldType, newType: updatedType } =
1386            await editNodeType({
1387              nodeId,
1388              newType,
1389              userId,
1390              wasAi: true,
1391              chatId,
1392              sessionId,
1393            });
1394          return text(
1395            `Node ${nodeId} type changed from "${oldType}" to "${updatedType}".`,
1396          );
1397        } catch (err) {
1398          return error(
1399            `Failed to update node type: ${err.message}`,
1400          );
1401        }
1402      },
1403    },
1404
1405    {
1406      name: "update-node-branch-parent-relationship",
1407      description:
1408        "Moves a node to a new parent within the tree hierarchy.",
1409      schema: {
1410        nodeChildId: z
1411          .string()
1412          .describe("The ID of the child node to move."),
1413        nodeNewParentId: z
1414          .string()
1415          .describe("The ID of the new parent node."),
1416        userId: z
1417          .string()
1418          .describe(
1419            "The user performing the operation (optional).",
1420          ),
1421        chatId: z
1422          .string()
1423          .nullable()
1424          .optional()
1425          .describe("Injected by server. Ignore."),
1426        sessionId: z
1427          .string()
1428          .nullable()
1429          .optional()
1430          .describe("Injected by server. Ignore."),
1431      },
1432      annotations: {
1433        readOnlyHint: false,
1434        destructiveHint: false,
1435        idempotentHint: true,
1436        openWorldHint: false,
1437      },
1438      handler: async ({
1439        nodeChildId,
1440        nodeNewParentId,
1441        userId,
1442        chatId,
1443        sessionId,
1444      }) => {
1445        try {
1446          const { nodeChild, nodeNewParent } =
1447            await updateParentRelationship(
1448              nodeChildId,
1449              nodeNewParentId,
1450              userId,
1451              true, // wasAi
1452              chatId,
1453              sessionId,
1454            );
1455          return text(
1456            `Node '${nodeChild.name}' successfully moved under '${nodeNewParent.name}'.`,
1457          );
1458        } catch (err) {
1459          return text(
1460            `Failed to update parent: ${err.message}`,
1461          );
1462        }
1463      },
1464    },
1465  ];
1466}
1467
1/**
2 * Core HTML intercept routes for treeos.
3 * Mounted at /api/v1 BEFORE kernel routes.
4 * Each route checks for ?html. If present, renders HTML. If not, next().
5 * The kernel route handles JSON. This route handles HTML. Clean separation.
6 */
7
8import express from "express";
9import log from "../../seed/log.js";
10import Node from "../../seed/models/node.js";
11import User from "../../seed/models/user.js";
12import mongoose from "mongoose";
13import { sendOk, sendError, ERR, DELETED } from "../../seed/protocol.js";
14import { getUserMeta } from "../../seed/tree/userMetadata.js";
15import { getExtMeta, setExtMeta } from "../../seed/tree/extensionMetadata.js";
16import { getTreeStructure } from "../../seed/tree/treeData.js";
17import { getContributions } from "../../seed/tree/contributions.js";
18import { buildPathString } from "../../seed/tree/treeFetch.js";
19import { getNodeChats } from "../../seed/llm/chatHistory.js";
20import { getConnectionsForUser, getAllRootLlmSlots } from "../../seed/llm/connections.js";
21import getNodeName from "../../routes/api/helpers/getNameById.js";
22import { getExtension } from "../loader.js";
23import { isHtmlEnabled } from "../html-rendering/config.js";
24import urlAuth from "../html-rendering/urlAuth.js";
25import authenticate from "../../seed/middleware/authenticate.js";
26import authenticateLite from "../html-rendering/authenticateLite.js";
27import { htmlOnly, buildQS, tokenQS } from "../html-rendering/htmlHelpers.js";
28
29// Page renderers (imported directly from local pages)
30import { renderUserProfile } from "./pages/profile.js";
31import { renderNodeDetail } from "./pages/nodeDetail.js";
32import { renderVersionDetail } from "./pages/versionDetail.js";
33import { renderNodeChats } from "./pages/nodeChats.js";
34import { renderRootChats } from "./pages/nodeChats.js";
35import { renderRootOverview } from "./pages/treeOverview.js";
36import { renderNotesList } from "./pages/notesList.js";
37import { renderNodeMetadata } from "./pages/nodeMetadata.js";
38import { renderTextNote, renderFileNote } from "./pages/noteDetail.js";
39import { renderEditorPage } from "./pages/editor.js";
40import { renderQueryPage } from "./pages/query.js";
41import { renderContributions } from "./pages/contributions.js";
42import { renderCommandCenter } from "./pages/commandCenter.js";
43import { renderShareToken } from "./pages/shareToken.js";
44import { renderLlmPage } from "./pages/llmPage.js";
45import { renderNodeLlmPage } from "./pages/nodeLlmPage.js";
46import { escapeHtml, renderMedia } from "../html-rendering/html/utils.js";
47import { notFoundPage } from "../html-rendering/notFoundPage.js";
48
49// Resolve "latest" to current version from metadata. Prestige extension owns versioning.
50// Inlined here to avoid cross-extension dependency.
51async function resolveVersion(nodeId, version) {
52  if (version === "latest" || version === undefined) {
53    const node = await Node.findById(nodeId).select("metadata").lean();
54    const meta = node?.metadata instanceof Map ? Object.fromEntries(node.metadata) : (node?.metadata || {});
55    return meta.prestige?.current || 0;
56  }
57  return Number(version);
58}
59
60export function buildTreeosHtmlRoutes() {
61  const router = express.Router();
62
63  // Sanitize token on every request before it reaches any renderer.
64  // Share tokens are [A-Za-z0-9\-_.~] only. Anything else is stripped.
65  const TOKEN_SAFE = /^[A-Za-z0-9\-_.~]+$/;
66  router.use((req, _res, next) => {
67    if (req.query.token && !TOKEN_SAFE.test(req.query.token)) {
68      req.query.token = "";
69    }
70    next();
71  });
72
73  // ===================================================================
74  // CONTRIBUTIONS
75  // ===================================================================
76
77  router.get("/node/:nodeId/:version/contributions", urlAuth, htmlOnly, async (req, res) => {
78    try {
79      const { nodeId } = req.params;
80      let version = req.params.version;
81      try { version = String(await resolveVersion(nodeId, version)); } catch {}
82      const parsedVersion = Number(version);
83      if (isNaN(parsedVersion)) return sendError(res, 400, ERR.INVALID_INPUT, "Invalid version");
84
85      const limit = req.query.limit !== undefined ? Number(req.query.limit) : undefined;
86      const result = await getContributions({
87        nodeId,
88        version: parsedVersion,
89        limit,
90        startDate: req.query.startDate,
91        endDate: req.query.endDate,
92      });
93
94      const nodeName = await getNodeName(nodeId);
95      return res.send(renderContributions({
96        nodeId,
97        version: parsedVersion,
98        nodeName,
99        contributions: result.contributions || [],
100        queryString: buildQS(req),
101      }));
102    } catch (err) {
103      log.error("HTML", "Contributions render error:", err.message);
104      sendError(res, 500, ERR.INTERNAL, err.message);
105    }
106  });
107
108  router.get("/node/:nodeId/contributions", urlAuth, htmlOnly, async (req, res) => {
109    try {
110      const version = String(await resolveVersion(req.params.nodeId, "latest"));
111      req.params.version = version;
112      req.url = `/node/${req.params.nodeId}/${version}/contributions?${new URLSearchParams(req.query)}`;
113      router.handle(req, res, () => {});
114    } catch (err) {
115      sendError(res, 404, ERR.NODE_NOT_FOUND, err.message);
116    }
117  });
118
119  // ===================================================================
120  // APPS
121  // ===================================================================
122
123  router.get("/user/:userId/apps", urlAuth, htmlOnly, async (req, res) => {
124    try {
125      const { userId } = req.params;
126      const user = await User.findById(userId).select("username").lean();
127      if (!user) return sendError(res, 404, ERR.USER_NOT_FOUND, "User not found");
128
129      // rootMap: appKey -> [{ id, name, ready }]
130      const rootMap = new Map();
131      function addToMap(key, id, name, ready) {
132        if (!rootMap.has(key)) rootMap.set(key, []);
133        // Deduplicate
134        if (rootMap.get(key).some(e => e.id === id)) return;
135        rootMap.get(key).push({ id, name, ready });
136      }
137
138      const NAME_MAP = {
139        food: "Food", fitness: "Fitness", recovery: "Recovery",
140        study: "Study", kb: "KB", relationships: "Relationships",
141        finance: "Finance", investor: "Investor", "market-researcher": "Market Researcher",
142      };
143
144      // Prefer life domains (organized under one tree)
145      const life = getExtension("life");
146      let foundViaLife = false;
147      if (life?.exports?.getDomainNodes && life?.exports?.findLifeRoot) {
148        try {
149          const lifeRootId = await life.exports.findLifeRoot(userId);
150          if (lifeRootId) {
151            const domainNodes = await life.exports.getDomainNodes(lifeRootId);
152            for (const [key, info] of Object.entries(domainNodes)) {
153              addToMap(NAME_MAP[key] || key, info.id, info.name, info.ready);
154              foundViaLife = true;
155            }
156          }
157        } catch {}
158      }
159
160      // Fallback: scan all roots only if life didn't find anything
161      if (!foundViaLife) {
162        const roots = await Node.find({
163          rootOwner: userId,
164          parent: { $ne: DELETED },
165        }).select("_id name metadata").lean();
166        const EXTENSIONS = Object.keys(NAME_MAP);
167        for (const r of roots) {
168          const meta = r.metadata instanceof Map ? Object.fromEntries(r.metadata) : (r.metadata || {});
169          for (const ext of EXTENSIONS) {
170            if (meta[ext]?.initialized) {
171              addToMap(NAME_MAP[ext], String(r._id), r.name, true);
172            }
173          }
174        }
175      }
176
177      // Find Life root ID for chat routing (reuse from discovery if available)
178      let chatRootId = null;
179      if (life?.exports?.findLifeRoot) {
180        try { chatRootId = await life.exports.findLifeRoot(userId); } catch {}
181      }
182
183      const { renderAppsPage } = await import("./pages/appsPage.js");
184      res.send(renderAppsPage({
185        userId,
186        username: user.username,
187        rootMap,
188        lifeRootId: chatRootId,
189        qs: req.query,
190      }));
191    } catch (err) {
192      log.error("HTML", "Apps page error:", err.message);
193      sendError(res, 500, ERR.INTERNAL, "Apps page failed");
194    }
195  });
196
197  router.post("/user/:userId/apps/create", authenticate, async (req, res) => {
198    try {
199      const { userId } = req.params;
200      if (req.userId !== userId) return sendError(res, 403, ERR.FORBIDDEN, "Not your account");
201
202      const { app: appKey, message } = req.body;
203      if (!appKey || !message) return sendError(res, 400, ERR.INVALID_INPUT, "app and message required");
204
205      // App definitions: key -> { treeName, dashboardPath, multiInstance }
206      const APP_DEFS = {
207        fitness:  { treeName: "Fitness",  dashboardPath: "fitness",  multiInstance: false },
208        food:     { treeName: "Food",     dashboardPath: "food",     multiInstance: false },
209        recovery: { treeName: "Recovery", dashboardPath: "recovery", multiInstance: false },
210        study:    { treeName: "Study",    dashboardPath: "study",    multiInstance: false },
211        kb:       { treeName: "Knowledge Base", dashboardPath: "kb", multiInstance: true },
212        relationships: { treeName: "Relationships", dashboardPath: "relationships", multiInstance: false },
213        finance:  { treeName: "Finance",  dashboardPath: "finance",  multiInstance: false },
214        investor: { treeName: "Investor", dashboardPath: "investor", multiInstance: false },
215        "market-researcher": { treeName: "Market Researcher", dashboardPath: "market-researcher", multiInstance: false },
216      };
217      const appDef = APP_DEFS[appKey];
218      if (!appDef) return sendError(res, 400, ERR.INVALID_INPUT, "Unknown app");
219
220      const qs = req.body.token ? `?html&token=${req.body.token}` : "?html";
221      const msgParam = `&startMsg=${encodeURIComponent(message)}`;
222
223      // Try life extension for organized scaffolding under Life tree
224      const life = getExtension("life");
225      if (life?.exports?.addDomain && !appDef.multiInstance) {
226        let lifeRootId = await life.exports.findLifeRoot(userId);
227        if (!lifeRootId) {
228          const result = await life.exports.scaffoldRoot(userId);
229          lifeRootId = result.rootId;
230        }
231
232        // Check if domain already exists under Life
233        const domains = await life.exports.getDomainNodes(lifeRootId);
234        if (domains[appKey]) {
235          return res.redirect(`/api/v1/root/${domains[appKey].id}/${appDef.dashboardPath}${qs}${msgParam}`);
236        }
237
238        const { id: domainId } = await life.exports.addDomain({ rootId: lifeRootId, domain: appKey, userId });
239        return res.redirect(`/api/v1/root/${domainId}/${appDef.dashboardPath}${qs}${msgParam}`);
240      }
241
242      // Fallback: no life extension or multi-instance. Create standalone root.
243      if (!appDef.multiInstance) {
244        const existing = await Node.findOne({
245          parent: { $ne: DELETED },
246          [`metadata.${appKey}.initialized`]: true,
247        }).select("_id").lean();
248        if (existing) {
249          return res.redirect(`/api/v1/root/${existing._id}/${appDef.dashboardPath}${qs}${msgParam}`);
250        }
251      }
252
253      const treeName = appDef.multiInstance ? (message.slice(0, 80) || appDef.treeName) : appDef.treeName;
254      const { createNode } = await import("../../seed/tree/treeManagement.js");
255      const rootNode = await createNode({ name: treeName, isRoot: true, userId });
256      return res.redirect(`/api/v1/root/${rootNode._id}/${appDef.dashboardPath}${qs}${msgParam}`);
257    } catch (err) {
258      log.error("HTML", "App create error:", err.message);
259      sendError(res, 500, ERR.INTERNAL, "App creation failed");
260    }
261  });
262
263  // USER PROFILE
264  // ===================================================================
265
266  // Share token management page
267  router.get("/user/:userId/llm", urlAuth, async (req, res) => {
268    try {
269      const { userId } = req.params;
270      const wantHtml = Object.prototype.hasOwnProperty.call(req.query, "html");
271      const connections = await getConnectionsForUser(userId);
272      const user = await User.findById(userId).select("username llmDefault metadata").lean();
273
274      if (!wantHtml || !isHtmlEnabled()) {
275        return sendOk(res, { connections, mainAssignment: user?.llmDefault || null });
276      }
277
278      const userSlots = getUserMeta(user, "userLlm")?.slots || {};
279      const { getAllUserLlmSlots } = await import("../../seed/llm/connections.js");
280      const allUserSlots = getAllUserLlmSlots();
281      const token = req.query.token ?? "";
282      const qs = token ? `?token=${encodeURIComponent(token)}&html` : "?html";
283
284      return res.send(renderLlmPage({
285        userId,
286        username: user?.username || "",
287        connections,
288        mainAssignment: user?.llmDefault || null,
289        allUserSlots,
290        userSlots,
291        treeSlots: {},
292        rootId: null,
293        rootName: null,
294        qs,
295      }));
296    } catch (err) {
297      log.error("HTML", "LLM page error:", err.message);
298      sendError(res, 500, ERR.INTERNAL, err.message);
299    }
300  });
301
302  router.get("/user/:userId/shareToken", urlAuth, htmlOnly, async (req, res) => {
303    try {
304      const { userId } = req.params;
305      const user = await User.findById(userId).select("username metadata").lean();
306      if (!user) return sendError(res, 404, ERR.USER_NOT_FOUND, "User not found");
307      const token = req.query.token ?? "";
308      const tqs = token ? `?token=${encodeURIComponent(token)}&html` : "?html";
309      const { getUserMeta: _gum } = await import("../../seed/tree/userMetadata.js");
310      const htmlMeta = _gum(user, "html");
311      const savedShareToken = htmlMeta?.shareToken || null;
312      return res.send(renderShareToken({ userId, user, token, tokenQS: tqs, savedShareToken }));
313    } catch (err) {
314      log.error("HTML", "Share token page error:", err.message);
315      sendError(res, 500, ERR.INTERNAL, err.message);
316    }
317  });
318
319  router.get("/user/:userId", urlAuth, htmlOnly, async (req, res) => {
320    try {
321      const userId = req.params.userId;
322      const user = await User.findById(userId).exec();
323      if (!user) return sendError(res, 404, ERR.USER_NOT_FOUND, "User not found");
324
325      (getExtension("energy")?.exports?.maybeResetEnergy || (() => false))(user);
326
327      const roots = (await getExtension("navigation")?.exports?.getUserRootsWithNames(userId)) || [];
328      const billingMeta = getUserMeta(user, "billing");
329      const plan = billingMeta.plan || "basic";
330      const energyData = getUserMeta(user, "energy");
331      const energy = energyData.available;
332      const extraEnergy = energyData.additional;
333
334      const ENERGY_RESET_MS = 24 * 60 * 60 * 1000;
335      const storageUsedKB = getUserMeta(user, "storage").usageKB || 0;
336      const lastResetAt = energy?.lastResetAt ? new Date(energy.lastResetAt) : null;
337      const nextResetAt = lastResetAt ? new Date(lastResetAt.getTime() + ENERGY_RESET_MS) : null;
338      const resetTimeLabel = nextResetAt
339        ? nextResetAt.toLocaleString("en-US", { hour: "numeric", minute: "2-digit", hour12: true, timeZoneName: "short" })
340        : "...";
341
342      return res.send(renderUserProfile({
343        userId,
344        user,
345        roots,
346        queryString: buildQS(req),
347        storageUsedKB,
348      }));
349    } catch (err) {
350      log.error("HTML", "User profile render error:", err.message);
351      sendError(res, 500, ERR.INTERNAL, err.message);
352    }
353  });
354
355  // ===================================================================
356  // NODE DETAIL
357  // ===================================================================
358
359  router.get("/node/:nodeId", urlAuth, htmlOnly, async (req, res) => {
360    try {
361      const { nodeId } = req.params;
362      const node = await Node.findById(nodeId).populate("children", "name").lean();
363      if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
364
365      const parentName = node.parent ? await getNodeName(node.parent) : null;
366      const qs = buildQS(req);
367      const rootUrl = `/api/v1/root/${nodeId}${qs}`;
368
369      return res.send(renderNodeDetail({
370        node,
371        nodeId,
372        qs,
373        parentName,
374        rootUrl,
375        isPublicAccess: !!req.isPublicAccess,
376      }));
377    } catch (err) {
378      log.error("HTML", "Node detail render error:", err.message);
379      sendError(res, 500, ERR.INTERNAL, err.message);
380    }
381  });
382
383  // -- NODE METADATA ---------------------------------------------------
384
385  router.get("/node/:nodeId/metadata", urlAuth, htmlOnly, async (req, res) => {
386    try {
387      const { nodeId } = req.params;
388      const node = await Node.findById(nodeId).lean();
389      if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
390
391      const qs = buildQS(req);
392      const rootId = node.rootOwner || nodeId;
393      const backUrl = node.rootOwner
394        ? `/api/v1/root/${rootId}${qs}`
395        : `/api/v1/node/${nodeId}${qs}`;
396
397      return res.send(renderNodeMetadata({ node, nodeId, qs, backUrl }));
398    } catch (err) {
399      log.error("HTML", "Node metadata render error:", err.message);
400      sendError(res, 500, ERR.INTERNAL, err.message);
401    }
402  });
403
404  // Edit a metadata field: POST /node/:nodeId/metadata/:namespace/:key
405  router.post("/node/:nodeId/metadata/:namespace/:key", authenticate, async (req, res) => {
406    try {
407      const { nodeId, namespace, key } = req.params;
408      const { value } = req.body;
409      const node = await Node.findById(nodeId);
410      if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
411
412      const { batchSetExtMeta } = await import("../../seed/tree/extensionMetadata.js");
413      await batchSetExtMeta(nodeId, namespace, { [key]: value });
414      return sendOk(res, { updated: true, namespace, key, value });
415    } catch (err) {
416      sendError(res, 500, ERR.INTERNAL, err.message);
417    }
418  });
419
420  // Delete a metadata namespace: DELETE /node/:nodeId/metadata/:namespace
421  router.delete("/node/:nodeId/metadata/:namespace", authenticate, async (req, res) => {
422    try {
423      const { nodeId, namespace } = req.params;
424      const node = await Node.findById(nodeId);
425      if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
426
427      const { unsetExtMeta } = await import("../../seed/tree/extensionMetadata.js");
428      await unsetExtMeta(nodeId, namespace);
429      return sendOk(res, { deleted: true, namespace });
430    } catch (err) {
431      sendError(res, 500, ERR.INTERNAL, err.message);
432    }
433  });
434
435  // -- COMMAND CENTER -------------------------------------------------
436  // Must be before /node/:nodeId/:version so "command-center" isn't matched as a version.
437
438  router.get("/node/:nodeId/command-center", urlAuth, async (req, res) => {
439    try {
440      const { nodeId } = req.params;
441      const node = await Node.findById(nodeId).select("name metadata parent rootOwner systemRole").lean();
442      if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
443
444      const rootId = node.rootOwner || nodeId;
445      const rootNode = rootId !== nodeId ? await Node.findById(rootId).select("name").lean() : node;
446
447      let path = node.name;
448      try { path = await buildPathString(nodeId); } catch {}
449
450      const { getBlockedExtensionsAtNode, getToolOwner, getModeOwner, getConfinedExtensions } = await import("../../seed/tree/extensionScope.js");
451      const scope = await getBlockedExtensionsAtNode(nodeId);
452      const confinedSet = getConfinedExtensions();
453
454      const isLandRoot = node.systemRole === "land-root";
455
456      const { getAllToolNamesForBigMode, getSubModes } = await import("../../seed/modes/registry.js");
457      const { resolveTools } = await import("../../seed/tools.js");
458      const { filterToolNamesByScope } = await import("../../seed/tree/extensionScope.js");
459
460      const toolZones = isLandRoot ? ["tree", "home", "land"] : [node.rootOwner ? "tree" : "home"];
461      const allToolNames = [...new Set(toolZones.flatMap(z => getAllToolNamesForBigMode(z)))];
462      const filteredToolNames = filterToolNamesByScope(allToolNames, scope.blocked, scope.restricted);
463      const toolDefs = resolveTools(allToolNames);
464
465      const toolConfig = getExtMeta(node, "tools");
466      const nodeBlocked = new Set(toolConfig.blocked || []);
467      const nodeAllowed = new Set(toolConfig.allowed || []);
468
469      const tools = allToolNames.map(name => {
470        const def = toolDefs.find(d => d.function?.name === name);
471        const owner = getToolOwner(name);
472        const isFiltered = !filteredToolNames.includes(name);
473        const isNodeBlocked = nodeBlocked.has(name);
474
475        let status = "active";
476        if (isFiltered) status = scope.restricted?.has(owner) ? "restricted" : "blocked";
477        if (isNodeBlocked) status = "blocked";
478
479        return {
480          name,
481          description: def?.function?.description || "",
482          extName: owner || "core",
483          readOnly: owner?.readOnly || false,
484          destructive: def?.function?.annotations?.destructiveHint || false,
485          status,
486          nodeBlocked: isNodeBlocked,
487        };
488      });
489
490      // Land root sees all modes (blocking here cascades everywhere).
491      // Tree nodes see only tree modes. Home sees home modes.
492      const allModes = isLandRoot
493        ? [...(getSubModes("tree") || []), ...(getSubModes("home") || []), ...(getSubModes("land") || [])]
494        : getSubModes(node.rootOwner ? "tree" : "home") || [];
495
496      const modeOverrides = getExtMeta(node, "modes");
497
498      const modes = allModes.map(m => {
499        const owner = getModeOwner(m.key);
500        const isBlocked = owner ? scope.blocked.has(owner) : false;
501
502        return {
503          key: m.key,
504          emoji: m.emoji || "",
505          label: m.label || m.key,
506          bigMode: m.key.split(":")[0] || "tree",
507          extName: owner || "",
508          intent: m.key.split(":")[1] || "",
509          status: isBlocked ? "blocked" : "active",
510        };
511      });
512
513      const { getLoadedManifests } = await import("../../extensions/loader.js");
514      const manifests = getLoadedManifests();
515
516      const extensions = manifests.map(m => {
517        let status = "active";
518        if (scope.blocked.has(m.name)) status = "blocked";
519        else if (scope.restricted?.has(m.name)) status = "restricted";
520        else if (confinedSet.has(m.name) && !scope.allowed?.has(m.name)) status = "confined";
521
522        return {
523          name: m.name,
524          version: m.version || "",
525          description: m.description || "",
526          status,
527        };
528      }).sort((a, b) => {
529        const order = { active: 0, restricted: 1, confined: 2, blocked: 3 };
530        return (order[a.status] || 4) - (order[b.status] || 4);
531      });
532
533      const wantHtml = Object.prototype.hasOwnProperty.call(req.query, "html");
534
535      if (!wantHtml) {
536        const active = tools.filter(t => t.status === "active");
537        const blocked = tools.filter(t => t.status !== "active");
538        return sendOk(res, {
539          node: { id: nodeId, name: node.name, path },
540          root: { id: rootId, name: rootNode?.name || rootId },
541          tools: { active: active.length, blocked: blocked.length, total: tools.length },
542          modes: modes.map(m => `${m.emoji} ${m.key} (${m.status})`),
543          extensions: extensions.map(e => `${e.name} (${e.status})`),
544        });
545      }
546
547      const qs = req.query.token ? `?token=${req.query.token}&html` : "?html";
548
549      return res.send(renderCommandCenter({
550        nodeId, nodeName: node.name || nodeId, rootId, rootName: rootNode?.name || rootId, path,
551        extensions, tools, modes, toolConfig, modeOverrides,
552        blocked: scope.blocked, restricted: scope.restricted, allowed: scope.allowed, confined: confinedSet, qs,
553      }));
554    } catch (err) {
555      log.error("HTML", "Command center error:", err.message);
556      sendError(res, 500, ERR.INTERNAL, err.message);
557    }
558  });
559
560  // ===================================================================
561  // NODE VERSION DETAIL
562  // ===================================================================
563
564  router.get("/node/:nodeId/:version", urlAuth, htmlOnly, async (req, res) => {
565    try {
566      const { nodeId } = req.params;
567      let v = Number(req.params.version);
568      if (isNaN(v)) {
569        try { v = Number(await resolveVersion(nodeId, req.params.version)); } catch {
570          return sendError(res, 404, ERR.NODE_NOT_FOUND, "Invalid version");
571        }
572      }
573
574      const node = await Node.findById(nodeId).lean();
575      if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
576
577      const meta = node.metadata instanceof Map ? Object.fromEntries(node.metadata) : (node.metadata || {});
578      const prestigeData = meta.prestige || {};
579      const status = (Array.isArray(meta.prestige?.history) ? meta.prestige.history.find(h => h.version === v)?.status : null) || node.status || "active";
580      const values = meta.values || {};
581      const goals = meta.goals || {};
582      const schedule = meta.schedules?.date || null;
583      const reeffectTime = meta.schedules?.reeffectTime || null;
584      const showPrestige = v === (prestigeData.current || 0);
585
586      const qs = buildQS(req);
587      const backUrl = `/api/v1/node/${nodeId}${qs}`;
588      const backTreeUrl = `/api/v1/root/${nodeId}${qs}`;
589      const createdDate = node.dateCreated ? new Date(node.dateCreated).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : null;
590      const scheduleHtml = "";
591
592      const ALL_STATUSES = ["active", "completed", "trimmed"];
593      const STATUS_LABELS = { active: "Active", completed: "Completed", trimmed: "Trimmed" };
594
595      return res.send(renderVersionDetail({
596        node, nodeId, version: v,
597        data: { status, values, goals, schedule, prestige: prestigeData, reeffectTime },
598        qs, backUrl, backTreeUrl, createdDate, scheduleHtml, reeffectTime,
599        showPrestige, prestigeData, ALL_STATUSES, STATUS_LABELS,
600      }));
601    } catch (err) {
602      log.error("HTML", "Version detail render error:", err.message);
603      sendError(res, 500, ERR.INTERNAL, err.message);
604    }
605  });
606
607  // ===================================================================
608  // NODE CHATS
609  // ===================================================================
610
611  // Shared handler for both /node/:nodeId/chats and /node/:nodeId/:version/chats
612  // Chats are not version-scoped (they reference nodes by meta.nodeId), so the
613  // version segment is cosmetic for URL consistency with notes/contributions.
614  async function renderNodeChatsHandler(req, res) {
615    try {
616      const { nodeId } = req.params;
617      const node = await Node.findById(nodeId).select("name rootOwner").lean();
618      if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
619
620      const sessionLimit = Math.min(Number(req.query.limit) || 3, 10);
621      const { sessions } = await getNodeChats({
622        nodeId,
623        sessionLimit,
624        sessionId: req.query.sessionId || null,
625        startDate: req.query.startDate || null,
626        endDate: req.query.endDate || null,
627        includeChildren: false,
628      });
629
630      const allChats = sessions.flatMap(s => s.chats);
631      const nodePath = await buildPathString(nodeId);
632      const token = req.query.token || "";
633      const tQS = token ? `?token=${encodeURIComponent(token)}&html` : "?html";
634
635      return res.send(renderNodeChats({
636        nodeId,
637        nodeName: node.name,
638        nodePath,
639        sessions,
640        allChats,
641        token,
642        tokenQS: tQS,
643      }));
644    } catch (err) {
645      log.error("HTML", "Node chats render error:", err.message);
646      sendError(res, 500, ERR.INTERNAL, err.message);
647    }
648  }
649
650  router.get("/node/:nodeId/chats", urlAuth, htmlOnly, renderNodeChatsHandler);
651  router.get("/node/:nodeId/:version/chats", urlAuth, htmlOnly, renderNodeChatsHandler);
652
653  // ===================================================================
654  // ROOT OVERVIEW
655  // ===================================================================
656
657  router.get("/root/:nodeId", urlAuth, htmlOnly, async (req, res) => {
658    try {
659      const { nodeId } = req.params;
660      const queryString = buildQS(req, ["token", "html", "trimmed", "active", "completed", "startDate", "endDate", "month", "year"]);
661
662      const allData = await getTreeStructure(nodeId, {
663        active: req.query.active !== "false",
664        trimmed: req.query.trimmed === "true",
665        completed: req.query.completed !== "false",
666      });
667
668      const rootMeta = await Node.findById(nodeId)
669        .populate("rootOwner", "username _id isAdmin metadata")
670        .populate("contributors", "username _id isRemote homeLand")
671        .select("rootOwner contributors metadata llmDefault visibility")
672        .lean().exec();
673      const rootNode = await Node.findById(nodeId).select("parent rootOwner").lean();
674      const isDeleted = rootNode.parent === DELETED;
675      const isRoot = !!rootNode.rootOwner;
676      const isPublicAccess = !!req.isPublicAccess;
677      const isOwner = rootMeta?.rootOwner?._id?.toString() === req.userId?.toString();
678      const queryAvailable = isPublicAccess
679        ? !!((rootMeta?.llmDefault && rootMeta.llmDefault !== "none") || req.canopyVisitor)
680        : false;
681
682      const currentUserId = req.userId ? req.userId.toString() : null;
683      const token = req.query.token ?? "";
684
685      let deferredItems = [];
686      if (!isPublicAccess && mongoose.models.ShortMemory) {
687        deferredItems = await mongoose.models.ShortMemory.find({
688          rootId: nodeId, status: { $in: ["pending", "escalated"] },
689        }).sort({ createdAt: -1 }).lean();
690      }
691
692      let ownerConnections = [];
693      if (!isPublicAccess && isOwner && rootMeta?.rootOwner) {
694        ownerConnections = await getConnectionsForUser(rootMeta.rootOwner._id.toString());
695      }
696
697      const { getAllRootLlmSlots } = await import("../../seed/llm/connections.js");
698      const allRootSlots = getAllRootLlmSlots();
699
700      return res.send(renderRootOverview({
701        allData, rootMeta, ancestors: allData.ancestors || [],
702        isOwner, isDeleted, isRoot, isPublicAccess, queryAvailable,
703        currentUserId, queryString, nodeId, userId: req.userId,
704        token, deferredItems, ownerConnections, allRootSlots,
705      }));
706    } catch (err) {
707      log.error("HTML", "Root overview render error:", err.message);
708      sendError(res, 500, ERR.INTERNAL, err.message);
709    }
710  });
711
712  // ===================================================================
713  // NODE LLM PAGE
714  // ===================================================================
715
716  router.get("/root/:rootId/llm", urlAuth, htmlOnly, async (req, res) => {
717    try {
718      const { rootId } = req.params;
719      const root = await Node.findById(rootId).select("name llmDefault metadata rootOwner").lean();
720      if (!root) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Tree not found");
721
722      const userId = req.userId;
723      const isOwner = root.rootOwner && String(root.rootOwner) === String(userId);
724      if (!isOwner) return sendError(res, 403, ERR.FORBIDDEN, "Only the tree owner can manage LLM assignments");
725
726      const connections = await getConnectionsForUser(userId);
727      const qs = buildQS(req);
728
729      const meta = root.metadata instanceof Map ? Object.fromEntries(root.metadata) : (root.metadata || {});
730      const llmSlots = meta.llm?.slots || {};
731      const allSlots = getAllRootLlmSlots();
732
733      return res.send(renderNodeLlmPage({
734        nodeId: rootId,
735        nodeName: root.name,
736        connections,
737        defaultLlm: root.llmDefault || null,
738        slots: llmSlots,
739        allSlots,
740        qs,
741        userId,
742      }));
743    } catch (err) {
744      log.error("HTML", "Node LLM page error:", err.message);
745      sendError(res, 500, ERR.INTERNAL, err.message);
746    }
747  });
748
749  // ===================================================================
750  // QUERY PAGE
751  // ===================================================================
752
753  router.get("/root/:rootId/query", urlAuth, htmlOnly, async (req, res) => {
754    try {
755      const { rootId } = req.params;
756      const root = await Node.findById(rootId)
757        .select("name rootOwner visibility llmDefault metadata contributors")
758        .populate("rootOwner", "username").lean();
759      if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Tree not found");
760
761      const isAuthenticated = !!req.userId;
762      const isOwner = isAuthenticated && String(root.rootOwner?._id) === String(req.userId);
763      const isContributor = isAuthenticated && (root.contributors || []).map(String).includes(String(req.userId));
764      if (root.visibility !== "public" && !isOwner && !isContributor) {
765        return sendError(res, 403, ERR.FORBIDDEN, "This tree is not public.");
766      }
767
768      const treeHasLlm = !!(root.llmDefault && root.llmDefault !== "none");
769      return res.send(renderQueryPage({
770        treeName: root.name || "Untitled",
771        ownerUsername: root.rootOwner?.username || "unknown",
772        rootId, queryAvailable: treeHasLlm || isOwner || isContributor,
773        isAuthenticated,
774      }));
775    } catch (err) {
776      log.error("HTML", "Query page render error:", err.message);
777      sendError(res, 500, ERR.INTERNAL, err.message);
778    }
779  });
780
781  // ===================================================================
782  // ROOT CHATS
783  // ===================================================================
784
785  router.get("/root/:rootId/chats", urlAuth, htmlOnly, async (req, res) => {
786    try {
787      const { rootId } = req.params;
788      const root = await Node.findById(rootId).select("name rootOwner").lean();
789      if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Tree not found");
790
791      const sessionLimit = Math.min(Number(req.query.limit) || 3, 10);
792      const { sessions } = await getNodeChats({
793        nodeId: rootId, sessionLimit,
794        sessionId: req.query.sessionId || null,
795        startDate: req.query.startDate || null,
796        endDate: req.query.endDate || null,
797        includeChildren: true,
798      });
799
800      const allChats = sessions.flatMap(s => s.chats);
801      const token = req.query.token || "";
802      const tQS = token ? `?token=${encodeURIComponent(token)}&html` : "?html";
803
804      return res.send(renderRootChats({
805        rootId, rootName: root.name, sessions, allChats, token, tokenQS: tQS,
806      }));
807    } catch (err) {
808      log.error("HTML", "Root chats render error:", err.message);
809      sendError(res, 500, ERR.INTERNAL, err.message);
810    }
811  });
812
813  // ===================================================================
814  // NOTES
815  // ===================================================================
816
817  router.get("/node/:nodeId/:version/notes/editor", authenticate, async (req, res, next) => {
818    if (!isHtmlEnabled()) return next("route");
819    try {
820      const { nodeId, version } = req.params;
821      const qs = buildQS(req);
822      const tqs = tokenQS(req);
823      return res.send(renderEditorPage({
824        nodeId, version, noteId: null, noteContent: "", qs, tokenQS: tqs, originalLength: 0,
825      }));
826    } catch (err) {
827      log.error("HTML", "Editor page error:", err.message);
828      sendError(res, 500, ERR.INTERNAL, err.message);
829    }
830  });
831
832  router.get("/node/:nodeId/:version/notes/:noteId/editor", authenticate, async (req, res, next) => {
833    if (!isHtmlEnabled()) return next("route");
834    try {
835      const { nodeId, version, noteId } = req.params;
836      const qs = buildQS(req);
837      const tqs = tokenQS(req);
838      const Note = (await import("../../seed/models/note.js")).default;
839      const note = await Note.findById(noteId).lean();
840      if (!note) return notFoundPage?.(req, res, "This note doesn't exist or may have been removed.") || sendError(res, 404, ERR.NOTE_NOT_FOUND, "Note not found");
841      if (note.contentType !== "text") return res.redirect(`/api/v1/node/${nodeId}/${version}/notes/${noteId}${tqs}`);
842      return res.send(renderEditorPage({
843        nodeId, version, noteId, noteContent: note.content || "", qs, tokenQS: tqs, originalLength: (note.content || "").length,
844      }));
845    } catch (err) {
846      log.error("HTML", "Editor page error:", err.message);
847      sendError(res, 500, ERR.INTERNAL, err.message);
848    }
849  });
850
851  router.get("/node/:nodeId/:version/notes", urlAuth, htmlOnly, async (req, res) => {
852    try {
853      const { nodeId, version } = req.params;
854      const Note = (await import("../../seed/models/note.js")).default;
855      const limit = Math.min(Number(req.query.limit) || 50, 200);
856      const query = { nodeId };
857      // Version lives in metadata.version (set by prestige beforeNote hook), not a top-level field
858      const v = Number(version);
859      if (!isNaN(v) && v > 0) query["metadata.version"] = v;
860      if (req.query.startDate) query.createdAt = { ...query.createdAt, $gte: new Date(req.query.startDate) };
861      if (req.query.endDate) query.createdAt = { ...query.createdAt, $lte: new Date(req.query.endDate) };
862
863      const notes = await Note.find(query)
864        .populate("userId", "username")
865        .sort({ date: -1 }).limit(limit).lean();
866
867      const nodeName = await getNodeName(nodeId);
868      const token = req.query.token || "";
869      return res.send(renderNotesList({
870        nodeId, version: Number(version), token, nodeName,
871        notes, currentUserId: req.userId,
872      }));
873    } catch (err) {
874      log.error("HTML", "Notes list render error:", err.message);
875      sendError(res, 500, ERR.INTERNAL, err.message);
876    }
877  });
878
879  router.get("/node/:nodeId/:version/notes/:noteId", authenticateLite, htmlOnly, async (req, res) => {
880    try {
881      const { nodeId, version, noteId } = req.params;
882      const Note = (await import("../../seed/models/note.js")).default;
883      const note = await Note.findById(noteId).populate("userId", "username").lean();
884      if (!note) return sendError(res, 404, ERR.NOTE_NOT_FOUND, "Note not found");
885
886      const token = req.query.token || "";
887      const hasToken = !!token;
888      const qs = buildQS(req);
889
890      // Public share link (no token): back goes to land home, no editor
891      // Authenticated (token or JWT): back goes to notes list, editor available
892      let back, backText;
893      if (hasToken || req.userId) {
894        back = `/api/v1/node/${nodeId}/${version}/notes${qs}`;
895        backText = "\u2190 Back to Notes";
896      } else {
897        try {
898          const { getLandUrl } = await import("../../canopy/identity.js");
899          back = getLandUrl() || "/";
900        } catch { back = "/"; }
901        backText = "\u2190 Back to Home";
902      }
903
904      const safeUsername = escapeHtml?.(note.userId?.username || "Unknown") || (note.userId?.username || "Unknown");
905      const userLink = hasToken || req.userId
906        ? `<a href="/api/v1/user/${note.userId?._id || ""}${qs}">${safeUsername}</a>`
907        : `<span>${safeUsername}</span>`;
908
909      if (note.contentType === "text") {
910        return res.send(renderTextNote({ back, backText, userLink, editorButton: hasToken || !!req.userId, note, hasToken }));
911      }
912
913      // File note
914      const filePath = note.content;
915      const fs = await import("fs");
916      const path = await import("path");
917      const fileName = path.default.basename(filePath || "");
918      const fileUrl = `/api/v1/uploads/${fileName}`;
919      const fileDeleted = filePath ? !fs.default.existsSync(filePath) : true;
920      const mime = (await import("mime-types")).default;
921      const mimeType = mime.lookup(fileName) || "application/octet-stream";
922      const mediaHtml = renderMedia?.(fileUrl, mimeType, { lazy: false }) || "";
923
924      return res.send(renderFileNote({ back, backText, userLink, note, fileName, fileUrl, mediaHtml, fileDeleted, hasToken }));
925    } catch (err) {
926      log.error("HTML", "Note detail render error:", err.message);
927      sendError(res, 500, ERR.INTERNAL, err.message);
928    }
929  });
930
931  // ===================================================================
932  // MUTATION REDIRECTS (POST/PUT/DELETE with ?html)
933  // The extension calls kernel functions, then redirects to HTML view.
934  // ===================================================================
935
936  // POST create note -> redirect to notes list
937  router.post("/node/:nodeId/:version/notes", authenticate, htmlOnly, async (req, res, next) => {
938    try {
939      const { createNote } = await import("../../seed/tree/notes.js");
940      const { nodeId, version } = req.params;
941
942      if (req.body.content) {
943        await createNote({
944          contentType: "text",
945          content: req.body.content,
946          userId: req.userId,
947          nodeId,
948        });
949      }
950
951      const tqs = tokenQS(req);
952      return res.redirect(`/api/v1/node/${nodeId}/${version}/notes${tqs}`);
953    } catch (err) {
954      log.error("HTML", "Create note redirect error:", err.message);
955      sendError(res, 500, ERR.INTERNAL, err.message);
956    }
957  });
958
959  // PUT edit node status -> redirect
960  router.put("/node/:nodeId/status", authenticate, htmlOnly, async (req, res) => {
961    try {
962      const { editNodeStatus } = await import("../../seed/tree/statuses.js");
963      await editNodeStatus({
964        nodeId: req.params.nodeId,
965        newStatus: req.body.status,
966        userId: req.userId,
967      });
968      return res.redirect(`/api/v1/node/${req.params.nodeId}${tokenQS(req)}`);
969    } catch (err) {
970      log.error("HTML", "Edit status redirect error:", err.message);
971      sendError(res, 500, ERR.INTERNAL, err.message);
972    }
973  });
974
975  // PUT edit node name -> redirect
976  router.put("/node/:nodeId/name", authenticate, htmlOnly, async (req, res) => {
977    try {
978      const { editNodeName } = await import("../../seed/tree/treeManagement.js");
979      await editNodeName({
980        nodeId: req.params.nodeId,
981        newName: req.body.name,
982        userId: req.userId,
983      });
984      return res.redirect(`/api/v1/node/${req.params.nodeId}${tokenQS(req)}`);
985    } catch (err) {
986      log.error("HTML", "Edit name redirect error:", err.message);
987      sendError(res, 500, ERR.INTERNAL, err.message);
988    }
989  });
990
991  // PUT move node (update parent) -> redirect
992  router.put("/node/:nodeId/parent", authenticate, htmlOnly, async (req, res) => {
993    try {
994      const { updateParentRelationship: updateParent } = await import("../../seed/tree/treeManagement.js");
995      await updateParent({
996        nodeId: req.params.nodeId,
997        newParentId: req.body.newParentId,
998        userId: req.userId,
999      });
1000      return res.redirect(`/api/v1/node/${req.params.nodeId}${tokenQS(req)}`);
1001    } catch (err) {
1002      log.error("HTML", "Move node redirect error:", err.message);
1003      sendError(res, 500, ERR.INTERNAL, err.message);
1004    }
1005  });
1006
1007  // PUT set modes -> redirect
1008  router.put("/node/:nodeId/modes", authenticate, htmlOnly, async (req, res) => {
1009    try {
1010      const node = await Node.findById(req.params.nodeId);
1011      if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
1012      const modes = getExtMeta(node, "modes");
1013      if (req.body.intent && req.body.modeKey) modes[req.body.intent] = req.body.modeKey;
1014      if (req.body.clearIntent) delete modes[req.body.clearIntent];
1015      await setExtMeta(node, "modes", Object.keys(modes).length > 0 ? modes : undefined);
1016      return res.redirect(`/api/v1/node/${req.params.nodeId}${tokenQS(req)}`);
1017    } catch (err) {
1018      log.error("HTML", "Set modes redirect error:", err.message);
1019      sendError(res, 500, ERR.INTERNAL, err.message);
1020    }
1021  });
1022
1023  // PUT set tools -> redirect
1024  router.put("/node/:nodeId/tools", authenticate, htmlOnly, async (req, res) => {
1025    try {
1026      const node = await Node.findById(req.params.nodeId);
1027      if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
1028      const toolConfig = getExtMeta(node, "tools");
1029      if (req.body.allow) {
1030        toolConfig.allowed = [...new Set([...(toolConfig.allowed || []), ...req.body.allow])];
1031      }
1032      if (req.body.block) {
1033        toolConfig.blocked = [...new Set([...(toolConfig.blocked || []), ...req.body.block])];
1034      }
1035      if (req.body.clearAllowed) toolConfig.allowed = [];
1036      if (req.body.clearBlocked) toolConfig.blocked = [];
1037      await setExtMeta(node, "tools", Object.keys(toolConfig).length > 0 ? toolConfig : undefined);
1038      return res.redirect(`/api/v1/node/${req.params.nodeId}${tokenQS(req)}`);
1039    } catch (err) {
1040      log.error("HTML", "Set tools redirect error:", err.message);
1041      sendError(res, 500, ERR.INTERNAL, err.message);
1042    }
1043  });
1044
1045  // PUT set ext-scope -> redirect
1046  router.put("/node/:nodeId/ext-scope", authenticate, htmlOnly, async (req, res) => {
1047    try {
1048      const node = await Node.findById(req.params.nodeId);
1049      if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
1050      const { clearScopeCache } = await import("../../seed/tree/extensionScope.js");
1051      const extConfig = getExtMeta(node, "extensions");
1052      if (req.body.block) {
1053        extConfig.blocked = [...new Set([...(extConfig.blocked || []), ...req.body.block])];
1054      }
1055      if (req.body.allow) {
1056        extConfig.blocked = (extConfig.blocked || []).filter(e => !req.body.allow.includes(e));
1057      }
1058      await setExtMeta(node, "extensions", Object.keys(extConfig).length > 0 ? extConfig : undefined);
1059      clearScopeCache();
1060      return res.redirect(`/api/v1/node/${req.params.nodeId}${tokenQS(req)}`);
1061    } catch (err) {
1062      log.error("HTML", "Set ext-scope redirect error:", err.message);
1063      sendError(res, 500, ERR.INTERNAL, err.message);
1064    }
1065  });
1066
1067  // PUT reorder children -> redirect
1068  router.put("/node/:nodeId/children", authenticate, htmlOnly, async (req, res) => {
1069    try {
1070      const { reorderChildren } = await import("../../seed/tree/treeManagement.js");
1071      await reorderChildren({
1072        nodeId: req.params.nodeId,
1073        children: req.body.children,
1074        userId: req.userId,
1075      });
1076      return res.redirect(`/api/v1/node/${req.params.nodeId}${tokenQS(req)}`);
1077    } catch (err) {
1078      log.error("HTML", "Reorder children redirect error:", err.message);
1079      sendError(res, 500, ERR.INTERNAL, err.message);
1080    }
1081  });
1082
1083  // DELETE node -> redirect to deleted page
1084  router.delete("/node/:nodeId", authenticate, htmlOnly, async (req, res) => {
1085    try {
1086      const { deleteNodeBranch } = await import("../../seed/tree/treeManagement.js");
1087      await deleteNodeBranch(req.params.nodeId, req.userId);
1088      return res.redirect(`/api/v1/user/${req.userId}/deleted${tokenQS(req)}`);
1089    } catch (err) {
1090      log.error("HTML", "Delete node redirect error:", err.message);
1091      sendError(res, 500, ERR.INTERNAL, err.message);
1092    }
1093  });
1094
1095  // -- HTML FORM POST HANDLERS ----------------------------------------
1096  // HTML forms can only POST. These intercept form submissions from node/version
1097  // detail pages, perform the operation, and redirect back to the page.
1098
1099  // Delete node (form POSTs to /node/:nodeId/delete)
1100  router.post("/node/:nodeId/delete", authenticate, htmlOnly, async (req, res) => {
1101    try {
1102      const { deleteNodeBranch } = await import("../../seed/tree/treeManagement.js");
1103      await deleteNodeBranch(req.params.nodeId, req.userId);
1104      return res.redirect(`/api/v1/user/${req.userId}/deleted${tokenQS(req)}`);
1105    } catch (err) {
1106      log.error("HTML", "Delete node error:", err.message);
1107      sendError(res, 500, ERR.INTERNAL, err.message);
1108    }
1109  });
1110
1111  // Edit name (form POSTs to /node/:nodeId/editName or /node/:nodeId/:version/editName)
1112  router.post("/node/:nodeId/:version/editName", authenticate, htmlOnly, async (req, res) => {
1113    try {
1114      const { editNodeName } = await import("../../seed/tree/treeManagement.js");
1115      await editNodeName({ nodeId: req.params.nodeId, newName: req.body.name, userId: req.userId });
1116      return res.redirect(`/api/v1/node/${req.params.nodeId}/${req.params.version}${tokenQS(req)}`);
1117    } catch (err) {
1118      log.error("HTML", "Edit name error:", err.message);
1119      sendError(res, 500, ERR.INTERNAL, err.message);
1120    }
1121  });
1122
1123  router.post("/node/:nodeId/editName", authenticate, htmlOnly, async (req, res) => {
1124    try {
1125      const { editNodeName } = await import("../../seed/tree/treeManagement.js");
1126      await editNodeName({ nodeId: req.params.nodeId, newName: req.body.name, userId: req.userId });
1127      return res.redirect(`/api/v1/node/${req.params.nodeId}${tokenQS(req)}`);
1128    } catch (err) {
1129      log.error("HTML", "Edit name error:", err.message);
1130      sendError(res, 500, ERR.INTERNAL, err.message);
1131    }
1132  });
1133
1134  // Edit type (form POSTs to /node/:nodeId/editType)
1135  router.post("/node/:nodeId/editType", authenticate, htmlOnly, async (req, res) => {
1136    try {
1137      const { editNodeType } = await import("../../seed/tree/treeManagement.js");
1138      await editNodeType({ nodeId: req.params.nodeId, newType: req.body.type || null, userId: req.userId });
1139      return res.redirect(`/api/v1/node/${req.params.nodeId}${tokenQS(req)}`);
1140    } catch (err) {
1141      log.error("HTML", "Edit type error:", err.message);
1142      sendError(res, 500, ERR.INTERNAL, err.message);
1143    }
1144  });
1145
1146  // Edit status (form POSTs to /node/:nodeId/:version/editStatus)
1147  router.post("/node/:nodeId/:version/editStatus", authenticate, htmlOnly, async (req, res) => {
1148    try {
1149      const { editStatus } = await import("../../seed/tree/statuses.js");
1150      await editStatus({ nodeId: req.params.nodeId, status: req.body.status, userId: req.userId });
1151      return res.redirect(`/api/v1/node/${req.params.nodeId}/${req.params.version}${tokenQS(req)}`);
1152    } catch (err) {
1153      log.error("HTML", "Edit status error:", err.message);
1154      sendError(res, 500, ERR.INTERNAL, err.message);
1155    }
1156  });
1157
1158  router.post("/node/:nodeId/editStatus", authenticate, htmlOnly, async (req, res) => {
1159    try {
1160      const { editStatus } = await import("../../seed/tree/statuses.js");
1161      await editStatus({ nodeId: req.params.nodeId, status: req.body.status, userId: req.userId });
1162      return res.redirect(`/api/v1/node/${req.params.nodeId}${tokenQS(req)}`);
1163    } catch (err) {
1164      log.error("HTML", "Edit status error:", err.message);
1165      sendError(res, 500, ERR.INTERNAL, err.message);
1166    }
1167  });
1168
1169  // Create child (form POSTs to /node/:nodeId/createChild)
1170  router.post("/node/:nodeId/createChild", authenticate, htmlOnly, async (req, res) => {
1171    try {
1172      const { createNode } = await import("../../seed/tree/treeManagement.js");
1173      const names = (req.body.name || "").split(",").map(n => n.trim()).filter(Boolean);
1174      for (const name of names) {
1175        await createNode({ name, parentId: req.params.nodeId, userId: req.userId });
1176      }
1177      return res.redirect(`/api/v1/node/${req.params.nodeId}${tokenQS(req)}`);
1178    } catch (err) {
1179      log.error("HTML", "Create child error:", err.message);
1180      sendError(res, 500, ERR.INTERNAL, err.message);
1181    }
1182  });
1183
1184  // Update parent (form POSTs to /node/:nodeId/updateParent)
1185  router.post("/node/:nodeId/updateParent", authenticate, htmlOnly, async (req, res) => {
1186    try {
1187      const { updateParentRelationship: updateParent } = await import("../../seed/tree/treeManagement.js");
1188      await updateParent({ nodeId: req.params.nodeId, newParentId: req.body.parentId, userId: req.userId });
1189      return res.redirect(`/api/v1/node/${req.params.nodeId}${tokenQS(req)}`);
1190    } catch (err) {
1191      log.error("HTML", "Update parent error:", err.message);
1192      sendError(res, 500, ERR.INTERNAL, err.message);
1193    }
1194  });
1195
1196  // Prestige (form POSTs to /node/:nodeId/prestige or /node/:nodeId/:version/prestige)
1197  router.post("/node/:nodeId/prestige", authenticate, htmlOnly, async (req, res) => {
1198    try {
1199      const prestigeExt = getExtension("prestige");
1200      if (!prestigeExt?.exports?.addPrestige) return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, "Prestige extension not loaded");
1201      await prestigeExt.exports.addPrestige(req.params.nodeId, req.userId);
1202      return res.redirect(`/api/v1/node/${req.params.nodeId}${tokenQS(req)}`);
1203    } catch (err) {
1204      log.error("HTML", "Prestige error:", err.message);
1205      sendError(res, 500, ERR.INTERNAL, err.message);
1206    }
1207  });
1208
1209  router.post("/node/:nodeId/:version/prestige", authenticate, htmlOnly, async (req, res) => {
1210    try {
1211      const prestigeExt = getExtension("prestige");
1212      if (!prestigeExt?.exports?.addPrestige) return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, "Prestige extension not loaded");
1213      await prestigeExt.exports.addPrestige(req.params.nodeId, req.userId);
1214      const node = await Node.findById(req.params.nodeId).select("metadata").lean();
1215      const meta = node?.metadata instanceof Map ? Object.fromEntries(node.metadata) : (node?.metadata || {});
1216      const current = meta.prestige?.current || 0;
1217      return res.redirect(`/api/v1/node/${req.params.nodeId}/${current}${tokenQS(req)}`);
1218    } catch (err) {
1219      log.error("HTML", "Prestige error:", err.message);
1220      sendError(res, 500, ERR.INTERNAL, err.message);
1221    }
1222  });
1223
1224  // Edit schedule (form POSTs to /node/:nodeId/:version/editSchedule)
1225  router.post("/node/:nodeId/:version/editSchedule", authenticate, htmlOnly, async (req, res) => {
1226    try {
1227      const node = await Node.findById(req.params.nodeId);
1228      if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
1229      const schedule = {};
1230      if (req.body.startDate) schedule.startDate = req.body.startDate;
1231      if (req.body.endDate) schedule.endDate = req.body.endDate;
1232      if (req.body.recurrence) schedule.recurrence = req.body.recurrence;
1233      await setExtMeta(node, "schedules", Object.keys(schedule).length > 0 ? schedule : null);
1234      return res.redirect(`/api/v1/node/${req.params.nodeId}/${req.params.version}${tokenQS(req)}`);
1235    } catch (err) {
1236      log.error("HTML", "Edit schedule error:", err.message);
1237      sendError(res, 500, ERR.INTERNAL, err.message);
1238    }
1239  });
1240
1241  // PUT root config -> redirect
1242  router.put("/root/:rootId/config", authenticate, htmlOnly, async (req, res) => {
1243    try {
1244      const node = await Node.findById(req.params.rootId);
1245      if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Tree not found");
1246
1247      const { clearScopeCache } = await import("../../seed/tree/extensionScope.js");
1248
1249      // Apply config updates from body
1250      const meta = node.metadata instanceof Map ? Object.fromEntries(node.metadata) : (node.metadata || {});
1251      const config = meta.config || {};
1252      for (const [key, value] of Object.entries(req.body)) {
1253        config[key] = value;
1254      }
1255      await setExtMeta(node, "config", config);
1256      clearScopeCache();
1257
1258      return res.redirect(`/api/v1/root/${req.params.rootId}${tokenQS(req)}`);
1259    } catch (err) {
1260      log.error("HTML", "Root config redirect error:", err.message);
1261      sendError(res, 500, ERR.INTERNAL, err.message);
1262    }
1263  });
1264
1265  // POST create tree -> redirect
1266  router.post("/user/:userId/trees", authenticate, htmlOnly, async (req, res) => {
1267    try {
1268      const { createNode } = await import("../../seed/tree/treeManagement.js");
1269      const rootNode = await createNode({
1270        name: req.body.name || "New Tree",
1271        isRoot: true,
1272        userId: req.userId,
1273      });
1274      return res.redirect(`/api/v1/root/${rootNode._id}${tokenQS(req)}`);
1275    } catch (err) {
1276      log.error("HTML", "Create tree redirect error:", err.message);
1277      sendError(res, 500, ERR.INTERNAL, err.message);
1278    }
1279  });
1280
1281  // -- COMMAND CENTER: extension block/allow (HTML form POST) --
1282  router.post("/node/:nodeId/extensions", authenticate, htmlOnly, async (req, res) => {
1283    try {
1284      const { nodeId } = req.params;
1285      const node = await Node.findById(nodeId);
1286      if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
1287
1288      const { clearScopeCache } = await import("../../seed/tree/extensionScope.js");
1289      const extConfig = getExtMeta(node, "extensions") || {};
1290
1291      if (req.body.block) {
1292        const name = req.body.block;
1293        extConfig.blocked = [...new Set([...(extConfig.blocked || []), name])];
1294      }
1295      if (req.body.allow) {
1296        const name = req.body.allow;
1297        extConfig.blocked = (extConfig.blocked || []).filter(e => e !== name);
1298      }
1299
1300      await setExtMeta(node, "extensions", Object.keys(extConfig).length > 0 ? extConfig : undefined);
1301      clearScopeCache();
1302
1303      const qs = req.query.token ? `?token=${req.query.token}&html` : "?html";
1304      return res.redirect(`/api/v1/node/${nodeId}/command-center${qs}`);
1305    } catch (err) {
1306      log.error("HTML", "CC ext toggle error:", err.message);
1307      sendError(res, 500, ERR.INTERNAL, err.message);
1308    }
1309  });
1310
1311  // -- COMMAND CENTER: tool block/allow (HTML form POST) --
1312  router.post("/node/:nodeId/tools", authenticate, htmlOnly, async (req, res) => {
1313    try {
1314      const { nodeId } = req.params;
1315      const node = await Node.findById(nodeId);
1316      if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
1317
1318      const toolConfig = getExtMeta(node, "tools") || {};
1319
1320      if (req.body.block) {
1321        const name = req.body.block;
1322        toolConfig.blocked = [...new Set([...(toolConfig.blocked || []), name])];
1323        toolConfig.allowed = (toolConfig.allowed || []).filter(t => t !== name);
1324      }
1325      if (req.body.allow) {
1326        const name = req.body.allow;
1327        toolConfig.allowed = [...new Set([...(toolConfig.allowed || []), name])];
1328        toolConfig.blocked = (toolConfig.blocked || []).filter(t => t !== name);
1329      }
1330
1331      const hasConfig = (toolConfig.allowed?.length || 0) + (toolConfig.blocked?.length || 0) > 0;
1332      await setExtMeta(node, "tools", hasConfig ? toolConfig : undefined);
1333
1334      const qs = req.query.token ? `?token=${req.query.token}&html` : "?html";
1335      return res.redirect(`/api/v1/node/${nodeId}/command-center${qs}`);
1336    } catch (err) {
1337      log.error("HTML", "CC tool toggle error:", err.message);
1338      sendError(res, 500, ERR.INTERNAL, err.message);
1339    }
1340  });
1341
1342  // Alias: /root/:rootId/command-center redirects to the root node's command center
1343  router.get("/root/:rootId/command-center", urlAuth, htmlOnly, async (req, res) => {
1344    const qs = req.query.token ? `?token=${req.query.token}&html` : "?html";
1345    return res.redirect(`/api/v1/node/${req.params.rootId}/command-center${qs}`);
1346  });
1347
1348  return router;
1349}
1350
1import log from "../../seed/log.js";
2import { buildNavigationHandler, registerToolNavigation, registerToolNavigations } from "./navigation.js";
3import { buildTools } from "./handlers.js";
4import { registerSlot, unregisterSlots, resolveSlots, resolveSlotsAsync, listSlots, emitSlotUpdate } from "./slots.js";
5
6// Tree modes
7import treeNavigate from "./modes/tree/navigate.js";
8import treeStructure from "./modes/tree/structure.js";
9import treeEdit from "./modes/tree/edit.js";
10import treeRespond from "./modes/tree/respond.js";
11import treeLibrarian from "./modes/tree/librarian.js";
12import treeGetContext from "./modes/tree/getContext.js";
13import treeBe from "./modes/tree/be.js";
14import treeNotes from "./modes/tree/notes.js";
15import treeConverse from "./modes/tree/converse.js";
16
17// Home modes
18import homeDefault from "./modes/home/default.js";
19import homeReflect from "./modes/home/reflect.js";
20
21// Tools (OpenAI-format TOOL_DEFS for mode toolNames resolution)
22import TOOL_DEFS from "./tools.js";
23
24export async function init(core) {
25  const { setModels, setCommandResolver } = await import("./handlers.js");
26  setModels(core.models);
27
28  // Wire extension CLI command resolution for get-tree responses.
29  // The home AI calls get-tree and sees availableCommands so it can give
30  // specific directions ("fitness 'pushups 20'" not "note ...").
31  setCommandResolver(async (nodeId) => {
32    try {
33      const { getLoadedExtensionNames, getExtensionManifest } = await import("../loader.js");
34      const { isExtensionBlockedAtNode } = await import("../../seed/tree/extensionScope.js");
35      const cmds = [];
36      for (const name of getLoadedExtensionNames()) {
37        const manifest = getExtensionManifest(name);
38        if (!manifest?.provides?.cli?.length) continue;
39        if (await isExtensionBlockedAtNode(name, nodeId)) continue;
40        for (const cli of manifest.provides.cli) {
41          const cmd = cli.command?.split(" ")[0];
42          if (cmd) cmds.push(`${cmd}: ${cli.description || name}`);
43        }
44      }
45      return cmds;
46    } catch { return []; }
47  });
48  // Register all tree modes
49  core.modes.registerMode("tree:navigate", treeNavigate, "treeos-base");
50  core.modes.registerMode("tree:structure", treeStructure, "treeos-base");
51  core.modes.registerMode("tree:edit", treeEdit, "treeos-base");
52  core.modes.registerMode("tree:respond", treeRespond, "treeos-base");
53  core.modes.registerMode("tree:librarian", treeLibrarian, "treeos-base");
54  core.modes.registerMode("tree:get-context", treeGetContext, "treeos-base");
55  core.modes.registerMode("tree:be", treeBe, "treeos-base");
56  core.modes.registerMode("tree:notes", treeNotes, "treeos-base");
57  core.modes.registerMode("tree:converse", treeConverse, "treeos-base");
58
59  // Register home modes
60  core.modes.registerMode("home:default", homeDefault, "treeos-base");
61  core.modes.registerMode("home:reflect", homeReflect, "treeos-base");
62
63  // Upgrade defaults from fallback to real modes
64  core.modes.setDefaultMode("home", "home:default");
65  core.modes.setDefaultMode("tree", "tree:navigate");
66
67  // Register LLM slots and mode-to-slot assignments.
68  // Operators assign models per slot: treeos llm tree-assign librarian <connectionId>
69  // Grouping: navigate (read-only, cheap), librarian (write, quality),
70  //           respond (user-facing, highest quality), notes (medium).
71  core.llm.registerRootLlmSlot("navigate");
72  core.llm.registerRootLlmSlot("librarian");
73  core.llm.registerRootLlmSlot("respond");
74  core.llm.registerRootLlmSlot("notes");
75  if (core.llm.registerModeAssignment) {
76    // Read-only tree observation
77    core.llm.registerModeAssignment("tree:navigate", "navigate");
78    core.llm.registerModeAssignment("tree:get-context", "navigate");
79    // Write operations (create, edit, restructure)
80    core.llm.registerModeAssignment("tree:librarian", "librarian");
81    core.llm.registerModeAssignment("tree:structure", "librarian");
82    core.llm.registerModeAssignment("tree:edit", "librarian");
83    core.llm.registerModeAssignment("tree:be", "librarian");
84    // User-facing conversation
85    core.llm.registerModeAssignment("tree:respond", "respond");
86    core.llm.registerModeAssignment("tree:converse", "respond");
87    // Note operations
88    core.llm.registerModeAssignment("tree:notes", "notes");
89  }
90
91  // Build MCP tools with zod schemas and handlers
92  const tools = buildTools();
93
94  // Protect scaffolded nodes — any node with a role in extension metadata is structural
95  core.hooks.register("beforeNodeDelete", async ({ node }) => {
96    if (!node?.metadata) return;
97    const meta = node.metadata instanceof Map
98      ? Object.fromEntries(node.metadata)
99      : node.metadata;
100    for (const [namespace, data] of Object.entries(meta)) {
101      if (data?.role) {
102        return {
103          cancelled: true,
104          reason: `This node is structural for the ${namespace} extension (role: ${data.role}). ` +
105                  `Deleting it will break functionality. Use --force to override.`,
106        };
107      }
108    }
109  }, "treeos-base");
110
111  // Register afterToolCall hook for frontend navigation
112  const onAfterToolCall = buildNavigationHandler(core);
113  core.hooks.register("afterToolCall", onAfterToolCall, "treeos-base");
114
115  // ── Register TreeOS HTML pages (if html-rendering is installed) ──
116  // html-rendering is infrastructure. TreeOS provides the actual pages.
117  try {
118    const { getExtension } = await import("../loader.js");
119    const htmlExt = getExtension("html-rendering");
120    if (htmlExt?.exports?.registerPage) {
121      const { registerPage } = htmlExt.exports;
122
123      // Register app pages (dashboard, chat, setup)
124      const { default: appRouter } = await import("./app/app.js");
125      const { default: chatRouter } = await import("./app/chat.js");
126      const { default: setupRouter } = await import("./app/setup.js");
127      const authenticate = (await import("../../seed/middleware/authenticate.js")).default;
128
129      htmlExt.pageRouter.use("/", appRouter);
130      htmlExt.pageRouter.use("/", chatRouter);
131      htmlExt.pageRouter.use("/", setupRouter);
132
133      // ── Welcome page ("/") ──
134      const authenticateLite = (await import("../html-rendering/authenticateLite.js")).default;
135      const { renderWelcome } = await import("./pages/welcome.js");
136      const { getLandConfigValue: _getLandConfigValue } = await import("../../seed/landConfig.js");
137      const { getLandIdentity: _getLandIdentity } = await import("../../canopy/identity.js");
138
139      registerPage("get", "/", authenticateLite, async (req, res) => {
140        try {
141          const landIdentity = _getLandIdentity();
142          const landName = _getLandConfigValue("LAND_NAME") || landIdentity.name || "My Land";
143          const isLoggedIn = !!req.userId;
144          let isAdmin = false;
145          let username = null;
146          if (isLoggedIn) {
147            const u = await core.models.User.findById(req.userId).select("isAdmin username").lean();
148            isAdmin = u?.isAdmin || false;
149            username = u?.username || null;
150          }
151          const userCount = await core.models.User.countDocuments({ isRemote: { $ne: true } });
152          const treeCount = await core.models.Node.countDocuments({ rootOwner: { $nin: [null, "SYSTEM"] } });
153          const { getLoadedExtensionNames: _getExts } = await import("../loader.js");
154          res.send(renderWelcome({ landName, landUrl: landIdentity.baseUrl, isLoggedIn, isAdmin, username, extensionCount: _getExts().length, userCount, treeCount }));
155        } catch (err) {
156          res.redirect("/login");
157        }
158      });
159
160      // ── Land admin page ("/land") ──
161      const { renderLandPage } = await import("./pages/land.js");
162      const { SEED_VERSION } = await import("../../seed/version.js");
163
164      registerPage("get", "/land", authenticate, async (req, res) => {
165        try {
166          const user = await core.models.User.findById(req.userId).select("isAdmin").lean();
167          if (!user?.isAdmin) return _sendError(res, 403, _ERR.FORBIDDEN, "Admin required");
168
169          const landIdentity = _getLandIdentity();
170          const landName = _getLandConfigValue("LAND_NAME") || landIdentity.name || "My Land";
171          const { getLoadedManifests: _getManifests, getLoadedExtensionNames: _getExts } = await import("../loader.js");
172          const { getAllLandConfig } = await import("../../seed/landConfig.js");
173          const { default: LandPeer } = await import("../../canopy/models/landPeer.js");
174
175          const userCount = await core.models.User.countDocuments({ isRemote: { $ne: true } });
176          const treeCount = await core.models.Node.countDocuments({ rootOwner: { $nin: [null, "SYSTEM"] } });
177          const peerCount = await LandPeer.countDocuments();
178          const disabledList = _getLandConfigValue("disabledExtensions") || [];
179          const horizonUrl = _getLandConfigValue("HORIZON_URL") || process.env.HORIZON_URL || "https://horizon.treeos.ai";
180
181          res.send(renderLandPage({
182            landName,
183            domain: landIdentity.domain,
184            seedVersion: SEED_VERSION,
185            landUrl: landIdentity.baseUrl,
186            userCount,
187            treeCount,
188            peerCount,
189            extensions: _getManifests(),
190            disabledExtensions: Array.isArray(disabledList) ? disabledList : [],
191            config: getAllLandConfig(),
192            horizonUrl,
193          }));
194        } catch (err) {
195          _sendError(res, 500, _ERR.INTERNAL, err.message);
196        }
197      });
198
199      // Mount HTML intercept routes (before kernel routes)
200      const { buildTreeosHtmlRoutes } = await import("./htmlRoutes.js");
201      htmlExt.router.use("/", buildTreeosHtmlRoutes());
202
203      // Canopy admin pages
204      const { isHtmlEnabled } = await import("../html-rendering/config.js");
205      const { renderCanopyAdmin, renderCanopyInvites, renderCanopyHorizon } = await import("./pages/canopy.js");
206      const { sendError: _sendError, ERR: _ERR } = await import("../../seed/protocol.js");
207
208      registerPage("get", "/canopy/admin", authenticate, async (req, res) => {
209        if (!isHtmlEnabled()) return _sendError(res, 404, _ERR.EXTENSION_NOT_FOUND, "HTML disabled");
210        try {
211          const user = await core.models.User.findById(req.userId).select("isAdmin").lean();
212          if (!user?.isAdmin) return _sendError(res, 403, _ERR.FORBIDDEN, "Admin required");
213          const { getAllPeers } = await import("../../canopy/peers.js");
214          const { getLandInfoPayload } = await import("../../canopy/identity.js");
215          const { getPendingEventCount, getFailedEvents } = await import("../../canopy/events.js");
216          res.send(renderCanopyAdmin({ land: getLandInfoPayload(), peers: await getAllPeers(), pendingEvents: await getPendingEventCount(), failedEvents: await getFailedEvents() }));
217        } catch (err) { _sendError(res, 500, _ERR.INTERNAL, err.message); }
218      });
219
220      registerPage("get", "/canopy/admin/invites", authenticate, async (req, res) => {
221        if (!isHtmlEnabled()) return _sendError(res, 404, _ERR.EXTENSION_NOT_FOUND, "HTML disabled");
222        try {
223          const user = await core.models.User.findById(req.userId).select("isAdmin").lean();
224          if (!user?.isAdmin) return _sendError(res, 403, _ERR.FORBIDDEN, "Admin required");
225          const mongoose = (await import("mongoose")).default;
226          const CanopyEvent = mongoose.models.CanopyEvent;
227          const RemoteUser = mongoose.models.RemoteUser;
228          const invites = CanopyEvent ? await CanopyEvent.find({ type: "invite" }).sort({ createdAt: -1 }).lean() : [];
229          const remoteUsers = RemoteUser ? await RemoteUser.find().lean() : [];
230          const localTrees = await core.models.Node.find({ rootOwner: { $exists: true, $ne: null } }).select("_id name").lean();
231          res.send(renderCanopyInvites({ invites, remoteUsers, localTrees }));
232        } catch (err) { _sendError(res, 500, _ERR.INTERNAL, err.message); }
233      });
234
235      registerPage("get", "/canopy/admin/horizon", authenticate, async (req, res) => {
236        if (!isHtmlEnabled()) return _sendError(res, 404, _ERR.EXTENSION_NOT_FOUND, "HTML disabled");
237        try {
238          const user = await core.models.User.findById(req.userId).select("isAdmin").lean();
239          if (!user?.isAdmin) return _sendError(res, 403, _ERR.FORBIDDEN, "Admin required");
240          res.send(renderCanopyHorizon({ hasHorizon: !!process.env.HORIZON_URL }));
241        } catch (err) { _sendError(res, 500, _ERR.INTERNAL, err.message); }
242      });
243
244      log.info("TreeOS", "HTML pages registered via html-rendering");
245    }
246  } catch (err) {
247    log.verbose("TreeOS", `HTML pages not registered: ${err.message}`);
248  }
249
250  // ── Register core quick links (ones no extension owns) ──
251  registerSlot("user-quick-links", "treeos-base", ({ userId, queryString }) =>
252    `<li><a href="/api/v1/user/${userId}/shareToken${queryString}">Share Token</a></li>
253     <li><a href="/api/v1/user/${userId}/inverse${queryString}">Inverse Profile</a></li>`,
254  { priority: 90 });
255
256  log.info("TreeOS", `Registered ${tools.length} tools, 10 modes, navigation hook`);
257
258  return {
259    tools,
260    exports: {
261      TOOL_DEFS, registerToolNavigation, registerToolNavigations,
262      registerSlot, unregisterSlots, resolveSlots, resolveSlotsAsync, listSlots, emitSlotUpdate,
263    },
264  };
265}
266
1export default {
2  name: "treeos-base",
3  version: "1.0.5",
4  builtFor: "TreeOS",
5  description:
6    "The reference implementation of how the AI thinks inside a tree. Eleven modes, thirty-plus " +
7    "MCP tools, and a navigation hook that keeps the frontend synchronized with every operation " +
8    "the AI performs. This is the foundation that every tree conversation builds on. " +
9    "\n\n" +
10    "Converse is the default mode for free-form nodes. It reads the node's notes, children, " +
11    "and path, then talks from that position's perspective. Every node has a voice. No extension " +
12    "needed. Navigate resolves natural language references to nodes. Librarian walks branches " +
13    "and gathers context. Structure creates, moves, and deletes nodes. Edit modifies node fields. " +
14    "Notes reads and writes note content. Respond synthesizes a turn into a natural language " +
15    "answer. Get Context fetches node data silently. Be mode is focused, present, guided work " +
16    "on one step at a time. " +
17    "\n\n" +
18    "Two home modes handle the space outside of trees. Home Default is a warm, conversational " +
19    "landing assistant. Home Reflect reviews notes and contributions across all trees. " +
20    "\n\n" +
21    "The navigation hook fires afterToolCall and emits a WebSocket navigate event that " +
22    "synchronizes the HTML frontend with the AI's actions. " +
23    "\n\n" +
24    "Every mode and tool is replaceable. Extensions register custom modes that override " +
25    "defaults at any node via per-node mode metadata. Remove this extension and the tree " +
26    "has no AI behavior. Install it and the tree thinks at every position.",
27
28  needs: {
29    services: ["websocket", "llm"],
30    models: ["Node", "User", "Note", "Contribution"],
31  },
32
33  optional: {
34    extensions: ["html-rendering", "navigation"],
35  },
36
37  provides: {
38    routes: false,
39    tools: true,
40    modes: true,
41    jobs: false,
42    orchestrator: false,
43    energyActions: {},
44    sessionTypes: {},
45    cli: [],
46  },
47};
48
1// extensions/treeos/modes/home/default.js
2// HOME default mode - landing state, conversational, aware but non-pushy
3
4export default {
5  name: "home:default",
6  emoji: "🏠",
7  label: "Home",
8  bigMode: "home",
9
10  toolNames: ["get-root-nodes", "get-tree", "create-tree"],
11
12  buildSystemPrompt({ username, userId }) {
13    return `You are TreeOS Helper, operating in HOME mode.
14
15[Context]
16- User: ${username}
17- User ID: ${userId}
18- Mode: Home (Default)
19
20[Conversation First Contract]
21- Always respond conversationally before taking visible action.
22- Being warm, present, and helpful is more important than acting quickly.
23- Tools that cause navigation or mode switches require clear user intent.
24- Doing nothing (just chatting) is a valid and correct behavior.
25
26[Startup Awareness]
27- At the start of the session, you MAY call get-root-nodes silently
28  to understand what trees the user has.
29- This information is for internal awareness only.
30- Do NOT present the list unless the user asks to see their trees.
31- Do NOT select a tree unless explicitly requested.
32
33[Onboarding - Zero Trees]
34If get-root-nodes returns an empty list, this is a new user. Welcome them warmly.
35Do NOT tell them to run commands. Do NOT list available domains.
36Just be present and say something like:
37
38"Welcome! Just start talking about whatever is on your mind. Want to
39track workouts? Log food? Study something? Just say it. The tree will
40grow around what you care about."
41
42The sprout system handles everything from here. When the user says something
43that implies a domain (food, fitness, study, etc.), sprout will detect it
44and offer to set it up. You do not need to guide them through any setup.
45
46Do not mention kernels, cascade, architecture, or commands.
47This section only applies when get-root-nodes returns [].
48
49[What You Do]
50You are the landing assistant. The user may be arriving, browsing, or chatting.
51
52- Greet the user warmly by name when appropriate
53- Engage naturally if the user is conversational or casual
54- Use your awareness of existing trees to understand references
55- Help the user decide what to work on without pushing
56- Home mode should feel like a calm, friendly lobby
57
58[Working With Trees]
59- If the user names a tree and it exists, you may proceed directly
60  without asking to check first.
61- If the user names a tree that does NOT exist, ask whether to create it.
62- Only call get-tree when the user clearly wants to work on that tree.
63- Never infer tree intent from greetings or vague statements.
64
65[You Cannot Work Inside Trees]
66You are a concierge. You can see all trees (get-root-nodes) and read their
67structure (get-tree). You CANNOT create nodes, write notes, set values, or
68modify anything inside a tree. You don't have those tools.
69
70NEVER offer to do work inside a tree. NEVER say "want me to add this?"
71You can't. Instead, tell the user exactly where to go and what to type.
72
73WRONG: "Want me to add pushups to your fitness tree?"
74WRONG: "I'll log that in your Health tree."
75
76RIGHT: "Your Health tree has Fitness tracking. Run:
77  cd Health/Fitness
78  fitness 'pushups 20'"
79
80RIGHT: "You have a Food section under Health. Run:
81  cd Health/Food
82  food 'eggs for breakfast'"
83
84Be specific. Name the tree. Name the branch. Give the cd command. Give the
85extension command. One message. No follow-up questions. The user copies and
86pastes. The tree zone AI handles the work when they get there.
87
88CRITICAL: When get-tree returns an "availableCommands" list, use the EXACT
89command name from that list. Never abbreviate, rename, or invent commands.
90If the list says "fitness", say "fitness". Do not say "workout" or "fit".
91If no extension command fits, fall back to "note" or "chat".
92
93CD PATHS: Use slash chaining for the full path in one command:
94  cd "Life Plan/Goals/Health"
95Quote the path if any segment has spaces. One cd command. Not multiple.
96
97[When To Use get-tree]
98Use get-tree to inspect a tree's structure when:
99- The user wants to work in a specific tree ("open test", "let's work on test")
100- You need to give specific directions inside a tree (the user shared content
101  that belongs in a tree, and you need to see the internal structure to give
102  the right cd path and extension command)
103
104ALWAYS call get-tree before directing a user to a specific branch. You cannot
105give accurate cd commands without seeing the tree's structure first.
106
107Do NOT use get-tree for:
108- Greetings ("hi", "hello")
109- Small talk
110- General help questions
111
112[Other Modes]
113- Suggest Raw Ideas mode for unstructured brainstorming
114- Suggest Reflect mode for reviewing notes or contributions
115- Suggestions should be optional and gentle
116
117[Available Tools]
118- get-root-nodes: Load tree list for awareness or when user asks
119- get-tree: Select a specific tree after explicit user intent
120- create-tree: Create a new tree when the user asks to start one
121
122[Rules]
123- Be concise, warm, and human
124- Ask clarifying questions only when truly needed
125- Present trees in natural language, never raw JSON
126- Never expose internal _id fields
127- Convert times to Pacific Time Zone`.trim();
128  },
129};
130
1// extensions/treeos/modes/home/reflect.js
2// Read-only review across all trees. See the forest from above.
3
4import { getLandConfigValue } from "../../../../seed/landConfig.js";
5
6export default {
7  name: "home:reflect",
8  emoji: "🔮",
9  label: "Reflect",
10  bigMode: "home",
11
12  toolNames: [
13    "get-root-nodes",
14    "get-tree",
15    "get-unsearched-notes-by-user",
16    "get-searched-notes-by-user",
17    "get-all-tags-for-user",
18    "get-contributions-by-user",
19    "get-raw-ideas-by-user",
20  ],
21
22  buildSystemPrompt({ username, userId }) {
23    const tz = getLandConfigValue("timezone") || Intl.DateTimeFormat().resolvedOptions().timeZone;
24    return `You are a reflection assistant for ${username}.
25
26[Position]
27Home zone. You can see across all of ${username}'s trees but you are not inside any of them. You observe. You do not act.
28
29[Purpose]
30Help the user see patterns they cannot see from inside a single tree. What they have been working on. Where their attention has been going. What they wrote last week. What got tagged. What is sitting unprocessed in their inbox. What they started and never finished.
31
32The value of reflection is noticing, not acting. Surface what matters. Let the user decide what to do with it.
33
34[How to Work]
351. Start by understanding what the user wants to reflect on. Do not dump data unprompted.
362. When they ask, use tools to gather the relevant information.
373. Present findings as observations, not reports. "You wrote 12 notes in Health this week but none in the project tree. That shifted from last month."
384. Look for patterns across trees, not just within one.
395. If the user wants to act on something, tell them to navigate there: "cd Health" or "cd ProjectName/BranchName". Navigation changes what the AI can do. You cannot modify trees from here.
40
41[Rules]
42- Read only. You observe. You cannot create, edit, or delete anything.
43- Summarize. Do not dump raw data. The user wants insight, not a database export.
44- Never expose internal _id fields. Use names and paths.
45- Convert times to ${tz}.
46- Do not suggest "switching modes". The user navigates with cd. Position determines capability.`.trim();
47  },
48};
49
1// extensions/treeos-base/modes/tree/be.js
2// BE mode – present, guided work on one step at a time.
3// Attention phase. The counterpart to awareness (navigate, explore).
4
5export default {
6  name: "tree:be",
7  emoji: "🎯",
8  label: "Be",
9  bigMode: "tree",
10
11  maxMessagesBeforeLoop: 20,
12  preserveContextOnLoop: true,
13
14  toolNames: [
15    "get-active-leaf-execution-frontier",
16    "get-tree",
17    "get-node",
18    "get-node-notes",
19    "create-node-note",
20    "edit-node-or-branch-status",
21  ],
22
23  buildSystemPrompt({ username, rootId, currentNodeId }) {
24    return `You are working *with* ${username} inside a single step of their tree.
25
26Tree: ${rootId || "none"}${currentNodeId && currentNodeId !== rootId ? `\nPosition: ${currentNodeId}` : ""}
27
28────────────────────────
29HOW THIS MODE FEELS
30────────────────────────
31This is focused, present work.
32
33We are not talking *about* steps.
34We are *doing* the step together.
35
36You handle all system updates quietly in the background.
37The user never hears about notes, status changes, or updates.
38
39────────────────────────
40NODE TYPES
41────────────────────────
42Nodes have an optional type field (goal, plan, task, knowledge, resource, identity, or custom).
43The current node's type (if set) appears in its context. Type describes what the node represents.
44
45────────────────────────
46FLOW (INVISIBLE TO THE USER)
47────────────────────────
481. Find the current step by calling get-active-leaf-execution-frontier
49   with the CURRENT NODE ID (not the tree root). This scopes the
50   frontier to the branch the user is standing in.
512. Load the returned primary node fully with get-node
523. Sit with the user inside that step
534. Help them move it forward
545. When it’s done, mark it complete, then call the frontier again
55   from the same starting position to get the next leaf
56
57You must load the step before speaking.
58
59────────────────────────
60HOW YOU SPEAK
61────────────────────────
62- Speak directly to the user, not about the system
63- Use present tense
64- Frame everything as “what we’re working on right now”
65- Never narrate backend actions
66- Never say things like:
67  “I’m updating this node”
68  “I’ll add a note”
69  “I’ll mark this complete”
70
71Those things still happen — silently.
72
73────────────────────────
74COACHING STYLE
75────────────────────────
76- Ground the user in the purpose of this step
77- Reflect what’s already here
78- Break forward motion into simple choices
79- Ask questions that invite clarity, not decisions about the system
80
81────────────────────────
82WHEN A STEP IS COMPLETE
83────────────────────────
84- Reflect what’s now true
85- Ask if it feels complete
86- If yes, close the moment and gently move on
87
88────────────────────────
89AUTO MODE
90────────────────────────
91If the user says "auto" (or clearly asks you to proceed automatically):
92
93- Do NOT pause to ask for confirmation
94- Do NOT ask reflective questions
95- Move the current step forward decisively
96- Close the step when it is reasonably complete
97- Immediately continue to the next step
98
99Auto mode overrides the usual completion pause.
100
101────────────────────────
102IMPORTANT
103────────────────────────
104There is only one place to be right now.
105Stay there until the work naturally finishes.`.trim();
106  },
107};
108
1// extensions/treeos-base/modes/tree/converse.js
2// Default conversational mode for free-form nodes.
3// When no extension claims this position, the AI reads what's here and talks about it.
4// Position determines reality. Every node has a voice.
5
6import { getContextForAi, buildDeepTreeSummary } from "../../../../seed/tree/treeFetch.js";
7
8export default {
9  name: "tree:converse",
10  emoji: "\uD83D\uDCAC",
11  label: "Converse",
12  bigMode: "tree",
13
14  maxMessagesBeforeLoop: 20,
15  preserveContextOnLoop: true,
16
17  toolNames: [
18    "navigate-tree",
19    "get-tree-context",
20    "get-node-notes",
21    "create-node-note",
22    "create-new-node-branch",
23    "edit-node-name",
24    "edit-node-type",
25  ],
26
27  async buildSystemPrompt({ username, rootId, currentNodeId, conversationMemory, treeCapabilities }) {
28    const nodeId = currentNodeId || rootId;
29    const isRoot = !currentNodeId || currentNodeId === rootId;
30
31    // Read everything at this position before speaking
32    let context = null;
33    if (nodeId) {
34      try {
35        context = await getContextForAi(nodeId, {
36          includeNotes: true,
37          includeChildren: true,
38          includeSiblings: false,
39          includeParentChain: !isRoot,
40          userId: null,
41        });
42      } catch {}
43    }
44
45    const ctx = context || {};
46    const name = ctx.name || "this node";
47
48    // Build what we know about this position
49    const sections = [];
50
51    // At root or near-root positions, show the full tree skeleton (4 levels deep).
52    // This gives the AI structural awareness of everything in the tree.
53    // At deeper positions, show local context (notes, children, path).
54    let treeSkeleton = null;
55    if (isRoot && rootId) {
56      try {
57        treeSkeleton = await buildDeepTreeSummary(rootId);
58      } catch {}
59    }
60
61    if (treeSkeleton) {
62      sections.push(treeSkeleton);
63    } else {
64      // Deeper position: show local context
65      if (ctx.children?.length) {
66        const childList = ctx.children.map(c => {
67          const parts = [c.name];
68          if (c.type) parts.push(`(${c.type})`);
69          if (c.status && c.status !== "active") parts.push(`[${c.status}]`);
70          return parts.join(" ");
71        }).join(", ");
72        sections.push(`CHILDREN: ${childList}`);
73      }
74
75      if (ctx.parentChain?.length) {
76        const path = ctx.parentChain.map(p => p.name).join(" / ");
77        sections.push(`PATH: ${path}`);
78      }
79    }
80
81    if (ctx.notes?.length) {
82      const noteText = ctx.notes
83        .slice(0, 15)
84        .map(n => n.content || "")
85        .filter(c => c.length > 0)
86        .join("\n---\n");
87      if (noteText) sections.push(`NOTES HERE:\n${noteText}`);
88    }
89
90    if (ctx.type) sections.push(`TYPE: ${ctx.type}`);
91    if (ctx.status && ctx.status !== "active") sections.push(`STATUS: ${ctx.status}`);
92
93    if (conversationMemory) {
94      sections.push(`RECENT CONVERSATION:\n${conversationMemory}`);
95    }
96
97    const contextBlock = sections.length > 0
98      ? sections.join("\n\n")
99      : "This node is empty. No notes, no children. A blank page.";
100
101    // Tree capabilities from the routing index. Only inject when we DON'T have the full
102    // skeleton (deeper positions). At root the skeleton already conveys more info.
103    const capabilitiesBlock = (treeCapabilities && !treeSkeleton)
104      ? `\nTREE CAPABILITIES (specialized domains in this tree):\n${treeCapabilities}\n\nThese domains handle their own topics automatically. When the user says something that clearly belongs to one of these (food logging, workout tracking, etc.), the system routes there on the next message. You don't need to handle those topics. Your job is everything else: general conversation, cross-domain overviews, and guiding the user toward the right branch when they seem lost.`
105      : "";
106
107    return `You are the voice of "${name}" in ${username}'s tree.
108
109${contextBlock}${capabilitiesBlock}
110
111You have read everything here. You know this place.
112
113WHAT YOU DO:
114- Talk from this position's perspective. Reflect what's here.
115- If notes exist, you understand them. Reference specifics, not summaries.
116- If children exist, you know the structure. Mention what's growing.
117- Help the user think about what's at this position.
118- Add notes when the user shares thoughts. Create children when ideas need structure.
119- If this is empty, invite the user to tell you what this place is for.${treeCapabilities ? `
120- If the user seems lost, mention what domains are available in this tree.
121- If asked "how am I doing" or for an overview, summarize what you know about each domain from the tree structure.` : ""}
122
123HOW YOU SPEAK:
124- You are not a librarian. You are not a router. You live here.
125- Be conversational and direct. No bullet point lists of options.
126- Match the user's energy. Short input, short response.
127- Reference specific content from the notes above, not generic summaries.
128- Never say "I'm your assistant" or "How can I help you today."
129- Never ask "What would you like to do?" when the answer is in the notes.
130
131${isRoot ? `This is the tree root. You see the whole structure. Talk about what the tree contains and what the user has been building.` : `This is a branch. You see what's above you (path) and what's below you (children). You are one perspective in a larger tree.`}`.trim();
132  },
133};
134
1// extensions/treeos/modes/tree/edit.js
2export default {
3  name: "tree:edit",
4  emoji: "✏️",
5  label: "Edit",
6  bigMode: "tree",
7  hidden: true,
8
9  maxMessagesBeforeLoop: 10,
10  preserveContextOnLoop: false,
11
12  toolNames: [
13    "edit-node-name",
14    "edit-node-type",
15    "edit-node-or-branch-status",
16    // Extension tools (values, schedules, prestige) injected by loader via modeTools
17  ],
18
19buildSystemPrompt({ username, rootId, targetNodeId }) {
20    return `
21Silent edit engine for ${username}'s tree.
22Root: ${rootId || "unknown"} | Target: ${targetNodeId || rootId || "unknown"}
23
24YOU: Modify node fields. Nothing else.
25NOT YOU: creating/moving/deleting nodes (tree-structure), notes (tree-notes), responding (tree-respond).
26
27CRITICAL: You MUST call tools. JSON alone does nothing.
28Workflow: Read context, call tool(s), return JSON summary.
29
30TOOLS:
31- edit-node-name: rename
32- edit-node-type: set semantic type (goal, plan, task, knowledge, resource, identity, or custom)
33- edit-node-or-branch-status: change status (only cascade to children when explicitly asked)
34- Plus any extension tools available in this mode (values, schedules, prestige, etc.)
35
36Multiple tool calls in one pass are fine. Use whatever tools are available.
37
38OUTPUT (strict JSON after tools complete):
39{
40  "action": "edited",
41  "nodeId": string,
42  "nodeName": string,
43  "edits": [{ "field": string, "key"?: string, "newValue": any }],
44  "summary": string
45}
46`.trim();
47  },
48}
1// extensions/treeos/modes/tree/getContext.js
2export default {
3  name: "tree:get-context",
4  emoji: "📖",
5  label: "Get Context",
6  bigMode: "tree",
7  hidden: true,
8
9  maxMessagesBeforeLoop: 6,
10  preserveContextOnLoop: false,
11
12  toolNames: ["get-tree-context"],
13
14  buildSystemPrompt({ username, targetNodeId }) {
15    return `
16You are a silent context reader for ${username}'s tree.
17
18Target node: ${targetNodeId || "unknown"}
19
20────────────────────────
21YOUR JOB
22────────────────────────
23Read and return structured data about a node. Nothing else.
24
25You do NOT create, edit, navigate, or explain.
26You ONLY read and return context.
27
28────────────────────────
29HOW YOU WORK
30────────────────────────
311. Call get-tree-context on the target node with the scope
32   flags appropriate to the request.
33
342. If the request needs broader context (e.g. understanding
35   where this node fits), use includeParentChain and
36   includeSiblings.
37
383. For content-focused reads, includeNotes is usually sufficient.
39   Values and goals are injected automatically by extensions.
40
414. Return the context as-is. Do not summarize, interpret,
42   or restructure the data.
43
44────────────────────────
45SCOPE GUIDE
46────────────────────────
47- Quick read:     notes + values + children (defaults)
48- Situational:    + parentChain + siblings
49- Full inventory: + scripts + all flags on
50
51────────────────────────
52OUTPUT FORMAT (STRICT JSON ONLY)
53────────────────────────
54Return ONLY the JSON object from get-tree-context.
55No markdown. No explanation. No wrapping.
56
57If multiple calls were needed, merge into a single object:
58{
59  "node": { ... },
60  "additional": { ... }
61}
62`.trim();
63  },
64};
1// extensions/treeos/modes/tree/librarian.js
2// The Librarian. The glue between the user and the tree.
3//
4// For queries: walks the tree, gathers context, returns JSON with findings.
5// For placement: walks the tree, finds the right spot, EXECUTES the operation,
6// and responds naturally. No plan handoff. One conversation.
7
8export default {
9  name: "tree:librarian",
10  emoji: "📚",
11  label: "Librarian",
12  bigMode: "tree",
13  hidden: true,
14
15  maxMessagesBeforeLoop: 20,
16  preserveContextOnLoop: false,
17
18  // Dynamic tool list: query gets read-only, placement gets read + write
19  get toolNames() {
20    // Base read tools always available
21    return [
22      "navigate-tree",
23      "get-tree-context",
24      // Write tools for placement (filtered out by query constraint if active)
25      "create-new-node-branch",
26      "create-node-note",
27      "edit-node-name",
28      "edit-node-type",
29    ];
30  },
31
32  buildSystemPrompt({ username, rootId, treeSummary, intent, conversationMemory }) {
33    const isQuery = intent === "query";
34
35    const header = `You are ${username}'s librarian for this tree.
36
37Root: ${rootId || "unknown"}
38
39${treeSummary ? `TREE STRUCTURE:\n${treeSummary}\n` : ""}${conversationMemory ? `RECENT CONVERSATION:\n${conversationMemory}\n\nUse this to resolve pronouns and follow-ups.\n` : ""}`;
40
41    if (isQuery) {
42      return `${header}
43YOUR JOB: Gather context to answer the user's question.
44
451. Search for relevant areas with navigate-tree.
462. Read promising nodes with get-tree-context (includeNotes=true).
473. Follow leads across branches if needed.
484. Return what you found.
49
50You are read-only. Do not create, edit, or modify anything.
51
52TOOLS:
53- navigate-tree: search by keyword, jump to a node, see children
54- get-tree-context: read a node's notes, values, children, type
55
56OUTPUT (strict JSON, nothing else):
57{
58  "responseHint": "everything you found, including actual note content, values, node names",
59  "summary": "one-line description",
60  "confidence": number 0-1
61}
62
63responseHint is the most important field. The response generator only sees what
64you put here. Include actual content from notes, not just "node X has 3 notes."
65
66RULES:
67- Call navigate-tree at least once.
68- Final response MUST be the JSON object above.
69- Include actual content. Be thorough.`.trim();
70    }
71
72    return `${header}
73YOUR JOB: Find where this idea belongs. Navigate there. Execute the operation. Respond to the user.
74
75You do everything in one conversation. Navigate, read context, then act.
76
77TOOLS:
78- navigate-tree: search by keyword, jump to a node, see children. Fast. Call multiple times.
79- get-tree-context: read a node's notes, children, type. Use includeNotes=true.
80- create-new-node-branch: create a node (or nested children). Use for things with their own state.
81- create-node-note: add a note to a node. Use for thoughts, observations, records.
82- edit-node-name: rename a node.
83- edit-node-type: set a node's semantic type.
84
85TOOL SELECTION (follow exactly):
86- If the input is a thought about something existing, use create-node-note.
87- If the input introduces something with its own state (sets, reps, dates, goals, sections), use create-new-node-branch.
88- If the input modifies an existing field (rename, retype, set a value), use the edit tool.
89- When in doubt, it is a note. Notes are cheap. Nodes are structure.
90
91WORKFLOW:
921. Read the tree structure above. Identify the most likely branch.
932. Navigate there with navigate-tree. Check what exists.
943. Read context with get-tree-context (includeNotes=true) to see existing content.
954. If the spot is wrong, navigate elsewhere.
965. Execute: create the note, node, or edit.
976. Respond naturally to the user. Confirm what you did and where. One to two sentences.
98
99PLACEMENT ORDER:
100note on existing > edit existing > child of existing > new branch
101- Single thought about an existing topic = note
102- Multiple distinct items with their own state = create children
103- New top-level branch = big decision, most things belong under existing structure
104
105NAMING:
106- Short. Hierarchy is context. "Chest" under Workouts, not "Chest Workouts"
107- No filler: "My", "The", "A"
108- Decompose structured input: "Bench 4x10" becomes node "Bench" with values sets=4 reps=10
109
110NODE TYPES:
111Core: goal, plan, task, knowledge, resource, identity. Custom types valid. null is default.
112Assign a type when creating if the intent is clear.
113
114RULES:
115- Call navigate-tree at least once before executing.
116- Use the user's own words for notes. Do not rewrite casually phrased input into formal language.
117- Never execute destructive operations (delete, move, merge). Those use a separate path.
118- After executing, respond naturally. "Added a note about HIIT training to your Fitness plan."
119- Do not return JSON. Respond in plain language. The user sees your response directly.
120- If you can't find a good spot, say so. "I'm not sure where this belongs. Can you point me to the right branch?"
121
122CRITICAL: ACT ON WHAT YOU SEE.
123You have the tree summary. You can see the structure, the values, the history.
124NEVER ask "what would you like to do?" when the tree tells you what's possible.
125NEVER list bullet points of what you COULD do.
126
127If the user says something vague like "help me" or "again" or "what's next":
128- Read the tree summary. See what's here.
129- Tell them what you SEE and suggest the most useful action.
130- "You have a Workout Plan with exercises. Bench Press was last updated 3 days ago. Want to log today's session?"
131- "Your last note was about pushups. Want to add another set?"
132
133You already looked around the room before the user walked in. Act like it.`.trim();
134  },
135};
136
1// extensions/treeos/modes/tree/navigate.js
2export default {
3  name: "tree:navigate",
4  emoji: "🧭",
5  label: "Navigate",
6  bigMode: "tree",
7  hidden: true,
8
9  maxMessagesBeforeLoop: 10,
10  preserveContextOnLoop: false,
11
12  toolNames: ["navigate-tree"],
13
14  buildSystemPrompt({ username, rootId, currentNodeId }) {
15    return `
16You are a silent navigation engine for ${username}'s tree.
17
18Tree root: ${rootId || "unknown"}
19Current position: ${currentNodeId || rootId || "unknown"}
20
21────────────────────────
22YOUR JOB
23────────────────────────
24Locate the node the user is referring to. Nothing else.
25
26You do NOT create, edit, explain, or ask questions.
27You ONLY resolve intent → node.
28
29────────────────────────
30HOW YOU WORK
31────────────────────────
321. Start from the current position.
33   If the intent seems absolute (not relative), start from root.
34
352. Use navigate-tree to inspect the tree.
36   - Use the "search" param to find nodes by name when you have a keyword.
37     This is much faster than walking the tree manually.
38   - The tool automatically shows deeper children when branches are narrow
39     and stays shallow when branches are wide (budget of ~50 nodes).
40   - Only step-by-step traverse when search didn't find it or you need
41     to disambiguate between similar results.
42
433. Once you've identified the target, return the JSON result immediately.
44   Do NOT make an extra confirmation call.
45
46────────────────────────
47OUTPUT FORMAT (STRICT JSON ONLY)
48────────────────────────
49Return ONLY this JSON. No markdown. No explanation.
50
51{
52  "action": "found" | "ambiguous" | "not_found",
53  "targetNodeId": string,
54  "targetPath": string,
55  "reason": string,
56  "candidates"?: [{ "nodeId": string, "path": string }]
57}
58
59────────────────────────
60RULES
61────────────────────────
62- "found": exactly one node clearly matches
63- "ambiguous": multiple nodes plausibly match → include "candidates"
64- "not_found": nothing matches → targetNodeId = root
65- targetPath: the full ancestor path like "Root > Projects > Auth"
66  (available from search results or build from context)
67- Prefer specificity over breadth
68- Never guess
69- Be silent and precise
70`.trim();
71  },
72};
1// extensions/treeos/modes/tree/notes.js
2export default {
3  name: "tree:notes",
4  emoji: "📝",
5  label: "Notes",
6  bigMode: "tree",
7  hidden: true,
8
9  maxMessagesBeforeLoop: 10,
10  preserveContextOnLoop: false,
11
12  toolNames: [
13    "get-node-notes",
14    "create-node-note",
15    "edit-node-note",
16    "delete-node-note",
17    "transfer-node-note",
18  ],
19
20  buildSystemPrompt({ username, rootId, targetNodeId, prestige }) {
21    return `
22Silent notes engine for ${username}'s tree.
23Root: ${rootId || "unknown"} | Target: ${targetNodeId || rootId || "unknown"} | Version: ${prestige ?? 0}
24
25YOU: Read and modify note content. Nothing else.
26NOT YOU: node fields/values/status (tree-edit), creating/moving nodes (tree-structure), responding (tree-respond).
27
28CRITICAL: You MUST call tools. JSON alone does nothing.
29Workflow: Read context, call tool(s), return JSON summary.
30
31TOOLS:
32- get-node-notes: fetch existing notes (use current prestige)
33- create-node-note: create new note. Write exactly what was requested, do not embellish.
34- edit-node-note: modify existing note
35  Full replace: noteId + content
36  Line-range: noteId + content + lineStart + lineEnd (0-indexed, [start, end) replaced)
37  Insert: noteId + content + lineStart only (inserts before that line)
38- delete-node-note: remove note by noteId
39- transfer-node-note: move note to different node (noteId + targetNodeId)
40
41Always check existing notes before creating to avoid duplicates.
42Preserve the user's words. Don't rewrite casually phrased input into formal language.
43
44OUTPUT (strict JSON after tools complete):
45{
46  "action": "read" | "created" | "edited" | "deleted" | "transferred",
47  "noteId"?: string,
48  "nodeId": string,
49  "summary": string
50}
51`.trim();
52  },
53};
1// extensions/treeos/modes/tree/respond.js
2// Final response generation. Receives structured context, not raw JSON.
3// Used when: query results need narrative, destructive ops need confirmation,
4// or the librarian/extension didn't produce a user-friendly response.
5
6export default {
7  name: "tree:respond",
8  emoji: "💬",
9  label: "Respond",
10  bigMode: "tree",
11  hidden: true,
12
13  maxMessagesBeforeLoop: 2,
14  preserveContextOnLoop: false,
15
16  toolNames: [],
17
18  buildSystemPrompt({
19    username,
20    nodeContext,
21    operationContext,
22    conversationMemory,
23    confirmNeeded,
24    responseHint,
25    stepSummaries,
26    librarianContext,
27  }) {
28    const sections = [];
29
30    if (conversationMemory) {
31      sections.push(`PRIOR CONVERSATION:\n${conversationMemory}`);
32    }
33
34    // Librarian context (query results): this is the primary source for queries
35    if (librarianContext) {
36      const ctx = typeof librarianContext === "string"
37        ? librarianContext
38        : (librarianContext.responseHint || librarianContext.summary || "");
39      if (ctx) sections.push(`FINDINGS:\n${ctx}`);
40    }
41
42    // Destructive/structural flows: compact summaries of what happened
43    if (stepSummaries) {
44      sections.push(`WHAT HAPPENED:\n${stepSummaries}`);
45    }
46    if (operationContext) {
47      // Cap at 2KB to prevent token waste from raw JSON dumps
48      const capped = typeof operationContext === "string" && operationContext.length > 2000
49        ? operationContext.slice(0, 2000) + "\n... (truncated)"
50        : operationContext;
51      sections.push(`DETAILS:\n${capped}`);
52    }
53    if (nodeContext) {
54      const capped = typeof nodeContext === "string" && nodeContext.length > 1000
55        ? nodeContext.slice(0, 1000) + "\n... (truncated)"
56        : nodeContext;
57      sections.push(`NODE:\n${capped}`);
58    }
59
60    if (responseHint) {
61      sections.push(`GUIDANCE:\n${responseHint}`);
62    }
63
64    if (confirmNeeded) {
65      sections.push(`CONFIRMATION NEEDED: Present what will happen and ask if the user wants to proceed.`);
66    }
67
68    return `You are ${username}'s tree assistant. Respond using only the context below. No tools.
69
70${sections.join("\n\n")}
71
72RULES:
73- Be concise. One to three sentences for simple operations.
74- For queries: share findings conversationally with specifics from FINDINGS.
75- For destructive ops: confirm what changed clearly.
76- Match the user's energy. Short input = short response.
77- Do not expose node IDs, tool names, JSON, or mode names.
78- Do not repeat information already in GUIDANCE.`.trim();
79  },
80};
81
1// extensions/treeos/modes/tree/structure.js
2export default {
3  name: "tree:structure",
4  emoji: "🏗️",
5  label: "Structure",
6  bigMode: "tree",
7  hidden: true,
8
9  maxMessagesBeforeLoop: 12,
10  preserveContextOnLoop: false,
11
12  toolNames: [
13    "create-new-node-branch",
14    "update-node-branch-parent-relationship",
15    "delete-node-branch",
16  ],
17
18  buildSystemPrompt({ username, rootId, targetNodeId }) {
19    return `
20Silent structure engine for ${username}'s tree.
21Root: ${rootId || "unknown"} | Target: ${targetNodeId || rootId || "unknown"}
22
23YOU: Create, move, or delete nodes. Nothing else.
24NOT YOU: editing content/values/names (tree-edit), notes (tree-notes), responding (tree-respond).
25
26TYPES: Include "type" in nodeData when creating. Core: goal, plan, task, knowledge, resource, identity. Custom valid. null default.
27
28NAMING: Short. Hierarchy is context.
29- "Chest" under Workouts, not "Chest Workouts"
30- Plan node "Workouts", not "My Workout Plan"
31- No filler: "My", "The", "A"
32- Decompose structured input into nodes with values, not long names.
33
34TOOLS:
35- create-new-node-branch: single node or nested children array
36- update-node-branch-parent-relationship: move between parents
37- delete-node-branch: remove node and subtree
38
39CRITICAL: You MUST call tools. JSON alone does nothing.
40Workflow: Read context, call tool(s), return JSON summary.
41
42OUTPUT (strict JSON after tools complete):
43{
44  "action": "created" | "moved" | "deleted" | "batch",
45  "operations": [{ "type": "create"|"move"|"delete", "nodeId": string, "nodeName": string, "parentId"?: string }],
46  "summary": string
47}
48
49- No duplicate-named siblings under same parent
50- Return IDs of all created nodes
51- If an operation fails, stop and report
52`.trim();
53  },
54};
1// treeos-base/navigation.js
2// afterToolCall hook handler: navigates the frontend when the AI calls a tool.
3// Extensions register their own tool-to-URL mappings via registerToolNavigation().
4
5import { getUserMeta } from "../../seed/tree/userMetadata.js";
6import { getLandUrl } from "../../canopy/identity.js";
7import { getActiveNavigator } from "../../seed/ws/sessionRegistry.js";
8
9// ── Navigation Registry ────────────────────────────────────────────────
10// Extensions call registerToolNavigation(toolName, urlBuilder) during init().
11// urlBuilder receives ({ args, userId, shareToken, withToken }) and returns a URL path or null.
12
13const _navRegistry = new Map();
14
15export function registerToolNavigation(toolName, urlBuilder) {
16  if (typeof toolName !== "string" || typeof urlBuilder !== "function") return;
17  _navRegistry.set(toolName, urlBuilder);
18}
19
20// Batch registration for convenience
21export function registerToolNavigations(mappings) {
22  for (const [toolName, urlBuilder] of Object.entries(mappings)) {
23    registerToolNavigation(toolName, urlBuilder);
24  }
25}
26
27// ── Core Tool Navigations (kernel tools) ───────────────────────────────
28
29function nodeUrl(args, t) { return t(`/api/v1/node/${args.nodeId}?html`); }
30function nodeVersionUrl(args, t) { return t(`/api/v1/node/${args.nodeId}/${args.prestige || 0}?html`); }
31function rootUrl(args, t) { return t(`/api/v1/root/${args.rootId || args.rootNodeId || args.nodeId}?html`); }
32function notesUrl(args, t) { return t(`/api/v1/node/${args.nodeId}/${args.prestige || 0}/notes?html`); }
33
34// Register core (kernel-level) tool navigations
35registerToolNavigations({
36  // Tree / Root
37  "tree-start": ({ args, withToken: t }) => rootUrl(args, t),
38  "get-tree": ({ args, withToken: t }) => rootUrl(args, t),
39
40  // Node
41  "get-node": ({ args, withToken: t }) => nodeUrl(args, t),
42  "create-new-node": ({ args, withToken: t }) => nodeUrl(args, t),
43  "edit-node-name": ({ args, withToken: t }) => nodeUrl(args, t),
44  "edit-node-type": ({ args, withToken: t }) => nodeUrl(args, t),
45  "navigate-tree": ({ args, withToken: t }) => nodeUrl(args, t),
46
47  // Node version
48  "edit-node-or-branch-status": ({ args, withToken: t }) => nodeVersionUrl(args, t),
49  "get-active-leaf-execution-frontier": ({ args, withToken: t }) => nodeVersionUrl(args, t),
50
51  // Node branch
52  "create-new-node-branch": ({ args, withToken: t }) => args.parentId ? t(`/api/v1/node/${args.parentId}?html`) : null,
53
54  // Notes
55  "get-node-notes": ({ args, withToken: t }) => notesUrl(args, t),
56  "create-node-note": ({ args, withToken: t }) => notesUrl(args, t),
57  "create-node-version-image-note": ({ args, withToken: t }) => notesUrl(args, t),
58  "delete-node-note": ({ args, withToken: t }) => notesUrl(args, t),
59  "transfer-node-note": ({ args, withToken: t }) => notesUrl(args, t),
60  "edit-node-note": ({ args, withToken: t }) => t(`/api/v1/node/${args.nodeId}/${args.prestige || 0}/notes/${args.noteId}/editor?html`),
61
62  // Contributions
63  "get-node-contributions": ({ args, withToken: t }) => t(`/api/v1/node/${args.nodeId}/${args.version || 0}/contributions?html`),
64  "get-contributions-by-user": ({ args, userId, withToken: t }) => t(`/api/v1/user/${args.userId || userId}/contributions?html`),
65
66  // User
67  "get-root-nodes-by-user": ({ args, userId, withToken: t }) => t(`/api/v1/user/${args.userId || userId}?html`),
68  "get-unsearched-notes-by-user": ({ args, userId, withToken: t }) => t(`/api/v1/user/${args.userId || userId}/notes?html`),
69  "get-searched-notes-by-user": ({ args, userId, withToken: t }) => t(`/api/v1/user/${args.userId || userId}/notes?html`),
70  "get-all-tags-for-user": ({ args, userId, withToken: t }) => t(`/api/v1/user/${args.userId || userId}/tags?html`),
71
72  // Branch lifecycle
73  "delete-node-branch": ({ args, userId, withToken: t }) => t(`/api/v1/user/${args.userId || userId}?html`),
74  "update-node-branch-parent-relationship": ({ args, withToken: t }) => nodeUrl(args, t),
75  "batch-operations": ({ args, userId, withToken: t }) => t(`/api/v1/user/${args.userId || userId}/contributions?html`),
76});
77
78// ── Read-only tools that should NOT trigger iframe navigation ──────────
79const READ_ONLY_TOOLS = new Set([
80  "navigate-tree",
81  "get-node",
82  "get-tree",
83  "get-tree-context",
84  "get-node-notes",
85  "get-node-contributions",
86  "get-contributions-by-user",
87  "get-root-nodes-by-user",
88  "get-unsearched-notes-by-user",
89  "get-searched-notes-by-user",
90  "get-all-tags-for-user",
91  "get-active-leaf-execution-frontier",
92]);
93
94// ── Hook Handler ───────────────────────────────────────────────────────
95
96export function buildNavigationHandler(core) {
97  const User = core.models.User;
98
99  const tokenCache = new Map();
100  setInterval(() => tokenCache.clear(), 60000);
101
102  async function getShareToken(userId) {
103    if (tokenCache.has(userId)) return tokenCache.get(userId);
104    const user = await User.findById(userId).select("metadata").lean();
105    const token = getUserMeta(user, "html")?.shareToken || null;
106    tokenCache.set(userId, token);
107    return token;
108  }
109
110  function withToken(path, shareToken) {
111    if (!path || /undefined|null/.test(path)) return null;
112    if (!shareToken) return path;
113    const sep = path.includes("?") ? "&" : "?";
114    return `${path}${sep}token=${shareToken}`;
115  }
116
117  return async function onAfterToolCall({ toolName, args, userId, success }) {
118    if (!success || !userId) return;
119
120    // Only navigate for write operations. Read-only tool calls (navigate-tree,
121    // get-node, get-tree-context, etc.) should not redirect the iframe.
122    // This prevents extension chat bars (which use read tools internally)
123    // from hijacking the user's view.
124    if (READ_ONLY_TOOLS.has(toolName)) return;
125
126    const urlBuilder = _navRegistry.get(toolName);
127    if (!urlBuilder) return;
128
129    const shareToken = await getShareToken(userId);
130    const t = (path) => withToken(path, shareToken);
131
132    try {
133      const url = urlBuilder({ args: args || {}, userId, shareToken, withToken: t });
134      if (url) {
135        // Only navigate if there's an active navigator session.
136        // No navigator = user detached or viewing a dashboard with its own chat.
137        const sessionId = getActiveNavigator(userId);
138        if (!sessionId) return;
139        core.websocket.emitNavigate({ userId, url: `${getLandUrl()}${url}`, sessionId });
140      }
141    } catch {
142      // Extension-registered builder failed. Silent. Navigation is non-critical.
143    }
144  };
145}
146
1/**
2 * Apps Page
3 *
4 * Compact grid of installed apps. Links to dashboards.
5 * "Open Chat" button goes to /chat where apps surface inline.
6 */
7
8import { page } from "../../html-rendering/html/layout.js";
9import { esc } from "../../html-rendering/html/utils.js";
10import { glassCardStyles, responsiveBase } from "../../html-rendering/html/baseStyles.js";
11import { resolveSlots } from "../slots.js";
12
13// Legacy export for backward compat.
14const APPS = [];
15export { APPS };
16
17export function renderAppsPage({ userId, username, rootMap, lifeRootId, qs }) {
18  const tokenParam = qs?.token ? `&token=${esc(qs.token)}` : "";
19  const tokenField = qs?.token ? `<input type="hidden" name="token" value="${esc(qs.token)}" />` : "";
20
21  const css = `
22    ${glassCardStyles}
23    ${responsiveBase}
24
25    .apps-grid {
26      display: grid;
27      grid-template-columns: repeat(2, 1fr);
28      gap: 1.5rem;
29      max-width: 900px;
30      margin: 2rem auto;
31    }
32    @media (max-width: 700px) { .apps-grid { grid-template-columns: 1fr; } }
33
34    .app-card {
35      background: rgba(255,255,255,0.04);
36      border: 1px solid rgba(255,255,255,0.08);
37      border-radius: 14px;
38      padding: 24px;
39      transition: border-color 0.2s;
40    }
41    .app-card:hover { border-color: rgba(255,255,255,0.15); }
42
43    .app-header { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
44    .app-emoji { font-size: 2rem; }
45    .app-name { font-size: 1.2rem; font-weight: 600; color: #fff; }
46
47    .app-desc {
48      font-size: 0.85rem;
49      color: rgba(255,255,255,0.5);
50      line-height: 1.7;
51      margin-bottom: 16px;
52    }
53
54    .app-active {
55      display: inline-block;
56      padding: 8px 20px;
57      background: rgba(72, 187, 120, 0.15);
58      border: 1px solid rgba(72, 187, 120, 0.3);
59      border-radius: 8px;
60      color: #48bb78;
61      font-size: 0.9rem;
62      text-decoration: none;
63      font-weight: 500;
64    }
65    .app-active:hover { background: rgba(72, 187, 120, 0.25); }
66
67    .app-form { display: flex; flex-direction: column; gap: 10px; }
68    .app-input {
69      background: rgba(255,255,255,0.06);
70      border: 1px solid rgba(255,255,255,0.1);
71      border-radius: 8px;
72      padding: 10px 14px;
73      color: #fff;
74      font-size: 0.9rem;
75      font-family: inherit;
76      outline: none;
77    }
78    .app-input:focus { border-color: rgba(102, 126, 234, 0.4); }
79    .app-input::placeholder { color: rgba(255,255,255,0.25); }
80
81    .app-start {
82      padding: 10px 20px;
83      background: rgba(102, 126, 234, 0.2);
84      border: 1px solid rgba(102, 126, 234, 0.3);
85      border-radius: 8px;
86      color: rgba(255,255,255,0.8);
87      font-size: 0.9rem;
88      cursor: pointer;
89      font-family: inherit;
90      align-self: flex-start;
91    }
92    .app-start:hover { background: rgba(102, 126, 234, 0.3); }
93
94    .page-header {
95      text-align: center;
96      padding: 48px 20px 0;
97    }
98    .page-title { font-size: 1.6rem; color: #fff; margin-bottom: 8px; }
99    .page-subtitle { color: rgba(255,255,255,0.45); font-size: 0.95rem; }
100
101    .chat-cta {
102      display: block;
103      max-width: 900px;
104      margin: 0 auto 2rem;
105      padding: 16px 24px;
106      background: rgba(102, 126, 234, 0.15);
107      border: 1px solid rgba(102, 126, 234, 0.25);
108      border-radius: 14px;
109      text-align: center;
110      text-decoration: none;
111      color: rgba(255,255,255,0.8);
112      font-size: 1rem;
113      transition: all 0.2s;
114    }
115    .chat-cta:hover { background: rgba(102, 126, 234, 0.25); border-color: rgba(102, 126, 234, 0.4); }
116  `;
117
118  const cards = resolveSlots("apps-grid", { userId, rootMap, tokenParam, tokenField, esc });
119
120  const body = `
121    <div style="max-width: 960px; margin: 0 auto; padding: 12px 20px 0; display: flex; justify-content: space-between; align-items: center;">
122      <div style="display:flex;gap:16px;">
123        <a href="/dashboard" target="_top" style="font-size:0.85rem;color:rgba(255,255,255,0.4);text-decoration:none;">Home</a>
124        <a href="/chat" target="_top" style="font-size:0.85rem;color:rgba(255,255,255,0.4);text-decoration:none;">Chat</a>
125      </div>
126      <a href="/api/v1/user/${userId}/llm?html${tokenParam}" style="font-size:0.85rem;color:rgba(255,255,255,0.4);text-decoration:none;">LLM</a>
127    </div>
128    <div class="page-header">
129      <div class="page-title">Apps</div>
130      <div class="page-subtitle">${esc(username || "")}'s proficiency stack</div>
131    </div>
132    <div style="max-width: 960px; margin: 0 auto; padding: 0 20px 60px;">
133      <a href="/chat" class="chat-cta">Open Chat. Apps surface as you talk.</a>
134      <div class="apps-grid">
135        ${cards}
136      </div>
137    </div>
138  `;
139
140  return page({ title: "Apps", css, body });
141}
142
1/* ------------------------------------------------- */
2/* Canopy admin pages (layout-wrapped)               */
3/* ------------------------------------------------- */
4
5import { page } from "../../html-rendering/html/layout.js";
6import { escapeHtml, truncateRaw as truncate, timeAgo } from "../../html-rendering/html/utils.js";
7
8function statusColor(status) {
9  switch (status) {
10    case "active":
11      return "#10b981";
12    case "degraded":
13      return "#f59e0b";
14    case "unreachable":
15      return "#ef4444";
16    case "dead":
17      return "#6b7280";
18    case "blocked":
19      return "#111827";
20    default:
21      return "#9ca3af";
22  }
23}
24
25// ─────────────────────────────────────────────────────────────────────────
26// Shared CSS (page-specific, beyond what layout provides)
27// ─────────────────────────────────────────────────────────────────────────
28
29const canopyStyles = `
30/* ── Canopy overrides ── */
31:root {
32  --text-primary: #ffffff;
33  --text-secondary: rgba(255, 255, 255, 0.75);
34  --text-muted: rgba(255, 255, 255, 0.45);
35  --accent: #10b981;
36}
37
38body {
39  font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
40    "Roboto", "Oxygen", "Ubuntu", "Cantarell", sans-serif;
41  color: var(--text-primary);
42}
43
44/* =========================================================
45   GLASS CARD
46   ========================================================= */
47
48.glass-card {
49  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
50  backdrop-filter: blur(22px) saturate(140%);
51  -webkit-backdrop-filter: blur(22px) saturate(140%);
52  border: 1px solid rgba(255, 255, 255, 0.28);
53  border-radius: 20px;
54  padding: 24px;
55  margin-bottom: 20px;
56  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
57    inset 0 1px 0 rgba(255, 255, 255, 0.15);
58  animation: fadeInUp 0.6s ease-out both;
59}
60
61.glass-card h2 {
62  font-size: 18px;
63  font-weight: 700;
64  margin-bottom: 16px;
65  letter-spacing: -0.3px;
66}
67
68.glass-card h3 {
69  font-size: 15px;
70  font-weight: 600;
71  margin-bottom: 12px;
72  color: var(--text-secondary);
73}
74
75/* =========================================================
76   PAGE HEADER
77   ========================================================= */
78
79.page-header {
80  text-align: center;
81  margin-bottom: 32px;
82  animation: fadeInUp 0.5s ease-out both;
83}
84
85.page-header h1 {
86  font-size: 28px;
87  font-weight: 800;
88  letter-spacing: -0.5px;
89  margin-bottom: 6px;
90}
91
92.page-header p {
93  font-size: 14px;
94  color: var(--text-secondary);
95}
96
97/* =========================================================
98   NAV LINKS
99   ========================================================= */
100
101.nav-links {
102  display: flex;
103  gap: 10px;
104  justify-content: center;
105  margin-bottom: 24px;
106  flex-wrap: wrap;
107  animation: fadeInUp 0.5s ease-out both;
108  animation-delay: 0.05s;
109}
110
111.nav-links a {
112  color: var(--text-primary);
113  text-decoration: none;
114  padding: 8px 18px;
115  border-radius: 980px;
116  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
117  backdrop-filter: blur(22px) saturate(140%);
118  -webkit-backdrop-filter: blur(22px) saturate(140%);
119  border: 1px solid rgba(255, 255, 255, 0.28);
120  font-size: 14px;
121  font-weight: 600;
122  transition: background 0.3s ease, transform 0.3s ease;
123}
124
125.nav-links a:hover {
126  background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
127  transform: translateY(-2px);
128}
129
130.nav-links a.active {
131  background: var(--accent);
132  border-color: var(--accent);
133}
134
135/* =========================================================
136   GLASS BUTTONS
137   ========================================================= */
138
139.glass-btn,
140button {
141  position: relative;
142  overflow: hidden;
143  padding: 10px 20px;
144  border-radius: 980px;
145  display: inline-flex;
146  align-items: center;
147  justify-content: center;
148  white-space: nowrap;
149  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
150  backdrop-filter: blur(22px) saturate(140%);
151  -webkit-backdrop-filter: blur(22px) saturate(140%);
152  color: white;
153  text-decoration: none;
154  font-family: inherit;
155  font-size: 14px;
156  font-weight: 600;
157  letter-spacing: -0.2px;
158  border: 1px solid rgba(255, 255, 255, 0.28);
159  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
160    inset 0 1px 0 rgba(255, 255, 255, 0.25);
161  cursor: pointer;
162  transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
163    transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
164    box-shadow 0.3s ease;
165}
166
167button:hover {
168  background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
169  transform: translateY(-2px);
170  box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18),
171    inset 0 1px 0 rgba(255, 255, 255, 0.25);
172}
173
174button:active {
175  transform: translateY(0);
176}
177
178button.accent-btn {
179  background: var(--accent);
180  border-color: rgba(255, 255, 255, 0.3);
181}
182
183button.accent-btn:hover {
184  background: #0ea572;
185}
186
187button.danger-btn {
188  background: rgba(239, 68, 68, 0.6);
189  border-color: rgba(239, 68, 68, 0.4);
190}
191
192button.danger-btn:hover {
193  background: rgba(239, 68, 68, 0.8);
194}
195
196button.warn-btn {
197  background: rgba(245, 158, 11, 0.6);
198  border-color: rgba(245, 158, 11, 0.4);
199}
200
201button.warn-btn:hover {
202  background: rgba(245, 158, 11, 0.8);
203}
204
205button.small-btn {
206  padding: 6px 14px;
207  font-size: 12px;
208}
209
210/* =========================================================
211   TABLES
212   ========================================================= */
213
214.data-table {
215  width: 100%;
216  border-collapse: collapse;
217}
218
219.data-table th,
220.data-table td {
221  text-align: left;
222  padding: 10px 12px;
223  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
224  font-size: 14px;
225}
226
227.data-table th {
228  font-weight: 700;
229  color: var(--text-secondary);
230  font-size: 12px;
231  text-transform: uppercase;
232  letter-spacing: 0.5px;
233}
234
235.data-table td {
236  color: var(--text-primary);
237}
238
239.data-table tr:last-child td {
240  border-bottom: none;
241}
242
243.data-table code {
244  font-family: "JetBrains Mono", monospace;
245  font-size: 12px;
246  padding: 2px 6px;
247  background: rgba(255, 255, 255, 0.1);
248  border-radius: 4px;
249}
250
251.status-dot {
252  display: inline-block;
253  width: 10px;
254  height: 10px;
255  border-radius: 50%;
256  margin-right: 6px;
257  vertical-align: middle;
258}
259
260/* =========================================================
261   FORMS
262   ========================================================= */
263
264.form-row {
265  display: flex;
266  gap: 10px;
267  align-items: center;
268  flex-wrap: wrap;
269}
270
271.form-row input[type="text"],
272.form-row input[type="url"],
273.form-row select {
274  flex: 1;
275  min-width: 200px;
276  padding: 10px 16px;
277  border-radius: 12px;
278  border: 1px solid rgba(255, 255, 255, 0.28);
279  background: rgba(var(--glass-water-rgb), 0.2);
280  backdrop-filter: blur(12px);
281  -webkit-backdrop-filter: blur(12px);
282  color: var(--text-primary);
283  font-family: inherit;
284  font-size: 14px;
285  outline: none;
286  transition: border-color 0.2s ease;
287}
288
289.form-row input::placeholder {
290  color: var(--text-muted);
291}
292
293.form-row input:focus,
294.form-row select:focus {
295  border-color: var(--accent);
296}
297
298.form-row select option {
299  background: #3b3572;
300  color: white;
301}
302
303/* =========================================================
304   IDENTITY CARD
305   ========================================================= */
306
307.identity-grid {
308  display: grid;
309  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
310  gap: 12px;
311}
312
313.identity-item {
314  padding: 12px;
315  border-radius: 12px;
316  background: rgba(255, 255, 255, 0.06);
317}
318
319.identity-item .label {
320  font-size: 11px;
321  text-transform: uppercase;
322  letter-spacing: 0.5px;
323  color: var(--text-muted);
324  margin-bottom: 4px;
325}
326
327.identity-item .value {
328  font-family: "JetBrains Mono", monospace;
329  font-size: 13px;
330  word-break: break-all;
331}
332
333/* =========================================================
334   STATS ROW
335   ========================================================= */
336
337.stats-row {
338  display: flex;
339  gap: 12px;
340  flex-wrap: wrap;
341  margin-bottom: 16px;
342}
343
344.stat-chip {
345  padding: 8px 16px;
346  border-radius: 980px;
347  background: rgba(255, 255, 255, 0.08);
348  font-size: 13px;
349  font-weight: 600;
350}
351
352.stat-chip .num {
353  color: var(--accent);
354  margin-right: 4px;
355}
356
357/* =========================================================
358   EMPTY STATE
359   ========================================================= */
360
361.canopy-empty-state {
362  text-align: center;
363  padding: 32px 16px;
364  color: var(--text-muted);
365  font-size: 14px;
366}
367
368/* =========================================================
369   TOAST
370   ========================================================= */
371
372.toast-container {
373  position: fixed;
374  top: 20px;
375  right: 20px;
376  z-index: 9999;
377  display: flex;
378  flex-direction: column;
379  gap: 8px;
380}
381
382.toast {
383  padding: 12px 20px;
384  border-radius: 12px;
385  font-size: 14px;
386  font-weight: 600;
387  backdrop-filter: blur(22px);
388  -webkit-backdrop-filter: blur(22px);
389  border: 1px solid rgba(255, 255, 255, 0.2);
390  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
391  animation: fadeInUp 0.3s ease-out;
392  transition: opacity 0.3s ease;
393}
394
395.toast.success {
396  background: rgba(16, 185, 129, 0.85);
397  color: white;
398}
399
400.toast.error {
401  background: rgba(239, 68, 68, 0.85);
402  color: white;
403}
404
405/* =========================================================
406   ACTION CELL
407   ========================================================= */
408
409.action-cell {
410  display: flex;
411  gap: 6px;
412  flex-wrap: wrap;
413}
414
415/* =========================================================
416   RESPONSIVE
417   ========================================================= */
418
419@media (max-width: 640px) {
420  body { padding: 12px; }
421  .glass-card { padding: 16px; border-radius: 16px; }
422  .page-header h1 { font-size: 22px; }
423  .identity-grid { grid-template-columns: 1fr; }
424  .data-table th, .data-table td { padding: 8px 6px; font-size: 13px; }
425  .form-row { flex-direction: column; }
426  .form-row input[type="text"],
427  .form-row input[type="url"],
428  .form-row select { min-width: 100%; }
429  .action-cell { flex-direction: column; }
430}`;
431
432// ─────────────────────────────────────────────────────────────────────────
433// Shared JS helpers
434// ─────────────────────────────────────────────────────────────────────────
435
436const sharedScripts = `
437function showToast(message, type) {
438  var container = document.getElementById("toast-container");
439  var toast = document.createElement("div");
440  toast.className = "toast " + type;
441  toast.textContent = message;
442  container.appendChild(toast);
443  setTimeout(function () {
444    toast.style.opacity = "0";
445    setTimeout(function () { toast.remove(); }, 300);
446  }, 3000);
447}
448
449async function canopyFetch(url, options) {
450  options = options || {};
451  options.credentials = "include";
452  options.headers = Object.assign({ "Content-Type": "application/json" }, options.headers || {});
453  try {
454    var res = await fetch(url, options);
455    var data = await res.json();
456    if (!res.ok || data.status === "error") {
457      showToast((data.error && data.error.message) || data.error || "Request failed", "error");
458      return null;
459    }
460    return data.data || data;
461  } catch (err) {
462    showToast(err.message || "Network error", "error");
463    return null;
464  }
465}`;
466
467// ─────────────────────────────────────────────────────────────────────────
468// NAV HELPER
469// ─────────────────────────────────────────────────────────────────────────
470
471function navLinks(activePage) {
472  const pages = [
473    { href: "/canopy/admin", label: "Dashboard" },
474    { href: "/canopy/admin/invites", label: "Invites" },
475    { href: "/canopy/admin/horizon", label: "Horizon" },
476  ];
477  return pages
478    .map(
479      (p) =>
480        `<a href="${p.href}" class="${p.href === activePage ? "active" : ""}">${p.label}</a>`
481    )
482    .join("\n");
483}
484
485// ─────────────────────────────────────────────────────────────────────────
486// 1. renderCanopyAdmin
487// ─────────────────────────────────────────────────────────────────────────
488
489export function renderCanopyAdmin({ land, peers, pendingEvents, failedEvents }) {
490  const landName = escapeHtml(land?.name || "Unknown Land");
491  const landDomain = escapeHtml(land?.domain || "");
492  const landId = escapeHtml(land?.landId || "");
493  const protocolVersion = escapeHtml(land?.protocolVersion || "");
494  const publicKey = escapeHtml(truncate(land?.publicKey || "", 32));
495
496  const peerRows =
497    peers && peers.length > 0
498      ? peers
499          .map((p) => {
500            const domain = escapeHtml(p.domain || "");
501            const name = escapeHtml(p.name || "");
502            const status = p.status || "unknown";
503            const color = statusColor(status);
504            const lastSeen = timeAgo(p.lastSeenAt);
505            const isBlocked = status === "blocked";
506
507            return `
508              <tr>
509                <td><code>${domain}</code></td>
510                <td>${name || '<span style="color: var(--text-muted);">unnamed</span>'}</td>
511                <td>
512                  <span class="status-dot" style="background: ${color};"></span>
513                  ${status}
514                </td>
515                <td style="color: var(--text-secondary);">${lastSeen}</td>
516                <td>
517                  <div class="action-cell">
518                    ${
519                      isBlocked
520                        ? `<button class="small-btn accent-btn" onclick="unblockPeer('${domain}')">Unblock</button>`
521                        : `<button class="small-btn warn-btn" onclick="blockPeer('${domain}')">Block</button>`
522                    }
523                    <button class="small-btn danger-btn" onclick="removePeer('${domain}')">Remove</button>
524                  </div>
525                </td>
526              </tr>
527            `;
528          })
529          .join("")
530      : `<tr><td colspan="5" class="canopy-empty-state">No peers connected yet. Add one below to start federating.</td></tr>`;
531
532  const failedEventRows =
533    failedEvents && failedEvents.length > 0
534      ? failedEvents
535          .map((evt) => {
536            const evtId = escapeHtml(evt._id || "");
537            const evtType = escapeHtml(evt.eventType || evt.type || "unknown");
538            const evtDomain = escapeHtml(evt.targetDomain || evt.domain || "");
539            const evtTime = timeAgo(evt.createdAt || evt.lastAttemptAt);
540            return `
541              <tr>
542                <td><code>${truncate(evtId, 12)}</code></td>
543                <td>${evtType}</td>
544                <td>${evtDomain}</td>
545                <td style="color: var(--text-secondary);">${evtTime}</td>
546                <td>
547                  <button class="small-btn accent-btn" onclick="retryEvent('${evtId}')">Retry</button>
548                </td>
549              </tr>
550            `;
551          })
552          .join("")
553      : `<tr><td colspan="5" class="canopy-empty-state">No failed events. All clear.</td></tr>`;
554
555  const bodyHtml = `
556    <div id="toast-container" class="toast-container"></div>
557
558    <div class="container">
559      <div class="page-header">
560        <h1>Canopy Admin</h1>
561        <p>Federation management for your land</p>
562      </div>
563
564      <div class="nav-links">
565        ${navLinks("/canopy/admin")}
566      </div>
567
568      <!-- Identity Card -->
569      <div class="glass-card" style="animation-delay: 0.1s;">
570        <h2>This Land</h2>
571        <div class="identity-grid">
572          <div class="identity-item">
573            <div class="label">Name</div>
574            <div class="value">${landName}</div>
575          </div>
576          <div class="identity-item">
577            <div class="label">Domain</div>
578            <div class="value">${landDomain}</div>
579          </div>
580          <div class="identity-item">
581            <div class="label">Land ID</div>
582            <div class="value">${truncate(landId, 16)}</div>
583          </div>
584          <div class="identity-item">
585            <div class="label">Protocol</div>
586            <div class="value">${protocolVersion}</div>
587          </div>
588          <div class="identity-item">
589            <div class="label">Public Key</div>
590            <div class="value">${publicKey}</div>
591          </div>
592        </div>
593      </div>
594
595      <!-- Stats + Actions -->
596      <div class="glass-card" style="animation-delay: 0.15s;">
597        <div class="stats-row">
598          <div class="stat-chip"><span class="num">${peers ? peers.length : 0}</span> peers</div>
599          <div class="stat-chip"><span class="num">${pendingEvents || 0}</span> pending events</div>
600          <div class="stat-chip"><span class="num">${failedEvents ? failedEvents.length : 0}</span> failed events</div>
601        </div>
602        <button class="accent-btn" onclick="runHeartbeat()">Run Heartbeat</button>
603      </div>
604
605      <!-- Peer List -->
606      <div class="glass-card" style="animation-delay: 0.2s;">
607        <h2>Peers</h2>
608        <div style="overflow-x: auto;">
609          <table class="data-table">
610            <thead>
611              <tr>
612                <th>Domain</th>
613                <th>Name</th>
614                <th>Status</th>
615                <th>Last Seen</th>
616                <th>Actions</th>
617              </tr>
618            </thead>
619            <tbody>
620              ${peerRows}
621            </tbody>
622          </table>
623        </div>
624      </div>
625
626      <!-- Add Peer -->
627      <div class="glass-card" style="animation-delay: 0.25s;">
628        <h2>Add Peer</h2>
629        <div class="form-row">
630          <input type="url" id="peer-url" placeholder="https://other.land.example.com" />
631          <button class="accent-btn" onclick="addPeer()">Add Peer</button>
632        </div>
633      </div>
634
635      <!-- Failed Events -->
636      <div class="glass-card" style="animation-delay: 0.3s;">
637        <h2>Failed Events</h2>
638        <div style="overflow-x: auto;">
639          <table class="data-table">
640            <thead>
641              <tr>
642                <th>ID</th>
643                <th>Type</th>
644                <th>Target</th>
645                <th>When</th>
646                <th>Actions</th>
647              </tr>
648            </thead>
649            <tbody id="failed-events-body">
650              ${failedEventRows}
651            </tbody>
652          </table>
653        </div>
654      </div>
655    </div>`;
656
657  const js = `
658      ${sharedScripts}
659
660      async function addPeer() {
661        var urlInput = document.getElementById("peer-url");
662        var url = urlInput.value.trim();
663        if (!url) { showToast("Please enter a URL", "error"); return; }
664        var data = await canopyFetch("/canopy/admin/peer/add", {
665          method: "POST",
666          body: JSON.stringify({ url: url })
667        });
668        if (data) {
669          showToast("Peer added successfully", "success");
670          setTimeout(function () { location.reload(); }, 800);
671        }
672      }
673
674      async function removePeer(domain) {
675        if (!confirm("Remove peer " + domain + "?")) return;
676        var data = await canopyFetch("/canopy/admin/peer/" + encodeURIComponent(domain), {
677          method: "DELETE"
678        });
679        if (data) {
680          showToast("Peer removed", "success");
681          setTimeout(function () { location.reload(); }, 800);
682        }
683      }
684
685      async function blockPeer(domain) {
686        var data = await canopyFetch("/canopy/admin/peer/" + encodeURIComponent(domain) + "/block", {
687          method: "POST"
688        });
689        if (data) {
690          showToast("Peer blocked", "success");
691          setTimeout(function () { location.reload(); }, 800);
692        }
693      }
694
695      async function unblockPeer(domain) {
696        var data = await canopyFetch("/canopy/admin/peer/" + encodeURIComponent(domain) + "/unblock", {
697          method: "POST"
698        });
699        if (data) {
700          showToast("Peer unblocked", "success");
701          setTimeout(function () { location.reload(); }, 800);
702        }
703      }
704
705      async function runHeartbeat() {
706        showToast("Running heartbeat...", "success");
707        var data = await canopyFetch("/canopy/admin/heartbeat", { method: "POST" });
708        if (data) {
709          showToast("Heartbeat complete", "success");
710          setTimeout(function () { location.reload(); }, 800);
711        }
712      }
713
714      async function retryEvent(eventId) {
715        var data = await canopyFetch("/canopy/admin/events/" + encodeURIComponent(eventId) + "/retry", {
716          method: "POST"
717        });
718        if (data) {
719          showToast("Event retried", "success");
720          setTimeout(function () { location.reload(); }, 800);
721        }
722      }`;
723
724  return page({
725    title: "Canopy Admin",
726    css: canopyStyles,
727    body: bodyHtml,
728    js,
729  });
730}
731
732// ─────────────────────────────────────────────────────────────────────────
733// 2. renderCanopyInvites
734// ─────────────────────────────────────────────────────────────────────────
735
736export function renderCanopyInvites({ invites, remoteUsers, localTrees }) {
737  // Build a map of remote user IDs to display info
738  const remoteMap = {};
739  if (remoteUsers && remoteUsers.length > 0) {
740    remoteUsers.forEach((ru) => {
741      remoteMap[ru._id] = ru;
742    });
743  }
744
745  const incomingRows =
746    invites && invites.length > 0
747      ? invites
748          .map((inv) => {
749            const remote = remoteMap[inv.userInviting] || {};
750            const canopyId = remote.username
751              ? escapeHtml(remote.username + "@" + remote.homeLandDomain)
752              : escapeHtml(inv.userInviting || "unknown");
753            const treeName = escapeHtml(inv.rootName || inv.rootId || "unknown tree");
754            const status = escapeHtml(inv.status || "pending");
755
756            return `
757              <tr>
758                <td><code>${canopyId}</code></td>
759                <td>${treeName}</td>
760                <td>${status}</td>
761              </tr>
762            `;
763          })
764          .join("")
765      : `<tr><td colspan="3" class="canopy-empty-state">No incoming invites.</td></tr>`;
766
767  const treeOptions =
768    localTrees && localTrees.length > 0
769      ? localTrees
770          .map(
771            (t) =>
772              `<option value="${escapeHtml(t._id)}">${escapeHtml(t.name || "Untitled")}${t.isOwner === false ? " (contributor)" : ""}</option>`
773          )
774          .join("")
775      : `<option value="" disabled>No trees available</option>`;
776
777  const bodyHtml = `
778    <div id="toast-container" class="toast-container"></div>
779
780    <div class="container">
781      <div class="page-header">
782        <h1>Canopy Invites</h1>
783        <p>Cross land collaboration invitations</p>
784      </div>
785
786      <div class="nav-links">
787        ${navLinks("/canopy/admin/invites")}
788      </div>
789
790      <!-- Incoming Invites -->
791      <div class="glass-card" style="animation-delay: 0.1s;">
792        <h2>Incoming Invites</h2>
793        <div style="overflow-x: auto;">
794          <table class="data-table">
795            <thead>
796              <tr>
797                <th>From</th>
798                <th>Tree</th>
799                <th>Status</th>
800              </tr>
801            </thead>
802            <tbody>
803              ${incomingRows}
804            </tbody>
805          </table>
806        </div>
807      </div>
808
809      <!-- Send Invite -->
810      <div class="glass-card" style="animation-delay: 0.2s;">
811        <h2>Invite Remote User</h2>
812        <p style="color: var(--text-secondary); font-size: 13px; margin-bottom: 16px;">
813          Enter a canopy ID (username@domain) and select a tree to invite them to.
814        </p>
815        <div class="form-row">
816          <input type="text" id="canopy-id" placeholder="username@other.land.com" />
817          <select id="root-select">
818            <option value="" disabled selected>Select a tree</option>
819            ${treeOptions}
820          </select>
821          <button class="accent-btn" onclick="sendInvite()">Send Invite</button>
822        </div>
823      </div>
824    </div>`;
825
826  const js = `
827      ${sharedScripts}
828
829      async function sendInvite() {
830        var canopyId = document.getElementById("canopy-id").value.trim();
831        var rootId = document.getElementById("root-select").value;
832
833        if (!canopyId) { showToast("Please enter a canopy ID", "error"); return; }
834        if (!rootId) { showToast("Please select a tree", "error"); return; }
835
836        var data = await canopyFetch("/canopy/invite-remote", {
837          method: "POST",
838          body: JSON.stringify({ canopyId: canopyId, rootId: rootId })
839        });
840        if (data) {
841          showToast("Invite sent to " + canopyId, "success");
842          document.getElementById("canopy-id").value = "";
843          setTimeout(function () { location.reload(); }, 800);
844        }
845      }`;
846
847  return page({
848    title: "Canopy Invites",
849    css: canopyStyles,
850    body: bodyHtml,
851    js,
852  });
853}
854
855// ─────────────────────────────────────────────────────────────────────────
856// 3. renderCanopyHorizon
857// ─────────────────────────────────────────────────────────────────────────
858
859export function renderCanopyHorizon({ hasHorizon }) {
860  const noHorizonMessage = !hasHorizon
861    ? '<div class="glass-card" style="animation-delay: 0.1s;">' +
862      '<div class="canopy-empty-state">' +
863      '<p>No Horizon service configured.</p>' +
864      '<p style="margin-top: 8px; font-size: 13px;">Set the <code>HORIZON_URL</code> environment variable to connect to the Horizon and discover other lands.</p>' +
865      '</div></div>'
866    : "";
867
868  const searchSection = hasHorizon
869    ? '<div class="glass-card" style="animation-delay: 0.1s;">' +
870      '<div class="tab-bar">' +
871      '<button class="active" id="tab-lands" onclick="switchTab(\'lands\')">Lands</button>' +
872      '<button id="tab-trees" onclick="switchTab(\'trees\')">Public Trees</button>' +
873      '</div>' +
874      '<div class="form-row">' +
875      '<input type="text" id="search-query" placeholder="Search lands or trees..." onkeydown="if(event.key===\'Enter\')doSearch()" />' +
876      '<button class="accent-btn" onclick="doSearch()">Search</button>' +
877      '</div>' +
878      '<div id="search-results" class="search-results">' +
879      '<div class="canopy-empty-state">Enter a search term or leave blank to browse all.</div>' +
880      '</div></div>'
881    : "";
882
883  const extraStyles = `
884    .search-results { margin-top: 16px; }
885    .result-card {
886      padding: 14px; border-radius: 12px; background: rgba(255, 255, 255, 0.06);
887      margin-bottom: 10px; display: flex; justify-content: space-between;
888      align-items: center; flex-wrap: wrap; gap: 10px;
889    }
890    .result-info { flex: 1; min-width: 200px; }
891    .result-info .result-name { font-size: 15px; font-weight: 700; margin-bottom: 2px; }
892    .result-info .result-detail { font-size: 13px; color: var(--text-secondary); }
893    .result-info .result-detail code {
894      font-family: "JetBrains Mono", monospace; font-size: 12px;
895      padding: 2px 6px; background: rgba(255, 255, 255, 0.1); border-radius: 4px;
896    }
897    .tab-bar {
898      display: flex; gap: 0; margin-bottom: 16px; border-radius: 12px;
899      overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.2);
900    }
901    .tab-bar button {
902      flex: 1; border-radius: 0; border: none; padding: 12px 16px;
903      background: rgba(var(--glass-water-rgb), 0.15); box-shadow: none; font-size: 14px;
904    }
905    .tab-bar button:hover { background: rgba(var(--glass-water-rgb), 0.25); transform: none; }
906    .tab-bar button.active { background: var(--accent); }
907    .loading { text-align: center; padding: 20px; color: var(--text-muted); font-size: 14px; }`;
908
909  const bodyHtml = `
910    <div id="toast-container" class="toast-container"></div>
911
912    <div class="container">
913      <div class="page-header">
914        <h1>Horizon</h1>
915        <p>Discover lands and public trees across the network</p>
916      </div>
917
918      <div class="nav-links">
919        ${navLinks("/canopy/admin/horizon")}
920      </div>
921
922      ${noHorizonMessage}
923      ${searchSection}
924    </div>`;
925
926  const horizonScript = `
927      var currentTab = "lands";
928
929      function switchTab(tab) {
930        currentTab = tab;
931        document.getElementById("tab-lands").className = tab === "lands" ? "active" : "";
932        document.getElementById("tab-trees").className = tab === "trees" ? "active" : "";
933        document.getElementById("search-results").innerHTML =
934          '<div class="canopy-empty-state">Enter a search term or leave blank to browse all.</div>';
935      }
936
937      async function doSearch() {
938        var query = document.getElementById("search-query").value.trim();
939        var resultsDiv = document.getElementById("search-results");
940        resultsDiv.innerHTML = '<div class="loading">Searching...</div>';
941
942        var endpoint = currentTab === "lands"
943          ? "/canopy/admin/horizon/lands"
944          : "/canopy/admin/horizon/trees";
945
946        var data = await canopyFetch(endpoint + "?q=" + encodeURIComponent(query));
947
948        if (!data) {
949          resultsDiv.innerHTML = '<div class="canopy-empty-state">Search failed.</div>';
950          return;
951        }
952
953        if (currentTab === "lands") {
954          renderLandResults(data.lands || []);
955        } else {
956          renderTreeResults(data.trees || []);
957        }
958      }
959
960      function renderLandResults(lands) {
961        var div = document.getElementById("search-results");
962        if (lands.length === 0) {
963          div.innerHTML = '<div class="canopy-empty-state">No lands found.</div>';
964          return;
965        }
966
967        div.innerHTML = lands.map(function (land) {
968          return '<div class="result-card">' +
969            '<div class="result-info">' +
970              '<div class="result-name">' + escapeHtml(land.name || "Unnamed Land") + '</div>' +
971              '<div class="result-detail"><code>' + escapeHtml(land.domain || "") + '</code></div>' +
972              '<div class="result-detail">Protocol v' + (land.protocolVersion || "?") +
973                ' . ' + (land.status || "unknown") + '</div>' +
974            '</div>' +
975            '<button class="small-btn accent-btn" onclick="discoverPeer(\\'' + escapeHtml(land.domain) + '\\')">Add as Peer</button>' +
976          '</div>';
977        }).join("");
978      }
979
980      function renderTreeResults(trees) {
981        var div = document.getElementById("search-results");
982        if (trees.length === 0) {
983          div.innerHTML = '<div class="canopy-empty-state">No public trees found.</div>';
984          return;
985        }
986
987        div.innerHTML = trees.map(function (tree) {
988          return '<div class="result-card">' +
989            '<div class="result-info">' +
990              '<div class="result-name">' + escapeHtml(tree.name || "Untitled") + '</div>' +
991              '<div class="result-detail">by ' + escapeHtml(tree.ownerUsername || "unknown") +
992                ' on <code>' + escapeHtml(tree.landDomain || "") + '</code></div>' +
993            '</div>' +
994          '</div>';
995        }).join("");
996      }
997
998      async function discoverPeer(domain) {
999        var data = await canopyFetch("/canopy/admin/peer/discover", {
1000          method: "POST",
1001          body: JSON.stringify({ domain: domain })
1002        });
1003        if (data) {
1004          showToast("Peered with " + domain, "success");
1005        }
1006      }
1007
1008      function escapeHtml(str) {
1009        if (!str) return "";
1010        var div = document.createElement("div");
1011        div.textContent = str;
1012        return div.innerHTML;
1013      }`;
1014
1015  const js = `
1016      ${sharedScripts}
1017      ${hasHorizon ? horizonScript : ""}`;
1018
1019  return page({
1020    title: "Canopy Horizon",
1021    css: canopyStyles + extraStyles,
1022    body: bodyHtml,
1023    js,
1024  });
1025}
1026
1/* ------------------------------------------------- */
2/* Command Center page (layout-wrapped)              */
3/* Dark theme -- uses bare: true                     */
4/* ------------------------------------------------- */
5
6import { page } from "../../html-rendering/html/layout.js";
7import { esc, truncate, modeLabel } from "../../html-rendering/html/utils.js";
8
9const STATUS_COLORS = {
10  active:     { bg: "rgba(74,222,128,0.12)", border: "rgba(74,222,128,0.3)", text: "#4ade80", label: "ACTIVE" },
11  blocked:    { bg: "rgba(239,68,68,0.12)",  border: "rgba(239,68,68,0.3)",  text: "#ef4444", label: "BLOCKED" },
12  restricted: { bg: "rgba(234,179,8,0.12)",  border: "rgba(234,179,8,0.3)",  text: "#eab308", label: "READ ONLY" },
13  confined:   { bg: "rgba(59,130,246,0.12)", border: "rgba(59,130,246,0.3)", text: "#3b82f6", label: "CONFINED" },
14  unavailable:{ bg: "rgba(107,114,128,0.08)",border: "rgba(107,114,128,0.2)",text: "#6b7280", label: "UNAVAILABLE" },
15};
16
17function badge(status) {
18  const c = STATUS_COLORS[status] || STATUS_COLORS.unavailable;
19  return `<span style="display:inline-block;padding:2px 8px;border-radius:6px;font-size:0.7rem;font-weight:600;letter-spacing:0.5px;background:${c.bg};color:${c.text};border:1px solid ${c.border};">${c.label}</span>`;
20}
21
22function dot(status) {
23  const c = STATUS_COLORS[status] || STATUS_COLORS.unavailable;
24  return `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${c.text};margin-right:8px;flex-shrink:0;"></span>`;
25}
26
27export function renderCommandCenter({
28  nodeId, nodeName, rootId, rootName, path,
29  extensions, tools, modes, toolConfig, modeOverrides,
30  blocked, restricted, allowed, confined, qs,
31}) {
32  const totalTools = tools.length;
33  const activeTools = tools.filter(t => t.status === "active").length;
34  const totalModes = modes.length;
35  const activeModes = modes.filter(m => m.status === "active").length;
36  const totalExts = extensions.length;
37  const activeExts = extensions.filter(e => e.status === "active").length;
38
39  // Group tools by extension
40  const toolsByExt = {};
41  for (const tool of tools) {
42    const ext = tool.extName || "unknown";
43    if (!toolsByExt[ext]) toolsByExt[ext] = [];
44    toolsByExt[ext].push(tool);
45  }
46
47  // Group modes by bigMode
48  const modesByBig = {};
49  for (const mode of modes) {
50    const big = mode.bigMode || "tree";
51    if (!modesByBig[big]) modesByBig[big] = [];
52    modesByBig[big].push(mode);
53  }
54
55  const css = `
56* { box-sizing: border-box; margin: 0; padding: 0; }
57
58body {
59  background: #0a0a0a;
60  color: #e5e5e5;
61  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
62  -webkit-font-smoothing: antialiased;
63  min-height: 100vh;
64}
65
66.cc-container {
67  max-width: 1400px;
68  margin: 0 auto;
69  padding: 0 24px;
70}
71
72/* HEADER */
73.cc-header {
74  padding: 48px 0 24px;
75  text-align: center;
76  border-bottom: 1px solid rgba(255,255,255,0.06);
77}
78.cc-breadcrumb {
79  font-size: 0.8rem;
80  color: rgba(255,255,255,0.3);
81  margin-bottom: 8px;
82  letter-spacing: 0.3px;
83}
84.cc-title {
85  font-size: 36px;
86  font-weight: 800;
87  color: #fff;
88  letter-spacing: -1px;
89  margin-bottom: 8px;
90}
91.cc-subtitle {
92  font-size: 0.9rem;
93  color: rgba(255,255,255,0.4);
94}
95
96/* KEY BAR */
97.cc-key {
98  padding: 12px 0;
99  border-bottom: 1px solid rgba(255,255,255,0.06);
100  background: rgba(255,255,255,0.02);
101}
102.cc-key-inner {
103  display: flex;
104  gap: 20px;
105  justify-content: center;
106  flex-wrap: wrap;
107}
108.cc-key-item {
109  display: flex;
110  align-items: center;
111  font-size: 0.75rem;
112  color: rgba(255,255,255,0.5);
113  letter-spacing: 0.3px;
114}
115
116/* GRID */
117.cc-grid {
118  display: grid;
119  grid-template-columns: 1fr 1.4fr 0.8fr;
120  gap: 24px;
121  padding: 32px 0;
122  align-items: start;
123}
124
125/* COLUMNS */
126.cc-col-title {
127  font-size: 0.85rem;
128  font-weight: 700;
129  color: rgba(255,255,255,0.6);
130  text-transform: uppercase;
131  letter-spacing: 1px;
132  margin-bottom: 16px;
133  padding-bottom: 8px;
134  border-bottom: 1px solid rgba(255,255,255,0.08);
135}
136.cc-count {
137  font-weight: 400;
138  color: rgba(255,255,255,0.3);
139  font-size: 0.75rem;
140}
141
142/* EXTENSION ITEMS */
143.cc-item {
144  margin-bottom: 4px;
145}
146.cc-item-row {
147  display: flex;
148  align-items: center;
149  gap: 8px;
150  padding: 10px 12px;
151  border-radius: 8px;
152  cursor: pointer;
153  transition: background 0.15s;
154  list-style: none;
155}
156.cc-item-row:hover {
157  background: rgba(255,255,255,0.04);
158}
159.cc-item-row::-webkit-details-marker { display: none; }
160.cc-item-name {
161  font-size: 0.85rem;
162  font-weight: 600;
163  color: #fff;
164  flex: 1;
165}
166.cc-item-version {
167  font-size: 0.7rem;
168  color: rgba(255,255,255,0.25);
169}
170.cc-item-detail {
171  padding: 8px 12px 16px 28px;
172  font-size: 0.8rem;
173}
174.cc-item-desc {
175  color: rgba(255,255,255,0.4);
176  margin-bottom: 8px;
177  line-height: 1.5;
178}
179.cc-item-sub {
180  color: rgba(255,255,255,0.35);
181  margin-bottom: 4px;
182  line-height: 1.6;
183}
184.cc-item-sub strong {
185  color: rgba(255,255,255,0.5);
186}
187
188/* TOOL GROUPS */
189.cc-group {
190  margin-bottom: 8px;
191}
192.cc-group-header {
193  display: flex;
194  align-items: center;
195  gap: 8px;
196  padding: 8px 12px;
197  border-radius: 8px;
198  cursor: pointer;
199  font-size: 0.8rem;
200  font-weight: 600;
201  color: rgba(255,255,255,0.6);
202  list-style: none;
203  transition: background 0.15s;
204}
205.cc-group-header:hover {
206  background: rgba(255,255,255,0.04);
207}
208.cc-group-header::-webkit-details-marker { display: none; }
209
210/* TOOL ROWS */
211.cc-tool-row {
212  display: flex;
213  align-items: flex-start;
214  gap: 8px;
215  padding: 8px 12px 8px 20px;
216  border-radius: 6px;
217  transition: background 0.15s;
218}
219.cc-tool-row:hover {
220  background: rgba(255,255,255,0.03);
221}
222.cc-tool-info {
223  flex: 1;
224  min-width: 0;
225}
226.cc-tool-name {
227  font-size: 0.8rem;
228  font-weight: 600;
229  color: #fff;
230}
231.cc-tool-desc {
232  font-size: 0.72rem;
233  color: rgba(255,255,255,0.35);
234  margin-top: 2px;
235  line-height: 1.4;
236}
237.cc-badge-sm {
238  display: inline-block;
239  padding: 1px 5px;
240  border-radius: 4px;
241  font-size: 0.6rem;
242  font-weight: 700;
243  letter-spacing: 0.5px;
244  margin-left: 6px;
245  vertical-align: middle;
246}
247.cc-badge-ro { background: rgba(234,179,8,0.15); color: #eab308; }
248.cc-badge-dest { background: rgba(239,68,68,0.15); color: #ef4444; }
249
250.cc-tool-actions {
251  flex-shrink: 0;
252}
253
254/* MODE ROWS */
255.cc-mode-row {
256  display: flex;
257  align-items: center;
258  gap: 8px;
259  padding: 8px 12px 8px 20px;
260  border-radius: 6px;
261  transition: background 0.15s;
262}
263.cc-mode-row:hover {
264  background: rgba(255,255,255,0.03);
265}
266.cc-mode-emoji {
267  font-size: 1rem;
268  width: 20px;
269  text-align: center;
270}
271.cc-mode-info {
272  flex: 1;
273}
274.cc-mode-name {
275  font-size: 0.8rem;
276  font-weight: 600;
277  color: #fff;
278}
279.cc-mode-key {
280  font-size: 0.7rem;
281  color: rgba(255,255,255,0.25);
282  margin-left: 8px;
283}
284.cc-mode-ext {
285  font-size: 0.65rem;
286  color: rgba(255,255,255,0.2);
287  margin-left: 8px;
288}
289.cc-mode-override {
290  font-size: 0.7rem;
291  color: #3b82f6;
292  margin-top: 2px;
293}
294
295/* BUTTONS */
296.cc-btn, .cc-btn-sm {
297  border: none;
298  cursor: pointer;
299  border-radius: 6px;
300  font-weight: 600;
301  transition: all 0.15s;
302}
303.cc-btn {
304  padding: 6px 14px;
305  font-size: 0.75rem;
306  margin-top: 8px;
307}
308.cc-btn-sm {
309  padding: 3px 8px;
310  font-size: 0.7rem;
311}
312.cc-btn-red {
313  background: rgba(239,68,68,0.15);
314  color: #ef4444;
315  border: 1px solid rgba(239,68,68,0.3);
316}
317.cc-btn-red:hover { background: rgba(239,68,68,0.25); }
318.cc-btn-green {
319  background: rgba(74,222,128,0.15);
320  color: #4ade80;
321  border: 1px solid rgba(74,222,128,0.3);
322}
323.cc-btn-green:hover { background: rgba(74,222,128,0.25); }
324.cc-toggle-form { display: inline; }
325
326/* FOOTER */
327.cc-footer {
328  padding: 32px 0;
329  border-top: 1px solid rgba(255,255,255,0.06);
330  text-align: center;
331}
332.cc-back {
333  color: rgba(255,255,255,0.4);
334  text-decoration: none;
335  font-size: 0.85rem;
336  transition: color 0.15s;
337}
338.cc-back:hover { color: rgba(255,255,255,0.7); }
339
340/* ── RESPONSIVE ── */
341@media (max-width: 1024px) {
342  .cc-grid { grid-template-columns: 1fr 1fr; }
343  .cc-col:nth-child(3) { grid-column: 1 / -1; }
344}
345
346@media (max-width: 768px) {
347  .cc-header { padding: 32px 0 16px; }
348  .cc-title { font-size: 28px; }
349  .cc-grid {
350    grid-template-columns: 1fr;
351    gap: 16px;
352  }
353  .cc-col {
354    background: rgba(255,255,255,0.02);
355    border: 1px solid rgba(255,255,255,0.06);
356    border-radius: 12px;
357    padding: 16px;
358  }
359  .cc-key-inner { gap: 12px; }
360  .cc-key-item { font-size: 0.7rem; }
361}
362
363@media (max-width: 480px) {
364  .cc-container { padding: 0 16px; }
365  .cc-title { font-size: 24px; }
366  .cc-tool-row, .cc-mode-row { padding: 6px 8px 6px 12px; }
367}`;
368
369  const bodyHtml = `
370  <div class="cc">
371
372    <!-- HEADER -->
373    <header class="cc-header">
374      <div class="cc-container">
375        <a href="/api/v1/node/${nodeId}${qs}" class="cc-back" style="margin-bottom:8px;">\u2190 Back to node</a>
376        <div class="cc-breadcrumb">${esc(path || rootName || rootId)}</div>
377        <h1 class="cc-title">Command Center</h1>
378        <p class="cc-subtitle">${esc(nodeName)} . ${activeTools}/${totalTools} tools . ${activeModes}/${totalModes} modes . ${activeExts}/${totalExts} extensions</p>
379      </div>
380    </header>
381
382    <!-- KEY -->
383    <div class="cc-key">
384      <div class="cc-container cc-key-inner">
385        ${Object.entries(STATUS_COLORS).map(([k, v]) =>
386          `<span class="cc-key-item">${dot(k)} ${v.label}</span>`
387        ).join("")}
388      </div>
389    </div>
390
391    <!-- MAIN GRID -->
392    <div class="cc-container">
393      <div class="cc-grid">
394
395        <!-- EXTENSIONS COLUMN -->
396        <section class="cc-col">
397          <h2 class="cc-col-title">Extensions <span class="cc-count">${activeExts}/${totalExts}</span></h2>
398          ${extensions.map(ext => {
399            const extTools = toolsByExt[ext.name] || [];
400            const extModes = modes.filter(m => m.extName === ext.name);
401            return `
402            <details class="cc-item">
403              <summary class="cc-item-row">
404                ${dot(ext.status)}
405                <span class="cc-item-name">${esc(ext.name)}</span>
406                <span class="cc-item-version">${esc(ext.version || "")}</span>
407                ${badge(ext.status)}
408              </summary>
409              <div class="cc-item-detail">
410                ${ext.description ? `<p class="cc-item-desc">${esc(truncate(ext.description, 200))}</p>` : ""}
411                ${extTools.length > 0 ? `<div class="cc-item-sub"><strong>Tools:</strong> ${extTools.map(t => `<span style="color:${(STATUS_COLORS[t.status]||{}).text||"#888"}">${esc(t.name)}</span>`).join(", ")}</div>` : ""}
412                ${extModes.length > 0 ? `<div class="cc-item-sub"><strong>Modes:</strong> ${extModes.map(m => `<span style="color:${(STATUS_COLORS[m.status]||{}).text||"#888"}">${m.emoji||""} ${esc(m.label || m.key)}</span>`).join(", ")}</div>` : ""}
413                ${ext.status === "active" ? `
414                  <form method="POST" action="/api/v1/node/${nodeId}/extensions${qs}" class="cc-toggle-form">
415                    <input type="hidden" name="block" value="${esc(ext.name)}" />
416                    <button type="submit" class="cc-btn cc-btn-red">Block at this node</button>
417                  </form>
418                ` : ""}
419                ${ext.status === "blocked" ? `
420                  <form method="POST" action="/api/v1/node/${nodeId}/extensions${qs}" class="cc-toggle-form">
421                    <input type="hidden" name="allow" value="${esc(ext.name)}" />
422                    <button type="submit" class="cc-btn cc-btn-green">Unblock</button>
423                  </form>
424                ` : ""}
425              </div>
426            </details>`;
427          }).join("")}
428        </section>
429
430        <!-- TOOLS COLUMN -->
431        <section class="cc-col">
432          <h2 class="cc-col-title">Tools <span class="cc-count">${activeTools}/${totalTools}</span></h2>
433          ${Object.entries(toolsByExt).map(([extName, extTools]) => `
434            <details class="cc-group" ${extTools.some(t => t.status === "active") ? "open" : ""}>
435              <summary class="cc-group-header">${esc(extName)} <span class="cc-count">${extTools.filter(t=>t.status==="active").length}/${extTools.length}</span></summary>
436              ${extTools.map(t => `
437                <div class="cc-tool-row">
438                  ${dot(t.status)}
439                  <div class="cc-tool-info">
440                    <span class="cc-tool-name">${esc(t.name)}</span>
441                    ${t.readOnly ? '<span class="cc-badge-sm cc-badge-ro">RO</span>' : ""}
442                    ${t.destructive ? '<span class="cc-badge-sm cc-badge-dest">DEST</span>' : ""}
443                    <div class="cc-tool-desc">${esc(truncate(t.description || "", 120))}</div>
444                  </div>
445                  <div class="cc-tool-actions">
446                    ${t.status === "active" && !t.nodeBlocked ? `
447                      <form method="POST" action="/api/v1/node/${nodeId}/tools${qs}" style="display:inline;">
448                        <input type="hidden" name="block" value="${esc(t.name)}" />
449                        <button type="submit" class="cc-btn-sm cc-btn-red" title="Block">X</button>
450                      </form>` : ""}
451                    ${t.nodeBlocked ? `
452                      <form method="POST" action="/api/v1/node/${nodeId}/tools${qs}" style="display:inline;">
453                        <input type="hidden" name="allow" value="${esc(t.name)}" />
454                        <button type="submit" class="cc-btn-sm cc-btn-green" title="Allow">+</button>
455                      </form>` : ""}
456                  </div>
457                </div>
458              `).join("")}
459            </details>
460          `).join("")}
461        </section>
462
463        <!-- MODES COLUMN -->
464        <section class="cc-col">
465          <h2 class="cc-col-title">Modes <span class="cc-count">${activeModes}/${totalModes}</span></h2>
466          ${Object.entries(modesByBig).map(([bigMode, bigModes]) => `
467            <details class="cc-group" open>
468              <summary class="cc-group-header">${esc(bigMode)} <span class="cc-count">${bigModes.filter(m=>m.status==="active").length}/${bigModes.length}</span></summary>
469              ${bigModes.map(m => {
470                const override = modeOverrides?.[m.intent];
471                return `
472                <div class="cc-mode-row">
473                  ${dot(m.status)}
474                  <span class="cc-mode-emoji">${m.emoji || ""}</span>
475                  <div class="cc-mode-info">
476                    <span class="cc-mode-name">${esc(m.label || m.key)}</span>
477                    <span class="cc-mode-key">${esc(m.key)}</span>
478                    ${m.extName ? `<span class="cc-mode-ext">${esc(m.extName)}</span>` : ""}
479                    ${override ? `<div class="cc-mode-override">Override: ${esc(override)}</div>` : ""}
480                  </div>
481                </div>`;
482              }).join("")}
483            </details>
484          `).join("")}
485        </section>
486
487      </div>
488    </div>
489
490    <!-- FOOTER -->
491    <footer class="cc-footer">
492      <div class="cc-container">
493        <a href="/api/v1/node/${nodeId}${qs}" class="cc-back">Back to node</a>
494      </div>
495    </footer>
496
497  </div>`;
498
499  return page({
500    title: `Command Center . ${esc(nodeName)}`,
501    css,
502    body: bodyHtml,
503    bare: true,
504  });
505}
506
1/* ------------------------------------------------- */
2/* Contributions page (layout-wrapped)               */
3/* ------------------------------------------------- */
4
5import { page } from "../../html-rendering/html/layout.js";
6import { esc, actionColorClass } from "../../html-rendering/html/utils.js";
7
8const link = (id, queryString) =>
9  id
10    ? `<a href="/api/v1/node/${id}${queryString}"><code>${esc(id)}</code></a>`
11    : `<code>unknown</code>`;
12
13const userTag = (u, queryString) => {
14  if (!u) return `<code>unknown user</code>`;
15  if (typeof u === "object" && u.username)
16    return `<a href="/api/v1/user/${u._id}${queryString}"><code>${esc(u.username)}</code></a>`;
17  if (typeof u === "string")
18    return `<code>${esc(u)}</code>`;
19  return `<code>unknown user</code>`;
20};
21
22const kvMap = (data) => {
23  if (!data) return "";
24  const entries =
25    data instanceof Map
26      ? [...data.entries()]
27      : typeof data === "object"
28        ? Object.entries(data)
29        : [];
30  if (entries.length === 0) return "";
31  return entries
32    .map(
33      ([k, v]) =>
34        `<span class="kv-chip"><code>${esc(k)}</code> ${esc(String(v))}</span>`,
35    )
36    .join(" ");
37};
38
39function renderAction(rawC, { nodeId, parsedVersion, nextVersion, queryString }) {
40  // Merge extensionData so action renderers can access extension fields directly
41  const c = rawC.extensionData ? { ...rawC, ...rawC.extensionData } : rawC;
42  switch (c.action) {
43    case "create":
44      return `Created node`;
45
46    case "editStatus":
47      return `Marked as <code>${esc(c.statusEdited)}</code>`;
48
49    case "editValue":
50      return `Adjusted values ${kvMap(c.valueEdited)}`;
51
52    case "prestige":
53      return `Prestiged to <a href="/api/v1/node/${nodeId}/${nextVersion}${queryString}"><code>Version ${nextVersion}</code></a>`;
54
55    case "trade":
56      return `Traded on node`;
57
58    case "delete":
59      return `Deleted node`;
60
61    case "invite": {
62      const ia = c.inviteAction || {};
63      const target = userTag(ia.receivingId, queryString);
64      const labels = {
65        invite: `Invited ${target} to collaborate`,
66        acceptInvite: `Accepted an invitation`,
67        denyInvite: `Declined an invitation`,
68        removeContributor: `Removed ${target}`,
69        switchOwner: `Transferred ownership to ${target}`,
70      };
71      return labels[ia.action] || "Updated collaboration";
72    }
73
74    case "editSchedule": {
75      const s = c.scheduleEdited || {};
76      const parts = [];
77      if (s.date)
78        parts.push(
79          `date to <code>${new Date(s.date).toLocaleString()}</code>`,
80        );
81      if (s.reeffectTime != null)
82        parts.push(`re-effect to <code>${s.reeffectTime}</code>`);
83      return parts.length
84        ? `Set ${parts.join(" and ")}`
85        : `Updated the schedule`;
86    }
87
88    case "editGoal":
89      return `Set new goals ${kvMap(c.goalEdited)}`;
90
91    case "transaction": {
92      const tm = c.transactionMeta;
93      if (!tm) return `Recorded a transaction`;
94      const eventLabel = esc(tm.event || "unknown").replace(/_/g, " ");
95      const counterparty = tm.counterpartyNodeId
96        ? ` with ${link(tm.counterpartyNodeId, queryString)}`
97        : "";
98      const sent = kvMap(tm.valuesSent);
99      const recv = kvMap(tm.valuesReceived);
100      let flow = "";
101      if (sent) flow += ` — sent ${sent}`;
102      if (recv) flow += `${sent ? "," : " —"} received ${recv}`;
103      return `Transaction <code>${eventLabel}</code> as ${esc(tm.role)} (side ${esc(tm.side)})${counterparty}${flow}`;
104    }
105
106    case "note": {
107      const na = c.noteAction || {};
108
109      let verb;
110      switch (na.action) {
111        case "add":
112          verb = "Added a note";
113          break;
114        case "edit":
115          verb = "Edited a note";
116          break;
117        case "remove":
118          verb = "Removed a note";
119          break;
120        default:
121          verb = "Updated a note";
122      }
123
124      const noteRef = na.noteId
125        ? ` <a href="/api/v1/node/${nodeId}/${parsedVersion}/notes/${na.noteId}${queryString}"><code>${esc(na.noteId)}</code></a>`
126        : "";
127
128      return `${verb}${noteRef}`;
129    }
130
131    case "updateParent": {
132      const up = c.updateParent || {};
133      const from = up.oldParentId
134        ? link(up.oldParentId, queryString)
135        : `<code>none</code>`;
136      const to = up.newParentId
137        ? link(up.newParentId, queryString)
138        : `<code>none</code>`;
139      return `Moved from ${from} to ${to}`;
140    }
141
142    case "editScript": {
143      const es = c.editScript || {};
144      return `Edited script <code>${esc(es.scriptName || es.scriptId)}</code>`;
145    }
146
147    case "executeScript": {
148      const xs = c.executeScript || {};
149      const icon = xs.success ? "\u2705" : "\u274C";
150      let text = `${icon} Ran <code>${esc(xs.scriptName || xs.scriptId)}</code>`;
151      if (xs.error) text += ` — <code>${esc(xs.error)}</code>`;
152      return text;
153    }
154
155    case "updateChild": {
156      const uc = c.updateChild || {};
157      return uc.action === "added"
158        ? `Added child ${link(uc.childId, queryString)}`
159        : `Removed child ${link(uc.childId, queryString)}`;
160    }
161
162    case "editName": {
163      const en = c.editName || {};
164      return `Renamed from <code>${esc(en.oldName)}</code> to <code>${esc(en.newName)}</code>`;
165    }
166
167    case "rawIdea": {
168      const ri = c.rawIdeaAction || {};
169      const uId = c.userId?._id || c.userId;
170      const ideaRef = ri.rawIdeaId && uId
171        ? `<a href="/api/v1/user/${uId}/raw-ideas/${ri.rawIdeaId}${queryString}"><code>${esc(ri.rawIdeaId)}</code></a>`
172        : ri.rawIdeaId
173          ? `<code>${esc(ri.rawIdeaId)}</code>`
174          : `<code>unknown</code>`;
175      if (ri.action === "add") return `Captured a raw idea ${ideaRef}`;
176      if (ri.action === "delete") return `Discarded raw idea ${ideaRef}`;
177      if (ri.action === "placed") {
178        const target = ri.targetNodeId ? link(ri.targetNodeId, queryString) : "node";
179        return `Placed raw idea ${ideaRef} into ${target}`;
180      }
181      if (ri.action === "aiStarted")
182        return `AI began processing raw idea ${ideaRef}`;
183      if (ri.action === "aiFailed")
184        return `AI failed to place raw idea ${ideaRef}`;
185      return `Updated raw idea ${ideaRef}`;
186    }
187
188    case "branchLifecycle": {
189      const bl = c.branchLifecycle || {};
190      if (bl.action === "retired") {
191        let text = `Retired branch`;
192        if (bl.fromParentId) text += ` from ${link(bl.fromParentId, queryString)}`;
193        return text;
194      }
195      if (bl.action === "revived") {
196        let text = `Revived branch`;
197        if (bl.toParentId) text += ` under ${link(bl.toParentId, queryString)}`;
198        return text;
199      }
200      return `Revived as a new root`;
201    }
202
203    case "purchase": {
204      const pm = c.purchaseMeta || {};
205      const parts = [];
206      if (pm.plan) parts.push(`the <code>${esc(pm.plan)}</code> plan`);
207      if (pm.energyAmount)
208        parts.push(`<code>${pm.energyAmount}</code> energy`);
209      const price = pm.totalCents
210        ? ` for $${(pm.totalCents / 100).toFixed(2)} ${esc(pm.currency || "usd").toUpperCase()}`
211        : "";
212      return parts.length
213        ? `Purchased ${parts.join(" and ")}${price}`
214        : `Made a purchase${price}`;
215    }
216
217    case "understanding": {
218      const um = c.understandingMeta || {};
219      const rootNode = um.rootNodeId || nodeId;
220      const runId = um.understandingRunId;
221
222      if (um.stage === "createRun") {
223        const runLink =
224          runId && rootNode
225            ? `<a href="/api/v1/root/${rootNode}/understandings/run/${runId}${queryString}"><code>${esc(runId)}</code></a>`
226            : `<code>unknown run</code>`;
227        let text = `Started understanding run ${runLink}`;
228        if (um.nodeCount != null)
229          text += ` spanning <code>${um.nodeCount}</code> nodes`;
230        if (um.perspective) text += ` — "${esc(um.perspective)}"`;
231        return text;
232      }
233
234      if (um.stage === "processStep") {
235        const uNodeId = um.understandingNodeId;
236        const uNodeLink =
237          uNodeId && runId && rootNode
238            ? `<a href="/api/v1/root/${rootNode}/understandings/run/${runId}/${uNodeId}${queryString}"><code>${esc(uNodeId)}</code></a>`
239            : uNodeId
240              ? `<code>${esc(uNodeId)}</code>`
241              : `<code>unknown</code>`;
242        let text = `Understanding encoded ${uNodeLink}`;
243        if (um.mode)
244          text += ` <span class="kv-chip">${esc(um.mode)}</span>`;
245        if (um.layer != null) text += ` at layer <code>${um.layer}</code>`;
246        return text;
247      }
248
249      return `Understanding activity`;
250    }
251
252    default:
253      return `<code>${esc(c.action)}</code>`;
254  }
255}
256
257export function renderContributions({ nodeId, version, nodeName, contributions, queryString }) {
258  const parsedVersion = Number(version);
259  const nextVersion = parsedVersion + 1;
260
261  const items = contributions.map((c) => {
262    const time = new Date(c.date).toLocaleString();
263    const actionHtml = renderAction(c, { nodeId, parsedVersion, nextVersion, queryString });
264    const colorClass = actionColorClass(c.action);
265
266    const aiBadge = c.wasAi ? `<span class="badge badge-ai">AI</span>` : "";
267    const energyBadge =
268      c.energyUsed != null && c.energyUsed > 0
269        ? `<span class="badge badge-energy">\u26A1 ${c.energyUsed}</span>`
270        : "";
271
272    const user = userTag(c.userId, queryString);
273
274    return `
275      <li class="note-card ${colorClass}">
276        <div class="note-content">
277          <div class="contribution-action">${actionHtml}</div>
278        </div>
279        <div class="note-meta">
280          ${user}
281          <span class="meta-separator">\u00B7</span>
282          ${time}
283          ${aiBadge}${energyBadge}
284          <span class="meta-separator">\u00B7</span>
285          <code class="contribution-id">${esc(c._id)}</code>
286        </div>
287      </li>`;
288  });
289
290  const qs = queryString || "";
291  const backTreeUrl = `/api/v1/root/${nodeId}${qs}`;
292  const backUrl = `/api/v1/node/${nodeId}/${version}${qs}`;
293
294  const css = `
295/* ── Page-specific: contributions ── */
296
297.version-badge {
298  display: inline-block;
299  padding: 6px 14px;
300  background: rgba(255,255,255,0.25);
301  color: white; border-radius: 980px;
302  font-size: 13px; font-weight: 600;
303  border: 1px solid rgba(255,255,255,0.3);
304  margin-right: 8px;
305}
306
307.header-subtitle { margin-bottom: 16px; }
308
309.notes-list { animation: fadeInUp 0.6s ease-out 0.2s both; }
310
311.note-card:nth-child(1) { animation-delay: 0.25s; }
312.note-card:nth-child(2) { animation-delay: 0.3s; }
313.note-card:nth-child(3) { animation-delay: 0.35s; }
314.note-card:nth-child(4) { animation-delay: 0.4s; }
315.note-card:nth-child(5) { animation-delay: 0.45s; }
316.note-card:nth-child(n+6) { animation-delay: 0.5s; }
317
318.contribution-action {
319  font-size: 15px; line-height: 1.6;
320  color: white; font-weight: 400;
321  word-wrap: break-word;
322}
323
324.contribution-action a {
325  color: white; text-decoration: none;
326  border-bottom: 1px solid rgba(255,255,255,0.3);
327  transition: all 0.2s;
328}
329
330.contribution-action a:hover {
331  border-bottom-color: white;
332  text-shadow: 0 0 12px rgba(255,255,255,0.8);
333}
334
335.contribution-action code {
336  background: rgba(255,255,255,0.18);
337  padding: 2px 7px; border-radius: 5px;
338  font-size: 13px;
339  font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
340  border: 1px solid rgba(255,255,255,0.15);
341}
342
343.contribution-id {
344  background: rgba(255,255,255,0.12);
345  padding: 2px 6px; border-radius: 4px;
346  font-size: 11px;
347  font-family: 'SF Mono', 'Fira Code', monospace;
348  color: rgba(255,255,255,0.6);
349  border: 1px solid rgba(255,255,255,0.1);
350}
351
352.badge {
353  display: inline-flex; align-items: center;
354  padding: 3px 10px; border-radius: 980px;
355  font-size: 11px; font-weight: 700; letter-spacing: 0.3px;
356  border: 1px solid rgba(255,255,255,0.2);
357}
358
359.badge-ai {
360  background: rgba(255,200,50,0.35);
361  color: #fff;
362  text-shadow: 0 1px 2px rgba(0,0,0,0.2);
363}
364
365.badge-energy {
366  background: rgba(100,220,255,0.3);
367  color: #fff;
368  text-shadow: 0 1px 2px rgba(0,0,0,0.2);
369}
370
371.kv-chip {
372  display: inline-block;
373  padding: 2px 8px;
374  background: rgba(255,255,255,0.15);
375  border-radius: 6px; font-size: 12px;
376  margin: 2px 2px;
377  border: 1px solid rgba(255,255,255,0.15);
378}
379
380.kv-chip code {
381  background: none !important;
382  border: none !important;
383  padding: 0 !important;
384  font-weight: 600;
385}`;
386
387  const bodyHtml = `
388  <div class="container">
389    <div class="back-nav">
390      <a href="${backTreeUrl}" class="back-link">\u2190 Back to Tree</a>
391      <a href="${backUrl}" class="back-link">Back to Version</a>
392    </div>
393
394    <div class="header">
395      <h1>
396        Contributions on
397        <a href="${backUrl}">${esc(nodeName || nodeId)}</a>
398        ${contributions.length > 0 ? `<span class="message-count">${contributions.length}</span>` : ""}
399      </h1>
400      <div class="header-subtitle">
401        <span class="version-badge">Version ${parsedVersion}</span>
402        Activity &amp; change history
403      </div>
404    </div>
405
406    ${
407      items.length
408        ? `<ul class="notes-list">${items.join("")}</ul>`
409        : `
410    <div class="empty-state">
411      <div class="empty-state-icon">\u{1F4CA}</div>
412      <div class="empty-state-text">No contributions yet</div>
413      <div class="empty-state-subtext">Contributions and activity will appear here</div>
414    </div>`
415    }
416  </div>`;
417
418  return page({
419    title: `${esc(nodeName || nodeId)} — Contributions`,
420    css,
421    body: bodyHtml,
422  });
423}
424
1/* --------------------------------------------------------- */
2/* Editor page                                               */
3/* --------------------------------------------------------- */
4
5import { page } from "../../html-rendering/html/layout.js";
6import { baseStyles } from "../../html-rendering/html/baseStyles.js";
7
8export function renderEditorPage({
9  nodeId,
10  version,
11  noteId,
12  noteContent,
13  qs,
14  tokenQS,
15  originalLength,
16}) {
17  const isNew = !noteId;
18  const safeContent = (noteContent || "")
19    .replace(/&/g, "&amp;")
20    .replace(/</g, "&lt;")
21    .replace(/>/g, "&gt;")
22    .replace(/"/g, "&quot;");
23
24  const css = `
25${baseStyles}
26
27/* ── Editor-specific overrides on base ── */
28:root {
29  --glass-rgb: 115, 111, 230;
30  --sidebar-w: 280px;
31  --toolbar-h: 52px;
32  --bottombar-h: 44px;
33  --editor-font-size: 13px;
34  --editor-line-height: 2.1;
35  --editor-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
36}
37
38html, body { height: 100%; overflow: hidden; }
39body {
40  font-family: var(--editor-font);
41  color: white; display: flex; flex-direction: column;
42  height: 100vh; height: 100dvh;
43  padding: 0; min-height: auto;
44}
45
46/* Override base orbs: editor uses a single subtler orb */
47body::before {
48  opacity: 0.05;
49  animation-duration: 25s;
50}
51body::before { transform: none; }
52body::after { display: none; }
53
54/* ── TOOLBAR ─────────────────── */
55.toolbar {
56  height: var(--toolbar-h); display: flex; align-items: center; gap: 6px;
57  padding: 0 12px;
58  background: rgba(var(--glass-rgb), 0.35);
59  backdrop-filter: blur(22px) saturate(140%);
60  -webkit-backdrop-filter: blur(22px) saturate(140%);
61  border-bottom: 1px solid rgba(255,255,255,0.15);
62  flex-shrink: 0; z-index: 20;
63  overflow-x: auto; overflow-y: hidden;
64  -webkit-overflow-scrolling: touch;
65}
66.toolbar::-webkit-scrollbar { display: none; }
67
68.tb-btn {
69  padding: 6px 12px; border-radius: 8px;
70  border: 1px solid rgba(255,255,255,0.15);
71  background: rgba(255,255,255,0.08);
72  color: rgba(255,255,255,0.8);
73  font-size: 13px; font-weight: 600; font-family: inherit;
74  cursor: pointer; transition: all 0.2s;
75  white-space: nowrap; flex-shrink: 0;
76  display: inline-flex; align-items: center; gap: 4px;
77}
78.tb-btn:hover { background: rgba(255,255,255,0.18); color: white; }
79.tb-btn.active { background: rgba(72,187,178,0.35); border-color: rgba(72,187,178,0.5); color: white; }
80
81.tb-sep { width: 1px; height: 24px; background: rgba(255,255,255,0.12); flex-shrink: 0; margin: 0 4px; }
82.tb-range-wrap { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
83.tb-range-label { font-size: 11px; color: rgba(255,255,255,0.5); font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; white-space: nowrap; }
84
85.tb-range {
86  -webkit-appearance: none; appearance: none;
87  width: 80px; height: 4px;
88  background: rgba(255,255,255,0.2);
89  border-radius: 4px; outline: none; cursor: pointer;
90}
91.tb-range::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: white; box-shadow: 0 2px 6px rgba(0,0,0,0.2); cursor: pointer; }
92.tb-range::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: white; box-shadow: 0 2px 6px rgba(0,0,0,0.2); border: none; cursor: pointer; }
93
94.tb-spacer { flex: 1; min-width: 8px; }
95
96.tb-copy {
97  padding: 6px 12px; border-radius: 8px;
98  border: 1px solid rgba(255,255,255,0.15);
99  background: rgba(255,255,255,0.08);
100  color: rgba(255,255,255,0.8);
101  font-size: 13px; font-weight: 600; font-family: inherit;
102  cursor: pointer; transition: all 0.2s;
103  white-space: nowrap; flex-shrink: 0;
104  display: inline-flex; align-items: center; gap: 4px;
105  min-width: 36px; justify-content: center;
106}
107.tb-copy:hover { background: rgba(255,255,255,0.18); color: white; }
108.tb-copy.copied { background: rgba(72,187,120,0.3); border-color: rgba(72,187,120,0.5); color: white; }
109
110@media (max-width: 768px) {
111  .tb-copy { padding: 6px 10px; }
112}
113
114.tb-back {
115  padding: 6px 14px; border-radius: 8px;
116  border: 1px solid rgba(255,255,255,0.15);
117  background: rgba(255,255,255,0.08);
118  color: rgba(255,255,255,0.8);
119  font-size: 13px; font-weight: 600; font-family: inherit;
120  cursor: pointer; text-decoration: none; transition: all 0.2s;
121  flex-shrink: 0; display: inline-flex; align-items: center; gap: 4px;
122}
123.tb-back:hover { background: rgba(255,255,255,0.18); color: white; }
124
125/* ── MAIN ────────────────────── */
126.main { flex: 1; display: flex; overflow: hidden; position: relative; }
127
128/* ── SIDEBAR ─────────────────── */
129.sidebar {
130  width: var(--sidebar-w); flex-shrink: 0;
131  display: flex; flex-direction: column;
132  background: rgba(var(--glass-rgb), 0.22);
133  backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
134  border-right: 1px solid rgba(255,255,255,0.12);
135  overflow: hidden;
136  z-index: 15;
137}
138.sidebar.hidden { display: none; }
139
140.sidebar-header {
141  padding: 16px; border-bottom: 1px solid rgba(255,255,255,0.1);
142  display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
143}
144.sidebar-title { font-size: 14px; font-weight: 700; color: rgba(255,255,255,0.9); }
145
146.sidebar-close {
147  width: 28px; height: 28px; border-radius: 8px;
148  border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.08);
149  color: rgba(255,255,255,0.6); font-size: 14px;
150  cursor: pointer; display: inline-flex; align-items: center; justify-content: center; transition: all 0.2s;
151}
152.sidebar-close:hover { background: rgba(255,255,255,0.2); color: white; }
153
154.sidebar-list { flex: 1; overflow-y: auto; padding: 8px; }
155.sidebar-list::-webkit-scrollbar { width: 4px; }
156.sidebar-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; }
157
158.note-item {
159  display: flex; align-items: center; gap: 10px;
160  padding: 10px 12px; border-radius: 10px;
161  cursor: pointer; transition: all 0.2s;
162  border: 1px solid transparent; margin-bottom: 2px;
163}
164.note-item:hover { background: rgba(255,255,255,0.1); }
165.note-item.active { background: rgba(72,187,178,0.2); border-color: rgba(72,187,178,0.35); }
166
167.note-item-icon {
168  width: 32px; height: 32px; border-radius: 8px;
169  background: rgba(255,255,255,0.1);
170  display: flex; align-items: center; justify-content: center;
171  font-size: 14px; flex-shrink: 0;
172}
173.note-item-info { min-width: 0; flex: 1; }
174.note-item-username { font-size: 11px; color: rgba(255,255,255,0.5); font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 2px; }
175.note-item-preview { font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.85); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
176.note-item-meta { font-size: 11px; color: rgba(255,255,255,0.4); margin-top: 2px; }
177
178.sidebar-new {
179  margin: 8px; padding: 10px; border-radius: 10px;
180  border: 2px dashed rgba(255,255,255,0.15); background: transparent;
181  color: rgba(255,255,255,0.5); font-size: 13px; font-weight: 600;
182  font-family: inherit; cursor: pointer; transition: all 0.2s;
183  text-align: center; flex-shrink: 0;
184}
185.sidebar-new:hover { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.25); color: rgba(255,255,255,0.8); }
186
187/* ── EDITOR ──────────────────── */
188.editor-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
189
190.editor-scroll {
191  flex: 1; overflow-y: auto; overflow-x: hidden;
192  padding: 16px 16px; display: flex; justify-content: center;
193  -webkit-overflow-scrolling: touch;
194}
195.editor-scroll::-webkit-scrollbar { width: 8px; height: 8px; }
196.editor-scroll::-webkit-scrollbar-track { background: rgba(255,255,255,0.05); }
197.editor-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 4px; }
198.editor-scroll::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
199
200/* Code mode: enable horizontal scroll on outer container */
201.editor-scroll.code-scroll-enabled {
202  overflow-x: auto;
203  justify-content: flex-start;
204}
205
206.editor-container { width: 100%; max-width: 100%; }
207
208/* Code mode: container expands to fit content */
209.editor-container.code-mode-active {
210  width: max-content;
211  min-width: 100%;
212  max-width: none;
213}
214
215/* ── LINE NUMBERS + EDITOR LAYOUT ── */
216.editor-with-lines {
217  display: flex;
218  width: 100%;
219}
220
221.editor-container.code-mode-active .editor-with-lines {
222  width: max-content;
223  min-width: 100%;
224}
225
226.line-numbers {
227  display: none;
228  flex-shrink: 0;
229  padding-right: 12px;
230  margin-right: 12px;
231  border-right: 1px solid rgba(255,255,255,0.03);
232  text-align: right;
233  user-select: none;
234  pointer-events: none;
235  color: rgba(255,255,255,0.3);
236  font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', Consolas, monospace;
237  font-size: var(--editor-font-size);
238  line-height: var(--editor-line-height);
239}
240
241.line-numbers.show {
242  display: block;
243}
244
245.line-numbers span {
246  display: block;
247}
248
249.editor-code-scroll {
250  flex: 1;
251  min-width: 0;
252  overflow-x: visible;
253  overflow-y: visible;
254}
255
256.editor-code-scroll::-webkit-scrollbar { height: 8px; }
257.editor-code-scroll::-webkit-scrollbar-track { background: rgba(255,255,255,0.05); }
258.editor-code-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 4px; }
259.editor-code-scroll::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
260
261#editor {
262  width: 100%;
263  min-height: calc(100vh - var(--toolbar-h) - var(--bottombar-h) - 32px);
264  background: transparent; border: none; outline: none; resize: none;
265  color: rgba(255,255,255,0.95);
266  font-family: var(--editor-font);
267  font-size: var(--editor-font-size);
268  line-height: var(--editor-line-height);
269  caret-color: rgba(72,187,178,0.9);
270  padding: 0; -webkit-font-smoothing: antialiased;
271  overflow: hidden;
272}
273#editor::placeholder { color: rgba(255,255,255,0.25); font-style: italic; }
274#editor.mono {
275  font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', Consolas, monospace;
276  white-space: pre;
277  word-wrap: normal;
278  overflow-wrap: normal;
279}
280
281/* ── BOTTOM BAR ──────────────── */
282.bottombar {
283  height: var(--bottombar-h);
284  display: flex; align-items: center; justify-content: space-between;
285  padding: 0 16px;
286  background: rgba(var(--glass-rgb), 0.3);
287  backdrop-filter: blur(22px) saturate(140%);
288  -webkit-backdrop-filter: blur(22px) saturate(140%);
289  border-top: 1px solid rgba(255,255,255,0.12);
290  flex-shrink: 0; z-index: 20; gap: 12px;
291}
292.bb-left, .bb-right { display: flex; align-items: center; gap: 12px; }
293.bb-stat { font-size: 12px; color: rgba(255,255,255,0.4); font-weight: 500; white-space: nowrap; }
294
295.bb-energy {
296  color: rgba(100,220,255,0.7); font-weight: 600;
297  padding: 2px 8px; background: rgba(100,220,255,0.1);
298  border-radius: 980px; border: 1px solid rgba(100,220,255,0.15);
299}
300
301.bb-status { font-size: 12px; font-weight: 600; white-space: nowrap; transition: color 0.3s; }
302.bb-status.saved { color: rgba(72,187,120,0.8); }
303.bb-status.unsaved { color: rgba(250,204,21,0.8); }
304.bb-status.saving { color: rgba(100,220,255,0.8); }
305.bb-status.error { color: rgba(239,68,68,0.8); }
306
307.save-btn {
308  padding: 6px 20px; border-radius: 980px;
309  border: 1px solid rgba(72,187,178,0.45); background: rgba(72,187,178,0.3);
310  color: white; font-size: 13px; font-weight: 700;
311  font-family: inherit; cursor: pointer; transition: all 0.2s; white-space: nowrap;
312}
313.save-btn:hover { background: rgba(72,187,178,0.45); transform: translateY(-1px); }
314.save-btn:active { transform: translateY(0); }
315.save-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
316
317.delete-btn {
318  padding: 6px 16px; border-radius: 980px;
319  border: 1px solid rgba(239,68,68,0.4); background: rgba(239,68,68,0.2);
320  color: rgba(255,255,255,0.8); font-size: 13px; font-weight: 600;
321  font-family: inherit; cursor: pointer; transition: all 0.2s;
322  white-space: nowrap; display: none;
323}
324.delete-btn:hover { background: rgba(239,68,68,0.35); color: white; }
325.delete-btn.show { display: inline-flex; }
326
327/* ── DELETE MODAL ────────────── */
328.modal-overlay {
329  display: none; position: fixed; inset: 0; z-index: 100;
330  background: rgba(0,0,0,0.6);
331  backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
332  align-items: center; justify-content: center; padding: 20px;
333}
334.modal-overlay.show { display: flex; }
335
336.modal-box {
337  background: rgba(var(--glass-rgb), 0.5);
338  backdrop-filter: blur(22px) saturate(140%);
339  -webkit-backdrop-filter: blur(22px) saturate(140%);
340  border-radius: 20px; padding: 32px;
341  border: 1px solid rgba(255,255,255,0.28);
342  box-shadow: 0 20px 60px rgba(0,0,0,0.3);
343  max-width: 420px; width: 100%; text-align: center;
344}
345.modal-icon { font-size: 48px; margin-bottom: 16px; }
346.modal-title { font-size: 20px; font-weight: 700; color: white; margin-bottom: 8px; }
347.modal-text { font-size: 14px; color: rgba(255,255,255,0.7); line-height: 1.6; margin-bottom: 24px; }
348.modal-actions { display: flex; gap: 12px; justify-content: center; }
349
350.modal-btn {
351  padding: 10px 24px; border-radius: 980px;
352  font-size: 14px; font-weight: 600; font-family: inherit;
353  cursor: pointer; transition: all 0.2s; border: 1px solid;
354}
355.modal-btn-cancel { background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.2); color: rgba(255,255,255,0.8); }
356.modal-btn-cancel:hover { background: rgba(255,255,255,0.22); color: white; }
357.modal-btn-delete { background: rgba(239,68,68,0.3); border-color: rgba(239,68,68,0.5); color: white; }
358.modal-btn-delete:hover { background: rgba(239,68,68,0.5); }
359
360/* ── ZEN ─────────────────────── */
361body.zen .toolbar { display: none; }
362body.zen .sidebar { display: none; }
363body.zen .bottombar { opacity: 0; transition: opacity 0.3s; }
364body.zen:hover .bottombar { opacity: 1; }
365body.zen .editor-scroll { padding: 24px; }
366
367/* ── ZEN EXIT BUTTON (mobile only) ── */
368.zen-exit-btn {
369  display: none;
370  position: fixed;
371  top: 16px;
372  right: 16px;
373  width: 44px;
374  height: 44px;
375  border-radius: 50%;
376  border: 1px solid rgba(255,255,255,0.2);
377  background: rgba(var(--glass-rgb), 0.5);
378  backdrop-filter: blur(16px);
379  -webkit-backdrop-filter: blur(16px);
380  color: rgba(255,255,255,0.8);
381  font-size: 18px;
382  cursor: pointer;
383  z-index: 30;
384  align-items: center;
385  justify-content: center;
386  transition: all 0.2s;
387  box-shadow: 0 4px 20px rgba(0,0,0,0.2);
388}
389.zen-exit-btn:hover {
390  background: rgba(var(--glass-rgb), 0.7);
391  color: white;
392  transform: scale(1.05);
393}
394
395@media (max-width: 768px) {
396  body.zen .zen-exit-btn {
397    display: flex;
398  }
399}
400
401/* ── MOBILE ──────────────────── */
402@media (max-width: 768px) {
403  :root { --sidebar-w: 280px; }
404
405  .sidebar {
406    position: fixed; top: 0; left: 0; bottom: 0;
407    width: var(--sidebar-w);
408    background: rgba(var(--glass-rgb), 0.95);
409    backdrop-filter: blur(30px); -webkit-backdrop-filter: blur(30px);
410    z-index: 50;
411    display: flex;
412  }
413
414  .sidebar.hidden { display: none; }
415  .toolbar { gap: 4px; padding: 0 8px; }
416  .tb-range-wrap { display: flex; }
417  .tb-range-label { display: none; }
418  .tb-range { width: 80px; }
419  .tb-sep { display: none; }
420  .editor-scroll { padding: 16px; }
421  body.zen .bottombar { opacity: 1; }
422  .tb-back span { display: none; }
423  .tb-copy span { display: none; }
424}
425
426@media (max-width: 480px) {
427  .bb-stat:not(.bb-energy) { display: none; }
428  .save-btn { padding: 6px 16px; }
429  .tb-range { width: 60px; }
430}
431
432/* ── Hidden text measurer ── */
433#textMeasurer {
434  position: absolute;
435  visibility: hidden;
436  height: auto;
437  width: auto;
438  white-space: pre;
439  font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', Consolas, monospace;
440  font-size: var(--editor-font-size);
441  line-height: var(--editor-line-height);
442  pointer-events: none;
443}
444
445/* ── HISTORY PANEL ──────────── */
446.history-overlay {
447  position: fixed; inset: 0; z-index: 90;
448  background: rgba(0,0,0,0.5);
449  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
450  display: flex; justify-content: center; align-items: center;
451  padding: 20px;
452}
453.history-overlay.hidden { display: none; }
454
455.history-panel {
456  background: rgba(var(--glass-rgb), 0.65);
457  backdrop-filter: blur(22px) saturate(140%);
458  -webkit-backdrop-filter: blur(22px) saturate(140%);
459  border-radius: 16px;
460  border: 1px solid rgba(255,255,255,0.2);
461  box-shadow: 0 20px 60px rgba(0,0,0,0.3);
462  width: 100%; max-width: 700px;
463  max-height: 80vh;
464  display: flex; flex-direction: column;
465  overflow: hidden;
466}
467
468.history-header {
469  padding: 16px 20px;
470  border-bottom: 1px solid rgba(255,255,255,0.1);
471  display: flex; align-items: center; justify-content: space-between;
472  flex-shrink: 0;
473}
474.history-title { font-size: 16px; font-weight: 700; color: rgba(255,255,255,0.9); }
475
476.history-list {
477  max-height: 200px; overflow-y: auto; padding: 8px 12px;
478  flex-shrink: 0;
479  border-bottom: 1px solid rgba(255,255,255,0.08);
480}
481.history-list::-webkit-scrollbar { width: 4px; }
482.history-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; }
483
484.history-item {
485  display: flex; align-items: center; gap: 10px;
486  padding: 8px 12px; border-radius: 8px;
487  cursor: pointer; transition: all 0.2s;
488  border: 1px solid transparent; margin-bottom: 2px;
489}
490.history-item:hover { background: rgba(255,255,255,0.1); }
491.history-item.active { background: rgba(72,187,178,0.2); border-color: rgba(72,187,178,0.35); }
492
493.history-item-badge {
494  padding: 2px 8px; border-radius: 980px;
495  font-size: 10px; font-weight: 700; text-transform: uppercase;
496  letter-spacing: 0.5px; flex-shrink: 0;
497}
498.history-item-badge.add { background: rgba(72,187,120,0.25); color: rgba(72,187,120,0.9); }
499.history-item-badge.edit { background: rgba(100,220,255,0.2); color: rgba(100,220,255,0.9); }
500
501.history-item-info { flex: 1; min-width: 0; }
502.history-item-user { font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.85); }
503.history-item-date { font-size: 11px; color: rgba(255,255,255,0.4); margin-top: 1px; }
504
505.history-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
506.history-view.hidden { display: none; }
507
508.history-view-header {
509  padding: 10px 16px;
510  display: flex; align-items: center; justify-content: space-between; gap: 8px;
511  border-bottom: 1px solid rgba(255,255,255,0.08);
512  flex-shrink: 0;
513}
514.history-view-modes { display: flex; gap: 4px; }
515
516.history-view-content {
517  flex: 1; overflow-y: auto; padding: 12px 16px;
518  font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', Consolas, monospace;
519  font-size: 12px; line-height: 1.6;
520  white-space: pre-wrap; word-break: break-word;
521}
522.history-view-content::-webkit-scrollbar { width: 4px; }
523.history-view-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; }
524
525.diff-line { padding: 1px 8px; border-radius: 3px; margin: 0; }
526.diff-same { color: rgba(255,255,255,0.7); }
527.diff-add { background: rgba(72,187,120,0.2); color: rgba(72,187,120,0.95); }
528.diff-del { background: rgba(239,68,68,0.2); color: rgba(239,68,68,0.95); }
529.history-empty { text-align: center; padding: 40px 20px; color: rgba(255,255,255,0.3); font-size: 13px; }
530
531@media (max-width: 768px) {
532  .history-panel { max-width: 100%; max-height: 90vh; border-radius: 12px; }
533  .history-list { max-height: 150px; }
534}
535`;
536
537  const body = `
538<!-- Hidden element for measuring text width -->
539<div id="textMeasurer"></div>
540
541<!-- ── ZEN EXIT BUTTON (mobile) ─────────────── -->
542<button class="zen-exit-btn" id="zenExitBtn" title="Exit Zen Mode">\u2715</button>
543
544<!-- ── TOOLBAR ──────────────────────────────── -->
545<div class="toolbar">
546  <a href="/api/v1/node/${nodeId}/${version}/notes${qs}" class="tb-back" id="backBtn">\u2190 <span>Notes</span></a>
547  <div class="tb-sep"></div>
548  <button class="tb-btn" id="sidebarToggle" title="Toggle sidebar">\u2630</button>
549  <button class="tb-btn" id="zenToggle" title="Zen mode">\ud83e\uddd8</button>
550  <button class="tb-btn" id="monoToggle" title="Code mode (monospace)">{ }</button>
551  <div class="tb-sep"></div>
552  <div class="tb-range-wrap tb-fontsize">
553    <span class="tb-range-label">Font</span>
554    <input type="range" class="tb-range" id="fontSizeRange" min="13" max="28" value="20" title="Font Size">
555  </div>
556  <div class="tb-range-wrap tb-lineheight">
557    <span class="tb-range-label">Spacing</span>
558    <input type="range" class="tb-range" id="lineHeightRange" min="12" max="30" value="16" step="1" title="Line Spacing">
559  </div>
560
561  <div class="tb-spacer"></div>
562  ${isNew ? "" : '<button class="tb-btn" id="historyToggle" title="Edit history">\ud83d\udd52</button>'}
563  <button class="tb-copy" id="copyBtn" title="Copy all text">\ud83d\udccb <span>Copy</span></button>
564</div>
565
566<!-- ── MAIN ─────────────────────────────────── -->
567<div class="main">
568
569  <!-- SIDEBAR -->
570  <div class="sidebar hidden" id="sidebar">
571    <div class="sidebar-header">
572      <span class="sidebar-title">Notes</span>
573      <button class="sidebar-close" id="sidebarCloseBtn">\u2715</button>
574    </div>
575    <div class="sidebar-list" id="notesList">
576      <div style="text-align:center;padding:20px;color:rgba(255,255,255,0.3);font-size:13px;">Loading\u2026</div>
577    </div>
578    <button class="sidebar-new" id="newNoteBtn">+ New Note</button>
579  </div>
580
581  <!-- EDITOR -->
582  <div class="editor-wrap">
583    <div class="editor-scroll" id="editorScroll">
584      <div class="editor-container" id="editorContainer">
585        <div class="editor-with-lines" id="editorWithLines">
586          <div class="line-numbers" id="lineNumbers"></div>
587          <div class="editor-code-scroll" id="editorCodeScroll">
588            <textarea id="editor" placeholder="Start writing\u2026">${safeContent}</textarea>
589          </div>
590        </div>
591      </div>
592    </div>
593  </div>
594</div>
595
596<!-- ── BOTTOM BAR ──────────────────────────── -->
597<div class="bottombar">
598  <div class="bb-left">
599    <span class="bb-stat" id="charCount">0 chars</span>
600    <span class="bb-stat" id="wordCount">0 words</span>
601    <span class="bb-stat" id="lineCount">0 lines</span>
602    <span class="bb-stat bb-energy" id="energyCost">\u26a10</span>
603  </div>
604  <div class="bb-right">
605    <span class="bb-status" id="saveStatus">${isNew ? "New note" : "Loaded"}</span>
606    <button class="delete-btn" id="deleteBtn">Delete</button>
607    <button class="save-btn" id="saveBtn">Save</button>
608  </div>
609</div>
610
611<!-- ── DELETE MODAL ─────────────────────────── -->
612<div class="modal-overlay" id="deleteModal">
613  <div class="modal-box">
614    <div class="modal-icon">\ud83d\uddd1\ufe0f</div>
615    <div class="modal-title">Delete this note?</div>
616    <div class="modal-text">
617      It looks like you cleared everything out.<br>
618      Would you like to delete this note entirely?<br>
619      This cannot be undone.
620    </div>
621    <div class="modal-actions">
622      <button class="modal-btn modal-btn-cancel" id="deleteCancelBtn">Cancel</button>
623      <button class="modal-btn modal-btn-delete" id="deleteConfirmBtn">Delete</button>
624    </div>
625  </div>
626</div>
627
628<!-- ── HISTORY PANEL ───────────────────────────── -->
629<div class="history-overlay hidden" id="historyOverlay">
630  <div class="history-panel">
631    <div class="history-header">
632      <span class="history-title">Edit History</span>
633      <button class="sidebar-close" id="historyCloseBtn">\u2715</button>
634    </div>
635    <div class="history-list" id="historyList">
636      <div style="text-align:center;padding:20px;color:rgba(255,255,255,0.3);font-size:13px;">Loading...</div>
637    </div>
638    <div class="history-view hidden" id="historyView">
639      <div class="history-view-header">
640        <div class="history-view-modes">
641          <button class="tb-btn active" id="historyFullBtn">Full Content</button>
642          <button class="tb-btn" id="historyDiffBtn">Show Changes</button>
643        </div>
644        <button class="save-btn" id="historyRestoreBtn">Restore</button>
645      </div>
646      <div class="history-view-content" id="historyViewContent"></div>
647    </div>
648  </div>
649</div>
650`;
651
652  const js = `
653/* ═══════════════════════════════════════════════════
654   STATE
655   ═══════════════════════════════════════════════════ */
656var nodeId      = "${nodeId}";
657var version     = "${version}";
658var currentNoteId = ${noteId ? '"' + noteId + '"' : "null"};
659var qs          = "${qs}";
660var tokenQS     = "${tokenQS}";
661var isNew       = ${isNew};
662var originalLen = ${originalLength || 0};
663var lastSaved   = ${isNew ? '""' : 'document.getElementById("editor").value'};
664var saving      = false;
665var navigatingAway = false;
666
667/* ═══════════════════════════════════════════════════
668   DOM REFS
669   ═══════════════════════════════════════════════════ */
670var editor       = document.getElementById("editor");
671var saveBtn      = document.getElementById("saveBtn");
672var deleteBtn    = document.getElementById("deleteBtn");
673var saveStatus   = document.getElementById("saveStatus");
674var charCountEl  = document.getElementById("charCount");
675var wordCountEl  = document.getElementById("wordCount");
676var lineCountEl  = document.getElementById("lineCount");
677var energyCostEl = document.getElementById("energyCost");
678var sidebar      = document.getElementById("sidebar");
679var notesList    = document.getElementById("notesList");
680var lineNumbersEl = document.getElementById("lineNumbers");
681var editorScroll = document.getElementById("editorScroll");
682var editorWithLines = document.getElementById("editorWithLines");
683var editorCodeScroll = document.getElementById("editorCodeScroll");
684var editorContainer = document.getElementById("editorContainer");
685var textMeasurer = document.getElementById("textMeasurer");
686
687/* ═══════════════════════════════════════════════════
688   SETTINGS (persisted in localStorage)
689   ═══════════════════════════════════════════════════ */
690function loadSettings() {
691  try {
692    var s = JSON.parse(localStorage.getItem("tree-editor-settings") || "{}");
693    if (s.fontSize)   document.getElementById("fontSizeRange").value   = s.fontSize;
694    if (s.lineHeight) document.getElementById("lineHeightRange").value = s.lineHeight;
695    if (s.mono) {
696      editor.classList.add("mono");
697      lineNumbersEl.classList.add("show");
698      editorScroll.classList.add("code-scroll-enabled");
699      editorContainer.classList.add("code-mode-active");
700      document.getElementById("monoToggle").classList.add("active");
701    }
702    applySettings();
703  } catch (e) {}
704}
705
706function persistSettings() {
707  try {
708    localStorage.setItem("tree-editor-settings", JSON.stringify({
709      fontSize:   document.getElementById("fontSizeRange").value,
710      lineHeight: document.getElementById("lineHeightRange").value,
711      mono:       editor.classList.contains("mono")
712    }));
713  } catch (e) {}
714}
715
716function applySettings() {
717  var fontSize = document.getElementById("fontSizeRange").value;
718  var lineHeight = document.getElementById("lineHeightRange").value;
719
720  document.documentElement.style.setProperty("--editor-font-size", fontSize + "px");
721  document.documentElement.style.setProperty("--editor-line-height", lineHeight / 10);
722
723  autoGrowEditor();
724}
725
726document.getElementById("fontSizeRange").oninput   = function() { applySettings(); persistSettings(); };
727document.getElementById("lineHeightRange").oninput  = function() { applySettings(); persistSettings(); };
728
729document.getElementById("monoToggle").onclick = function() {
730  editor.classList.toggle("mono");
731  lineNumbersEl.classList.toggle("show");
732  editorScroll.classList.toggle("code-scroll-enabled");
733  editorContainer.classList.toggle("code-mode-active");
734  this.classList.toggle("active");
735  autoGrowEditor();
736  persistSettings();
737};
738
739/* ═══════════════════════════════════════════════════
740   MEASURE TEXT WIDTH FOR CODE MODE
741   ═══════════════════════════════════════════════════ */
742function measureTextWidth(text) {
743  // Update measurer styles to match editor
744  var computedStyle = getComputedStyle(editor);
745  textMeasurer.style.fontFamily = computedStyle.fontFamily;
746  textMeasurer.style.fontSize = computedStyle.fontSize;
747  textMeasurer.style.lineHeight = computedStyle.lineHeight;
748  textMeasurer.style.letterSpacing = computedStyle.letterSpacing;
749
750  // Find the longest line
751  var lines = text.split("\\n");
752  var maxWidth = 0;
753
754  for (var i = 0; i < lines.length; i++) {
755    textMeasurer.textContent = lines[i] || " ";
756    var width = textMeasurer.offsetWidth;
757    if (width > maxWidth) maxWidth = width;
758  }
759
760  return maxWidth;
761}
762
763function updateEditorWidth() {
764  if (!editor.classList.contains("mono")) {
765    // Normal mode: reset width
766    editor.style.width = "100%";
767    return;
768  }
769
770  // Code mode: measure and set width to fit longest line
771  var contentWidth = measureTextWidth(editor.value);
772  var minWidth = editorScroll.clientWidth - 80; // Account for padding and line numbers
773  var newWidth = Math.max(contentWidth + 20, minWidth); // Add some padding
774
775  editor.style.width = newWidth + "px";
776}
777
778/* ═══════════════════════════════════════════════════
779   AUTO-GROW EDITOR (like VS Code / Word)
780   ═══════════════════════════════════════════════════ */
781function autoGrowEditor() {
782  var minH = window.innerHeight - 52 - 44 - 32;
783
784  editor.style.height = 'auto';
785  var newHeight = Math.max(editor.scrollHeight, minH);
786  editor.style.height = newHeight + 'px';
787
788  updateLineNumbers();
789  updateEditorWidth();
790}
791
792/* ═══════════════════════════════════════════════════
793   LINE NUMBERS
794   ═══════════════════════════════════════════════════ */
795function updateLineNumbers() {
796  if (!editor.classList.contains("mono")) return;
797
798  var lines = editor.value.split("\\n");
799  var count = lines.length;
800  var html = "";
801  for (var i = 1; i <= count; i++) {
802    html += "<span>" + i + "</span>";
803  }
804  lineNumbersEl.innerHTML = html;
805}
806
807/* ═══════════════════════════════════════════════════
808   ZEN MODE
809   ═══════════════════════════════════════════════════ */
810function exitZenMode() {
811  document.body.classList.remove("zen");
812  document.getElementById("zenToggle").classList.remove("active");
813  autoGrowEditor();
814}
815
816document.getElementById("zenToggle").onclick = function() {
817  document.body.classList.toggle("zen");
818  this.classList.toggle("active");
819  autoGrowEditor();
820};
821
822document.getElementById("zenExitBtn").onclick = exitZenMode;
823
824/* ═══════════════════════════════════════════════════
825   COPY ALL TEXT
826   ═══════════════════════════════════════════════════ */
827document.getElementById("copyBtn").onclick = function() {
828  var btn = this;
829  var btnSpan = btn.querySelector("span");
830
831  // Select all text
832  editor.select();
833  editor.setSelectionRange(0, editor.value.length);
834
835  // Copy to clipboard
836  navigator.clipboard.writeText(editor.value).then(function() {
837    btn.firstChild.textContent = "\\u2713 ";
838    if (btnSpan) btnSpan.textContent = "Copied";
839    btn.classList.add("copied");
840
841    setTimeout(function() {
842      btn.firstChild.textContent = "\\ud83d\\udccb ";
843      if (btnSpan) btnSpan.textContent = "Copy";
844      btn.classList.remove("copied");
845    }, 1500);
846  }).catch(function() {
847    // Fallback for older browsers
848    try {
849      document.execCommand("copy");
850      btn.firstChild.textContent = "\\u2713 ";
851      if (btnSpan) btnSpan.textContent = "Copied";
852      btn.classList.add("copied");
853
854      setTimeout(function() {
855        btn.firstChild.textContent = "\\ud83d\\udccb ";
856        if (btnSpan) btnSpan.textContent = "Copy";
857        btn.classList.remove("copied");
858      }, 1500);
859    } catch (e) {
860      if (btnSpan) btnSpan.textContent = "Failed";
861      setTimeout(function() {
862        btn.firstChild.textContent = "\\ud83d\\udccb ";
863        if (btnSpan) btnSpan.textContent = "Copy";
864      }, 1500);
865    }
866  });
867};
868
869/* ═══════════════════════════════════════════════════
870   SIDEBAR TOGGLE
871   ═══════════════════════════════════════════════════ */
872function toggleSidebar() {
873  sidebar.classList.toggle("hidden");
874  document.getElementById("sidebarToggle").classList.toggle("active");
875}
876
877document.getElementById("sidebarToggle").onclick = toggleSidebar;
878
879document.getElementById("sidebarCloseBtn").onclick = function() {
880  sidebar.classList.add("hidden");
881  document.getElementById("sidebarToggle").classList.remove("active");
882};
883
884/* ═══════════════════════════════════════════════════
885   ENERGY ESTIMATE (mirrors server: min 1, max 5)
886   ═══════════════════════════════════════════════════ */
887function estimateEnergy(chars) {
888  return Math.min(5, Math.max(1, 1 + Math.floor(chars / 1000)));
889}
890
891/* ═══════════════════════════════════════════════════
892   STATS + ENERGY + EMPTY DETECTION
893   ═══════════════════════════════════════════════════ */
894function updateStats() {
895  var text    = editor.value;
896  var len     = text.length;
897  var trimmed = text.trim().length;
898
899  charCountEl.textContent = len + " chars";
900  wordCountEl.textContent = (text.trim() ? text.trim().split(/\\s+/).length : 0) + " words";
901  lineCountEl.textContent = text.split("\\n").length + " lines";
902
903  var cost;
904  if (isNew && !currentNoteId) {
905    cost = len > 0 ? estimateEnergy(len) : 0;
906  } else {
907    var delta = Math.max(0, len - originalLen);
908    cost = delta > 0 ? estimateEnergy(delta) : 1;
909  }
910  energyCostEl.textContent = "\\u26A1" + cost;
911
912  if (!isNew && currentNoteId) {
913    if (trimmed === 0) {
914      deleteBtn.classList.add("show");
915      saveBtn.disabled = true;
916    } else {
917      deleteBtn.classList.remove("show");
918      saveBtn.disabled = false;
919    }
920  } else {
921    saveBtn.disabled = trimmed === 0;
922  }
923}
924
925/* ═══════════════════════════════════════════════════
926   DIRTY TRACKING
927   ═══════════════════════════════════════════════════ */
928function isDirty() {
929  return editor.value !== lastSaved;
930}
931
932function markDirty() {
933  if (isDirty()) {
934    saveStatus.textContent = "Unsaved changes";
935    saveStatus.className = "bb-status unsaved";
936  }
937}
938
939editor.addEventListener("input", function() {
940  updateStats();
941  markDirty();
942  autoGrowEditor();
943});
944
945editor.addEventListener("paste", function() {
946  setTimeout(autoGrowEditor, 0);
947});
948
949/* ═══════════════════════════════════════════════════
950   NAVIGATION WITH UNSAVED CHECK
951   ═══════════════════════════════════════════════════ */
952function navigateWithCheck(url) {
953  if (isDirty()) {
954    if (!confirm("Unsaved changes. Discard?")) {
955      return false;
956    }
957  }
958  navigatingAway = true;
959  window.location.href = url;
960  return true;
961}
962
963document.getElementById("backBtn").onclick = function(e) {
964  e.preventDefault();
965  navigateWithCheck("/api/v1/node/" + nodeId + "/" + version + "/notes" + qs);
966};
967
968/* ═══════════════════════════════════════════════════
969   SAVE -> POST (new) or PUT (existing)
970   ═══════════════════════════════════════════════════ */
971async function doSave() {
972  if (saving) return;
973  var content = editor.value;
974
975  if (!isNew && currentNoteId && !content.trim()) {
976    openDeleteModal();
977    return;
978  }
979
980  if (!content.trim()) {
981    saveStatus.textContent = "Cannot save empty note";
982    saveStatus.className = "bb-status error";
983    return;
984  }
985
986  saving = true;
987  saveBtn.disabled = true;
988  saveStatus.textContent = "Saving\\u2026";
989  saveStatus.className = "bb-status saving";
990
991  try {
992    var url, method;
993
994    if (currentNoteId) {
995      url    = "/api/v1/node/" + nodeId + "/" + version + "/notes/" + currentNoteId;
996      method = "PUT";
997    } else {
998      url    = "/api/v1/node/" + nodeId + "/" + version + "/notes";
999      method = "POST";
1000    }
1001
1002    var res = await fetch(url, {
1003      method: method,
1004      headers: { "Content-Type": "application/json" },
1005      body: JSON.stringify({ content: content, contentType: "text" }),
1006      credentials: "include"
1007    });
1008
1009    if (!res.ok) {
1010      var errData = await res.json().catch(function() { return {}; });
1011      throw new Error((errData.error && errData.error.message) || errData.error || "Save failed (" + res.status + ")");
1012    }
1013
1014    var data = await res.json();
1015    var inner = data.data || data;
1016
1017    if (!currentNoteId) {
1018      var newId = inner._id || (inner.note && inner.note._id);
1019      if (newId) {
1020        currentNoteId = newId;
1021        isNew = false;
1022        originalLen = content.length;
1023        history.replaceState(null, "",
1024          "/api/v1/node/" + nodeId + "/" + version + "/notes/" + currentNoteId + "/editor" + qs
1025        );
1026      }
1027    } else {
1028      originalLen = content.length;
1029    }
1030
1031    lastSaved = content;
1032
1033    var msg = "Saved";
1034    var eu  = data.energyUsed || 0;
1035    if (eu > 0) msg += " \\u00b7 \\u26A1" + eu;
1036    saveStatus.textContent = msg;
1037    saveStatus.className   = "bb-status saved";
1038
1039    navigatingAway = true;
1040    if (currentNoteId) {
1041      window.location.href =
1042        "/api/v1/node/" + nodeId + "/" + version + "/notes/" + currentNoteId + qs;
1043    } else {
1044      window.location.href =
1045        "/api/v1/node/" + nodeId + "/" + version + "/notes" + qs;
1046    }
1047
1048    loadNotes();
1049
1050  } catch (err) {
1051    saveStatus.textContent = err.message;
1052    saveStatus.className   = "bb-status error";
1053  } finally {
1054    saving = false;
1055    saveBtn.disabled = false;
1056    updateStats();
1057  }
1058}
1059
1060saveBtn.onclick = doSave;
1061
1062/* ═══════════════════════════════════════════════════
1063   DELETE -> DELETE route
1064   ═══════════════════════════════════════════════════ */
1065function openDeleteModal()  { document.getElementById("deleteModal").classList.add("show"); }
1066function closeDeleteModal() { document.getElementById("deleteModal").classList.remove("show"); }
1067
1068document.getElementById("deleteCancelBtn").onclick = closeDeleteModal;
1069document.getElementById("deleteModal").onclick = function(e) { if (e.target === this) closeDeleteModal(); };
1070
1071document.getElementById("deleteConfirmBtn").onclick = async function() {
1072  if (!currentNoteId) return;
1073  this.disabled   = true;
1074  this.textContent = "Deleting\\u2026";
1075
1076  try {
1077    var res = await fetch(
1078      "/api/v1/node/" + nodeId + "/" + version + "/notes/" + currentNoteId,
1079      { method: "DELETE", credentials: "include" }
1080    );
1081
1082    if (!res.ok) {
1083      var errData = await res.json().catch(function() { return {}; });
1084      throw new Error((errData.error && errData.error.message) || errData.error || "Delete failed");
1085    }
1086
1087    navigatingAway = true;
1088    window.location.href = "/api/v1/node/" + nodeId + "/notes" + qs;
1089
1090  } catch (err) {
1091    closeDeleteModal();
1092    saveStatus.textContent = err.message;
1093    saveStatus.className   = "bb-status error";
1094    this.disabled   = false;
1095    this.textContent = "Delete";
1096  }
1097};
1098
1099deleteBtn.onclick = openDeleteModal;
1100
1101/* ═══════════════════════════════════════════════════
1102   KEYBOARD SHORTCUTS
1103   ═══════════════════════════════════════════════════ */
1104document.addEventListener("keydown", function(e) {
1105  if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); doSave(); }
1106  if (e.key === "Escape") {
1107    if (document.getElementById("deleteModal").classList.contains("show")) closeDeleteModal();
1108    else if (document.body.classList.contains("zen")) {
1109      exitZenMode();
1110    }
1111  }
1112});
1113
1114editor.addEventListener("keydown", function(e) {
1115  // Enter key in code mode: scroll to left to see line numbers
1116  if (e.key === "Enter" && editor.classList.contains("mono")) {
1117    setTimeout(function() {
1118      editorScroll.scrollLeft = 0;
1119    }, 0);
1120  }
1121
1122  if (e.key !== "Tab") return;
1123  e.preventDefault();
1124  var s = this.selectionStart, end = this.selectionEnd, v = this.value;
1125
1126  if (e.shiftKey) {
1127    var ls = v.lastIndexOf("\\n", s - 1) + 1;
1128    if (v.substring(ls, ls + 2) === "  ") {
1129      this.value = v.substring(0, ls) + v.substring(ls + 2);
1130      this.selectionStart = Math.max(s - 2, ls);
1131      this.selectionEnd   = Math.max(end - 2, ls);
1132    }
1133  } else {
1134    this.value = v.substring(0, s) + "  " + v.substring(end);
1135    this.selectionStart = this.selectionEnd = s + 2;
1136  }
1137  updateStats(); markDirty(); autoGrowEditor();
1138});
1139
1140/* ═══════════════════════════════════════════════════
1141   SIDEBAR: LOAD NOTES LIST
1142   ═══════════════════════════════════════════════════ */
1143async function loadNotes() {
1144  try {
1145    var token = new URLSearchParams(qs.replace("?","")).get("token");
1146    var fetchUrl = "/api/v1/node/" + nodeId + "/" + version + "/notes";
1147    if (token) fetchUrl += "?token=" + encodeURIComponent(token);
1148    var res = await fetch(fetchUrl, { credentials: "include" });
1149    var data  = await res.json();
1150    var inner = data.data || data;
1151    var notes = inner.notes || inner || [];
1152    if (!Array.isArray(notes)) notes = [];
1153    if (!notes.length) { notesList.innerHTML = emptyMsg("No notes yet"); return; }
1154
1155    var html = "";
1156    for (var i = 0; i < notes.length; i++) {
1157      var n      = notes[i];
1158      var nId    = n._id || n.id;
1159      var isFile = n.contentType === "file";
1160      var icon   = isFile ? "\\ud83d\\udcce" : "\\ud83d\\udcdd";
1161      var preview;
1162      var username = n.username || n.user || n.author || "Unknown";
1163
1164      if (isFile) preview = n.content ? n.content.split("/").pop() : "File";
1165      else        preview = (n.content || "").slice(0, 60) || "Empty note";
1166
1167      var active = nId === currentNoteId;
1168      var date   = n.createdAt ? new Date(n.createdAt).toLocaleDateString() : "";
1169
1170      html +=
1171        '<div class="note-item' + (active ? " active" : "") +
1172        '" data-id="' + nId + '" data-type="' + (n.contentType || "text") + '">' +
1173          '<div class="note-item-icon">' + icon + '</div>' +
1174          '<div class="note-item-info">' +
1175            '<div class="note-item-username">' + esc(username) + '</div>' +
1176            '<div class="note-item-preview">' + esc(preview) + '</div>' +
1177            '<div class="note-item-meta">' + date + '</div>' +
1178          '</div>' +
1179        '</div>';
1180    }
1181    notesList.innerHTML = html;
1182
1183    notesList.querySelectorAll(".note-item").forEach(function(item) {
1184      item.onclick = function() {
1185        var nId   = item.dataset.id;
1186        var nType = item.dataset.type;
1187        if (nId === currentNoteId) return;
1188
1189        var targetUrl;
1190        if (nType === "file")
1191          targetUrl = "/api/v1/node/" + nodeId + "/" + version + "/notes/" + nId + tokenQS;
1192        else
1193          targetUrl = "/api/v1/node/" + nodeId + "/" + version + "/notes/" + nId + "/editor" + qs;
1194
1195        navigateWithCheck(targetUrl);
1196      };
1197    });
1198
1199  } catch (err) {
1200    notesList.innerHTML = emptyMsg("Error loading notes");
1201  }
1202}
1203
1204function emptyMsg(t) {
1205  return '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.3);font-size:13px;">' + t + '</div>';
1206}
1207
1208function esc(s) { return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
1209
1210/* ═══════════════════════════════════════════════════
1211   NEW NOTE BUTTON
1212   ═══════════════════════════════════════════════════ */
1213document.getElementById("newNoteBtn").onclick = function() {
1214  navigateWithCheck("/api/v1/node/" + nodeId + "/" + version + "/notes/editor" + qs);
1215};
1216
1217/* ═══════════════════════════════════════════════════
1218   WARN ON LEAVE
1219   ═══════════════════════════════════════════════════ */
1220window.addEventListener("beforeunload", function(e) {
1221  if (!navigatingAway && isDirty()) {
1222    e.preventDefault();
1223    e.returnValue = "";
1224  }
1225});
1226
1227/* ═══════════════════════════════════════════════════
1228   WINDOW RESIZE
1229   ═══════════════════════════════════════════════════ */
1230window.addEventListener("resize", function() {
1231  autoGrowEditor();
1232});
1233
1234/* ═══════════════════════════════════════════════════
1235   EDIT HISTORY
1236   ═══════════════════════════════════════════════════ */
1237var historyData = [];
1238var selectedHistoryIdx = -1;
1239var historyMode = "full"; // "full" or "diff"
1240
1241var historyToggleBtn = document.getElementById("historyToggle");
1242var historyOverlay = document.getElementById("historyOverlay");
1243var historyCloseBtn = document.getElementById("historyCloseBtn");
1244var historyListEl = document.getElementById("historyList");
1245var historyView = document.getElementById("historyView");
1246var historyViewContent = document.getElementById("historyViewContent");
1247var historyFullBtn = document.getElementById("historyFullBtn");
1248var historyDiffBtn = document.getElementById("historyDiffBtn");
1249var historyRestoreBtn = document.getElementById("historyRestoreBtn");
1250
1251if (historyToggleBtn) {
1252  historyToggleBtn.onclick = function() {
1253    historyOverlay.classList.remove("hidden");
1254    loadHistory();
1255  };
1256}
1257
1258if (historyCloseBtn) {
1259  historyCloseBtn.onclick = function() {
1260    historyOverlay.classList.add("hidden");
1261  };
1262}
1263
1264if (historyOverlay) {
1265  historyOverlay.onclick = function(e) {
1266    if (e.target === historyOverlay) historyOverlay.classList.add("hidden");
1267  };
1268}
1269
1270async function loadHistory() {
1271  historyListEl.innerHTML = '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.3);font-size:13px;">Loading...</div>';
1272  historyView.classList.add("hidden");
1273  selectedHistoryIdx = -1;
1274
1275  try {
1276    var token = new URLSearchParams(qs.replace("?","")).get("token");
1277    var fetchUrl = "/api/v1/node/" + nodeId + "/" + version + "/notes/" + currentNoteId + "/history";
1278    if (token) fetchUrl += "?token=" + encodeURIComponent(token);
1279    var res = await fetch(fetchUrl, { credentials: "include" });
1280    var data = await res.json();
1281    var histInner = data.data || data;
1282    historyData = histInner.history || [];
1283
1284    if (!historyData.length) {
1285      historyListEl.innerHTML = '<div class="history-empty">No edit history available yet.<br>History is recorded on future saves.</div>';
1286      return;
1287    }
1288
1289    var html = "";
1290    for (var i = historyData.length - 1; i >= 0; i--) {
1291      var h = historyData[i];
1292      var d = new Date(h.date);
1293      var dateStr = d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1294      var badgeClass = h.action === "add" ? "add" : "edit";
1295      var badgeLabel = h.action === "add" ? "Created" : "Edit";
1296
1297      html +=
1298        '<div class="history-item" data-idx="' + i + '">' +
1299          '<span class="history-item-badge ' + badgeClass + '">' + badgeLabel + '</span>' +
1300          '<div class="history-item-info">' +
1301            '<div class="history-item-user">' + esc(h.username) + '</div>' +
1302            '<div class="history-item-date">' + esc(dateStr) + '</div>' +
1303          '</div>' +
1304        '</div>';
1305    }
1306    historyListEl.innerHTML = html;
1307
1308    var items = historyListEl.querySelectorAll(".history-item");
1309    for (var j = 0; j < items.length; j++) {
1310      items[j].onclick = function() {
1311        var idx = parseInt(this.getAttribute("data-idx"));
1312        selectHistoryEntry(idx);
1313        var all = historyListEl.querySelectorAll(".history-item");
1314        for (var k = 0; k < all.length; k++) all[k].classList.remove("active");
1315        this.classList.add("active");
1316      };
1317    }
1318  } catch (e) {
1319    historyListEl.innerHTML = '<div class="history-empty">Failed to load history.</div>';
1320  }
1321}
1322
1323function selectHistoryEntry(idx) {
1324  selectedHistoryIdx = idx;
1325  historyView.classList.remove("hidden");
1326  renderHistoryView();
1327}
1328
1329if (historyFullBtn) {
1330  historyFullBtn.onclick = function() {
1331    historyMode = "full";
1332    historyFullBtn.classList.add("active");
1333    historyDiffBtn.classList.remove("active");
1334    renderHistoryView();
1335  };
1336}
1337
1338if (historyDiffBtn) {
1339  historyDiffBtn.onclick = function() {
1340    historyMode = "diff";
1341    historyDiffBtn.classList.add("active");
1342    historyFullBtn.classList.remove("active");
1343    renderHistoryView();
1344  };
1345}
1346
1347if (historyRestoreBtn) {
1348  historyRestoreBtn.onclick = function() {
1349    if (selectedHistoryIdx < 0 || !historyData[selectedHistoryIdx]) return;
1350    editor.value = historyData[selectedHistoryIdx].content;
1351    historyOverlay.classList.add("hidden");
1352    updateStats();
1353    markDirty();
1354    autoGrowEditor();
1355  };
1356}
1357
1358function renderHistoryView() {
1359  if (selectedHistoryIdx < 0) return;
1360  var entry = historyData[selectedHistoryIdx];
1361
1362  if (entry.content === null || entry.content === undefined) {
1363    historyViewContent.innerHTML = '<div class="history-empty">Content was not recorded for this entry.</div>';
1364    historyRestoreBtn.style.display = "none";
1365    return;
1366  }
1367  historyRestoreBtn.style.display = "";
1368
1369  if (historyMode === "full") {
1370    historyViewContent.innerHTML = '<pre style="margin:0;white-space:pre-wrap;word-break:break-word;color:rgba(255,255,255,0.85);">' + esc(entry.content) + '</pre>';
1371  } else {
1372    var prevContent = "";
1373    for (var p = selectedHistoryIdx - 1; p >= 0; p--) {
1374      if (historyData[p].content !== null && historyData[p].content !== undefined) {
1375        prevContent = historyData[p].content;
1376        break;
1377      }
1378    }
1379    var diffHtml = computeDiff(prevContent, entry.content);
1380    historyViewContent.innerHTML = diffHtml;
1381  }
1382}
1383
1384// ── LCS-based line diff ──
1385function computeDiff(oldText, newText) {
1386  var oldLines = oldText.split("\\n");
1387  var newLines = newText.split("\\n");
1388  var m = oldLines.length;
1389  var n = newLines.length;
1390
1391  // Build LCS table
1392  var dp = [];
1393  for (var i = 0; i <= m; i++) {
1394    dp[i] = [];
1395    for (var j = 0; j <= n; j++) {
1396      if (i === 0 || j === 0) dp[i][j] = 0;
1397      else if (oldLines[i-1] === newLines[j-1]) dp[i][j] = dp[i-1][j-1] + 1;
1398      else dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
1399    }
1400  }
1401
1402  // Backtrack to get diff ops
1403  var ops = [];
1404  var ci = m, cj = n;
1405  while (ci > 0 || cj > 0) {
1406    if (ci > 0 && cj > 0 && oldLines[ci-1] === newLines[cj-1]) {
1407      ops.push({ type: "same", text: oldLines[ci-1] });
1408      ci--; cj--;
1409    } else if (cj > 0 && (ci === 0 || dp[ci][cj-1] >= dp[ci-1][cj])) {
1410      ops.push({ type: "add", text: newLines[cj-1] });
1411      cj--;
1412    } else {
1413      ops.push({ type: "del", text: oldLines[ci-1] });
1414      ci--;
1415    }
1416  }
1417  ops.reverse();
1418
1419  var html = '<div>';
1420  for (var k = 0; k < ops.length; k++) {
1421    var op = ops[k];
1422    var cls = op.type === "same" ? "diff-same" : (op.type === "add" ? "diff-add" : "diff-del");
1423    var prefix = op.type === "same" ? "  " : (op.type === "add" ? "+ " : "- ");
1424    html += '<div class="diff-line ' + cls + '">' + esc(prefix + op.text) + '</div>';
1425  }
1426  html += '</div>';
1427  return html;
1428}
1429
1430/* ═══════════════════════════════════════════════════
1431   INIT
1432   ═══════════════════════════════════════════════════ */
1433loadSettings();
1434updateStats();
1435loadNotes();
1436if (!isNew) lastSaved = editor.value;
1437autoGrowEditor();
1438setTimeout(function() { editor.focus(); }, 100);
1439
1440try {
1441  var draft = sessionStorage.getItem("tree-editor-draft");
1442  if (draft && isNew && !editor.value) {
1443    editor.value = draft;
1444    sessionStorage.removeItem("tree-editor-draft");
1445    updateStats();
1446    markDirty();
1447    autoGrowEditor();
1448  }
1449} catch (e) {}
1450`;
1451
1452  return page({
1453    title: `${isNew ? "New Note" : "Edit Note"} \u00b7 Editor`,
1454    css,
1455    body,
1456    js,
1457    bare: true,
1458  });
1459}
1460
1/**
2 * Land Admin Page ("/land")
3 *
4 * Admin-only dashboard showing land identity, installed extensions,
5 * land stats, and key config values. Extension management (enable/disable)
6 * via inline JavaScript fetch calls.
7 */
8
9export function renderLandPage({
10  landName, domain, seedVersion, landUrl,
11  userCount, treeCount, peerCount,
12  extensions, disabledExtensions,
13  config,
14  horizonUrl,
15}) {
16  const esc = (s) => String(s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
17
18  const extRows = extensions.map(ext => {
19    const isDisabled = disabledExtensions.includes(ext.name);
20    return `
21      <tr id="ext-${esc(ext.name)}">
22        <td style="font-family:monospace;color:#4ade80">${esc(ext.name)}</td>
23        <td style="color:#888">${esc(ext.version)}</td>
24        <td style="color:#666;max-width:300px">${esc(ext.description?.slice(0, 100) || "")}</td>
25        <td>
26          <span class="status-badge ${isDisabled ? "disabled" : "active"}">${isDisabled ? "Disabled" : "Active"}</span>
27        </td>
28        <td>
29          <button class="btn-sm" onclick="toggleExt('${esc(ext.name)}', ${isDisabled})">${isDisabled ? "Enable" : "Disable"}</button>
30        </td>
31      </tr>`;
32  }).join("");
33
34  const configRows = Object.entries(config)
35    .filter(([k]) => !k.startsWith("_"))
36    .sort(([a], [b]) => a.localeCompare(b))
37    .slice(0, 40)
38    .map(([key, val]) => `
39      <tr>
40        <td style="font-family:monospace;color:#60a5fa">${esc(key)}</td>
41        <td style="color:#888">${esc(typeof val === "object" ? JSON.stringify(val) : String(val))}</td>
42      </tr>`)
43    .join("");
44
45  return `<!DOCTYPE html>
46<html lang="en">
47<head>
48  <meta charset="UTF-8">
49  <meta name="viewport" content="width=device-width, initial-scale=1.0">
50  <meta name="theme-color" content="#0a0a0a">
51  <title>${esc(landName)} . Admin</title>
52  <style>
53    * { margin: 0; padding: 0; box-sizing: border-box; }
54    body {
55      background: #0a0a0a;
56      color: #e5e5e5;
57      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
58      -webkit-font-smoothing: antialiased;
59      padding: 32px;
60    }
61
62    .container { max-width: 1000px; margin: 0 auto; }
63
64    .top-nav {
65      display: flex;
66      justify-content: space-between;
67      align-items: center;
68      margin-bottom: 40px;
69    }
70    .top-nav h1 { font-size: 28px; font-weight: 700; }
71    .top-nav a {
72      color: rgba(255,255,255,0.5);
73      text-decoration: none;
74      font-size: 14px;
75    }
76    .top-nav a:hover { color: #fff; }
77
78    .section {
79      margin-bottom: 48px;
80    }
81    .section h2 {
82      font-size: 20px;
83      font-weight: 700;
84      color: #fff;
85      margin-bottom: 16px;
86      padding-bottom: 8px;
87      border-bottom: 1px solid rgba(255,255,255,0.06);
88    }
89
90    .stats-row {
91      display: flex;
92      gap: 24px;
93      margin-bottom: 32px;
94      flex-wrap: wrap;
95    }
96    .stat-card {
97      background: rgba(255,255,255,0.03);
98      border: 1px solid rgba(255,255,255,0.06);
99      border-radius: 10px;
100      padding: 20px 28px;
101      text-align: center;
102      min-width: 120px;
103    }
104    .stat-card .num { font-size: 28px; font-weight: 700; color: #fff; }
105    .stat-card .label { font-size: 12px; color: rgba(255,255,255,0.35); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
106
107    table {
108      width: 100%;
109      border-collapse: collapse;
110    }
111    th {
112      text-align: left;
113      padding: 8px 12px;
114      font-size: 11px;
115      color: rgba(255,255,255,0.3);
116      text-transform: uppercase;
117      letter-spacing: 0.5px;
118      border-bottom: 1px solid rgba(255,255,255,0.06);
119    }
120    td {
121      padding: 10px 12px;
122      font-size: 14px;
123      border-bottom: 1px solid rgba(255,255,255,0.03);
124    }
125
126    .status-badge {
127      display: inline-block;
128      padding: 3px 10px;
129      border-radius: 12px;
130      font-size: 11px;
131      font-weight: 600;
132    }
133    .status-badge.active { background: rgba(74,222,128,0.15); color: #4ade80; }
134    .status-badge.disabled { background: rgba(248,113,113,0.15); color: #f87171; }
135
136    .btn-sm {
137      padding: 5px 14px;
138      border-radius: 6px;
139      font-size: 12px;
140      font-weight: 600;
141      cursor: pointer;
142      border: 1px solid rgba(255,255,255,0.15);
143      background: transparent;
144      color: #e5e5e5;
145      transition: all 0.2s;
146    }
147    .btn-sm:hover { border-color: rgba(255,255,255,0.3); background: rgba(255,255,255,0.05); }
148
149    .search-row {
150      display: flex;
151      gap: 10px;
152      margin-bottom: 16px;
153    }
154    .search-row input {
155      flex: 1;
156      padding: 10px 14px;
157      border-radius: 8px;
158      border: 1px solid rgba(255,255,255,0.1);
159      background: rgba(255,255,255,0.03);
160      color: #e5e5e5;
161      font-size: 14px;
162      outline: none;
163    }
164    .search-row input:focus { border-color: rgba(255,255,255,0.2); }
165    .search-row button {
166      padding: 10px 20px;
167      border-radius: 8px;
168      border: none;
169      background: #fff;
170      color: #0a0a0a;
171      font-weight: 600;
172      cursor: pointer;
173      font-size: 14px;
174    }
175
176    #horizon-results { margin-top: 12px; }
177    .horizon-item {
178      display: flex;
179      justify-content: space-between;
180      align-items: center;
181      padding: 10px 0;
182      border-bottom: 1px solid rgba(255,255,255,0.03);
183    }
184    .horizon-item .name { font-family: monospace; color: #60a5fa; }
185    .horizon-item .desc { color: #666; font-size: 13px; margin-left: 12px; flex: 1; }
186
187    .toast {
188      position: fixed;
189      bottom: 20px;
190      right: 20px;
191      padding: 12px 20px;
192      border-radius: 8px;
193      font-size: 13px;
194      font-weight: 600;
195      z-index: 100;
196      transition: opacity 0.3s;
197    }
198    .toast.ok { background: rgba(74,222,128,0.2); color: #4ade80; border: 1px solid rgba(74,222,128,0.3); }
199    .toast.err { background: rgba(248,113,113,0.2); color: #f87171; border: 1px solid rgba(248,113,113,0.3); }
200
201    @media (max-width: 600px) {
202      body { padding: 16px; }
203      .stats-row { gap: 12px; }
204      .stat-card { padding: 14px 18px; min-width: 80px; }
205      .stat-card .num { font-size: 20px; }
206    }
207  </style>
208</head>
209<body>
210  <div class="container">
211    <div class="top-nav">
212      <h1>${esc(landName)}</h1>
213      <div>
214        <a href="/">Home</a>
215        <span style="color:rgba(255,255,255,0.15);margin:0 8px">.</span>
216        <a href="/dashboard">Dashboard</a>
217      </div>
218    </div>
219
220    <!-- Stats -->
221    <div class="stats-row">
222      <div class="stat-card"><div class="num">${extensions.length}</div><div class="label">Extensions</div></div>
223      <div class="stat-card"><div class="num">${userCount}</div><div class="label">Users</div></div>
224      <div class="stat-card"><div class="num">${treeCount}</div><div class="label">Trees</div></div>
225      <div class="stat-card"><div class="num">${peerCount}</div><div class="label">Peers</div></div>
226      <div class="stat-card"><div class="num">${esc(seedVersion)}</div><div class="label">Seed</div></div>
227    </div>
228
229    <!-- Extensions -->
230    <div class="section">
231      <h2>Extensions (${extensions.length})</h2>
232      <table>
233        <thead><tr><th>Name</th><th>Version</th><th>Description</th><th>Status</th><th></th></tr></thead>
234        <tbody>${extRows}</tbody>
235      </table>
236    </div>
237
238    <!-- Horizon -->
239    <div class="section">
240      <h2>Horizon</h2>
241      <div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
242        <span style="color:rgba(255,255,255,0.3);font-size:0.8rem;">Connected to:</span>
243        <code style="color:#60a5fa;font-size:0.8rem;" id="horizonUrlDisplay">${esc(horizonUrl)}</code>
244        <button class="btn-sm" onclick="changeHorizon()" style="font-size:0.7rem;padding:3px 8px;">Change</button>
245      </div>
246      <div class="search-row">
247        <input type="text" id="horizon-search" placeholder="Search extensions on the network..." />
248        <button onclick="searchHorizon()">Search</button>
249      </div>
250      <div id="horizon-results"></div>
251    </div>
252
253    <!-- Config -->
254    <div class="section">
255      <h2>Config</h2>
256      <table>
257        <thead><tr><th>Key</th><th>Value</th></tr></thead>
258        <tbody>${configRows}</tbody>
259      </table>
260    </div>
261  </div>
262
263  <script>
264    function toast(msg, type) {
265      const el = document.createElement("div");
266      el.className = "toast " + type;
267      el.textContent = msg;
268      document.body.appendChild(el);
269      setTimeout(() => { el.style.opacity = "0"; setTimeout(() => el.remove(), 300); }, 3000);
270    }
271
272    async function changeHorizon() {
273      const newUrl = prompt("Horizon URL:", document.getElementById("horizonUrlDisplay").textContent);
274      if (!newUrl || !newUrl.startsWith("http")) return;
275      try {
276        const res = await fetch("/api/v1/land/config", {
277          method: "POST",
278          headers: { "Content-Type": "application/json" },
279          credentials: "include",
280          body: JSON.stringify({ key: "HORIZON_URL", value: newUrl }),
281        });
282        if (res.ok) {
283          document.getElementById("horizonUrlDisplay").textContent = newUrl;
284          toast("Horizon URL updated.", "ok");
285        } else {
286          const data = await res.json();
287          toast(data.error?.message || "Failed", "err");
288        }
289      } catch (err) { toast(err.message, "err"); }
290    }
291
292    async function toggleExt(name, isCurrentlyDisabled) {
293      const action = isCurrentlyDisabled ? "enable" : "disable";
294      try {
295        const res = await fetch("/api/v1/land/extensions/" + encodeURIComponent(name) + "/" + action, { method: "POST", credentials: "include" });
296        const data = await res.json();
297        if (res.ok) {
298          toast(name + " " + action + "d. Restart to apply.", "ok");
299          setTimeout(() => location.reload(), 1500);
300        } else {
301          toast((data.error?.message || data.message || "Failed"), "err");
302        }
303      } catch (err) { toast(err.message, "err"); }
304    }
305
306    async function searchHorizon() {
307      const q = document.getElementById("horizon-search").value.trim();
308      const container = document.getElementById("horizon-results");
309      container.innerHTML = '<div style="color:#666;font-size:13px">Searching...</div>';
310      try {
311        const horizonUrl = ${JSON.stringify(horizonUrl || "https://horizon.treeos.ai")};
312        const res = await fetch(horizonUrl + "/extensions" + (q ? "?q=" + encodeURIComponent(q) : ""));
313        const raw = await res.json();
314        const data = raw.data || raw;
315        const exts = data.extensions || data || [];
316        if (!Array.isArray(exts) || !exts.length) {
317          container.innerHTML = '<div style="color:#666;font-size:13px">No results.</div>';
318          return;
319        }
320        container.innerHTML = exts.slice(0, 20).map(e =>
321          '<div class="horizon-item">' +
322          '<span class="name">' + (e.name || "?") + '</span>' +
323          '<span class="desc">' + ((e.description || "").slice(0, 80)) + '</span>' +
324          '<button class="btn-sm" onclick="installExt(\\'' + (e.name || "") + '\\')">Install</button>' +
325          '</div>'
326        ).join("");
327      } catch (err) {
328        container.innerHTML = '<div style="color:#f87171;font-size:13px">' + err.message + '</div>';
329      }
330    }
331
332    async function installExt(name) {
333      try {
334        const res = await fetch("/api/v1/land/extensions/install", {
335          method: "POST",
336          headers: { "Content-Type": "application/json" },
337          credentials: "include",
338          body: JSON.stringify({ name }),
339        });
340        const data = await res.json();
341        if (res.ok) { toast(name + " installed. Restart to activate.", "ok"); }
342        else { toast((data.error?.message || "Install failed"), "err"); }
343      } catch (err) { toast(err.message, "err"); }
344    }
345  </script>
346</body>
347</html>`;
348}
349
1/**
2 * Standalone LLM Connections page.
3 * View, add, edit, remove LLM connections. Assign to slots.
4 */
5
6import { page } from "../../html-rendering/html/layout.js";
7import { baseStyles, glassHeaderStyles, glassCardStyles, responsiveBase } from "../../html-rendering/html/baseStyles.js";
8
9function esc(s) {
10  return String(s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
11}
12
13export function renderLlmPage({ userId, username, connections, mainAssignment, userSlots, allUserSlots = [], treeSlots, rootId, rootName, qs }) {
14  const activeConn = mainAssignment ? connections.find(c => c._id === mainAssignment) : null;
15
16  const connCards = connections.length > 0
17    ? connections.map(c => {
18        const isDefault = c._id === mainAssignment;
19        return `
20          <div class="note-card${isDefault ? " glass-green" : ""}" data-id="${esc(c._id)}">
21            <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
22              <strong>${esc(c.name || c._id)}</strong>
23              ${isDefault ? '<span style="font-size:11px;background:rgba(72,187,120,0.2);color:rgba(72,187,120,0.9);padding:2px 8px;border-radius:8px;">default</span>' : ""}
24            </div>
25            <div style="font-size:13px;color:rgba(255,255,255,0.7);line-height:1.8;">
26              <div>Model: <code>${esc(c.model)}</code></div>
27              <div>URL: <code>${esc(c.baseUrl)}</code></div>
28              <div style="font-size:11px;color:rgba(255,255,255,0.4);">ID: ${esc(c._id)}</div>
29            </div>
30            <div style="display:flex;gap:8px;margin-top:12px;">
31              ${!isDefault ? `<button class="action-btn set-default-btn" data-id="${esc(c._id)}">Set Default</button>` : ""}
32              <button class="action-btn delete-btn" data-id="${esc(c._id)}" style="background:rgba(200,80,80,0.2);color:rgba(255,120,120,0.9);">Remove</button>
33            </div>
34          </div>`;
35      }).join("")
36    : '<div style="text-align:center;padding:24px;color:rgba(255,255,255,0.4);">No connections. Add one below.</div>';
37
38  // Slot assignments: dynamic dropdowns from registered slots
39  const slotRows = allUserSlots.map(slot => {
40    const connId = slot === "main" ? mainAssignment : (userSlots[slot] || null);
41    const options = connections.map(c =>
42      `<option value="${esc(c._id)}"${c._id === connId ? " selected" : ""}>${esc(c.name || c.model)}</option>`
43    ).join("");
44    return `<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.05);gap:12px;">
45      <code style="color:#4ade80;font-size:0.85rem;min-width:100px;">${esc(slot)}</code>
46      <select class="slot-select" data-slot="${esc(slot)}" style="flex:1;max-width:260px;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.15);border-radius:8px;padding:8px 12px;color:white;font-size:13px;">
47        <option value=""${!connId ? " selected" : ""}>${slot === "main" ? "Account default" : "Use default"}</option>
48        ${options}
49      </select>
50    </div>`;
51  }).join("");
52
53  const body = `
54    <div class="container">
55      <div class="back-nav">
56        <a href="/api/v1/user/${esc(userId)}${qs}" class="back-link">Home</a>
57        ${rootId ? `<a href="/api/v1/root/${esc(rootId)}${qs}" class="back-link">Back to ${esc(rootName || "Tree")}</a>` : ""}
58      </div>
59
60      <div class="header">
61        <h1>LLM Connections</h1>
62        <div class="header-subtitle">Manage your AI model connections and slot assignments.</div>
63      </div>
64
65      <div class="notes-list">
66        ${connCards}
67      </div>
68
69      <!-- Add Connection -->
70      <div class="header" style="margin-top:24px;">
71        <h1 style="font-size:20px;">Add Connection</h1>
72        <form id="addForm" style="margin-top:12px;">
73          <div style="display:grid;gap:10px;">
74            <input type="text" name="name" placeholder="Connection name (e.g. my-ollama)" required
75              style="background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.15);border-radius:8px;padding:10px 14px;color:white;font-size:14px;">
76            <input type="text" name="baseUrl" placeholder="Base URL (e.g. http://localhost:11434/v1)" required
77              style="background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.15);border-radius:8px;padding:10px 14px;color:white;font-size:14px;">
78            <input type="text" name="model" placeholder="Model (e.g. qwen3:32b)" required
79              style="background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.15);border-radius:8px;padding:10px 14px;color:white;font-size:14px;">
80            <input type="text" name="apiKey" placeholder="API Key (not required for Ollama/local)"
81              style="background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.15);border-radius:8px;padding:10px 14px;color:white;font-size:14px;">
82            <button type="submit" class="action-btn" style="width:100%;">Add Connection</button>
83          </div>
84          <div id="addStatus" style="margin-top:8px;font-size:13px;"></div>
85        </form>
86      </div>
87
88      <!-- Slot Assignments -->
89      <div class="header" style="margin-top:24px;">
90        <h1 style="font-size:20px;">Slot Assignments</h1>
91        <div class="header-subtitle">Each slot can use a different model. Unassigned slots use your default. Changes save automatically.</div>
92        <div style="margin-top:12px;">
93          ${slotRows || '<div style="color:rgba(255,255,255,0.4);font-size:13px;">No slots registered. Install extensions that use LLM.</div>'}
94        </div>
95        <div id="slotStatus" style="margin-top:8px;font-size:12px;min-height:16px;"></div>
96      </div>
97
98      <!-- Free LLM Guide -->
99      <div style="text-align:center;margin-top:24px;font-size:13px;color:rgba(255,255,255,0.4);">
100        Free LLM setup guide: <a href="https://www.youtube.com/watch?v=_cXGZXdiVgw" style="color:rgba(74,222,128,0.8);" target="_blank">YouTube</a>
101      </div>
102    </div>
103  `;
104
105  const css = `
106    ${baseStyles}
107    ${glassHeaderStyles}
108    ${glassCardStyles}
109    ${responsiveBase}
110    .action-btn {
111      background: rgba(115, 111, 230, 0.3);
112      border: 1px solid rgba(255,255,255,0.2);
113      color: white;
114      padding: 8px 16px;
115      border-radius: 8px;
116      font-size: 13px;
117      cursor: pointer;
118      transition: all 0.2s;
119    }
120    .action-btn:hover { background: rgba(115, 111, 230, 0.5); }
121    .slot-select {
122      appearance: none;
123      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='rgba(255,255,255,0.5)' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
124      background-repeat: no-repeat;
125      background-position: right 12px center;
126      padding-right: 32px;
127    }
128    .slot-select option { background: #2d1b4e; color: white; }
129    .back-nav { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
130    .back-link {
131      display: inline-flex; align-items: center; gap: 6px; padding: 10px 20px;
132      background: rgba(115, 111, 230, 0.28); backdrop-filter: blur(22px);
133      color: white; text-decoration: none; border-radius: 980px;
134      font-weight: 600; font-size: 14px; border: 1px solid rgba(255,255,255,0.28);
135    }
136    .back-link:hover { background: rgba(115, 111, 230, 0.38); }
137    .container > * {
138      animation: fadeInUp 0.5s ease-out both;
139    }
140    .container > :nth-child(1) { animation-delay: 0s; }
141    .container > :nth-child(2) { animation-delay: 0.08s; }
142    .container > :nth-child(3) { animation-delay: 0.16s; }
143    .container > :nth-child(4) { animation-delay: 0.24s; }
144    .container > :nth-child(5) { animation-delay: 0.32s; }
145    .container > :nth-child(6) { animation-delay: 0.4s; }
146    .container > :nth-child(7) { animation-delay: 0.48s; }
147  `;
148
149  const js = `
150    var userId = "${esc(userId)}";
151    var qs = "${esc(qs)}";
152
153    document.getElementById("addForm").onsubmit = async function(e) {
154      e.preventDefault();
155      var form = e.target;
156      var status = document.getElementById("addStatus");
157      try {
158        var res = await fetch("/api/v1/user/" + userId + "/custom-llm", {
159          method: "POST",
160          headers: { "Content-Type": "application/json" },
161          credentials: "include",
162          body: JSON.stringify({
163            name: form.name.value,
164            baseUrl: form.baseUrl.value,
165            model: form.model.value,
166            apiKey: form.apiKey.value || "none",
167          }),
168        });
169        var data = await res.json();
170        if (!res.ok) { status.innerHTML = '<span style="color:#f87171;">' + ((data.error && data.error.message) || data.error || "Failed") + '</span>'; return; }
171        status.innerHTML = '<span style="color:#4ade80;">Added. Refreshing...</span>';
172        setTimeout(function() { location.reload(); }, 500);
173      } catch (err) {
174        status.innerHTML = '<span style="color:#f87171;">' + err.message + '</span>';
175      }
176    };
177
178    document.querySelectorAll(".set-default-btn").forEach(function(btn) {
179      btn.onclick = async function() {
180        try {
181          await fetch("/api/v1/user/" + userId + "/llm-assign", {
182            method: "POST",
183            headers: { "Content-Type": "application/json" },
184            credentials: "include",
185            body: JSON.stringify({ slot: "main", connectionId: btn.dataset.id }),
186          });
187          location.reload();
188        } catch {}
189      };
190    });
191
192    document.querySelectorAll(".delete-btn").forEach(function(btn) {
193      btn.onclick = async function() {
194        if (!confirm("Remove this connection?")) return;
195        try {
196          await fetch("/api/v1/user/" + userId + "/custom-llm/" + btn.dataset.id, {
197            method: "DELETE",
198            credentials: "include",
199          });
200          location.reload();
201        } catch {}
202      };
203    });
204
205    // Slot assignment dropdowns: auto-save on change
206    document.querySelectorAll(".slot-select").forEach(function(sel) {
207      sel.onchange = async function() {
208        var slot = sel.dataset.slot;
209        var connId = sel.value || null;
210        var status = document.getElementById("slotStatus");
211        try {
212          var res = await fetch("/api/v1/user/" + userId + "/llm-assign", {
213            method: "POST",
214            headers: { "Content-Type": "application/json" },
215            credentials: "include",
216            body: JSON.stringify({ slot: slot, connectionId: connId }),
217          });
218          var data = await res.json();
219          if (!res.ok) {
220            status.innerHTML = '<span style="color:#f87171;">' + ((data.error && data.error.message) || data.error || "Failed") + '</span>';
221            return;
222          }
223          status.innerHTML = '<span style="color:#4ade80;">' + slot + ' updated</span>';
224          setTimeout(function() { status.innerHTML = ""; }, 2000);
225        } catch (err) {
226          status.innerHTML = '<span style="color:#f87171;">' + err.message + '</span>';
227        }
228      };
229    });
230  `;
231
232  return page({ title: "LLM Connections", css, body, js });
233}
234
1/* ------------------------------------------------------------------ */
2/* renderNodeChats -- AI chat sessions for a node                      */
3/* ------------------------------------------------------------------ */
4
5import { page } from "../../html-rendering/html/layout.js";
6import {
7  esc,
8  truncate,
9  formatTime,
10  formatDuration,
11  modeLabel,
12  sourceLabel,
13  actionLabel,
14  actionColorHex,
15  groupIntoChains,
16} from "../../html-rendering/html/utils.js";
17
18/* ── helpers (local to this page) ── */
19
20const linkifyNodeIds = (html, token) =>
21  html.replace(
22    /Placed on node ([0-9a-f-]{36})/g,
23    (_, id) =>
24      `Placed on node <a class="node-link" href="/api/v1/root/${id}${token ? `?token=${encodeURIComponent(token)}&html` : "?html"}">${id}</a>`,
25  );
26
27const formatContent = (str) => {
28  if (!str) return "";
29  const s = String(str).trim();
30  if (
31    (s.startsWith("{") && s.endsWith("}")) ||
32    (s.startsWith("[") && s.endsWith("]"))
33  ) {
34    try {
35      const parsed = JSON.parse(s);
36      const pretty = JSON.stringify(parsed, null, 2);
37      return `<span class="chain-json">${esc(pretty)}</span>`;
38    } catch (_) {}
39  }
40  return esc(s);
41};
42
43const renderTreeContext = (tc, tokenQS) => {
44  if (!tc) return "";
45  const parts = [];
46  const tcNodeId = tc.targetNodeId?._id || tc.targetNodeId;
47  const tcNodeName = tc.targetNodeId?.name || tc.targetNodeName;
48  if (tcNodeId && tcNodeName && typeof tcNodeId === "string") {
49    parts.push(
50      `<a href="/api/v1/node/${tcNodeId}${tokenQS}" class="tree-target-link">${esc(tcNodeName)}</a>`,
51    );
52  } else if (tcNodeName) {
53    parts.push(`<span class="tree-target-name">${esc(tcNodeName)}</span>`);
54  } else if (tc.targetPath) {
55    const pathParts = tc.targetPath.split(" / ");
56    const last = pathParts[pathParts.length - 1];
57    parts.push(`<span class="tree-target-name">${esc(last)}</span>`);
58  }
59  if (tc.planStepIndex != null && tc.planTotalSteps != null) {
60    parts.push(
61      `<span class="badge badge-step">${tc.planStepIndex}/${tc.planTotalSteps}</span>`,
62    );
63  }
64  if (tc.stepResult) {
65    const resultClasses = {
66      success: "badge-done",
67      failed: "badge-stopped",
68      skipped: "badge-skipped",
69      pending: "badge-pending",
70    };
71    const resultIcons = {
72      success: "done",
73      failed: "failed",
74      skipped: "skip",
75      pending: "...",
76    };
77    parts.push(
78      `<span class="badge ${resultClasses[tc.stepResult] || "badge-pending"}">${resultIcons[tc.stepResult] || ""} ${tc.stepResult}</span>`,
79    );
80  }
81  if (parts.length === 0) return "";
82  return `<div class="tree-context-bar">${parts.join("")}</div>`;
83};
84
85const renderDirective = (tc) => {
86  if (!tc?.directive) return "";
87  return `<div class="tree-directive">${esc(tc.directive)}</div>`;
88};
89
90const getTargetName = (tc) => {
91  if (!tc) return null;
92  return tc.targetNodeId?.name || tc.targetNodeName || null;
93};
94
95const renderModelBadge = (chat) => {
96  const connName = chat.llmProvider?.connectionId?.name;
97  const model = connName || chat.llmProvider?.model;
98  if (!model) return "";
99  return `<span class="chain-model">${esc(model)}</span>`;
100};
101
102const groupStepsIntoPhases = (steps) => {
103  const phases = [];
104  let currentPlan = null;
105  for (const step of steps) {
106    const mode = step.aiContext?.mode || "";
107    if (mode === "translator") {
108      currentPlan = null;
109      phases.push({ type: "translate", step });
110    } else if (mode.startsWith("tree:orchestrator:plan:")) {
111      currentPlan = { type: "plan", marker: step, substeps: [] };
112      phases.push(currentPlan);
113    } else if (mode === "tree:respond") {
114      currentPlan = null;
115      phases.push({ type: "respond", step });
116    } else if (currentPlan) {
117      currentPlan.substeps.push(step);
118    } else {
119      phases.push({ type: "step", step });
120    }
121  }
122  return phases;
123};
124
125const renderSubstep = (chat, tokenQS) => {
126  const duration = formatDuration(
127    chat.startMessage?.time,
128    chat.endMessage?.time,
129  );
130  const stopped = chat.endMessage?.stopped;
131  const tc = chat.treeContext;
132  const dotClass = stopped
133    ? "chain-dot-stopped"
134    : tc?.stepResult === "failed"
135      ? "chain-dot-stopped"
136      : tc?.stepResult === "skipped"
137        ? "chain-dot-skipped"
138        : chat.endMessage?.time
139          ? "chain-dot-done"
140          : "chain-dot-pending";
141  const targetName = getTargetName(tc);
142  const inputFull = formatContent(chat.startMessage?.content);
143  const outputFull = formatContent(chat.endMessage?.content);
144
145  return `
146      <details class="chain-substep">
147        <summary class="chain-substep-summary">
148          <span class="chain-dot ${dotClass}"></span>
149          <span class="chain-step-mode">${modeLabel(chat.aiContext?.mode)}</span>
150          ${targetName ? `<span class="chain-step-target">${esc(targetName)}</span>` : ""}
151          ${tc?.stepResult === "failed" ? `<span class="chain-step-failed">FAILED</span>` : ""}
152          ${tc?.resultDetail && tc.stepResult === "failed" ? `<span class="chain-step-fail-reason">${truncate(tc.resultDetail, 60)}</span>` : ""}
153          ${renderModelBadge(chat)}
154          ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
155        </summary>
156        <div class="chain-step-body">
157          ${renderTreeContext(tc, tokenQS)}
158          ${renderDirective(tc)}
159          <div class="chain-step-input"><span class="chain-io-label chain-io-in">IN</span>${inputFull}</div>
160          ${outputFull ? `<div class="chain-step-output"><span class="chain-io-label chain-io-out">OUT</span>${outputFull}</div>` : ""}
161        </div>
162      </details>`;
163};
164
165const renderPhases = (steps, tokenQS) => {
166  const phases = groupStepsIntoPhases(steps);
167  if (phases.length === 0) return "";
168
169  const phaseHtml = phases
170    .map((phase) => {
171      if (phase.type === "translate") {
172        const s = phase.step;
173        const tc = s.treeContext;
174        const duration = formatDuration(
175          s.startMessage?.time,
176          s.endMessage?.time,
177        );
178        const outputFull = formatContent(s.endMessage?.content);
179        return `
180          <details class="chain-phase chain-phase-translate">
181            <summary class="chain-phase-summary">
182              <span class="chain-phase-icon">T</span>
183              <span class="chain-phase-label">Translator</span>
184              ${tc?.planTotalSteps ? `<span class="chain-step-counter">${tc.planTotalSteps}-step plan</span>` : ""}
185              ${tc?.directive ? `<span class="chain-plan-summary-text">${truncate(tc.directive, 80)}</span>` : ""}
186              ${renderModelBadge(s)}
187              ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
188            </summary>
189            ${outputFull ? `<div class="chain-step-body"><div class="chain-step-output"><span class="chain-io-label chain-io-out">PLAN</span>${outputFull}</div></div>` : ""}
190          </details>`;
191      }
192
193      if (phase.type === "plan") {
194        const m = phase.marker;
195        const tc = m.treeContext;
196        const targetName = getTargetName(tc);
197        const hasSubsteps = phase.substeps.length > 0;
198        const counts = { success: 0, failed: 0, skipped: 0 };
199        for (const sub of phase.substeps) {
200          const r = sub.treeContext?.stepResult;
201          if (r && counts[r] !== undefined) counts[r]++;
202        }
203        const countBadges = [
204          counts.success > 0
205            ? `<span class="badge badge-done">${counts.success} done</span>`
206            : "",
207          counts.failed > 0
208            ? `<span class="badge badge-stopped">${counts.failed} failed</span>`
209            : "",
210          counts.skipped > 0
211            ? `<span class="badge badge-skipped">${counts.skipped} skipped</span>`
212            : "",
213        ]
214          .filter(Boolean)
215          .join("");
216
217        const directiveText = tc?.directive || "";
218        const inputFull = directiveText
219          ? esc(directiveText)
220          : formatContent(m.startMessage?.content);
221
222        return `
223          <div class="chain-phase chain-phase-plan">
224            <div class="chain-phase-header">
225              <span class="chain-phase-icon">P</span>
226              <span class="chain-phase-label">${modeLabel(m.aiContext?.mode)}</span>
227              ${targetName ? `<span class="chain-step-target">${esc(targetName)}</span>` : ""}
228              ${tc?.planStepIndex != null && tc?.planTotalSteps != null ? `<span class="chain-step-counter">Step ${tc.planStepIndex} of ${tc.planTotalSteps}</span>` : ""}
229              ${countBadges}
230              ${renderModelBadge(m)}
231            </div>
232            <div class="chain-plan-directive">${inputFull}</div>
233            ${hasSubsteps ? `<div class="chain-substeps">${phase.substeps.map((s) => renderSubstep(s, tokenQS)).join("")}</div>` : ""}
234          </div>`;
235      }
236
237      if (phase.type === "respond") {
238        const s = phase.step;
239        const tc = s.treeContext;
240        const duration = formatDuration(
241          s.startMessage?.time,
242          s.endMessage?.time,
243        );
244        const inputFull = formatContent(s.startMessage?.content);
245        const outputFull = formatContent(s.endMessage?.content);
246        return `
247          <details class="chain-phase chain-phase-respond">
248            <summary class="chain-phase-summary">
249              <span class="chain-phase-icon">R</span>
250              <span class="chain-phase-label">${modeLabel(s.aiContext?.mode)}</span>
251              ${renderModelBadge(s)}
252              ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
253            </summary>
254            <div class="chain-step-body">
255              ${renderTreeContext(tc, tokenQS)}
256              ${inputFull ? `<div class="chain-step-input"><span class="chain-io-label chain-io-in">IN</span>${inputFull}</div>` : ""}
257              ${outputFull ? `<div class="chain-step-output"><span class="chain-io-label chain-io-out">OUT</span>${outputFull}</div>` : ""}
258            </div>
259          </details>`;
260      }
261
262      return renderSubstep(phase.step, tokenQS);
263    })
264    .join("");
265
266  const summaryParts = phases
267    .map((p) => {
268      if (p.type === "translate") {
269        const tc = p.step.treeContext;
270        return tc?.planTotalSteps ? `T ${tc.planTotalSteps}-step` : "T";
271      }
272      if (p.type === "plan") {
273        const tc = p.marker.treeContext;
274        const targetName = getTargetName(tc);
275        const sub = p.substeps
276          .map((s) => {
277            const stc = s.treeContext;
278            const icon =
279              stc?.stepResult === "failed"
280                ? "X "
281                : stc?.stepResult === "skipped"
282                  ? "- "
283                  : stc?.stepResult === "success"
284                    ? "v "
285                    : "";
286            return `${icon}${modeLabel(s.aiContext?.mode)}`;
287          })
288          .join(" > ");
289        const label = targetName ? `P ${esc(targetName)}` : "P";
290        return sub ? `${label}: ${sub}` : label;
291      }
292      if (p.type === "respond") return "R";
293      return modeLabel(p.step?.aiContext?.mode);
294    })
295    .join("  ");
296
297  return `
298      <details class="chain-dropdown">
299        <summary class="chain-summary">
300          ${phases.length} phase${phases.length !== 1 ? "s" : ""}
301          <span class="chain-modes">${summaryParts}</span>
302        </summary>
303        <div class="chain-phases">${phaseHtml}</div>
304      </details>`;
305};
306
307const renderChain = (chain, tokenQS, token) => {
308  const chat = chain.root;
309  const steps = chain.steps;
310  const duration = formatDuration(
311    chat.startMessage?.time,
312    chat.endMessage?.time,
313  );
314  const stopped = chat.endMessage?.stopped;
315  const contribs = chat.contributions || [];
316  const hasContribs = contribs.length > 0;
317  const hasSteps = steps.length > 0;
318  const modelName =
319    chat.llmProvider?.connectionId?.name ||
320    chat.llmProvider?.model ||
321    "unknown";
322
323  const tc = chat.treeContext;
324  const treeNodeId = tc?.targetNodeId?._id || tc?.targetNodeId;
325  const treeNodeName = tc?.targetNodeId?.name || tc?.targetNodeName;
326  const treeLink =
327    treeNodeId && treeNodeName
328      ? `<a href="/api/v1/node/${treeNodeId}${tokenQS}" class="tree-target-link">${esc(treeNodeName)}</a>`
329      : treeNodeName
330        ? `<span class="tree-target-name">${esc(treeNodeName)}</span>`
331        : "";
332
333  const statusBadge = stopped
334    ? `<span class="badge badge-stopped">Stopped</span>`
335    : chat.endMessage?.time
336      ? `<span class="badge badge-done">Done</span>`
337      : `<span class="badge badge-pending">Pending</span>`;
338
339  const contribRows = contribs
340    .map((c) => {
341      const nId = c.nodeId?._id || c.nodeId;
342      const nName = c.nodeId?.name || nId || "--";
343      const nodeRef = nId
344        ? `<a href="/api/v1/node/${nId}${tokenQS}">${esc(nName)}</a>`
345        : `<span style="opacity:0.5">--</span>`;
346      const aiBadge = c.wasAi
347        ? `<span class="mini-badge mini-ai">AI</span>`
348        : "";
349      const cEnergyBadge =
350        c.energyUsed > 0
351          ? `<span class="mini-badge mini-energy">E${c.energyUsed}</span>`
352          : "";
353      const understandingLink =
354        c.action === "understanding" &&
355        c.understandingMeta?.understandingRunId &&
356        c.understandingMeta?.rootNodeId
357          ? ` <a class="understanding-link" href="/api/v1/root/${c.understandingMeta.rootNodeId}/understandings/run/${c.understandingMeta.understandingRunId}${tokenQS}">View run</a>`
358          : "";
359      const color = actionColorHex(c.action);
360      return `
361        <tr class="contrib-row">
362          <td><span class="action-dot" style="background:${color}"></span>${esc(actionLabel(c.action))}${understandingLink}</td>
363          <td>${nodeRef}</td>
364          <td>${aiBadge}${cEnergyBadge}</td>
365          <td class="contrib-time">${formatTime(c.date)}</td>
366        </tr>`;
367    })
368    .join("");
369
370  const stepsHtml = hasSteps ? renderPhases(steps, tokenQS) : "";
371
372  return `
373      <li class="note-card">
374        <div class="chat-header">
375          <div class="chat-header-left">
376            <span class="chat-mode">${modeLabel(chat.aiContext?.mode)}</span>
377            ${treeLink}
378            <span class="chat-model">${esc(modelName)}</span>
379          </div>
380          <div class="chat-badges">
381            ${statusBadge}
382            ${duration ? `<span class="badge badge-duration">${duration}</span>` : ""}
383            <span class="badge badge-source">${sourceLabel(chat.startMessage?.source)}</span>
384          </div>
385        </div>
386
387        <div class="note-content">
388          <div class="chat-message chat-user">
389            <span class="msg-label">${chat.userId?._id ? `<a href="/api/v1/user/${chat.userId._id}${tokenQS}" class="msg-user-link">${esc(chat.userId.username || "User")}</a>` : esc("User")}</span>
390            <div class="msg-text msg-clamp">${esc(chat.startMessage?.content || "")}</div>
391            ${(chat.startMessage?.content || "").length > 300 ? `<button class="expand-btn" onclick="toggleExpand(this)">Show more</button>` : ""}
392          </div>
393          ${
394            chat.endMessage?.content
395              ? `
396          <div class="chat-message chat-ai">
397            <span class="msg-label">AI</span>
398            <div class="msg-text msg-clamp">${linkifyNodeIds(esc(chat.endMessage.content), token)}</div>
399            ${chat.endMessage.content.length > 300 ? `<button class="expand-btn" onclick="toggleExpand(this)">Show more</button>` : ""}
400          </div>`
401              : ""
402          }
403        </div>
404
405        ${stepsHtml}
406
407        ${
408          hasContribs
409            ? `
410        <details class="contrib-dropdown">
411          <summary class="contrib-summary">
412            ${contribs.length} contribution${contribs.length !== 1 ? "s" : ""} during this chat
413          </summary>
414          <div class="contrib-table-wrap">
415            <table class="contrib-table">
416              <thead><tr><th>Action</th><th>Node</th><th></th><th>Time</th></tr></thead>
417              <tbody>${contribRows}</tbody>
418            </table>
419          </div>
420        </details>`
421            : ""
422        }
423
424        <div class="note-meta">
425          ${formatTime(chat.startMessage?.time)}
426          <span class="meta-separator">|</span>
427          <code class="contribution-id">${esc(chat._id)}</code>
428        </div>
429      </li>`;
430};
431
432/* ── page-specific CSS ── */
433
434const css = `
435.header-path { font-size: 12px; color: rgba(255,255,255,0.6); margin-top: 4px; font-family: 'SF Mono', 'Fira Code', monospace; }
436
437.session-group { margin-bottom: 20px; animation: fadeInUp 0.6s ease-out both; }
438.session-pane {
439  background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
440  border-radius: 20px; overflow: hidden; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
441}
442.session-pane-header {
443  display: flex; align-items: center; justify-content: space-between; padding: 14px 20px;
444  background: rgba(255,255,255,0.08); border-bottom: 1px solid rgba(255,255,255,0.1);
445}
446.session-header-left { display: flex; align-items: center; gap: 10px; }
447.session-id {
448  font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px; font-weight: 600;
449  color: rgba(255,255,255,0.55); background: rgba(255,255,255,0.1); padding: 3px 8px;
450  border-radius: 6px; border: 1px solid rgba(255,255,255,0.12);
451}
452.session-info { font-size: 13px; color: rgba(255,255,255,0.7); font-weight: 600; }
453.session-time { font-size: 12px; color: rgba(255,255,255,0.4); font-weight: 500; }
454
455.notes-list { padding: 16px; }
456.note-card { opacity: 0; transform: translateY(30px); }
457.note-card.visible { animation: fadeInUp 0.6s cubic-bezier(0.4,0,0.2,1) forwards; }
458
459.chat-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px; }
460.chat-header-left { display: flex; align-items: center; gap: 8px; }
461.chat-mode {
462  font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.1);
463  padding: 3px 10px; border-radius: 980px; border: 1px solid rgba(255,255,255,0.15);
464}
465.chat-model {
466  font-size: 11px; font-weight: 500; color: rgba(255,255,255,0.45);
467  font-family: 'SF Mono', 'Fira Code', monospace; overflow: hidden;
468  text-overflow: ellipsis; white-space: nowrap; max-width: 200px;
469}
470.chat-badges { display: flex; flex-wrap: wrap; gap: 6px; }
471
472.note-content { margin-bottom: 16px; display: flex; flex-direction: column; gap: 14px; }
473.chat-message { display: flex; gap: 10px; align-items: flex-start; }
474.msg-label {
475  flex-shrink: 0; font-weight: 700; font-size: 10px; text-transform: uppercase;
476  letter-spacing: 0.5px; padding: 3px 10px; border-radius: 980px; margin-top: 3px;
477}
478.chat-user .msg-label { background: rgba(255,255,255,0.2); color: white; }
479.chat-ai .msg-label   { background: rgba(100,220,255,0.25); color: white; }
480.msg-user-link { color: inherit; text-decoration: none; }
481.msg-user-link:hover { text-decoration: underline; }
482.msg-text { color: rgba(255,255,255,0.95); word-wrap: break-word; min-width: 0; font-size: 15px; line-height: 1.65; font-weight: 400; }
483.msg-clamp {
484  display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical;
485  overflow: hidden; max-height: calc(1.65em * 4); transition: max-height 0.3s ease;
486}
487.msg-clamp.expanded { -webkit-line-clamp: unset; max-height: none; overflow: visible; }
488.expand-btn {
489  background: none; border: none; color: rgba(100,220,255,0.9); cursor: pointer;
490  font-size: 12px; font-weight: 600; padding: 2px 0; margin-top: 2px; transition: color 0.2s;
491}
492.expand-btn:hover { color: rgba(100,220,255,1); text-decoration: underline; }
493.node-link { color: #7effc0; text-decoration: none; background: rgba(50,220,120,0.15); padding: 1px 6px; border-radius: 4px; font-family: monospace; font-size: 13px; }
494.node-link:hover { background: rgba(50,220,120,0.3); }
495.understanding-link {
496  color: rgba(100,100,210,0.9); text-decoration: none; font-size: 11px; font-weight: 500;
497  margin-left: 4px; transition: color 0.2s;
498}
499.understanding-link:hover { color: rgba(130,130,255,1); text-decoration: underline; }
500.chat-user .msg-text { font-weight: 500; }
501
502.chain-dropdown { margin-bottom: 12px; }
503.chain-summary {
504  cursor: pointer; font-size: 13px; font-weight: 600;
505  color: rgba(255,255,255,0.85); padding: 8px 14px;
506  background: rgba(255,255,255,0.1); border-radius: 10px;
507  border: 1px solid rgba(255,255,255,0.15);
508  transition: all 0.2s; list-style: none;
509  display: flex; align-items: center; gap: 8px;
510}
511.chain-summary::-webkit-details-marker { display: none; }
512.chain-summary::before { content: ">"; font-size: 10px; transition: transform 0.15s; display: inline-block; }
513details[open] > .chain-summary::before { transform: rotate(90deg); }
514.chain-summary:hover { background: rgba(255,255,255,0.18); }
515.chain-modes { font-size: 11px; color: rgba(255,255,255,0.5); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
516.chain-phases { margin-top: 12px; display: flex; flex-direction: column; gap: 12px; }
517
518.chain-phase { border-radius: 10px; overflow: hidden; }
519.chain-phase-header {
520  display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 12px; font-weight: 600; flex-wrap: wrap;
521}
522.chain-phase-icon { font-size: 14px; }
523.chain-phase-label { color: rgba(255,255,255,0.85); }
524.chain-phase-translate { background: rgba(100,100,220,0.12); border: 1px solid rgba(100,100,220,0.2); }
525.chain-phase-plan { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); }
526.chain-phase-respond { background: rgba(72,187,120,0.1); border: 1px solid rgba(72,187,120,0.2); }
527.chain-plan-directive { padding: 6px 12px 10px; font-size: 12px; color: rgba(255,255,255,0.6); line-height: 1.5; white-space: pre-wrap; }
528
529.chain-phase-summary, .chain-substep-summary {
530  cursor: pointer; list-style: none;
531  display: flex; align-items: center; gap: 8px;
532  padding: 8px 12px; font-size: 12px; font-weight: 600; flex-wrap: wrap;
533}
534.chain-phase-summary::-webkit-details-marker,
535.chain-substep-summary::-webkit-details-marker { display: none; }
536.chain-phase-summary::before,
537.chain-substep-summary::before {
538  content: ">"; font-size: 8px; color: rgba(255,255,255,0.35);
539  transition: transform 0.15s; display: inline-block;
540}
541details[open] > .chain-phase-summary::before,
542details[open] > .chain-substep-summary::before { transform: rotate(90deg); }
543.chain-phase-summary:hover, .chain-substep-summary:hover { background: rgba(255,255,255,0.05); }
544
545.chain-substeps { display: flex; flex-direction: column; gap: 2px; padding: 0 8px 8px; }
546.chain-substep { border-radius: 6px; background: rgba(255,255,255,0.04); }
547.chain-substep:hover { background: rgba(255,255,255,0.07); }
548
549.chain-dot {
550  display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
551  border: 2px solid rgba(255,255,255,0.3);
552}
553.chain-dot-done    { background: rgba(72,187,120,0.8); border-color: rgba(72,187,120,0.4); }
554.chain-dot-stopped { background: rgba(200,80,80,0.8); border-color: rgba(200,80,80,0.4); }
555.chain-dot-pending { background: rgba(255,200,50,0.8); border-color: rgba(255,200,50,0.4); }
556.chain-dot-skipped { background: rgba(160,160,160,0.6); border-color: rgba(160,160,160,0.3); }
557
558.chain-step-mode {
559  font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.8);
560  background: rgba(255,255,255,0.12); padding: 2px 8px; border-radius: 6px;
561}
562.chain-step-duration { font-size: 10px; color: rgba(255,255,255,0.45); }
563.chain-model {
564  font-size: 10px; font-family: 'SF Mono', 'Fira Code', monospace;
565  color: rgba(255,255,255,0.4); margin-left: auto; white-space: nowrap;
566  overflow: hidden; text-overflow: ellipsis; max-width: 150px;
567}
568
569.chain-step-body { padding: 10px 12px; border-top: 1px solid rgba(255,255,255,0.08); }
570.chain-io-label {
571  display: inline-block; font-size: 9px; font-weight: 700; letter-spacing: 0.5px;
572  padding: 1px 6px; border-radius: 4px; margin-right: 8px; vertical-align: middle;
573}
574.chain-io-in  { background: rgba(100,220,255,0.2); color: rgba(100,220,255,0.9); }
575.chain-io-out { background: rgba(72,187,120,0.2); color: rgba(72,187,120,0.9); }
576
577.chain-step-input {
578  font-size: 12px; color: rgba(255,255,255,0.8); line-height: 1.6;
579  word-break: break-word; white-space: pre-wrap;
580  font-family: 'SF Mono', 'Fira Code', monospace;
581}
582.chain-step-output {
583  font-size: 12px; color: rgba(255,255,255,0.65); line-height: 1.6;
584  margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.1);
585  word-break: break-word; white-space: pre-wrap;
586  font-family: 'SF Mono', 'Fira Code', monospace;
587}
588.chain-json { color: rgba(255,255,255,0.8); }
589
590.tree-context-bar {
591  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
592  padding: 6px 12px; margin-bottom: 6px;
593  background: rgba(255,255,255,0.06); border-radius: 6px; font-size: 12px;
594}
595.tree-target-link {
596  color: rgba(100,220,255,0.95); text-decoration: none;
597  border-bottom: 1px solid rgba(100,220,255,0.3);
598  font-weight: 600; font-size: 12px; transition: all 0.2s;
599}
600.tree-target-link:hover {
601  border-bottom-color: rgba(100,220,255,0.8);
602  text-shadow: 0 0 8px rgba(100,220,255,0.5);
603}
604.tree-target-name { color: rgba(255,255,255,0.8); font-weight: 600; font-size: 12px; }
605.tree-directive {
606  padding: 4px 12px 8px; font-size: 11px; color: rgba(255,255,255,0.55);
607  line-height: 1.5; font-style: italic;
608  border-left: 2px solid rgba(255,255,255,0.15); margin: 0 12px 8px;
609}
610.chain-step-counter {
611  font-size: 10px; color: rgba(255,255,255,0.5); font-weight: 500;
612  background: rgba(255,255,255,0.08); padding: 2px 8px; border-radius: 4px;
613}
614.chain-step-target {
615  font-size: 10px; color: rgba(100,220,255,0.7); font-weight: 500;
616  max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
617}
618.chain-step-failed {
619  font-size: 9px; font-weight: 700; color: rgba(200,80,80,0.9);
620  background: rgba(200,80,80,0.15); padding: 1px 6px; border-radius: 4px; letter-spacing: 0.5px;
621}
622.chain-step-fail-reason {
623  font-size: 10px; color: rgba(200,80,80,0.7); font-weight: 400;
624  font-style: italic; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
625}
626.badge-step {
627  background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7);
628  font-family: 'SF Mono', 'Fira Code', monospace; font-size: 10px;
629}
630.badge-skipped { background: rgba(160,160,160,0.25); color: rgba(255,255,255,0.7); }
631.chain-plan-summary-text {
632  font-size: 11px; color: rgba(255,255,255,0.45); font-weight: 400;
633  font-style: italic; overflow: hidden; text-overflow: ellipsis;
634  white-space: nowrap; max-width: 300px;
635}
636
637.contrib-dropdown { margin-bottom: 12px; }
638.contrib-summary {
639  cursor: pointer; font-size: 13px; font-weight: 600;
640  color: rgba(255,255,255,0.85); padding: 8px 14px;
641  background: rgba(255,255,255,0.1); border-radius: 10px;
642  border: 1px solid rgba(255,255,255,0.15);
643  transition: all 0.2s; list-style: none;
644  display: flex; align-items: center; gap: 6px;
645}
646.contrib-summary::-webkit-details-marker { display: none; }
647.contrib-summary::before { content: ">"; font-size: 10px; transition: transform 0.2s; display: inline-block; }
648details[open] .contrib-summary::before { transform: rotate(90deg); }
649.contrib-summary:hover { background: rgba(255,255,255,0.18); }
650.contrib-table-wrap { margin-top: 10px; overflow-x: auto; -webkit-overflow-scrolling: touch; }
651.contrib-table { width: 100%; border-collapse: collapse; font-size: 13px; }
652.contrib-table thead th {
653  text-align: left; font-size: 11px; font-weight: 600; text-transform: uppercase;
654  letter-spacing: 0.5px; color: rgba(255,255,255,0.55); padding: 6px 10px;
655  border-bottom: 1px solid rgba(255,255,255,0.15);
656}
657.contrib-row td {
658  padding: 7px 10px; border-bottom: 1px solid rgba(255,255,255,0.08);
659  color: rgba(255,255,255,0.88); vertical-align: middle; white-space: nowrap;
660}
661.contrib-row:last-child td { border-bottom: none; }
662.contrib-row a { color: white; text-decoration: none; border-bottom: 1px solid rgba(255,255,255,0.3); transition: all 0.2s; }
663.contrib-row a:hover { border-bottom-color: white; text-shadow: 0 0 12px rgba(255,255,255,0.8); }
664.contrib-time { font-size: 11px; color: rgba(255,255,255,0.5); }
665.action-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
666
667.mini-badge {
668  display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 980px;
669  font-size: 10px; font-weight: 700; letter-spacing: 0.2px; margin-right: 3px;
670}
671.mini-ai    { background: rgba(255,200,50,0.35); color: #fff; }
672.mini-energy { background: rgba(100,220,255,0.3); color: #fff; }
673
674.badge {
675  display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 980px;
676  font-size: 11px; font-weight: 700; letter-spacing: 0.3px; border: 1px solid rgba(255,255,255,0.2);
677}
678.badge-done     { background: rgba(72,187,120,0.35); color: #fff; }
679.badge-stopped  { background: rgba(200,80,80,0.35); color: #fff; }
680.badge-pending  { background: rgba(255,200,50,0.3); color: #fff; }
681.badge-duration { background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.9); }
682.badge-source   { background: rgba(100,100,210,0.3); color: #fff; }
683
684.contribution-id {
685  background: rgba(255,255,255,0.12); padding: 2px 6px; border-radius: 4px;
686  font-size: 11px; font-family: 'SF Mono', 'Fira Code', monospace;
687  color: rgba(255,255,255,0.6); border: 1px solid rgba(255,255,255,0.1);
688}
689
690@media (max-width: 640px) {
691  .chat-header { flex-direction: column; align-items: flex-start; }
692  .contrib-row td { font-size: 12px; padding: 5px 6px; }
693  .session-pane-header { flex-direction: column; align-items: flex-start; gap: 6px; padding: 12px 16px; }
694  .notes-list { padding: 12px; gap: 12px; }
695  .chat-model { max-width: 140px; }
696  .msg-text { font-size: 14px; }
697  .chain-plan-directive { font-size: 11px; }
698  .chain-step-target { max-width: 100px; }
699  .chain-plan-summary-text { max-width: 160px; }
700  .chain-step-fail-reason { max-width: 120px; }
701}
702`;
703
704/* ── client-side JS ── */
705
706const js = `
707    var observer = new IntersectionObserver(function(entries) {
708      entries.forEach(function(entry, index) {
709        if (entry.isIntersecting) {
710          setTimeout(function() { entry.target.classList.add('visible'); }, index * 50);
711          observer.unobserve(entry.target);
712        }
713      });
714    }, { root: null, rootMargin: '50px', threshold: 0.1 });
715    document.querySelectorAll('.note-card').forEach(function(card) { observer.observe(card); });
716
717    function toggleExpand(btn) {
718      var text = btn.previousElementSibling;
719      if (!text) return;
720      var expanded = text.classList.toggle('expanded');
721      btn.textContent = expanded ? 'Show less' : 'Show more';
722    }
723`;
724
725/* ================================================================== */
726/* renderNodeChats                                                     */
727/* ================================================================== */
728
729export function renderNodeChats({
730  nodeId,
731  nodeName,
732  nodePath,
733  sessions,
734  allChats,
735  token,
736  tokenQS,
737}) {
738  const sessionGroups = sessions;
739
740  const renderedSections = sessionGroups
741    .map((group) => {
742      const chatCount = group.chatCount;
743      const sessionTime = formatTime(group.startTime);
744      const shortId = group.sessionId.slice(0, 8);
745      const chains = groupIntoChains(group.chats);
746      const chatCards = chains.map((c) => renderChain(c, tokenQS, token)).join("");
747
748      return `
749      <div class="session-group">
750        <div class="session-pane">
751          <div class="session-pane-header">
752            <div class="session-header-left">
753              <span class="session-id">${esc(shortId)}</span>
754              <span class="session-info">${chatCount} chat${chatCount !== 1 ? "s" : ""}</span>
755            </div>
756            <span class="session-time">${sessionTime}</span>
757          </div>
758          <ul class="notes-list">${chatCards}</ul>
759        </div>
760      </div>`;
761    })
762    .join("");
763
764  const body = `
765  <div class="container">
766    <div class="back-nav">
767      <a href="/api/v1/node/${nodeId}${tokenQS}" class="back-link">&lt;- Back to Node</a>
768    </div>
769
770    <div class="header">
771      <h1>
772        AI Chats for
773        <a href="/api/v1/node/${nodeId}${tokenQS}">${esc(nodeName)}</a>
774        ${allChats.length > 0 ? `<span class="message-count">${allChats.length}</span>` : ""}
775      </h1>
776      <div class="header-subtitle">
777        AI sessions that targeted or modified this node.
778      </div>
779      <div class="header-path">${esc(nodePath)}</div>
780    </div>
781
782    ${
783      sessionGroups.length
784        ? renderedSections
785        : `
786    <div class="empty-state">
787      <div class="empty-state-icon">AI</div>
788      <div class="empty-state-text">No AI chats yet</div>
789      <div class="empty-state-subtext">AI conversations involving this node will appear here</div>
790    </div>`
791    }
792  </div>
793`;
794
795  return page({
796    title: `${esc(nodeName)} -- AI Chats`,
797    css,
798    body,
799    js,
800  });
801}
802
803/**
804 * Alias for tree-root chat history.
805 * The route passes rootId/rootName; we map to nodeId/nodeName.
806 */
807export function renderRootChats({ rootId, rootName, sessions, allChats, token, tokenQS }) {
808  return renderNodeChats({
809    nodeId: rootId,
810    nodeName: rootName,
811    nodePath: rootName,
812    sessions,
813    allChats,
814    token,
815    tokenQS,
816  });
817}
818
1/* ------------------------------------------------------------------ */
2/* renderNodeDetail -- Node detail page with hierarchy, scripts, etc.  */
3/* ------------------------------------------------------------------ */
4
5import { page } from "../../html-rendering/html/layout.js";
6import { resolveSlots } from "../slots.js";
7
8/* ── page-specific CSS ── */
9
10const css = `
11
12/* =========================================================
13   UNIFIED GLASS BUTTON SYSTEM
14   ========================================================= */
15
16.glass-btn,
17button,
18.action-button,
19.back-link,
20.versions-list a,
21.children-list a,
22button[type="submit"],
23.primary-button,
24.warning-button,
25.danger-button {
26  position: relative;
27  overflow: hidden;
28
29  padding: 10px 20px;
30  border-radius: 980px;
31
32  display: inline-flex;
33  align-items: center;
34  justify-content: center;
35  white-space: nowrap;
36
37  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
38  backdrop-filter: blur(22px) saturate(140%);
39  -webkit-backdrop-filter: blur(22px) saturate(140%);
40
41  color: white;
42  text-decoration: none;
43  font-family: inherit;
44
45  font-size: 15px;
46  font-weight: 600;
47  letter-spacing: -0.2px;
48
49  border: 1px solid rgba(255, 255, 255, 0.28);
50
51  box-shadow:
52    0 8px 24px rgba(0, 0, 0, 0.12),
53    inset 0 1px 0 rgba(255, 255, 255, 0.25);
54
55  cursor: pointer;
56
57  transition:
58    background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
59    transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
60    box-shadow 0.3s ease;
61}
62
63/* Liquid light layer */
64.glass-btn::before,
65button::before,
66.action-button::before,
67.back-link::before,
68.versions-list a::before,
69.children-list a::before,
70button[type="submit"]::before,
71.primary-button::before,
72.warning-button::before,
73.danger-button::before {
74  content: "";
75  position: absolute;
76  inset: -40%;
77
78  background:
79    radial-gradient(
80      120% 60% at 0% 0%,
81      rgba(255, 255, 255, 0.35),
82      transparent 60%
83    ),
84    linear-gradient(
85      120deg,
86      transparent 30%,
87      rgba(255, 255, 255, 0.25),
88      transparent 70%
89    );
90
91  opacity: 0;
92  transform: translateX(-30%) translateY(-10%);
93  transition:
94    opacity 0.35s ease,
95    transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
96
97  pointer-events: none;
98}
99
100/* Hover motion */
101.glass-btn:hover,
102button:hover,
103.action-button:hover,
104.back-link:hover,
105.versions-list a:hover,
106.children-list a:hover,
107button[type="submit"]:hover,
108.primary-button:hover,
109.warning-button:hover,
110.danger-button:hover {
111  background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
112  transform: translateY(-2px);
113}
114
115.glass-btn:hover::before,
116button:hover::before,
117.action-button:hover::before,
118.back-link:hover::before,
119.versions-list a:hover::before,
120.children-list a:hover::before,
121button[type="submit"]:hover::before,
122.primary-button:hover::before,
123.warning-button:hover::before,
124.danger-button:hover::before {
125  opacity: 1;
126  transform: translateX(30%) translateY(10%);
127}
128
129/* Active press */
130.glass-btn:active,
131button:active,
132.primary-button:active,
133.warning-button:active,
134.danger-button:active {
135  background: rgba(var(--glass-water-rgb), 0.45);
136  transform: translateY(0);
137}
138
139/* Emphasis variants */
140.primary-button {
141  --glass-water-rgb: 72, 187, 178;
142  --glass-alpha: 0.34;
143  --glass-alpha-hover: 0.46;
144  font-weight: 600;
145}
146
147.warning-button {
148  --glass-water-rgb: 100, 116, 139;
149  font-weight: 600;
150}
151
152.danger-button {
153  --glass-water-rgb: 198, 40, 40;
154  font-weight: 600;
155}
156
157/* =========================================================
158   CONTENT CARDS - UPDATED TO MATCH ROOT ROUTE
159   ========================================================= */
160
161.header,
162.hierarchy-section,
163.versions-section,
164.scripts-section,
165.actions-section {
166  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
167  backdrop-filter: blur(22px) saturate(140%);
168  -webkit-backdrop-filter: blur(22px) saturate(140%);
169  border-radius: 16px;
170  padding: 28px;
171  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
172    inset 0 1px 0 rgba(255, 255, 255, 0.25);
173  border: 1px solid rgba(255, 255, 255, 0.28);
174  margin-bottom: 24px;
175  animation: fadeInUp 0.6s ease-out;
176  animation-fill-mode: both;
177  position: relative;
178  overflow: hidden;
179}
180
181.header {
182  animation-delay: 0.1s;
183}
184
185.versions-section {
186  animation-delay: 0.15s;
187}
188
189.hierarchy-section {
190  animation-delay: 0.2s;
191}
192
193.scripts-section {
194  animation-delay: 0.25s;
195}
196
197.actions-section {
198  animation-delay: 0.3s;
199}
200
201.header::before,
202.hierarchy-section::before,
203.versions-section::before,
204.scripts-section::before,
205.actions-section::before {
206  content: "";
207  position: absolute;
208  inset: 0;
209  border-radius: inherit;
210  background: linear-gradient(
211    180deg,
212    rgba(255, 255, 255, 0.18),
213    rgba(255, 255, 255, 0.05)
214  );
215  pointer-events: none;
216}
217
218.meta-card {
219  background: rgba(255, 255, 255, 0.15);
220  backdrop-filter: blur(22px) saturate(140%);
221  -webkit-backdrop-filter: blur(22px) saturate(140%);
222  border-radius: 12px;
223  padding: 16px 20px;
224  border: 1px solid rgba(255, 255, 255, 0.28);
225  color: white;
226}
227
228.header h1 {
229  font-size: 28px;
230  font-weight: 600;
231  letter-spacing: -0.5px;
232  line-height: 1.3;
233  margin-bottom: 8px;
234  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
235  color: white;
236}
237
238.hierarchy-section h2,
239.versions-section h2,
240.scripts-section h2,
241.actions-section h3 {
242  font-size: 18px;
243  font-weight: 600;
244  color: white;
245  margin-bottom: 16px;
246  letter-spacing: -0.3px;
247  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
248}
249
250.hierarchy-section h3 {
251  font-size: 16px;
252  font-weight: 600;
253  color: rgba(255, 255, 255, 0.9);
254  margin: 24px 0 12px 0;
255}
256
257/* =========================================================
258   NAV + META
259   ========================================================= */
260
261.back-nav {
262  display: flex;
263  gap: 12px;
264  margin-bottom: 20px;
265  flex-wrap: wrap;
266  animation: fadeInUp 0.5s ease-out;
267}
268
269.node-id-container {
270  display: flex;
271  align-items: center;
272  gap: 8px;
273  margin-top: 12px;
274  flex-wrap: wrap;
275  padding: 10px 14px;
276  background: rgba(255, 255, 255, 0.15);
277  border-radius: 8px;
278  border: 1px solid rgba(255, 255, 255, 0.2);
279}
280
281code {
282  background: transparent;
283  padding: 0;
284  border-radius: 0;
285  font-size: 13px;
286  font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
287  color: white;
288  word-break: break-all;
289  flex: 1;
290}
291
292#copyNodeIdBtn {
293  background: rgba(255, 255, 255, 0.2);
294  border: 1px solid rgba(255, 255, 255, 0.3);
295  cursor: pointer;
296  padding: 6px 10px;
297  border-radius: 6px;
298  opacity: 1;
299  font-size: 16px;
300  transition: all 0.2s;
301  flex-shrink: 0;
302}
303
304#copyNodeIdBtn:hover {
305  background: rgba(255, 255, 255, 0.3);
306  transform: scale(1.1);
307}
308
309#copyNodeIdBtn::before {
310  display: none;
311}
312
313.meta-row {
314  display: flex;
315  gap: 24px;
316  flex-wrap: wrap;
317  padding-top: 12px;
318  border-top: 1px solid rgba(255, 255, 255, 0.2);
319}
320
321.meta-item {
322  display: flex;
323  flex-direction: column;
324  gap: 4px;
325}
326
327.meta-label {
328  font-size: 12px;
329  font-weight: 600;
330  text-transform: uppercase;
331  letter-spacing: 0.5px;
332  color: rgba(255, 255, 255, 0.7);
333}
334
335.meta-value {
336  font-size: 16px;
337  font-weight: 600;
338  color: white;
339}
340
341/* =========================================================
342   LISTS
343   ========================================================= */
344
345.versions-list {
346  list-style: none;
347  display: grid;
348  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
349  gap: 10px;
350}
351
352.versions-list li {
353  margin: 0;
354}
355
356.versions-list a {
357  display: block;
358  padding: 14px 18px;
359  text-align: center;
360}
361
362.children-list {
363  list-style: none;
364  margin-bottom: 20px;
365}
366
367.children-list li {
368  margin: 0 0 8px 0;
369}
370
371.children-list a {
372  display: block;
373  padding: 12px 16px;
374}
375
376.hierarchy-section a {
377  color: white;
378  text-decoration: none;
379  font-weight: 600;
380  transition: opacity 0.2s;
381}
382
383.hierarchy-section a:hover {
384  opacity: 0.8;
385}
386
387.hierarchy-section em {
388  color: rgba(255, 255, 255, 0.7);
389  font-style: normal;
390}
391
392.hierarchy-section > p {
393  margin-bottom: 16px;
394}
395
396/* =========================================================
397   SCRIPTS
398   ========================================================= */
399
400.scripts-list {
401  list-style: none;
402}
403
404.scripts-list li {
405  margin-bottom: 16px;
406  padding: 16px;
407  background: rgba(255, 255, 255, 0.1);
408  border-radius: 10px;
409  border: 1px solid rgba(255, 255, 255, 0.2);
410}
411
412.scripts-list li:last-child {
413  margin-bottom: 0;
414}
415
416.scripts-list a {
417  color: white;
418  text-decoration: none;
419  display: block;
420}
421
422.scripts-list a:hover {
423  opacity: 0.9;
424}
425
426.scripts-list strong {
427  display: block;
428  margin-bottom: 8px;
429  color: white;
430  font-size: 15px;
431}
432
433.scripts-list pre {
434  background: rgba(0, 0, 0, 0.3);
435  color: #e0e0e0;
436  padding: 14px;
437  border-radius: 8px;
438  overflow-x: auto;
439  font-size: 13px;
440  line-height: 1.5;
441  font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
442  border: 1px solid rgba(255, 255, 255, 0.1);
443}
444
445.scripts-list em {
446  color: rgba(255, 255, 255, 0.6);
447  font-style: normal;
448}
449
450.scripts-section h2 a {
451  color: white;
452  text-decoration: none;
453}
454
455.scripts-section h2 a:hover {
456  opacity: 0.8;
457}
458
459/* =========================================================
460   FORMS
461   ========================================================= */
462
463.action-form {
464  display: flex;
465  gap: 10px;
466  align-items: stretch;
467  margin-top: 12px;
468  flex-wrap: wrap;
469}
470
471.action-form input[type="text"] {
472  flex: 1;
473  min-width: 200px;
474  padding: 12px 14px;
475  font-size: 15px;
476  border-radius: 10px;
477  border: 2px solid rgba(255, 255, 255, 0.3);
478  background: rgba(255, 255, 255, 0.15);
479  color: white;
480  font-family: inherit;
481  font-weight: 500;
482  transition: all 0.2s;
483}
484
485.action-form input[type="text"]::placeholder {
486  color: rgba(255, 255, 255, 0.5);
487}
488
489.action-form input[type="text"]:focus {
490  outline: none;
491  border-color: rgba(255, 255, 255, 0.6);
492  background: rgba(255, 255, 255, 0.25);
493  box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
494  transform: translateY(-2px);
495}
496
497/* =========================================================
498   RESPONSIVE
499   ========================================================= */
500
501@media (max-width: 640px) {
502  .container {
503    max-width: 100%;
504  }
505
506  .header,
507  .hierarchy-section,
508  .versions-section,
509  .scripts-section,
510  .actions-section {
511    padding: 20px;
512  }
513
514  .meta-row {
515    flex-direction: column;
516    gap: 12px;
517  }
518
519  .versions-list {
520    grid-template-columns: 1fr;
521  }
522
523  .action-form {
524    flex-direction: column;
525  }
526
527  .action-form input[type="text"] {
528    width: 100%;
529    min-width: 0;
530  }
531
532  .action-form button {
533    width: 100%;
534  }
535
536  code {
537    font-size: 11px;
538    max-width: 180px;
539    overflow: hidden;
540    text-overflow: ellipsis;
541  }
542}
543
544@media (min-width: 641px) and (max-width: 1024px) {
545  .versions-list {
546    grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
547  }
548}
549`;
550
551/* ── client-side JS ── */
552
553const jsCode = `
554    // Copy ID functionality
555    const btn = document.getElementById("copyNodeIdBtn");
556    const code = document.getElementById("nodeIdCode");
557
558    btn.addEventListener("click", () => {
559      navigator.clipboard.writeText(code.textContent).then(() => {
560        btn.textContent = "✔️";
561        setTimeout(() => (btn.textContent = "📋"), 900);
562      });
563    });
564`;
565
566/* ================================================================== */
567/* renderNodeDetail                                                    */
568/* ================================================================== */
569
570export function renderNodeDetail({ node, nodeId, qs, parentName, rootUrl, isPublicAccess }) {
571
572  const body = `
573  <div class="container">
574    <!-- Back Navigation -->
575    <div class="back-nav">
576      ${rootUrl ? `<a href="${rootUrl}" class="back-link">← Back to Tree</a>` : ""}
577      <a href="/api/v1/node/${nodeId}/chats${qs}" class="back-link">
578        AI Chats
579      </a>
580      <a href="/api/v1/node/${nodeId}/metadata${qs}" class="back-link">
581        Metadata
582      </a>
583    </div>
584
585    <!-- Header -->
586    <div class="header">
587      <h1
588        id="nodeNameDisplay"
589        ${!isPublicAccess ? `style="cursor:pointer;" title="Click to rename" onclick="document.getElementById('nodeNameDisplay').style.display='none';document.getElementById('renameForm').style.display='flex';"` : ""}
590      >${node.name}</h1>
591      ${!isPublicAccess ? `<form
592        id="renameForm"
593        method="POST"
594        action="/api/v1/node/${nodeId}/${0}/editName${qs}"
595        style="display:none;align-items:center;gap:8px;margin-bottom:12px;"
596      >
597        <input
598          type="text"
599          name="name"
600          value="${node.name.replace(/"/g, '&quot;')}"
601          required
602          style="flex:1;font-size:20px;font-weight:700;padding:8px 12px;border-radius:12px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.1);color:white;"
603        />
604        <button type="submit" class="primary-button" style="padding:8px 16px;">Save</button>
605        <button
606          type="button"
607          class="warning-button"
608          style="padding:8px 16px;"
609          onclick="document.getElementById('renameForm').style.display='none';document.getElementById('nodeNameDisplay').style.display='';"
610        >Cancel</button>
611      </form>` : ""}
612
613      <div class="node-id-container">
614        <code id="nodeIdCode">${node._id}</code>
615        <button id="copyNodeIdBtn" title="Copy ID">📋</button>
616      </div>
617
618      <div class="meta-row">
619        <div class="meta-item">
620          <div class="meta-label">Type</div>
621          <div class="meta-value">${node.type ?? "None"}</div>
622        </div>
623        <div class="meta-item">
624          <div class="meta-label">Status</div>
625          <div class="meta-value">${node.status || "active"}</div>
626        </div>
627      </div>
628    </div>
629
630    ${!isPublicAccess ? `<!-- Edit Type -->
631    <div class="hierarchy-section">
632      <h2>Node Type</h2>
633      <form
634        method="POST"
635        action="/api/v1/node/${nodeId}/editType${qs}"
636        class="action-form"
637      >
638        <select name="type" style="flex:1;padding:10px 14px;border-radius:12px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.1);color:white;font-size:14px;">
639          <option value="" ${!node.type ? "selected" : ""}>None</option>
640          ${resolveSlots("node-type-options", { node, nodeType: node.type }, { raw: true })}
641        </select>
642        <input
643          type="text"
644          name="customType"
645          placeholder="or custom type..."
646          style="flex:1;"
647        />
648        <button type="submit" class="primary-button">Set Type</button>
649      </form>
650    </div>` : ""}
651
652    <!-- AI Tools Config -->
653    ${!isPublicAccess ? (() => {
654      const meta = node.metadata instanceof Map ? Object.fromEntries(node.metadata) : (node.metadata || {});
655      const tools = meta.tools || {};
656      const allowed = (tools.allowed || []).join(", ");
657      const blocked = (tools.blocked || []).join(", ");
658      return `<div class="hierarchy-section">
659        <h2>AI Tools</h2>
660        <p style="color:rgba(255,255,255,0.5);font-size:0.85rem;margin-bottom:12px;">
661          Control what the AI can do at this node. Inherits up the tree.
662          <a href="/api/v1/node/${nodeId}/command-center?html" style="color:rgba(74,222,128,0.9);text-decoration:none;margin-left:8px;font-weight:600;">Command Center</a>
663        </p>
664        ${allowed ? `<div style="margin-bottom:8px;"><span style="color:rgba(16,185,129,0.9);font-size:0.85rem;">Added: ${allowed}</span></div>` : ""}
665        ${blocked ? `<div style="margin-bottom:8px;"><span style="color:rgba(239,68,68,0.9);font-size:0.85rem;">Blocked: ${blocked}</span></div>` : ""}
666        <form method="POST" action="/api/v1/node/${nodeId}/tools${qs}">
667          <div style="margin-bottom:8px;">
668            <label style="display:block;font-size:0.8rem;color:rgba(255,255,255,0.6);margin-bottom:4px;">Allow tools (comma-separated)</label>
669            <input type="text" name="allowedRaw" value="${allowed}" placeholder="execute-shell, web-search" style="width:100%;padding:8px;border-radius:8px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.08);color:white;font-size:0.9rem;" />
670          </div>
671          <div style="margin-bottom:8px;">
672            <label style="display:block;font-size:0.8rem;color:rgba(255,255,255,0.6);margin-bottom:4px;">Block tools (comma-separated)</label>
673            <input type="text" name="blockedRaw" value="${blocked}" placeholder="delete-node-branch" style="width:100%;padding:8px;border-radius:8px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.08);color:white;font-size:0.9rem;" />
674          </div>
675          <button type="submit" class="primary-button" style="padding:8px 16px;">Save</button>
676        </form>
677      </div>`;
678    })() : ""}
679
680    <!-- Extension sections (versions, scripts, etc.) -->
681    ${resolveSlots("node-detail-sections", { node, nodeId, qs, isPublicAccess })}
682
683    <!-- Parent Section -->
684    <div class="hierarchy-section">
685      <h2>Parent</h2>
686      ${
687        node.parent
688          ? `<a href="/api/v1/node/${node.parent}${qs}" style="display:block;padding:12px 16px;margin-bottom:16px;">${parentName}</a>`
689          : `<p style="margin-bottom:16px;"><em>None (This is a root node)</em></p>`
690      }
691
692      ${!isPublicAccess ? `<h3>Change Parent</h3>
693      <form
694        method="POST"
695        action="/api/v1/node/${nodeId}/updateParent${qs}"
696        class="action-form"
697      >
698        <input
699          type="text"
700          name="newParentId"
701          placeholder="New parent node ID"
702          required
703        />
704        <button type="submit" class="warning-button">
705          Move Node
706        </button>
707      </form>` : ""}
708    </div>
709
710    <!-- Children Section -->
711    <div class="hierarchy-section">
712      <h2>Children</h2>
713      <ul class="children-list">
714        ${
715          node.children && node.children.length
716            ? node.children
717                .map(
718                  (c) =>
719                    `<li><a href="/api/v1/node/${c._id}${qs}">${c.name}</a></li>`,
720                )
721                .join("")
722            : `<li><em>No children yet</em></li>`
723        }
724      </ul>
725
726      <h3>Add Child</h3>
727      <form
728        method="POST"
729        action="/api/v1/node/${nodeId}/createChild${qs}"
730        class="action-form"
731      >
732        <input
733          type="text"
734          name="name"
735          placeholder="Child name"
736          required
737        />
738        <button type="submit" class="primary-button">
739          Create Child
740        </button>
741      </form>
742    </div>
743
744    <!-- Extension sections below hierarchy (scripts, etc.) -->
745    ${resolveSlots("node-detail-below", { node, nodeId, qs, isPublicAccess })}
746
747    ${!isPublicAccess ? `<!-- Delete Section -->
748    <div class="actions-section">
749      <h3>Delete</h3>
750      <form
751        method="POST"
752        action="/api/v1/node/${nodeId}/delete${qs}"
753        onsubmit="return confirm('Delete this node and its branch? This can be revived later.')"
754      >
755        <button type="submit" class="danger-button">
756          Delete Node
757        </button>
758      </form>
759    </div>` : ""}
760  </div>
761`;
762
763  return page({
764    title: `${node.name} — Node`,
765    css,
766    body,
767    js: jsCode,
768  });
769}
770
1/**
2 * Node-level LLM page.
3 * Assign models per tree. Default + per-slot overrides.
4 */
5
6import { page } from "../../html-rendering/html/layout.js";
7import { baseStyles, glassHeaderStyles, glassCardStyles, responsiveBase } from "../../html-rendering/html/baseStyles.js";
8
9function esc(s) {
10  return String(s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
11}
12
13export function renderNodeLlmPage({ nodeId, nodeName, connections, defaultLlm, slots, allSlots = [], qs, userId }) {
14  const activeConn = defaultLlm ? connections.find(c => c._id === defaultLlm) : null;
15
16  // Slot assignment rows
17  const slotRows = [
18    { key: "default", label: "Default", isDefault: true },
19    ...allSlots.filter(s => s !== "default").map(s => ({ key: s, label: s.charAt(0).toUpperCase() + s.slice(1) })),
20  ].map(slot => {
21    const connId = slot.key === "default" ? defaultLlm : (slots[slot.key] || null);
22    const options = connections.map(c =>
23      `<option value="${esc(c._id)}"${c._id === connId ? " selected" : ""}>${esc(c.name || c.model)}</option>`
24    ).join("");
25    return `<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.05);gap:12px;">
26      <code style="color:#4ade80;font-size:0.85rem;min-width:100px;">${esc(slot.label)}</code>
27      <select class="slot-select" data-slot="${esc(slot.key)}" style="flex:1;max-width:280px;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.15);border-radius:8px;padding:8px 12px;color:white;font-size:13px;">
28        <option value=""${!connId ? " selected" : ""}>${slot.isDefault ? "Account default" : "Use tree default"}</option>
29        ${options}
30        ${slot.isDefault ? `<option value="none"${connId === "none" ? " selected" : ""} style="color:rgba(255,107,107,0.8);">Off (no AI)</option>` : ""}
31      </select>
32    </div>`;
33  }).join("");
34
35  const body = `
36    <div class="container">
37      <div class="back-nav">
38        <a href="/api/v1/root/${esc(nodeId)}${qs}" class="back-link">\u2190 Back to ${esc(nodeName || "Tree")}</a>
39        <a href="/api/v1/node/${esc(nodeId)}/command-center${qs}" class="back-link">Command Center</a>
40        <a href="/api/v1/user/${esc(userId)}/llm${qs}" class="back-link">User LLM</a>
41      </div>
42
43      <div class="header">
44        <h1>Tree LLM Assignments</h1>
45        <div class="header-subtitle">${esc(nodeName || "Tree")}</div>
46      </div>
47
48      ${connections.length === 0
49        ? `<div class="header" style="margin-top:16px;text-align:center;">
50            <div style="color:rgba(255,255,255,0.5);font-size:14px;line-height:1.8;">
51              No LLM connections configured.<br/>
52              <a href="/api/v1/user/${esc(userId)}/llm${qs}" style="color:#4ade80;">Add one on your profile</a>
53            </div>
54          </div>`
55        : `
56      <div class="header" style="margin-top:16px;">
57        <h1 style="font-size:20px;">Slot Assignments</h1>
58        <div class="header-subtitle">
59          Set a default LLM for this tree. All modes fall back to it.
60          Per-mode overrides below. "Off" disables AI entirely.
61          Unassigned slots use your account default.
62        </div>
63        <div style="margin-top:16px;">
64          ${slotRows}
65        </div>
66        <div id="slotStatus" style="margin-top:8px;font-size:12px;min-height:16px;"></div>
67      </div>
68
69      <!-- Current assignment summary -->
70      <div class="header" style="margin-top:16px;">
71        <h1 style="font-size:18px;">Active Model</h1>
72        ${activeConn
73          ? `<div style="font-size:14px;color:rgba(255,255,255,0.7);line-height:1.8;margin-top:8px;">
74              <div><strong>${esc(activeConn.name)}</strong></div>
75              <div>Model: <code>${esc(activeConn.model)}</code></div>
76              <div>URL: <code>${esc(activeConn.baseUrl)}</code></div>
77            </div>`
78          : defaultLlm === "none"
79            ? '<div style="color:rgba(255,107,107,0.8);font-size:14px;">AI disabled for this tree</div>'
80            : '<div style="color:rgba(255,255,255,0.4);font-size:14px;">Using account default</div>'
81        }
82      </div>
83      `}
84    </div>
85  `;
86
87  const css = `
88    ${baseStyles}
89    ${glassHeaderStyles}
90    ${glassCardStyles}
91    ${responsiveBase}
92    .slot-select {
93      appearance: none;
94      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='rgba(255,255,255,0.5)' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
95      background-repeat: no-repeat;
96      background-position: right 12px center;
97      padding-right: 32px;
98    }
99    .slot-select option { background: #1a1145; color: white; }
100    .back-nav { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
101    .back-link {
102      display: inline-flex; align-items: center; gap: 6px; padding: 10px 20px;
103      background: rgba(var(--glass-water-rgb), var(--glass-alpha)); backdrop-filter: blur(22px);
104      color: white; text-decoration: none; border-radius: 980px;
105      font-weight: 600; font-size: 14px; border: 1px solid rgba(255,255,255,0.12);
106    }
107    .back-link:hover { background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover)); }
108  `;
109
110  const js = `
111    var nodeId = "${esc(nodeId)}";
112
113    document.querySelectorAll(".slot-select").forEach(function(sel) {
114      sel.onchange = async function() {
115        var slot = sel.dataset.slot;
116        var connId = sel.value || null;
117        var status = document.getElementById("slotStatus");
118        try {
119          var res = await fetch("/api/v1/root/" + nodeId + "/llm-assign", {
120            method: "POST",
121            headers: { "Content-Type": "application/json" },
122            credentials: "include",
123            body: JSON.stringify({ slot: slot, connectionId: connId }),
124          });
125          var data = await res.json();
126          if (!res.ok) {
127            status.innerHTML = '<span style="color:#f87171;">' + ((data.error && data.error.message) || data.error || "Failed") + '</span>';
128            return;
129          }
130          status.innerHTML = '<span style="color:#4ade80;">' + slot + ' updated</span>';
131          setTimeout(function() { status.innerHTML = ""; }, 2000);
132        } catch (err) {
133          status.innerHTML = '<span style="color:#f87171;">' + err.message + '</span>';
134        }
135      };
136    });
137  `;
138
139  return page({ title: `${nodeName || "Tree"} . Tree LLM Assignments`, css, body, js });
140}
141
1/**
2 * Node Metadata Page
3 *
4 * Shows all metadata namespaces for a node. Each extension's data
5 * is displayed in its own collapsible section. Click values to edit.
6 * Delete namespaces or individual fields.
7 */
8
9import { page } from "../../html-rendering/html/layout.js";
10import { esc } from "../../html-rendering/html/utils.js";
11import { glassCardStyles, glassHeaderStyles, responsiveBase } from "../../html-rendering/html/baseStyles.js";
12
13function renderValue(val, depth = 0, path = "") {
14  if (val === null || val === undefined) return `<span class="mv-null">null</span>`;
15  if (typeof val === "boolean") return `<span class="mv-bool mv-editable" data-path="${esc(path)}" data-type="boolean">${val}</span>`;
16  if (typeof val === "number") return `<span class="mv-num mv-editable" data-path="${esc(path)}" data-type="number">${val}</span>`;
17  if (typeof val === "string") {
18    const display = val.length > 200 ? val.slice(0, 200) + "..." : val;
19    return `<span class="mv-str mv-editable" data-path="${esc(path)}" data-type="string">"${esc(display)}"</span>`;
20  }
21  if (Array.isArray(val)) {
22    if (val.length === 0) return `<span class="mv-null">[]</span>`;
23    if (depth > 3) return `<span class="mv-null">[${val.length} items]</span>`;
24    return `<div class="mv-array">[${val.map((v, i) =>
25      `<div class="mv-indent">${renderValue(v, depth + 1, `${path}[${i}]`)}${i < val.length - 1 ? "," : ""}</div>`
26    ).join("")}]</div>`;
27  }
28  if (typeof val === "object") {
29    const entries = Object.entries(val);
30    if (entries.length === 0) return `<span class="mv-null">{}</span>`;
31    if (depth > 3) return `<span class="mv-null">{${entries.length} keys}</span>`;
32    return `<div class="mv-obj">{${entries.map(([k, v], i) =>
33      `<div class="mv-indent"><span class="mv-key">${esc(k)}</span>: ${renderValue(v, depth + 1, path ? `${path}.${k}` : k)}${i < entries.length - 1 ? "," : ""}</div>`
34    ).join("")}}</div>`;
35  }
36  return `<span class="mv-null">${esc(String(val))}</span>`;
37}
38
39export function renderNodeMetadata({ node, nodeId, qs, backUrl }) {
40  const metadata = node.metadata instanceof Map
41    ? Object.fromEntries(node.metadata)
42    : (node.metadata || {});
43
44  const namespaces = Object.entries(metadata).sort(([a], [b]) => a.localeCompare(b));
45  const token = qs.includes("token=") ? qs.split("token=")[1]?.split("&")[0] : "";
46
47  const css = `
48    ${glassHeaderStyles}
49    ${glassCardStyles}
50    ${responsiveBase}
51
52    .meta-layout { max-width: 900px; margin: 0 auto; padding: 1.5rem; }
53
54    .back-nav { display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
55    .back-link { color: rgba(255,255,255,0.4); text-decoration: none; font-size: 0.85rem; }
56    .back-link:hover { color: rgba(255,255,255,0.7); }
57
58    .ns-card { margin-bottom: 12px; border-radius: 12px; overflow: hidden; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); }
59    .ns-header {
60      display: flex; justify-content: space-between; align-items: center;
61      padding: 12px 16px; cursor: pointer; user-select: none;
62    }
63    .ns-header:hover { background: rgba(255,255,255,0.04); }
64    .ns-name { font-weight: 600; color: #4ade80; font-size: 0.95rem; }
65    .ns-count { font-size: 0.75rem; color: rgba(255,255,255,0.3); margin-left: 8px; }
66    .ns-toggle { color: rgba(255,255,255,0.3); font-size: 0.8rem; transition: transform 0.2s; }
67    .ns-actions { display: flex; gap: 8px; align-items: center; }
68    .ns-delete {
69      font-size: 0.7rem; color: rgba(239,68,68,0.5); cursor: pointer; padding: 2px 8px;
70      border: 1px solid rgba(239,68,68,0.2); border-radius: 6px; background: none;
71    }
72    .ns-delete:hover { color: #ef4444; border-color: rgba(239,68,68,0.4); }
73    .ns-body { padding: 0 16px 16px; font-family: monospace; font-size: 0.82rem; line-height: 1.6; overflow-x: auto; }
74
75    .mv-key { color: #60a5fa; }
76    .mv-str { color: #fbbf24; }
77    .mv-num { color: #4ade80; }
78    .mv-bool { color: #c084fc; }
79    .mv-null { color: rgba(255,255,255,0.25); }
80    .mv-indent { padding-left: 16px; }
81    .mv-obj, .mv-array { display: inline; }
82
83    .mv-editable { cursor: pointer; border-bottom: 1px dashed rgba(255,255,255,0.1); }
84    .mv-editable:hover { border-bottom-color: rgba(255,255,255,0.4); }
85
86    .mv-edit-input {
87      background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.3);
88      border-radius: 4px; color: #fff; font-family: monospace; font-size: 0.82rem;
89      padding: 2px 6px; outline: none;
90    }
91    .mv-edit-input:focus { border-color: #4ade80; }
92
93    .empty-state { color: rgba(255,255,255,0.35); font-size: 0.9rem; padding: 2rem 0; font-style: italic; text-align: center; }
94
95    .role-badge {
96      display: inline-block; padding: 2px 8px; border-radius: 8px; font-size: 0.7rem;
97      background: rgba(72,187,120,0.1); border: 1px solid rgba(72,187,120,0.25); color: #48bb78;
98      margin-left: 8px;
99    }
100
101    .toast {
102      position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
103      background: rgba(72,187,120,0.9); color: #fff; padding: 8px 20px;
104      border-radius: 8px; font-size: 0.85rem; opacity: 0; transition: opacity 0.3s;
105      pointer-events: none; z-index: 100;
106    }
107    .toast.show { opacity: 1; }
108  `;
109
110  const namespacesHtml = namespaces.length > 0
111    ? namespaces.map(([ns, data], idx) => {
112        const keys = typeof data === "object" && data !== null ? Object.keys(data) : [];
113        const hasRole = data?.role;
114        return `
115          <div class="ns-card" data-ns="${esc(ns)}">
116            <div class="ns-header">
117              <div onclick="toggleNs(this)">
118                <span class="ns-name">${esc(ns)}</span>
119                ${hasRole ? `<span class="role-badge">role: ${esc(data.role)}</span>` : ""}
120                <span class="ns-count">${keys.length} key${keys.length !== 1 ? "s" : ""}</span>
121              </div>
122              <div class="ns-actions">
123                <button class="ns-delete" onclick="deleteNs('${esc(ns)}')">delete</button>
124                <span class="ns-toggle" onclick="toggleNs(this)">${idx === 0 ? "\u25BC" : "\u25B6"}</span>
125              </div>
126            </div>
127            <div class="ns-body" style="display:${idx === 0 ? "block" : "none"}">
128              ${renderValue(data, 0, "")}
129            </div>
130          </div>`;
131      }).join("")
132    : '<div class="empty-state">No metadata on this node.</div>';
133
134  const js = `
135    const nodeId = "${esc(nodeId)}";
136    const token = "${esc(token)}";
137    const headers = { "Content-Type": "application/json" };
138    if (token) headers["Authorization"] = "Bearer " + token;
139
140    function toggleNs(el) {
141      const card = el.closest('.ns-card');
142      const body = card.querySelector('.ns-body');
143      const toggle = card.querySelector('.ns-toggle');
144      const open = body.style.display !== 'none';
145      body.style.display = open ? 'none' : 'block';
146      toggle.textContent = open ? '\u25B6' : '\u25BC';
147    }
148
149    function toast(msg) {
150      const t = document.getElementById('toast');
151      t.textContent = msg;
152      t.classList.add('show');
153      setTimeout(() => t.classList.remove('show'), 2000);
154    }
155
156    async function deleteNs(ns) {
157      if (!confirm('Delete the entire ' + ns + ' namespace?')) return;
158      const res = await fetch('/api/v1/node/' + nodeId + '/metadata/' + ns, {
159        method: 'DELETE', headers
160      });
161      if (res.ok) {
162        document.querySelector('[data-ns="' + ns + '"]').remove();
163        toast('Deleted ' + ns);
164      } else {
165        toast('Failed to delete');
166      }
167    }
168
169    document.addEventListener('click', function(e) {
170      const el = e.target.closest('.mv-editable');
171      if (!el || el.querySelector('input')) return;
172
173      const ns = el.closest('.ns-card')?.dataset?.ns;
174      const path = el.dataset.path;
175      const type = el.dataset.type;
176      if (!ns || !path) return;
177
178      // Only handle top-level keys (no dots, no brackets)
179      if (path.includes('.') || path.includes('[')) return;
180
181      const currentText = el.textContent.replace(/^"|"$/g, '');
182      const input = document.createElement('input');
183      input.className = 'mv-edit-input';
184      input.value = currentText;
185      input.style.width = Math.max(60, currentText.length * 8) + 'px';
186
187      const original = el.innerHTML;
188      el.innerHTML = '';
189      el.appendChild(input);
190      input.focus();
191      input.select();
192
193      async function save() {
194        let val = input.value;
195        if (type === 'number') val = Number(val);
196        else if (type === 'boolean') val = val === 'true';
197
198        const res = await fetch('/api/v1/node/' + nodeId + '/metadata/' + ns + '/' + path, {
199          method: 'POST', headers, body: JSON.stringify({ value: val })
200        });
201        if (res.ok) {
202          if (type === 'string') el.innerHTML = '<span class="mv-str">"' + val + '"</span>';
203          else if (type === 'number') el.innerHTML = '<span class="mv-num">' + val + '</span>';
204          else if (type === 'boolean') el.innerHTML = '<span class="mv-bool">' + val + '</span>';
205          el.dataset.path = path;
206          el.dataset.type = type;
207          toast('Saved ' + ns + '.' + path + ' = ' + val);
208        } else {
209          el.innerHTML = original;
210          toast('Failed to save');
211        }
212      }
213
214      input.addEventListener('keydown', function(ev) {
215        if (ev.key === 'Enter') { ev.preventDefault(); save(); }
216        if (ev.key === 'Escape') { el.innerHTML = original; }
217      });
218      input.addEventListener('blur', save);
219    });
220  `;
221
222  const body = `
223    <div class="meta-layout">
224      <div class="back-nav">
225        ${backUrl ? `<a href="${backUrl}" class="back-link">\u2190 Back</a>` : ""}
226        <a href="/api/v1/node/${esc(nodeId)}${qs}" class="back-link">Node Detail</a>
227      </div>
228      <h1 style="font-size:1.4rem;color:#fff;margin-bottom:4px">${esc(node.name)}</h1>
229      <div style="color:rgba(255,255,255,0.3);font-size:0.8rem;margin-bottom:20px">
230        ${esc(nodeId)} . ${namespaces.length} namespace${namespaces.length !== 1 ? "s" : ""} . click values to edit
231      </div>
232      ${namespacesHtml}
233    </div>
234    <div id="toast" class="toast"></div>
235  `;
236
237  return page({
238    title: `${node.name} . Metadata`,
239    css,
240    body,
241    js,
242  });
243}
244
1/* --------------------------------------------------------- */
2/* Note detail pages (renderTextNote, renderFileNote)        */
3/* --------------------------------------------------------- */
4
5import { getLandUrl } from "../../../canopy/identity.js";
6import { page } from "../../html-rendering/html/layout.js";
7import { escapeHtml } from "../../html-rendering/html/utils.js";
8
9export function renderTextNote({
10  back,
11  backText,
12  userLink,
13  editorButton,
14  note,
15  hasToken,
16}) {
17  const css = `
18    /* Note Card */
19    .note-card {
20      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
21      backdrop-filter: blur(22px) saturate(140%);
22      -webkit-backdrop-filter: blur(22px) saturate(140%);
23      border-radius: 16px;
24      padding: 32px;
25      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
26        inset 0 1px 0 rgba(255, 255, 255, 0.25);
27      border: 1px solid rgba(255, 255, 255, 0.28);
28      position: relative;
29      overflow: hidden;
30      animation: fadeInUp 0.6s ease-out 0.1s both;
31    }
32
33    /* User Info */
34    .user-info {
35      display: flex;
36      align-items: center;
37      gap: 8px;
38      margin-bottom: 20px;
39      padding-bottom: 16px;
40      border-bottom: 1px solid rgba(255, 255, 255, 0.2);
41    }
42
43    .user-info::before {
44      content: '\ud83d\udc64';
45      font-size: 18px;
46    }
47
48    .user-info a {
49      color: white;
50      text-decoration: none;
51      font-weight: 600;
52      font-size: 15px;
53      transition: all 0.2s;
54      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
55    }
56
57    .user-info a:hover {
58      text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
59      transform: translateX(2px);
60    }
61
62    .note-time {
63      margin-left: auto;
64      font-size: 13px;
65      color: rgba(255, 255, 255, 0.6);
66      font-weight: 400;
67    }
68
69    /* Copy Button Bar */
70    .copy-bar {
71      display: flex;
72      justify-content: flex-end;
73      gap: 8px;
74      margin-bottom: 16px;
75    }
76
77    .copy-btn {
78      background: rgba(255, 255, 255, 0.2);
79      backdrop-filter: blur(10px);
80      border: 1px solid rgba(255, 255, 255, 0.3);
81      cursor: pointer;
82      font-size: 20px;
83      padding: 8px 12px;
84      border-radius: 980px;
85      transition: all 0.3s;
86      position: relative;
87      overflow: hidden;
88    }
89
90    .copy-btn::before {
91      content: "";
92      position: absolute;
93      inset: -40%;
94      background: radial-gradient(
95        120% 60% at 0% 0%,
96        rgba(255, 255, 255, 0.35),
97        transparent 60%
98      );
99      opacity: 0;
100      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
101      pointer-events: none;
102    }
103
104    .copy-btn:hover {
105      background: rgba(255, 255, 255, 0.3);
106      transform: translateY(-2px);
107      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
108    }
109
110    .copy-btn:hover::before {
111      opacity: 1;
112      transform: translateX(30%) translateY(10%);
113    }
114
115    .copy-btn:active {
116      transform: translateY(0);
117    }
118
119    #copyUrlBtn {
120      background: rgba(255, 255, 255, 0.25);
121    }
122
123    /* Note Content */
124    pre {
125      background: rgba(255, 255, 255, 0.3);
126      backdrop-filter: blur(20px) saturate(150%);
127      -webkit-backdrop-filter: blur(20px) saturate(150%);
128      padding: 20px;
129      border-radius: 12px;
130      font-size: 16px;
131      line-height: 1.7;
132      white-space: pre-wrap;
133      word-wrap: break-word;
134      border: 1px solid rgba(255, 255, 255, 0.3);
135      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
136      color: #3d2f8f;
137      font-weight: 600;
138      text-shadow:
139        0 0 10px rgba(102, 126, 234, 0.4),
140        0 1px 3px rgba(255, 255, 255, 1);
141      box-shadow:
142        0 4px 20px rgba(0, 0, 0, 0.1),
143        inset 0 1px 0 rgba(255, 255, 255, 0.4);
144      position: relative;
145      overflow: hidden;
146      transition: all 0.3s ease;
147    }
148
149    pre::before {
150      content: "";
151      position: absolute;
152      inset: 0;
153      background: linear-gradient(
154        110deg,
155        transparent 40%,
156        rgba(255, 255, 255, 0.4),
157        transparent 60%
158      );
159      opacity: 0;
160      transform: translateX(-100%);
161      pointer-events: none;
162    }
163
164    pre:hover {
165      border-color: rgba(255, 255, 255, 0.5);
166      box-shadow:
167        0 8px 32px rgba(102, 126, 234, 0.2),
168        inset 0 1px 0 rgba(255, 255, 255, 0.6);
169    }
170/* Programmatic shimmer trigger */
171pre.flash::before {
172  opacity: 1;
173  animation: glassShimmer 1.2s ease forwards;
174}
175
176    pre:hover::before {
177      opacity: 1;
178      animation: glassShimmer 1.2s ease forwards;
179    }
180
181    pre.copied {
182      animation: textGlow 0.8s ease-out;
183    }
184
185    @keyframes textGlow {
186      0% {
187        box-shadow:
188          0 4px 20px rgba(0, 0, 0, 0.1),
189          inset 0 1px 0 rgba(255, 255, 255, 0.4);
190      }
191      50% {
192        box-shadow:
193          0 0 40px rgba(102, 126, 234, 0.6),
194          0 0 60px rgba(102, 126, 234, 0.4),
195          inset 0 1px 0 rgba(255, 255, 255, 0.8);
196        text-shadow:
197          0 0 20px rgba(102, 126, 234, 0.8),
198          0 0 30px rgba(102, 126, 234, 0.6),
199          0 1px 3px rgba(255, 255, 255, 1);
200      }
201      100% {
202        box-shadow:
203          0 4px 20px rgba(0, 0, 0, 0.1),
204          inset 0 1px 0 rgba(255, 255, 255, 0.4);
205      }
206    }
207
208    @keyframes glassShimmer {
209      0% {
210        opacity: 0;
211        transform: translateX(-120%) skewX(-15deg);
212      }
213      50% {
214        opacity: 1;
215      }
216      100% {
217        opacity: 0;
218        transform: translateX(120%) skewX(-15deg);
219      }
220    }
221
222    /* Responsive */
223    @media (max-width: 640px) {
224      body {
225        padding: 16px;
226      }
227
228      .note-card {
229        padding: 24px 20px;
230      }
231
232      pre {
233        font-size: 17px;
234        padding: 16px;
235      }
236
237      .back-nav {
238        flex-direction: column;
239      }
240
241      .back-link {
242        justify-content: center;
243      }
244    }
245
246    @media (min-width: 641px) and (max-width: 1024px) {
247      .container {
248        max-width: 700px;
249      }
250    }
251      .editor-btn {
252  text-decoration: none;
253  display: inline-flex;
254  align-items: center;
255  justify-content: center;
256}
257
258.editor-btn:hover {
259  background: rgba(255, 255, 255, 0.35);
260}
261
262  `;
263
264  const body = `
265  <div class="container">
266    <!-- Back Navigation -->
267    <div class="back-nav">
268      <a href="${back}" class="back-link">${backText}</a>
269      <button id="copyUrlBtn" class="copy-btn" title="Copy URL to share">\ud83d\udd17</button>
270    </div>
271
272    <!-- Note Card -->
273    <div class="note-card">
274      <div class="user-info">
275        ${userLink}
276        ${note.createdAt ? `<span class="note-time">${new Date(note.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} at ${new Date(note.createdAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</span>` : ""}
277      </div>
278<div class="copy-bar">
279  ${editorButton ? `<a href="${back.replace('/notes?', '/notes/' + note._id + '/editor?')}" class="copy-btn" title="Edit note">\u270F\uFE0F</a>` : ""}
280  <button id="copyNoteBtn" class="copy-btn" title="Copy note">\ud83d\udccb</button>
281</div>
282
283
284<pre id="noteContent">${escapeHtml(note.content)}</pre>
285    </div>
286  </div>
287  `;
288
289  const js = `
290    const copyNoteBtn = document.getElementById("copyNoteBtn");
291    const copyUrlBtn = document.getElementById("copyUrlBtn");
292    const noteContent = document.getElementById("noteContent");
293
294    copyNoteBtn.addEventListener("click", () => {
295  navigator.clipboard.writeText(noteContent.textContent).then(() => {
296    copyNoteBtn.textContent = "\\u2714\\ufe0f";
297    setTimeout(() => (copyNoteBtn.textContent = "\\ud83d\\udccb"), 900);
298
299    // text glow (already existing)
300    noteContent.classList.add("copied");
301    setTimeout(() => noteContent.classList.remove("copied"), 800);
302
303    // delayed glass shimmer (0.5s)
304    setTimeout(() => {
305      noteContent.classList.remove("flash"); // reset if still present
306      void noteContent.offsetWidth;          // force reflow so animation restarts
307      noteContent.classList.add("flash");
308
309      setTimeout(() => {
310        noteContent.classList.remove("flash");
311      }, 1300); // slightly longer than animation
312    }, 600);
313  });
314});
315
316
317    copyUrlBtn.addEventListener("click", () => {
318      const url = new URL(window.location.href);
319      url.searchParams.delete('token');
320      if (!url.searchParams.has('html')) {
321        url.searchParams.set('html', '');
322      }
323      navigator.clipboard.writeText(url.toString()).then(() => {
324        copyUrlBtn.textContent = "\\u2714\\ufe0f";
325        setTimeout(() => (copyUrlBtn.textContent = "\\ud83d\\udd17"), 900);
326      });
327    });
328  `;
329
330  return page({
331    title: `Note by ${escapeHtml(note.userId?.username || "User")} - TreeOS`,
332    css,
333    body,
334    js,
335  });
336}
337
338export function renderFileNote({
339  back,
340  backText,
341  userLink,
342  note,
343  fileName,
344  fileUrl,
345  mediaHtml,
346  fileDeleted,
347  hasToken,
348}) {
349  const css = `
350    /* File Card */
351    .file-card {
352      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
353      backdrop-filter: blur(22px) saturate(140%);
354      -webkit-backdrop-filter: blur(22px) saturate(140%);
355      border-radius: 16px;
356      padding: 32px;
357      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
358        inset 0 1px 0 rgba(255, 255, 255, 0.25);
359      border: 1px solid rgba(255, 255, 255, 0.28);
360      position: relative;
361      overflow: hidden;
362      animation: fadeInUp 0.6s ease-out 0.1s both;
363    }
364
365    /* User Info */
366    .user-info {
367      display: flex;
368      align-items: center;
369      gap: 8px;
370      margin-bottom: 20px;
371      padding-bottom: 16px;
372      border-bottom: 1px solid rgba(255, 255, 255, 0.2);
373    }
374
375    .user-info::before {
376      content: '\ud83d\udc64';
377      font-size: 18px;
378    }
379
380    .user-info a {
381      color: white;
382      text-decoration: none;
383      font-weight: 600;
384      font-size: 15px;
385      transition: all 0.2s;
386      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
387    }
388
389    .user-info a:hover {
390      text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
391      transform: translateX(2px);
392    }
393
394    .note-time {
395      margin-left: auto;
396      font-size: 13px;
397      color: rgba(255, 255, 255, 0.6);
398      font-weight: 400;
399    }
400
401    /* File Header */
402    h1 {
403      font-size: 24px;
404      font-weight: 700;
405      color: white;
406      margin-bottom: 20px;
407      word-break: break-word;
408      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
409    }
410
411    /* Action Buttons */
412    .action-bar {
413      display: flex;
414      gap: 12px;
415      margin-bottom: 24px;
416      flex-wrap: wrap;
417    }
418
419    .download {
420      display: inline-flex;
421      align-items: center;
422      gap: 8px;
423      padding: 12px 20px;
424      background: rgba(255, 255, 255, 0.25);
425      backdrop-filter: blur(10px);
426      color: white;
427      text-decoration: none;
428      border-radius: 980px;
429      font-weight: 600;
430      font-size: 15px;
431      transition: all 0.3s;
432      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
433      border: 1px solid rgba(255, 255, 255, 0.3);
434      cursor: pointer;
435      position: relative;
436      overflow: hidden;
437    }
438
439    .download::after {
440      content: '\u2b07\ufe0f';
441      font-size: 16px;
442      margin-left: 4px;
443    }
444
445    .download::before {
446      content: "";
447      position: absolute;
448      inset: -40%;
449      background: radial-gradient(
450        120% 60% at 0% 0%,
451        rgba(255, 255, 255, 0.35),
452        transparent 60%
453      );
454      opacity: 0;
455      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
456      pointer-events: none;
457    }
458
459    .download:hover {
460      background: rgba(255, 255, 255, 0.35);
461      transform: translateY(-2px);
462      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
463    }
464
465    .download:hover::before {
466      opacity: 1;
467      transform: translateX(30%) translateY(10%);
468    }
469
470    .copy-url-btn {
471      display: inline-flex;
472      align-items: center;
473      gap: 8px;
474      padding: 12px 20px;
475      background: rgba(255, 255, 255, 0.2);
476      backdrop-filter: blur(10px);
477      color: white;
478      border: 1px solid rgba(255, 255, 255, 0.3);
479      border-radius: 980px;
480      font-weight: 600;
481      font-size: 15px;
482      transition: all 0.3s;
483      cursor: pointer;
484      position: relative;
485      overflow: hidden;
486    }
487
488    .copy-url-btn::after {
489      content: '\ud83d\udd17';
490      font-size: 16px;
491      margin-left: 4px;
492    }
493
494    .copy-url-btn::before {
495      content: "";
496      position: absolute;
497      inset: -40%;
498      background: radial-gradient(
499        120% 60% at 0% 0%,
500        rgba(255, 255, 255, 0.35),
501        transparent 60%
502      );
503      opacity: 0;
504      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
505      pointer-events: none;
506    }
507
508    .copy-url-btn:hover {
509      background: rgba(255, 255, 255, 0.3);
510      transform: translateY(-2px);
511      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
512    }
513
514    .copy-url-btn:hover::before {
515      opacity: 1;
516      transform: translateX(30%) translateY(10%);
517    }
518
519    /* Media Container */
520    .media {
521      margin-top: 24px;
522      padding-top: 24px;
523      border-top: 1px solid rgba(255, 255, 255, 0.2);
524    }
525
526    .media img,
527    .media video,
528    .media audio {
529      max-width: 100%;
530      border-radius: 12px;
531      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
532      border: 1px solid rgba(255, 255, 255, 0.2);
533    }
534
535    /* Responsive */
536    @media (max-width: 640px) {
537      body {
538        padding: 16px;
539      }
540
541      .file-card {
542        padding: 24px 20px;
543      }
544
545      h1 {
546        font-size: 22px;
547      }
548
549      .action-bar {
550        flex-direction: column;
551      }
552
553      .download,
554      .copy-url-btn {
555        padding: 12px 18px;
556        font-size: 16px;
557        width: 100%;
558        justify-content: center;
559      }
560
561      .back-nav {
562        flex-direction: column;
563      }
564
565      .back-link {
566        justify-content: center;
567      }
568    }
569
570    @media (min-width: 641px) and (max-width: 1024px) {
571      .container {
572        max-width: 700px;
573      }
574    }
575      @media (max-width: 768px) {
576  .send-progress {
577    animation: shimmer 1.2s infinite linear;
578  }
579}
580
581@keyframes shimmer {
582  0% { background-position: -200px 0; }
583  100% { background-position: 200px 0; }
584}
585
586  `;
587
588  const body = `
589  <div class="container">
590    <!-- Back Navigation -->
591    <div class="back-nav">
592      <a href="${back}" class="back-link">${backText}</a>
593    </div>
594
595    <!-- File Card -->
596    <div class="file-card">
597      <div class="user-info">
598        ${userLink}
599        ${note.createdAt ? `<span class="note-time">${new Date(note.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} at ${new Date(note.createdAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</span>` : ""}
600      </div>
601
602<h1>${escapeHtml(fileName)}</h1>
603
604      ${fileDeleted ? "" : `<div class="action-bar">
605        <a class="download" href="${fileUrl}" download>Download</a>
606        <button id="copyUrlBtn" class="copy-url-btn">Share</button>
607      </div>`}
608
609      <div class="media">
610        ${fileDeleted ? `<p style="color:rgba(255,255,255,0.6); padding:40px 0;">File was deleted</p>` : mediaHtml}
611      </div>
612    </div>
613  </div>
614  `;
615
616  const js = `
617    const copyUrlBtn = document.getElementById("copyUrlBtn");
618
619    copyUrlBtn.addEventListener("click", () => {
620      const url = new URL(window.location.href);
621      url.searchParams.delete('token');
622      if (!url.searchParams.has('html')) {
623        url.searchParams.set('html', '');
624      }
625      navigator.clipboard.writeText(url.toString()).then(() => {
626        const originalText = copyUrlBtn.textContent;
627        copyUrlBtn.textContent = "\\u2714\\ufe0f Copied!";
628        setTimeout(() => (copyUrlBtn.textContent = originalText), 900);
629      });
630    });
631  `;
632
633  return page({
634    title: escapeHtml(fileName),
635    css,
636    body,
637    js,
638  });
639}
640
1/* --------------------------------------------------------- */
2/* Notes list page                                           */
3/* --------------------------------------------------------- */
4
5import { page } from "../../html-rendering/html/layout.js";
6import { escapeHtml } from "../../html-rendering/html/utils.js";
7
8export function renderNotesList({
9  nodeId,
10  version,
11  token,
12  nodeName,
13  notes,
14  currentUserId,
15}) {
16  const base = `/api/v1/node/${nodeId}/${version}`;
17
18  const css = `
19/* ── Notes list overrides on base ── */
20body {
21  height: 100vh;
22  height: 100dvh;
23  display: flex;
24  flex-direction: column;
25  overflow: hidden;
26  padding: 0;
27  min-height: auto;
28}
29
30/* Glass Top Navigation */
31.top-nav {
32  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
33  backdrop-filter: blur(22px) saturate(140%);
34  -webkit-backdrop-filter: blur(22px) saturate(140%);
35  padding: 16px 20px;
36  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
37    inset 0 1px 0 rgba(255, 255, 255, 0.25);
38  border-bottom: 1px solid rgba(255, 255, 255, 0.28);
39  flex-shrink: 0;
40}
41
42.top-nav-content {
43  max-width: 900px;
44  margin: 0 auto;
45  display: flex;
46  justify-content: space-between;
47  align-items: center;
48  gap: 12px;
49  flex-wrap: wrap;
50}
51
52.nav-left {
53  display: flex;
54  gap: 10px;
55  flex-wrap: wrap;
56}
57
58/* Glass Navigation Buttons */
59.nav-button,
60.book-button {
61  position: relative;
62  overflow: hidden;
63  display: inline-flex;
64  align-items: center;
65  gap: 6px;
66  padding: 10px 20px;
67  border-radius: 980px;
68  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
69  backdrop-filter: blur(22px) saturate(140%);
70  -webkit-backdrop-filter: blur(22px) saturate(140%);
71  color: white;
72  text-decoration: none;
73  font-size: 15px;
74  font-weight: 500;
75  letter-spacing: -0.2px;
76  border: 1px solid rgba(255, 255, 255, 0.28);
77  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
78    inset 0 1px 0 rgba(255, 255, 255, 0.25);
79  cursor: pointer;
80  transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
81    transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
82    box-shadow 0.3s ease;
83  white-space: nowrap;
84}
85
86.nav-button::before,
87.book-button::before {
88  content: "";
89  position: absolute;
90  inset: -40%;
91  background: radial-gradient(
92      120% 60% at 0% 0%,
93      rgba(255, 255, 255, 0.35),
94      transparent 60%
95    ),
96    linear-gradient(
97      120deg,
98      transparent 30%,
99      rgba(255, 255, 255, 0.25),
100      transparent 70%
101    );
102  opacity: 0;
103  transform: translateX(-30%) translateY(-10%);
104  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
105  pointer-events: none;
106}
107
108.nav-button:hover,
109.book-button:hover {
110  background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
111  transform: translateY(-1px);
112  animation: waterDrift 2.2s ease-in-out infinite alternate;
113}
114
115.nav-button:hover::before,
116.book-button:hover::before {
117  opacity: 1;
118  transform: translateX(30%) translateY(10%);
119}
120
121@keyframes waterDrift {
122  0% { transform: translateY(-1px); }
123  100% { transform: translateY(1px); }
124}
125
126.book-button {
127  --glass-alpha: 0.34;
128  --glass-alpha-hover: 0.46;
129  font-weight: 600;
130}
131
132.page-title {
133  width: 100%;
134  margin-top: 12px;
135  font-size: 18px;
136  font-weight: 600;
137  color: white;
138  letter-spacing: -0.3px;
139  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
140}
141
142.page-title a {
143  color: white;
144  text-decoration: none;
145  border-bottom: 1px solid rgba(255, 255, 255, 0.3);
146  transition: all 0.2s;
147}
148
149.page-title a:hover {
150  border-bottom-color: white;
151  text-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
152}
153
154/* Notes Container */
155.notes-container {
156  flex: 1;
157  overflow-y: auto;
158  padding: 20px;
159  position: relative;
160  z-index: 1;
161}
162
163.notes-wrapper {
164  max-width: 900px;
165  margin: 0 auto;
166  width: 100%;
167}
168
169.notes-list {
170  list-style: none;
171  padding: 0;
172  display: flex;
173  flex-direction: column;
174  gap: 12px;
175}
176
177/* Glass Note Messages */
178.note-item {
179  display: flex;
180  animation: slideIn 0.3s ease-out;
181}
182
183@keyframes slideIn {
184  from {
185    opacity: 0;
186    transform: translateY(10px);
187  }
188  to {
189    opacity: 1;
190    transform: translateY(0);
191  }
192}
193
194.note-item.self {
195  flex-direction: row-reverse;
196}
197
198.note-bubble {
199  position: relative;
200  max-width: 70%;
201  padding: 14px 18px;
202  border-radius: 12px;
203  background: rgba(255, 255, 255, 0.15);
204  backdrop-filter: blur(22px) saturate(140%);
205  -webkit-backdrop-filter: blur(22px) saturate(140%);
206  border: 1px solid rgba(255, 255, 255, 0.28);
207  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1),
208    inset 0 1px 0 rgba(255, 255, 255, 0.25);
209  color: white;
210  word-wrap: break-word;
211  overflow-wrap: break-word;
212}
213
214/* Self messages - slightly more opaque */
215.note-item.self .note-bubble {
216  background: rgba(255, 255, 255, 0.2);
217}
218
219/* Reflection messages - golden tint */
220.note-item.reflection .note-bubble {
221  background: rgba(255, 215, 79, 0.25);
222  border-color: rgba(255, 215, 79, 0.4);
223  box-shadow: 0 4px 16px rgba(255, 193, 7, 0.2),
224    inset 0 1px 0 rgba(255, 255, 255, 0.3);
225}
226
227.file-badge {
228  display: inline-block;
229  padding: 4px 10px;
230  background: rgba(255, 255, 255, 0.2);
231  border-radius: 12px;
232  font-size: 11px;
233  font-weight: 600;
234  margin-bottom: 8px;
235  text-transform: uppercase;
236  letter-spacing: 0.5px;
237  border: 1px solid rgba(255, 255, 255, 0.3);
238}
239
240.note-author {
241  font-weight: 600;
242  margin-bottom: 6px;
243  font-size: 13px;
244  opacity: 0.85;
245  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
246  letter-spacing: -0.2px;
247}
248
249.note-author a {
250  color: inherit;
251  text-decoration: none;
252  border-bottom: 1px solid rgba(255, 255, 255, 0.3);
253}
254
255.note-author a:hover {
256  border-bottom-color: white;
257}
258
259.note-item.self .note-author {
260  display: none;
261}
262
263.note-content {
264  font-size: 15px;
265  line-height: 1.5;
266  margin-bottom: 6px;
267  font-weight: 400;
268}
269
270.note-content a {
271  color: inherit;
272  text-decoration: none;
273}
274
275.note-content a:hover {
276  text-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
277}
278
279.note-meta {
280  font-size: 11px;
281  opacity: 0.7;
282  margin-top: 6px;
283  display: flex;
284  justify-content: space-between;
285  align-items: center;
286  gap: 8px;
287}
288
289.delete-button {
290  background: rgba(255, 255, 255, 0.15);
291  border: 1px solid rgba(255, 255, 255, 0.25);
292  border-radius: 50%;
293  width: 24px;
294  height: 24px;
295  display: flex;
296  align-items: center;
297  justify-content: center;
298  cursor: pointer;
299  padding: 0;
300  opacity: 0.7;
301  transition: all 0.2s;
302  font-size: 12px;
303  color: white;
304}
305
306/* Character counter */
307.char-counter {
308  display: flex;
309  justify-content: flex-end;
310  align-items: center;
311  margin-top: 6px;
312  font-size: 12px;
313  color: rgba(255, 255, 255, 0.6);
314  font-weight: 500;
315  transition: color 0.2s;
316}
317
318.char-counter.warning {
319  color: rgba(255, 193, 7, 0.9);
320}
321
322.char-counter.danger {
323  color: rgba(239, 68, 68, 0.9);
324  font-weight: 600;
325}
326
327.char-counter.disabled {
328  opacity: 0.4;
329}
330
331/* Energy display (shared between text and file) */
332.energy-display {
333  display: inline-flex;
334  align-items: center;
335  gap: 4px;
336  margin-left: 10px;
337  padding: 2px 8px;
338  background: rgba(255, 215, 79, 0.2);
339  border: 1px solid rgba(255, 215, 79, 0.3);
340  border-radius: 10px;
341  font-size: 11px;
342  font-weight: 600;
343  color: rgba(255, 215, 79, 1);
344  transition: all 0.2s;
345}
346
347.energy-display:empty {
348  display: none;
349}
350
351.energy-display.file-energy {
352  background: rgba(255, 220, 100, 0.9);
353  border-color: rgba(255, 200, 50, 1);
354  color: #1a1a1a;
355  font-size: 13px;
356  font-weight: 700;
357  padding: 4px 12px;
358  box-shadow: 0 2px 8px rgba(255, 200, 50, 0.4);
359}
360
361/* File selected indicator */
362.file-selected-badge {
363  display: none;
364  align-items: center;
365  gap: 6px;
366  padding: 6px 12px;
367  background: rgba(255, 255, 255, 0.15);
368  border: 1px solid rgba(255, 255, 255, 0.25);
369  border-radius: 20px;
370  font-size: 12px;
371  font-weight: 500;
372  color: white;
373}
374
375.file-selected-badge.visible {
376  display: inline-flex;
377}
378
379.file-selected-badge .file-name {
380  max-width: 120px;
381  overflow: hidden;
382  text-overflow: ellipsis;
383  white-space: nowrap;
384}
385
386.file-selected-badge .clear-file {
387  background: rgba(255, 255, 255, 0.2);
388  border: none;
389  border-radius: 50%;
390  width: 18px;
391  height: 18px;
392  display: flex;
393  align-items: center;
394  justify-content: center;
395  cursor: pointer;
396  font-size: 10px;
397  color: white;
398  transition: all 0.2s;
399}
400
401.file-selected-badge .clear-file:hover {
402  background: rgba(239, 68, 68, 0.4);
403}
404
405@keyframes fadeIn {
406  from { opacity: 0; transform: scale(0.95); }
407  to { opacity: 1; transform: scale(1); }
408}
409
410.delete-button:hover {
411  opacity: 1;
412  background: rgba(239, 68, 68, 0.3);
413  border-color: rgba(239, 68, 68, 0.5);
414  transform: scale(1.1);
415}
416
417/* Glass Input Bar */
418.input-bar {
419  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
420  backdrop-filter: blur(22px) saturate(140%);
421  -webkit-backdrop-filter: blur(22px) saturate(140%);
422  padding: 20px;
423  border-top: 1px solid rgba(255, 255, 255, 0.28);
424  box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.12),
425    inset 0 1px 0 rgba(255, 255, 255, 0.25);
426  flex-shrink: 0;
427}
428
429.input-form {
430  max-width: 900px;
431  margin: 0 auto;
432}
433
434textarea {
435  width: 100%;
436  padding: 14px 16px;
437  border: 2px solid rgba(255, 255, 255, 0.3);
438  border-radius: 12px;
439  font-family: inherit;
440  font-size: 16px;
441  line-height: 1.5;
442  resize: none;
443  transition: all 0.3s;
444  background: rgba(255, 255, 255, 0.15);
445  backdrop-filter: blur(20px) saturate(150%);
446  -webkit-backdrop-filter: blur(20px) saturate(150%);
447  color: white;
448  font-weight: 500;
449  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1),
450    inset 0 1px 0 rgba(255, 255, 255, 0.25);
451  height: 56px;
452  max-height: 120px;
453  overflow-y: hidden;
454}
455
456textarea::placeholder {
457  color: rgba(255, 255, 255, 0.5);
458}
459
460textarea:focus {
461  outline: none;
462  border-color: rgba(255, 255, 255, 0.6);
463  background: rgba(255, 255, 255, 0.25);
464  box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.15),
465    0 8px 30px rgba(0, 0, 0, 0.15),
466    inset 0 1px 0 rgba(255, 255, 255, 0.4);
467  transform: translateY(-2px);
468}
469
470textarea:disabled {
471  opacity: 0.4;
472  cursor: not-allowed;
473  background: rgba(255, 255, 255, 0.08);
474  transform: none;
475}
476
477textarea:disabled::placeholder {
478  color: rgba(255, 255, 255, 0.3);
479}
480
481.input-controls {
482  display: flex;
483  justify-content: space-between;
484  align-items: center;
485  gap: 12px;
486  margin-top: 12px;
487  flex-wrap: wrap;
488}
489
490.input-options {
491  display: flex;
492  align-items: center;
493  gap: 12px;
494  flex-wrap: wrap;
495}
496
497input[type="file"] {
498  font-size: 13px;
499  color: rgba(255, 255, 255, 0.9);
500  cursor: pointer;
501}
502
503input[type="file"]::file-selector-button {
504  padding: 8px 16px;
505  border-radius: 980px;
506  border: 1px solid rgba(255, 255, 255, 0.3);
507  background: rgba(255, 255, 255, 0.2);
508  backdrop-filter: blur(10px);
509  color: white;
510  cursor: pointer;
511  font-size: 13px;
512  font-weight: 600;
513  transition: all 0.2s;
514  margin-right: 10px;
515}
516
517input[type="file"]::file-selector-button:hover {
518  background: rgba(255, 255, 255, 0.3);
519  transform: translateY(-1px);
520}
521
522/* Hide file input when file is selected, show badge instead */
523input[type="file"].hidden-input {
524  display: none;
525}
526
527/* Glass Send Button */
528.send-button {
529  position: relative;
530  overflow: hidden;
531  padding: 12px 28px;
532  border-radius: 980px;
533  background: rgba(255, 255, 255, 0.25);
534  backdrop-filter: blur(10px);
535  color: white;
536  border: 1px solid rgba(255, 255, 255, 0.3);
537  font-size: 15px;
538  font-weight: 600;
539  letter-spacing: -0.2px;
540  cursor: pointer;
541  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
542  white-space: nowrap;
543  transition: all 0.3s;
544}
545
546.send-button::before {
547  content: "";
548  position: absolute;
549  inset: -40%;
550  background: radial-gradient(
551    120% 60% at 0% 0%,
552    rgba(255, 255, 255, 0.35),
553    transparent 60%
554  );
555  opacity: 0;
556  transform: translateX(-30%) translateY(-10%);
557  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
558  pointer-events: none;
559}
560
561.send-button:hover {
562  background: rgba(255, 255, 255, 0.35);
563  transform: translateY(-2px);
564  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
565}
566
567.send-button:hover::before {
568  opacity: 1;
569  transform: translateX(30%) translateY(10%);
570}
571
572.send-button.loading .send-label {
573  opacity: 0;
574}
575
576/* Progress bar */
577.send-progress {
578  position: absolute;
579  left: 0;
580  top: 0;
581  height: 100%;
582  width: 0%;
583  background: linear-gradient(
584    90deg,
585    rgba(255,255,255,0.25),
586    rgba(255,255,255,0.6),
587    rgba(255,255,255,0.25)
588  );
589  transition: width 0.2s ease;
590  pointer-events: none;
591}
592
593/* Loading state */
594.send-button.loading {
595  cursor: default;
596  animation: none;
597  transform: none;
598}
599
600/* Responsive Design */
601@media (max-width: 768px) {
602  .top-nav {
603    padding: 12px 16px;
604  }
605
606  .nav-button,
607  .book-button {
608    padding: 8px 16px;
609    font-size: 14px;
610  }
611
612  .page-title {
613    font-size: 16px;
614  }
615
616  .notes-container {
617    padding: 16px 12px;
618  }
619
620  .note-bubble {
621    max-width: 85%;
622    padding: 12px 16px;
623  }
624
625  .input-bar {
626    padding: 16px;
627  }
628
629  .input-controls {
630    flex-direction: column;
631    align-items: stretch;
632  }
633
634  .input-options {
635    flex-direction: column;
636    align-items: flex-start;
637    gap: 10px;
638  }
639
640  .send-button {
641    width: 100%;
642  }
643
644  textarea {
645    font-size: 16px;
646    height: 60px;
647  }
648}
649
650@media (max-width: 480px) {
651  .nav-left {
652    width: 100%;
653    flex-direction: column;
654  }
655
656  .nav-button,
657  .book-button {
658    width: 100%;
659    justify-content: center;
660  }
661}
662     html, body {
663        background: #736fe6;
664        margin: 0;
665        padding: 0;
666      }
667        .editor-open-btn {
668  width: 44px; height: 44px;
669  border-radius: 50%;
670  border: 1px solid rgba(255, 255, 255, 0.3);
671  background: rgba(255, 255, 255, 0.2);
672  backdrop-filter: blur(10px);
673  color: white; font-size: 18px;
674  cursor: pointer; transition: all 0.3s;
675  display: flex; align-items: center; justify-content: center;
676  flex-shrink: 0;
677}
678
679.editor-open-btn:hover {
680  background: rgba(255, 255, 255, 0.35);
681  transform: translateY(-2px);
682  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
683}
684  @media (max-width: 768px) {
685  .input-controls {
686    flex-direction: row;
687    align-items: center;
688    flex-wrap: nowrap;
689  }
690
691  .input-options {
692    flex-direction: row;
693    align-items: center;
694    gap: 8px;
695    flex: 1;
696    min-width: 0;
697  }
698
699  .input-options input[type="file"] {
700    max-width: 140px;
701    font-size: 0;
702  }
703
704  .input-options input[type="file"]::file-selector-button {
705    margin-right: 0;
706    padding: 8px 12px;
707    font-size: 12px;
708  }
709
710  .send-button {
711    width: auto;
712    padding: 10px 20px;
713    flex-shrink: 0;
714  }
715}
716  `;
717
718  const body = `
719  <!-- Top Navigation -->
720  <div class="top-nav">
721    <div class="top-nav-content">
722      <div class="nav-left">
723        <a href="/api/v1/root/${nodeId}?token=${encodeURIComponent(token)}&html" class="nav-button">
724          \u2190 Back to Tree
725        </a>
726        <a href="${base}?token=${encodeURIComponent(token)}&html" class="nav-button">
727          Back to Version
728        </a>
729      </div>
730
731      <div class="page-title">
732        Notes for <a href="${base}?token=${encodeURIComponent(token)}&html">${escapeHtml(nodeName)} v${version}</a>
733      </div>
734    </div>
735  </div>
736
737  <!-- Notes Container -->
738  <div class="notes-container">
739    <div class="notes-wrapper">
740      <ul class="notes-list">
741      ${notes
742        .map((n) => {
743          const noteUserId = typeof n.userId === "object" ? n.userId?._id?.toString() : n.userId?.toString();
744          const noteUsername = (typeof n.userId === "object" ? n.userId?.username : null) || n.username;
745          const isSelf =
746            currentUserId && noteUserId && noteUserId === currentUserId;
747          const rawPreview =
748            n.contentType === "text"
749              ? n.content.length > 169
750                ? n.content.substring(0, 500) + "..."
751                : n.content
752              : n.content.split("/").pop();
753          const preview = escapeHtml(rawPreview);
754
755          const userLabel = noteUserId
756            ? `<a href="/api/v1/user/${noteUserId}?token=${encodeURIComponent(token)}&html">${escapeHtml(noteUsername ?? noteUserId)}</a>`
757            : escapeHtml(noteUsername ?? "Unknown user");
758
759          return `
760          <li
761            class="note-item ${isSelf ? "self" : "other"} ${
762              n.metadata?.treeos?.isReflection ? "reflection" : ""
763            }"
764            data-note-id="${n._id}"
765            data-node-id="${n.nodeId}"
766          >
767            <div class="note-bubble">
768              ${
769                n.contentType === "file"
770                  ? '<div class="file-badge">\ud83d\udcce File</div>'
771                  : ""
772              }
773              ${!isSelf ? `<div class="note-author">${userLabel}</div>` : ""}
774              <div class="note-content">
775                <a href="${base}/notes/${n._id}?token=${encodeURIComponent(token)}&html">
776                  ${preview}
777                </a>
778              </div>
779              <div class="note-meta">
780                <span>${new Date(n.createdAt).toLocaleTimeString([], {
781                  hour: "2-digit",
782                  minute: "2-digit",
783                })}</span>
784                <button class="delete-button" title="Delete note">\u2715</button>
785              </div>
786            </div>
787          </li>
788        `;
789        })
790        .join("")}
791    </ul>
792    </div>
793  </div>
794
795  <!-- Input Bar -->
796  <div class="input-bar">
797    <form
798      method="POST"
799      action="/api/v1/node/${nodeId}/${version}/notes?token=${encodeURIComponent(token)}&html"
800      enctype="multipart/form-data"
801      class="input-form"
802      id="noteForm"
803    >
804      <textarea
805        name="content"
806        rows="1"
807        placeholder="Write a note..."
808        id="noteTextarea"
809        maxlength="5000"
810      ></textarea>
811      <div class="char-counter" id="charCounter">
812        <span id="charCount">0</span> / 5000
813        <span class="energy-display" id="energyDisplay"></span>
814      </div>
815
816      <div class="input-controls">
817        <div class="input-options">
818          <input type="file" name="file" id="fileInput" />
819          <div class="file-selected-badge" id="fileSelectedBadge">
820            <span>\ud83d\udcce</span>
821            <span class="file-name" id="fileName"></span>
822            <button type="button" class="clear-file" id="clearFileBtn" title="Remove file">\u2715</button>
823          </div>
824          <button type="button" class="editor-open-btn" id="openEditorBtn" title="Open in Editor">\u270f\ufe0f</button>
825        </div>
826        <button type="submit" class="send-button" id="sendBtn">
827          <span class="send-label">Send</span>
828          <span class="send-progress"></span>
829        </button>
830      </div>
831    </form>
832  </div>
833  `;
834
835  const js = `
836    // Auto-scroll to bottom on load
837    const container = document.querySelector('.notes-container');
838    container.scrollTop = container.scrollHeight;
839
840    // Elements
841    const form = document.getElementById('noteForm');
842    const textarea = document.getElementById('noteTextarea');
843    const charCounter = document.getElementById('charCounter');
844    const charCount = document.getElementById('charCount');
845    const energyDisplay = document.getElementById('energyDisplay');
846    const fileInput = document.getElementById('fileInput');
847    const fileSelectedBadge = document.getElementById('fileSelectedBadge');
848    const fileName = document.getElementById('fileName');
849    const clearFileBtn = document.getElementById('clearFileBtn');
850    const sendBtn = document.getElementById('sendBtn');
851    const progressBar = sendBtn.querySelector('.send-progress');
852
853    const MAX_CHARS = 5000;
854    let hasFile = false;
855
856    // Auto-resize textarea
857    textarea.addEventListener('input', function() {
858      this.style.height = 'auto';
859      const newHeight = Math.min(this.scrollHeight, 120);
860      this.style.height = newHeight + 'px';
861      this.style.overflowY = this.scrollHeight > 120 ? 'auto' : 'hidden';
862      updateCharCounter();
863    });
864
865    // Character counter with energy (1 energy per 1000 chars)
866    function updateCharCounter() {
867      const len = textarea.value.length;
868      charCount.textContent = len;
869
870      // Styling based on remaining
871      const remaining = MAX_CHARS - len;
872      charCounter.classList.remove('warning', 'danger', 'disabled');
873
874      if (hasFile) {
875        charCounter.classList.add('disabled');
876      } else if (remaining <= 100) {
877        charCounter.classList.add('danger');
878      } else if (remaining <= 500) {
879        charCounter.classList.add('warning');
880      }
881
882      // Energy cost: 1 per 1000 chars (minimum 1 if any text)
883      if (len > 0 && !hasFile) {
884        const cost = Math.max(1, Math.ceil(len / 1000));
885        energyDisplay.textContent = '\u26a1' + cost;
886        energyDisplay.classList.remove('file-energy');
887      } else if (!hasFile) {
888        energyDisplay.textContent = '';
889      }
890    }
891
892    // File energy calculation
893    const FILE_MIN_COST = 5;
894    const FILE_BASE_RATE = 1.5;
895    const FILE_MID_RATE = 3;
896    const SOFT_LIMIT_MB = 100;
897    const HARD_LIMIT_MB = 1024;
898
899    function calculateFileEnergy(sizeMB) {
900      if (sizeMB <= SOFT_LIMIT_MB) {
901        return Math.max(FILE_MIN_COST, Math.ceil(sizeMB * FILE_BASE_RATE));
902      }
903      if (sizeMB <= HARD_LIMIT_MB) {
904        const base = SOFT_LIMIT_MB * FILE_BASE_RATE;
905        const extra = (sizeMB - SOFT_LIMIT_MB) * FILE_MID_RATE;
906        return Math.ceil(base + extra);
907      }
908      const base = SOFT_LIMIT_MB * FILE_BASE_RATE +
909                   (HARD_LIMIT_MB - SOFT_LIMIT_MB) * FILE_MID_RATE;
910      const overGB = sizeMB - HARD_LIMIT_MB;
911      return Math.ceil(base + Math.pow(overGB / 50, 2) * 50);
912    }
913
914    // File selection - blocks text input
915    fileInput.addEventListener('change', function() {
916      if (this.files && this.files[0]) {
917        const file = this.files[0];
918        hasFile = true;
919
920        // Disable textarea
921        textarea.disabled = true;
922        textarea.value = '';
923        textarea.placeholder = 'File selected - text disabled';
924
925        // Show file badge, hide file input
926        fileInput.classList.add('hidden-input');
927        fileSelectedBadge.classList.add('visible');
928
929        // Truncate filename for display
930        let displayName = file.name;
931        if (displayName.length > 20) {
932          displayName = displayName.substring(0, 17) + '...';
933        }
934        fileName.textContent = displayName;
935        fileSelectedBadge.title = file.name;
936
937        // Calculate and show energy (+1 for the note itself)
938        const sizeMB = file.size / (1024 * 1024);
939        const fileCost = calculateFileEnergy(sizeMB);
940        const totalCost = fileCost + 1;
941        energyDisplay.textContent = '~\u26a1' + totalCost;
942        energyDisplay.classList.add('file-energy');
943
944        // Update char counter state
945        updateCharCounter();
946      }
947    });
948
949    // Clear file selection
950    clearFileBtn.addEventListener('click', function() {
951      hasFile = false;
952      fileInput.value = '';
953      fileInput.classList.remove('hidden-input');
954      fileSelectedBadge.classList.remove('visible');
955
956      // Re-enable textarea
957      textarea.disabled = false;
958      textarea.placeholder = 'Write a note...';
959
960      // Clear energy display
961      energyDisplay.textContent = '';
962      energyDisplay.classList.remove('file-energy');
963
964      updateCharCounter();
965    });
966
967    // Delete note functionality
968    document.addEventListener('click', async (e) => {
969      if (!e.target.classList.contains('delete-button')) return;
970
971      const noteItem = e.target.closest('.note-item');
972      const noteId = noteItem.dataset.noteId;
973      const nodeId = noteItem.dataset.nodeId;
974      const version = noteItem.dataset.version;
975
976      if (!confirm('Delete this note? This cannot be undone.')) return;
977
978      const token = new URLSearchParams(window.location.search).get('token') || '';
979      const qs = token ? '?token=' + encodeURIComponent(token) : '';
980
981      try {
982        const res = await fetch(
983          '/api/v1/node/' + nodeId + '/' + version + '/notes/' + noteId + qs,
984          { method: 'DELETE' }
985        );
986
987        const data = await res.json();
988        if (!res.ok || data.status === 'error') throw new Error((data.error && data.error.message) || data.error || 'Delete failed');
989
990        noteItem.style.opacity = '0';
991        noteItem.style.transform = 'translateY(-10px)';
992        setTimeout(() => noteItem.remove(), 300);
993      } catch (err) {
994        alert('Failed to delete: ' + (err.message || 'Unknown error'));
995      }
996    });
997
998    // Form submission with progress
999    form.addEventListener('submit', (e) => {
1000      e.preventDefault();
1001
1002      sendBtn.classList.add('loading');
1003      sendBtn.disabled = true;
1004
1005      const formData = new FormData(form);
1006      const xhr = new XMLHttpRequest();
1007
1008      xhr.open('POST', form.action, true);
1009
1010      xhr.upload.onprogress = (e) => {
1011        if (!e.lengthComputable) return;
1012        const percent = Math.round((e.loaded / e.total) * 100);
1013        progressBar.style.width = percent + '%';
1014      };
1015
1016      xhr.onload = () => {
1017        document.location.reload();
1018      };
1019
1020      xhr.onerror = () => {
1021        alert('Send failed');
1022        sendBtn.classList.remove('loading');
1023        sendBtn.disabled = false;
1024        progressBar.style.width = '0%';
1025      };
1026
1027      xhr.send(formData);
1028    });
1029
1030    // Enter to submit
1031    textarea.addEventListener('keydown', (e) => {
1032      if (e.key === 'Enter' && !e.shiftKey) {
1033        e.preventDefault();
1034        form.requestSubmit();
1035      }
1036    });
1037
1038    // Editor button
1039    document.getElementById("openEditorBtn").addEventListener("click", function() {
1040      var token = new URLSearchParams(window.location.search).get("token") || "";
1041      var qs = token ? "?token=" + encodeURIComponent(token) + "&html" : "?html";
1042      var content = textarea.value.trim();
1043      var editorUrl = "/api/v1/node/${nodeId}/${version}/notes/editor" + qs;
1044
1045      if (content) {
1046        sessionStorage.setItem("tree-editor-draft", content);
1047      }
1048
1049      window.location.href = editorUrl;
1050    });
1051
1052    // Form reset handler
1053    form.addEventListener('reset', () => {
1054      hasFile = false;
1055      fileInput.classList.remove('hidden-input');
1056      fileSelectedBadge.classList.remove('visible');
1057      textarea.disabled = false;
1058      textarea.placeholder = 'Write a note...';
1059      energyDisplay.textContent = '';
1060      energyDisplay.classList.remove('file-energy');
1061      charCount.textContent = '0';
1062      charCounter.classList.remove('warning', 'danger', 'disabled');
1063    });
1064  `;
1065
1066  return page({
1067    title: `${escapeHtml(nodeName)} \u2014 Notes`,
1068    css,
1069    body,
1070    js,
1071  });
1072}
1073
1import { page } from "../../html-rendering/html/layout.js";
2import { esc, escapeHtml } from "../../html-rendering/html/utils.js";
3import { getUserMeta } from "../../../seed/tree/userMetadata.js";
4import { resolveSlots } from "../slots.js";
5
6export function renderUserProfile({ userId, user, roots, queryString, storageUsedKB }) {
7  const safeUsername = escapeHtml(user.username);
8
9  const css = `
10    html { overflow-y: auto; height: 100%; }
11
12    /* Card Base */
13    .glass-card {
14      background: #161b24;
15      border-radius: 12px;
16      padding: 24px 28px;
17      margin-bottom: 16px;
18      border: 1px solid #232a38;
19      position: relative;
20      animation: fadeInUp 0.35s ease-out both;
21    }
22
23    /* Header Section */
24    .header {
25      animation-delay: 0.05s;
26    }
27
28    .user-info h1 {
29      font-size: 22px;
30      font-weight: 600;
31      color: #e6e8eb;
32      margin-bottom: 14px;
33      letter-spacing: -0.3px;
34    }
35
36    .user-info h1::before {
37      content: '\uD83D\uDC64 ';
38      font-size: 28px;
39    }
40
41    /* User Meta Info */
42    .user-meta {
43      display: flex;
44      gap: 12px;
45      align-items: center;
46      margin-bottom: 16px;
47      flex-wrap: wrap;
48    }
49.send-button.loading {
50  pointer-events: none;
51  opacity: 0.9;
52}
53
54.send-progress {
55  position: absolute;
56  left: 0;
57  top: 0;
58  height: 100%;
59  width: 0%;
60  background: linear-gradient(
61    90deg,
62    rgba(255,255,255,0.25),
63    rgba(255,255,255,0.6),
64    rgba(255,255,255,0.25)
65  );
66  transition: width 0.2s ease;
67  pointer-events: none;
68}
69
70    .plan-badge {
71      padding: 5px 12px;
72      border-radius: 980px;
73      font-weight: 600;
74      font-size: 12px;
75      display: inline-flex;
76      align-items: center;
77      gap: 6px;
78      background: #161b24;
79      color: #9ba1ad;
80      border: 1px solid #232a38;
81      letter-spacing: 0.3px;
82    }
83.plan-basic {
84  background: #161b24;
85  color: #9ba1ad;
86  border: 1px solid #232a38;
87}
88
89/* STANDARD */
90.plan-standard {
91  background: rgba(122, 146, 184, 0.12);
92  color: #a8c0e0;
93  border: 1px solid rgba(122, 146, 184, 0.35);
94}
95
96/* PREMIUM */
97.plan-premium {
98  background: rgba(158, 130, 196, 0.12);
99  color: #c4afde;
100  border: 1px solid rgba(158, 130, 196, 0.35);
101}
102
103/* GOD */
104.plan-god {
105  background: rgba(212, 165, 116, 0.12);
106  color: #e0c290;
107  border: 1px solid rgba(212, 165, 116, 0.45);
108}
109    .meta-item {
110      display: inline-flex;
111      align-items: center;
112      gap: 6px;
113      padding: 6px 12px;
114      background: #161b24;
115      border-radius: 980px;
116      font-size: 12px;
117      font-weight: 500;
118      color: #9ba1ad;
119      border: 1px solid #232a38;
120    }
121
122    .storage-toggle-btn {
123      padding: 2px 8px;
124      margin-left: 4px;
125      border-radius: 6px;
126      border: 1px solid #232a38;
127      background: #161b24;
128      font-size: 11px;
129      font-weight: 600;
130      cursor: pointer;
131      transition: background 150ms ease, border-color 150ms ease;
132      color: #9ba1ad;
133    }
134
135    .storage-toggle-btn:hover {
136      background: #1c222e;
137      border-color: #2f3849;
138      color: #e6e8eb;
139    }
140
141    .logout-btn {
142      padding: 7px 14px;
143      border-radius: 8px;
144      border: 1px solid rgba(201, 126, 106, 0.35);
145      background: rgba(201, 126, 106, 0.1);
146      color: #c97e6a;
147      font-weight: 600;
148      font-size: 12px;
149      cursor: pointer;
150      transition: background 150ms ease, border-color 150ms ease;
151    }
152
153    .logout-btn:hover {
154      background: rgba(201, 126, 106, 0.18);
155      border-color: rgba(201, 126, 106, 0.55);
156    }
157
158    .header { position: relative; }
159
160    .basic-btn {
161      position: absolute;
162      top: 16px;
163      right: 16px;
164      padding: 7px 14px;
165      border-radius: 8px;
166      border: 1px solid rgba(125, 211, 133, 0.4);
167      background: rgba(125, 211, 133, 0.12);
168      color: #9ce0a2;
169      font-weight: 600;
170      font-size: 12px;
171      cursor: pointer;
172      transition: background 150ms ease, border-color 150ms ease;
173      text-decoration: none;
174    }
175
176    .basic-btn:hover {
177      background: rgba(125, 211, 133, 0.2);
178      border-color: #7dd385;
179    }
180
181    /* User ID */
182    .user-id-container {
183      display: flex;
184      align-items: center;
185      gap: 8px;
186      padding: 10px 14px;
187      background: #0d1117;
188      border-radius: 8px;
189      margin-top: 12px;
190      border: 1px solid #232a38;
191    }
192
193    .user-id-container code {
194      flex: 1;
195      background: transparent;
196      padding: 0;
197      font-size: 12px;
198      font-family: 'SF Mono', Monaco, monospace;
199      color: #c4c8d0;
200      font-weight: 500;
201      word-break: break-all;
202    }
203
204    #copyNodeIdBtn {
205      background: #161b24;
206      border: 1px solid #232a38;
207      cursor: pointer;
208      padding: 5px 9px;
209      border-radius: 6px;
210      font-size: 14px;
211      transition: background 150ms ease, border-color 150ms ease;
212      flex-shrink: 0;
213      color: #9ba1ad;
214    }
215
216    #copyNodeIdBtn:hover {
217      background: #1c222e;
218      border-color: #2f3849;
219    }
220
221    /* Raw Ideas Capture - subtle accent border, no glow */
222    .raw-ideas-section {
223      animation-delay: 0.15s;
224      border: 1px solid rgba(125, 211, 133, 0.25);
225    }
226
227    .raw-ideas-section h2 {
228      font-size: 16px;
229      font-weight: 600;
230      color: #e6e8eb;
231      margin-bottom: 14px;
232      position: relative;
233      z-index: 1;
234      letter-spacing: -0.2px;
235    }
236
237    .raw-ideas-section h2::before {
238      content: '\uD83D\uDCA1 ';
239      font-size: 20px;
240    }
241
242    .raw-idea-form {
243      display: flex;
244      flex-direction: column;
245      gap: 16px;
246      position: relative;
247      z-index: 1;
248    }
249
250    #rawIdeaInput {
251      width: 100%;
252      padding: 16px 20px;
253      font-size: 16px;
254      line-height: 1.6;
255      border-radius: 10px;
256      border: 1px solid #232a38;
257      background: #0d1117;
258      font-family: inherit;
259      resize: vertical;
260      min-height: 80px;
261      max-height: 400px;
262      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
263      box-shadow:
264        0 4px 20px rgba(0, 0, 0, 0.15),
265        inset 0 1px 0 rgba(255, 255, 255, 0.4);
266      color: #e6e8eb;
267      font-weight: 500;
268      letter-spacing: 0;
269    }
270
271    #rawIdeaInput:focus {
272      outline: none;
273      border-color: rgba(125, 211, 133, 0.5);
274      box-shadow:
275        0 0 0 3px rgba(125, 211, 133, 0.15);
276    }
277
278    #rawIdeaInput:focus::placeholder {
279      color: rgba(155, 161, 173, 0.6);
280    }
281
282    #rawIdeaInput::placeholder {
283      color: rgba(155, 161, 173, 0.6);
284      font-weight: 400;
285    }
286
287    #rawIdeaInput:disabled {
288      opacity: 0.4;
289      cursor: not-allowed;
290      background: rgba(255, 255, 255, 0.04);
291    }
292
293    #rawIdeaInput:disabled::placeholder {
294      color: rgba(155, 161, 173, 0.3);
295    }
296
297    /* Character counter */
298    .char-counter {
299      display: flex;
300      justify-content: flex-end;
301      align-items: center;
302      margin-top: -8px;
303      margin-bottom: 8px;
304      font-size: 12px;
305      color: rgba(255, 255, 255, 0.6);
306      font-weight: 500;
307      transition: color 0.2s;
308    }
309
310    .char-counter.warning {
311      color: rgba(255, 193, 7, 0.9);
312    }
313
314    .char-counter.danger {
315      color: rgba(239, 68, 68, 0.9);
316      font-weight: 600;
317    }
318
319    .char-counter.disabled {
320      opacity: 0.4;
321    }
322
323    /* Energy display */
324    .energy-display {
325      display: inline-flex;
326      align-items: center;
327      gap: 4px;
328      margin-left: 10px;
329      padding: 2px 8px;
330      background: rgba(212, 165, 116, 0.12);
331      border: 1px solid rgba(212, 165, 116, 0.4);
332      border-radius: 10px;
333      font-size: 11px;
334      font-weight: 600;
335      color: #d4a574;
336      transition: all 0.2s;
337    }
338
339    .energy-display:empty {
340      display: none;
341    }
342
343    .energy-display.file-energy {
344      background: rgba(212, 165, 116, 0.18);
345      border-color: rgba(212, 165, 116, 0.55);
346      color: #e0c290;
347      font-size: 12px;
348      font-weight: 700;
349      padding: 4px 12px;
350    }
351
352    /* File selected badge */
353    .file-selected-badge {
354      display: none;
355      align-items: center;
356      gap: 6px;
357      padding: 6px 12px;
358      background: rgba(255, 255, 255, 0.15);
359      border: 1px solid rgba(255, 255, 255, 0.25);
360      border-radius: 20px;
361      font-size: 12px;
362      font-weight: 500;
363      color: white;
364    }
365
366    .file-selected-badge.visible {
367      display: inline-flex;
368    }
369
370    .file-selected-badge .file-name {
371      max-width: 120px;
372      overflow: hidden;
373      text-overflow: ellipsis;
374      white-space: nowrap;
375    }
376
377    .file-selected-badge .clear-file {
378      background: rgba(255, 255, 255, 0.2);
379      border: none;
380      border-radius: 50%;
381      width: 18px;
382      height: 18px;
383      display: flex;
384      align-items: center;
385      justify-content: center;
386      cursor: pointer;
387      font-size: 10px;
388      color: white;
389      transition: all 0.2s;
390    }
391
392    .file-selected-badge .clear-file:hover {
393      background: rgba(239, 68, 68, 0.4);
394    }
395
396    .form-actions {
397      display: flex;
398      justify-content: space-between;
399      align-items: center;
400      gap: 12px;
401      flex-wrap: wrap;
402    }
403
404    .file-input-wrapper {
405      flex: 1;
406      min-width: 180px;
407      display: flex;
408      align-items: center;
409      gap: 8px;
410    }
411
412    input[type="file"] {
413      font-size: 13px;
414      color: rgba(255, 255, 255, 0.9);
415      cursor: pointer;
416    }
417
418    input[type="file"]::file-selector-button {
419      padding: 8px 16px;
420      border-radius: 980px;
421      border: 1px solid rgba(255, 255, 255, 0.3);
422      background: rgba(255, 255, 255, 0.2);
423      backdrop-filter: blur(10px);
424      color: white;
425      cursor: pointer;
426      font-size: 13px;
427      font-weight: 600;
428      transition: all 0.2s;
429      margin-right: 10px;
430    }
431
432    input[type="file"]::file-selector-button:hover {
433      background: rgba(255, 255, 255, 0.3);
434      transform: translateY(-1px);
435    }
436
437    input[type="file"].hidden-input {
438      display: none;
439    }
440
441    .send-button {
442      padding: 12px 28px;
443      font-size: 14px;
444      font-weight: 600;
445      border-radius: 10px;
446      border: 1px solid #7dd385;
447      background: #7dd385;
448      color: #0d1117;
449      cursor: pointer;
450      transition: background 200ms ease, box-shadow 200ms ease, transform 150ms ease;
451      box-shadow: 0 0 20px rgba(125, 211, 133, 0.35);
452      white-space: nowrap;
453    }
454
455    .send-button:hover {
456      background: #9ce0a2;
457      border-color: #9ce0a2;
458      box-shadow: 0 0 28px rgba(125, 211, 133, 0.55);
459      transform: translateY(-1px);
460    }
461
462    .send-button:active {
463      transform: translateY(0);
464    }
465
466    .send-button:disabled {
467      opacity: 0.4;
468      cursor: not-allowed;
469      box-shadow: none;
470      transform: none;
471    }
472
473    /* Navigation Section */
474    .nav-section {
475      animation-delay: 0.3s;
476    }
477
478    .nav-section h2 {
479      font-size: 16px;
480      font-weight: 600;
481      color: #e6e8eb;
482      margin-bottom: 14px;
483      letter-spacing: -0.2px;
484    }
485
486    .nav-links {
487      list-style: none;
488      display: grid;
489      grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
490      gap: 12px;
491      padding: 0;
492      margin: 0;
493    }
494
495    .nav-links li {
496      list-style: none;
497    }
498
499    .nav-links a {
500      display: block;
501      padding: 10px 14px;
502      background: #161b24;
503      border-radius: 8px;
504      color: #c4c8d0;
505      text-decoration: none;
506      font-weight: 500;
507      font-size: 13px;
508      transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
509      border: 1px solid #232a38;
510      text-align: center;
511    }
512
513    .nav-links a:hover {
514      background: #1c222e;
515      border-color: #2f3849;
516      color: #e6e8eb;
517    }
518
519    /* Roots Section */
520    .roots-section {
521      animation-delay: 0.4s;
522    }
523
524    .roots-section h2 {
525      font-size: 16px;
526      font-weight: 600;
527      color: #e6e8eb;
528      margin-bottom: 14px;
529      letter-spacing: -0.2px;
530    }
531
532    .roots-section h2::before {
533      content: '\uD83C\uDF33 ';
534      font-size: 20px;
535    }
536
537    .roots-list {
538      list-style: none;
539      margin-bottom: 24px;
540    }
541
542    .roots-list li {
543      margin-bottom: 10px;
544    }
545
546    .roots-list a {
547      display: block;
548      padding: 12px 16px;
549      background: #161b24;
550      border-radius: 10px;
551      color: #c4c8d0;
552      text-decoration: none;
553      font-weight: 500;
554      font-size: 14px;
555      transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
556      border: 1px solid #232a38;
557      border-left: 3px solid #232a38;
558    }
559
560    .roots-list a:hover {
561      background: #1c222e;
562      border-color: #2f3849;
563      border-left-color: #7dd385;
564      color: #e6e8eb;
565    }
566
567    .roots-list em {
568      color: #5d6371;
569      font-style: italic;
570      display: block;
571      padding: 20px;
572      text-align: center;
573    }
574
575    /* Create Root Form */
576    .create-root-form {
577      display: flex;
578      gap: 12px;
579      align-items: stretch;
580    }
581
582    .create-root-form input[type="text"] {
583      flex: 1;
584      padding: 12px 16px;
585      font-size: 14px;
586      border-radius: 10px;
587      border: 1px solid #232a38;
588      background: #0d1117;
589      color: #e6e8eb;
590      font-family: inherit;
591      transition: border-color 150ms ease, background 150ms ease;
592    }
593
594    .create-root-form input[type="text"]::placeholder {
595      color: #5d6371;
596    }
597
598    .create-root-form input[type="text"]:focus {
599      outline: none;
600      border-color: rgba(125, 211, 133, 0.5);
601      background: #0d1117;
602      box-shadow: 0 0 0 3px rgba(125, 211, 133, 0.15);
603    }
604
605    .create-root-button {
606      padding: 12px 18px;
607      font-size: 20px;
608      line-height: 1;
609      border-radius: 10px;
610      border: 1px solid #232a38;
611      background: #161b24;
612      color: #9ba1ad;
613      cursor: pointer;
614      transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
615      font-weight: 300;
616    }
617
618    .create-root-button:hover {
619      background: #1c222e;
620      border-color: #2f3849;
621      color: #e6e8eb;
622    }
623
624    /* Responsive Design */
625
626
627      a {
628text-decoration: none;
629        color: inherit;}`;
630
631  const body = `
632  <div class="container">
633    <!-- Header -->
634    <div class="glass-card header">
635      <button class="basic-btn" onclick="try{window.top.location='/chat'}catch(e){window.location='/chat'}">Back to Basic Chat</button>
636      <div class="user-info">
637        <h1>@${safeUsername}</h1>
638
639        <div class="user-meta">
640          ${resolveSlots("user-profile-badge", { userId, queryString, user }) ||
641            `<span class="plan-badge plan-basic">${user.isAdmin ? "Admin" : "User"}</span>`}
642
643          ${resolveSlots("user-profile-energy", { userId, queryString, user })}
644
645          <span class="meta-item">
646            \uD83D\uDCBE <span id="storageValue"></span>
647            <button
648              id="storageToggle"
649              class="storage-toggle-btn"
650              data-storage-kb="${storageUsedKB}"
651            >
652              MB
653            </button>
654            used
655          </span>
656
657          <button id="logoutBtn" class="logout-btn">
658            Log out
659          </button>
660        </div>
661
662        <div class="user-id-container">
663          <code id="nodeIdCode">${user._id}</code>
664          <button id="copyNodeIdBtn" title="Copy ID">\uD83D\uDCCB</button>
665        </div>
666      </div>
667    </div>
668
669    <!-- Extension sections (raw ideas capture, etc.) -->
670    ${resolveSlots("user-profile-sections", { userId, queryString, user })}
671
672    <!-- Navigation Links -->
673    <div class="glass-card nav-section">
674      <h2>Quick Links</h2>
675      <ul class="nav-links">
676        <li><a href="/api/v1/user/${userId}/apps${queryString}">Apps</a></li>
677        <li><a href="/api/v1/user/${userId}/llm${queryString}">LLM</a></li>
678        ${resolveSlots("user-quick-links", { userId, queryString }, { raw: true })}
679      </ul>
680    </div>
681
682    <!-- Roots Section -->
683    <div class="glass-card roots-section">
684      <h2>My Roots</h2>
685      ${
686        roots.length > 0
687          ? `
688        <ul class="roots-list">
689          ${roots
690            .map(
691              (r) => `
692            <li>
693              <a href="/api/v1/root/${r._id}${queryString}">
694                  ${escapeHtml(r.name || "Untitled")}
695              </a>
696            </li>
697          `,
698            )
699            .join("")}
700        </ul>
701      `
702          : `<ul class="roots-list"><li><em>No roots yet \u2014 create your first one below!</em></li></ul>`
703      }
704
705      <form
706        method="POST"
707        action="/api/v1/user/${userId}/createRoot${queryString}"
708        class="create-root-form"
709      >
710        <input
711          type="text"
712          name="name"
713          placeholder="New root name..."
714          required
715        />
716        <button type="submit" class="create-root-button" title="Create root">
717          \uFF0B
718        </button>
719      </form>
720    </div>
721  </div>`;
722
723  const js = `
724    // Copy ID functionality
725    document.getElementById("copyNodeIdBtn").addEventListener("click", () => {
726      const code = document.getElementById("nodeIdCode");
727      const btn = document.getElementById("copyNodeIdBtn");
728
729      navigator.clipboard.writeText(code.textContent).then(() => {
730        btn.textContent = "\u2714\uFE0F";
731        setTimeout(() => (btn.textContent = "\uD83D\uDCCB"), 1000);
732      });
733    });
734
735    // Storage toggle
736    (() => {
737      const toggleBtn = document.getElementById("storageToggle");
738      const valueEl = document.getElementById("storageValue");
739      const storageKB = Number(toggleBtn.dataset.storageKb || 0);
740      let unit = "MB";
741
742      function render() {
743        if (unit === "MB") {
744          const mb = storageKB / 1024;
745          valueEl.textContent = mb.toFixed(mb < 10 ? 2 : 1);
746          toggleBtn.textContent = "MB";
747        } else {
748          const gb = storageKB / (1024 * 1024);
749          valueEl.textContent = gb.toFixed(gb < 1 ? 3 : 2);
750          toggleBtn.textContent = "GB";
751        }
752      }
753
754      toggleBtn.addEventListener("click", () => {
755        unit = unit === "GB" ? "MB" : "GB";
756        render();
757      });
758
759      render();
760    })();
761
762    // Logout
763    document.getElementById("logoutBtn").addEventListener("click", async () => {
764      try {
765        await fetch("/api/v1/logout", {
766          method: "POST",
767          credentials: "include",
768        });
769        window.top.location.href = "/login";
770      } catch (err) {
771        console.error("Logout failed", err);
772        alert("Logout failed. Please try again.");
773      }
774    });
775
776    // Elements
777    const form = document.getElementById('rawIdeaForm');
778    const textarea = document.getElementById('rawIdeaInput');
779    const charCounter = document.getElementById('charCounter');
780    const charCount = document.getElementById('charCount');
781    const energyDisplay = document.getElementById('energyDisplay');
782    const fileInput = document.getElementById('fileInput');
783    const fileSelectedBadge = document.getElementById('fileSelectedBadge');
784    const fileName = document.getElementById('fileName');
785    const clearFileBtn = document.getElementById('clearFileBtn');
786    const sendBtn = document.getElementById('rawIdeaSendBtn');
787    const progressBar = sendBtn.querySelector('.send-progress');
788
789    const MAX_CHARS = 5000;
790    let hasFile = false;
791
792    // Auto-resize textarea
793    function autoResize() {
794      textarea.style.height = 'auto';
795      const maxHeight = 400;
796      const newHeight = Math.min(textarea.scrollHeight, maxHeight);
797      textarea.style.height = newHeight + 'px';
798      textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden';
799      updateCharCounter();
800    }
801
802    textarea.addEventListener('input', autoResize);
803    autoResize();
804
805    // Character counter with energy (1 per 1000 chars)
806    function updateCharCounter() {
807      const len = textarea.value.length;
808      charCount.textContent = len;
809
810      const remaining = MAX_CHARS - len;
811      charCounter.classList.remove('warning', 'danger', 'disabled');
812
813      if (hasFile) {
814        charCounter.classList.add('disabled');
815      } else if (remaining <= 100) {
816        charCounter.classList.add('danger');
817      } else if (remaining <= 500) {
818        charCounter.classList.add('warning');
819      }
820
821      // Energy cost: 1 per 1000 chars (minimum 1 if any text)
822      if (len > 0 && !hasFile) {
823        const cost = Math.max(1, Math.ceil(len / 1000));
824        energyDisplay.textContent = '\u26A1' + cost;
825        energyDisplay.classList.remove('file-energy');
826      } else if (!hasFile) {
827        energyDisplay.textContent = '';
828      }
829    }
830
831    // File energy calculation
832    const FILE_MIN_COST = 5;
833    const FILE_BASE_RATE = 1.5;
834    const FILE_MID_RATE = 3;
835    const SOFT_LIMIT_MB = 100;
836    const HARD_LIMIT_MB = 1024;
837
838    function calculateFileEnergy(sizeMB) {
839      if (sizeMB <= SOFT_LIMIT_MB) {
840        return Math.max(FILE_MIN_COST, Math.ceil(sizeMB * FILE_BASE_RATE));
841      }
842      if (sizeMB <= HARD_LIMIT_MB) {
843        const base = SOFT_LIMIT_MB * FILE_BASE_RATE;
844        const extra = (sizeMB - SOFT_LIMIT_MB) * FILE_MID_RATE;
845        return Math.ceil(base + extra);
846      }
847      const base = SOFT_LIMIT_MB * FILE_BASE_RATE +
848                   (HARD_LIMIT_MB - SOFT_LIMIT_MB) * FILE_MID_RATE;
849      const overGB = sizeMB - HARD_LIMIT_MB;
850      return Math.ceil(base + Math.pow(overGB / 50, 2) * 50);
851    }
852
853    // File selection - blocks text input
854    fileInput.addEventListener('change', function() {
855      if (this.files && this.files[0]) {
856        const file = this.files[0];
857        hasFile = true;
858
859        // Disable textarea
860        textarea.disabled = true;
861        textarea.value = '';
862        textarea.placeholder = 'File selected - text disabled';
863
864        // Show file badge, hide file input
865        fileInput.classList.add('hidden-input');
866        fileSelectedBadge.classList.add('visible');
867
868        // Truncate filename
869        let displayName = file.name;
870        if (displayName.length > 20) {
871          displayName = displayName.substring(0, 17) + '...';
872        }
873        fileName.textContent = displayName;
874        fileSelectedBadge.title = file.name;
875
876        // Calculate and show energy (+1 for the note itself)
877        const sizeMB = file.size / (1024 * 1024);
878        const fileCost = calculateFileEnergy(sizeMB);
879        const totalCost = fileCost + 1;
880        energyDisplay.textContent = '~\u26A1' + totalCost;
881        energyDisplay.classList.add('file-energy');
882
883        updateCharCounter();
884      }
885    });
886
887    // Clear file selection
888    clearFileBtn.addEventListener('click', function() {
889      hasFile = false;
890      fileInput.value = '';
891      fileInput.classList.remove('hidden-input');
892      fileSelectedBadge.classList.remove('visible');
893
894      textarea.disabled = false;
895      textarea.placeholder = "What's on your mind?";
896
897      energyDisplay.textContent = '';
898      energyDisplay.classList.remove('file-energy');
899
900      updateCharCounter();
901    });
902
903    // Submit with Enter (desktop only)
904    textarea.addEventListener("keydown", (e) => {
905      const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
906      if (!isMobile && e.key === "Enter" && !e.shiftKey) {
907        e.preventDefault();
908        form.requestSubmit();
909      }
910    });
911
912    // Form submission with progress + cancel
913    let activeXhr = null;
914
915    sendBtn.addEventListener('click', (e) => {
916      if (activeXhr) {
917        e.preventDefault();
918        activeXhr.abort();
919        activeXhr = null;
920        sendBtn.classList.remove('loading');
921        sendBtn.querySelector('.send-label').textContent = 'Send';
922        progressBar.style.width = '0%';
923        return;
924      }
925    });
926
927    form.addEventListener('submit', (e) => {
928      e.preventDefault();
929
930      sendBtn.classList.add('loading');
931      sendBtn.querySelector('.send-label').textContent = 'Cancel';
932      progressBar.style.width = '15%';
933
934      const formData = new FormData(form);
935      const xhr = new XMLHttpRequest();
936      activeXhr = xhr;
937
938      xhr.open('POST', form.action, true);
939
940      xhr.upload.onprogress = (e) => {
941        if (!e.lengthComputable) return;
942        const realPercent = (e.loaded / e.total) * 100;
943        const lagged = Math.min(90, Math.round(realPercent * 0.8));
944        progressBar.style.width = lagged + '%';
945      };
946
947      xhr.onload = () => {
948        activeXhr = null;
949        if (xhr.status >= 200 && xhr.status < 300) {
950          progressBar.style.width = '100%';
951          setTimeout(() => document.location.reload(), 150);
952        } else {
953          fail();
954        }
955      };
956
957      xhr.onerror = fail;
958      xhr.onabort = () => {
959        activeXhr = null;
960      };
961
962      function fail() {
963        activeXhr = null;
964        var msg = 'Send failed';
965        try {
966          var body = JSON.parse(xhr.responseText);
967          if (body.error) msg = body.error.message || body.error;
968        } catch(e) {}
969        alert(msg);
970        sendBtn.classList.remove('loading');
971        sendBtn.querySelector('.send-label').textContent = 'Send';
972        progressBar.style.width = '0%';
973      }
974
975      xhr.send(formData);
976    });
977
978    // Form reset handler
979    form.addEventListener('reset', () => {
980      hasFile = false;
981      fileInput.classList.remove('hidden-input');
982      fileSelectedBadge.classList.remove('visible');
983      textarea.disabled = false;
984      textarea.placeholder = "What's on your mind?";
985      energyDisplay.textContent = '';
986      energyDisplay.classList.remove('file-energy');
987      charCount.textContent = '0';
988      charCounter.classList.remove('warning', 'danger', 'disabled');
989    });`;
990
991  return page({
992    title: `@${safeUsername} \u2014 Profile`,
993    css,
994    body,
995    js,
996  });
997}
998
1/* ------------------------------------------------- */
2/* Public query page (layout-wrapped)                */
3/* Dark theme -- uses bare: true                     */
4/* ------------------------------------------------- */
5
6import { page } from "../../html-rendering/html/layout.js";
7import { escapeHtml } from "../../html-rendering/html/utils.js";
8
9export function renderQueryPage({ treeName, ownerUsername, rootId, queryAvailable, isAuthenticated }) {
10  const css = `
11    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
12
13    body {
14      font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
15      background: linear-gradient(135deg, #0f0c29 0%, #1a1a2e 40%, #16213e 100%);
16      color: #e0e0e0;
17      min-height: 100vh;
18      display: flex;
19      flex-direction: column;
20    }
21
22    .header {
23      padding: 24px 32px;
24      border-bottom: 1px solid rgba(255,255,255,0.08);
25      display: flex;
26      align-items: center;
27      gap: 16px;
28    }
29
30    .header h1 {
31      font-size: 1.3rem;
32      font-weight: 700;
33      color: #fff;
34    }
35
36    .header .meta {
37      font-size: 0.85rem;
38      color: rgba(255,255,255,0.5);
39    }
40
41    .header .badge {
42      font-size: 0.7rem;
43      padding: 3px 10px;
44      border-radius: 12px;
45      background: rgba(72,187,120,0.15);
46      color: rgba(72,187,120,0.9);
47      border: 1px solid rgba(72,187,120,0.25);
48      font-weight: 600;
49      text-transform: uppercase;
50      letter-spacing: 0.5px;
51    }
52
53    .chat-area {
54      flex: 1;
55      overflow-y: auto;
56      padding: 24px 32px;
57      display: flex;
58      flex-direction: column;
59      gap: 16px;
60    }
61
62    .message {
63      max-width: 720px;
64      width: 100%;
65      margin: 0 auto;
66      padding: 16px 20px;
67      border-radius: 16px;
68      line-height: 1.6;
69      font-size: 0.95rem;
70    }
71
72    .message.user {
73      background: rgba(88,86,214,0.15);
74      border: 1px solid rgba(88,86,214,0.25);
75      align-self: flex-end;
76    }
77
78    .message.assistant {
79      background: rgba(255,255,255,0.05);
80      border: 1px solid rgba(255,255,255,0.1);
81    }
82
83    .message.error {
84      background: rgba(255,59,48,0.1);
85      border: 1px solid rgba(255,59,48,0.25);
86      color: rgba(255,107,107,0.9);
87    }
88
89    .message p { margin: 0 0 8px; }
90    .message p:last-child { margin-bottom: 0; }
91    .message code {
92      background: rgba(255,255,255,0.1);
93      padding: 2px 6px;
94      border-radius: 4px;
95      font-size: 0.9em;
96    }
97    .message pre {
98      background: rgba(0,0,0,0.3);
99      padding: 12px 16px;
100      border-radius: 8px;
101      overflow-x: auto;
102      margin: 8px 0;
103    }
104    .message pre code {
105      background: none;
106      padding: 0;
107    }
108
109    .empty-state {
110      text-align: center;
111      color: rgba(255,255,255,0.4);
112      padding: 48px 24px;
113      font-size: 0.95rem;
114      max-width: 480px;
115      margin: auto;
116    }
117
118    .empty-state .icon {
119      font-size: 2rem;
120      margin-bottom: 12px;
121      opacity: 0.6;
122    }
123
124    .input-area {
125      padding: 16px 32px 24px;
126      border-top: 1px solid rgba(255,255,255,0.08);
127      max-width: 784px;
128      width: 100%;
129      margin: 0 auto;
130    }
131
132    .input-row {
133      display: flex;
134      gap: 12px;
135      align-items: flex-end;
136    }
137
138    .input-row textarea {
139      flex: 1;
140      resize: none;
141      padding: 12px 16px;
142      border-radius: 16px;
143      border: 1px solid rgba(255,255,255,0.15);
144      background: rgba(255,255,255,0.06);
145      color: #fff;
146      font-size: 0.95rem;
147      font-family: inherit;
148      line-height: 1.5;
149      min-height: 48px;
150      max-height: 200px;
151      outline: none;
152      transition: border-color 0.2s;
153    }
154
155    .input-row textarea:focus {
156      border-color: rgba(88,86,214,0.5);
157    }
158
159    .input-row textarea::placeholder {
160      color: rgba(255,255,255,0.3);
161    }
162
163    .input-row button {
164      padding: 12px 20px;
165      border-radius: 16px;
166      border: 1px solid rgba(72,187,120,0.4);
167      background: rgba(72,187,120,0.15);
168      color: rgba(72,187,120,0.9);
169      font-weight: 600;
170      font-size: 0.9rem;
171      cursor: pointer;
172      transition: background 0.2s;
173      white-space: nowrap;
174    }
175
176    .input-row button:hover {
177      background: rgba(72,187,120,0.25);
178    }
179
180    .input-row button:disabled {
181      opacity: 0.4;
182      cursor: not-allowed;
183    }
184
185    .footer {
186      text-align: center;
187      padding: 12px;
188      font-size: 0.75rem;
189      color: rgba(255,255,255,0.3);
190    }
191
192    .footer a {
193      color: rgba(88,86,214,0.7);
194      text-decoration: none;
195    }
196
197    .spinner {
198      display: inline-block;
199      width: 16px;
200      height: 16px;
201      border: 2px solid rgba(255,255,255,0.2);
202      border-top-color: rgba(72,187,120,0.8);
203      border-radius: 50%;
204      animation: spin 0.8s linear infinite;
205      margin-right: 8px;
206      vertical-align: middle;
207    }
208
209    @keyframes spin { to { transform: rotate(360deg); } }
210
211    .unavailable {
212      text-align: center;
213      padding: 48px 24px;
214      color: rgba(255,255,255,0.5);
215    }
216
217    .unavailable h2 {
218      font-size: 1.1rem;
219      margin-bottom: 8px;
220      color: rgba(255,255,255,0.7);
221    }`;
222
223  const body = `
224  <div class="header">
225    <div>
226      <h1>${escapeHtml(treeName)}</h1>
227      <div class="meta">by ${escapeHtml(ownerUsername)}</div>
228      <a href="https://horizon.treeos.ai" target="_blank" rel="noopener"
229        style="font-size:0.75rem;color:rgba(255,255,255,0.35);text-decoration:none;margin-top:4px;display:inline-block;transition:color 0.2s;"
230        onmouseover="this.style.color='rgba(255,255,255,0.7)'"
231        onmouseout="this.style.color='rgba(255,255,255,0.35)'"
232      >Canopy Horizon</a>
233    </div>
234    <span class="badge">Public</span>
235  </div>
236
237  ${queryAvailable ? `
238  <div class="chat-area" id="chatArea">
239    <div class="empty-state" id="emptyState">
240      Ask the tree anything to find knowledge. Responses are read only and will not modify the tree.
241    </div>
242  </div>
243
244  <div class="input-area">
245    <div class="input-row">
246      <textarea
247        id="queryInput"
248        placeholder="Ask a question about this tree..."
249        rows="1"
250        onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendQuery();}"
251        oninput="autoResize(this)"
252      ></textarea>
253      <button id="sendBtn" onclick="sendQuery()">Ask</button>
254    </div>
255  </div>
256  ` : `
257  <div class="unavailable">
258    <h2>Query not available</h2>
259    <p>This tree does not have AI configured for public queries.${isAuthenticated ? "" : " If you have an account on another land, you can query through the CLI or API using your own AI connection."}</p>
260  </div>
261  `}
262
263  <div class="footer">
264    Powered by <a href="https://treeos.ai" target="_blank">TreeOS</a>
265  </div>`;
266
267  const js = `
268  var ROOT_ID = "${rootId}";
269  var sending = false;
270
271  function autoResize(el) {
272    el.style.height = "auto";
273    el.style.height = Math.min(el.scrollHeight, 200) + "px";
274  }
275
276  function addMessage(role, html) {
277    var empty = document.getElementById("emptyState");
278    if (empty) empty.remove();
279
280    var area = document.getElementById("chatArea");
281    var div = document.createElement("div");
282    div.className = "message " + role;
283    div.innerHTML = html;
284    area.appendChild(div);
285    area.scrollTop = area.scrollHeight;
286    return div;
287  }
288
289  function markdownToHtml(text) {
290    if (!text) return "";
291    var BT = String.fromCharCode(96);
292    var html = text
293      .replace(/&/g, "&amp;")
294      .replace(/</g, "&lt;")
295      .replace(/>/g, "&gt;");
296
297    // Code blocks (triple backtick fenced)
298    var codeBlockRe = new RegExp(BT + BT + BT + "(\\\\w*)?\\\\n([\\\\s\\\\S]*?)" + BT + BT + BT, "g");
299    html = html.replace(codeBlockRe, function(m, lang, code) {
300      return "<pre><code>" + code.trim() + "</code></pre>";
301    });
302
303    // Inline code
304    var inlineCodeRe = new RegExp(BT + "([^" + BT + "]+)" + BT, "g");
305    html = html.replace(inlineCodeRe, "<code>$1</code>");
306
307    // Bold
308    html = html.replace(/[*][*](.+?)[*][*]/g, "<strong>$1</strong>");
309
310    // Italic
311    html = html.replace(/[*](.+?)[*]/g, "<em>$1</em>");
312
313    // Paragraphs
314    html = html.split(new RegExp("\\\\n\\\\n+")).map(function(p) {
315      p = p.trim();
316      if (!p) return "";
317      if (p.startsWith("<pre>")) return p;
318      return "<p>" + p.replace(new RegExp("\\\\n", "g"), "<br>") + "</p>";
319    }).join("");
320
321    return html;
322  }
323
324  async function sendQuery() {
325    if (sending) return;
326    var input = document.getElementById("queryInput");
327    var btn = document.getElementById("sendBtn");
328    var msg = input.value.trim();
329    if (!msg) return;
330
331    sending = true;
332    btn.disabled = true;
333    btn.textContent = "...";
334    input.value = "";
335    input.style.height = "auto";
336
337    addMessage("user", "<p>" + msg.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/\\n/g,"<br>") + "</p>");
338
339    var loadingDiv = addMessage("assistant", '<span class="spinner"></span> Thinking...');
340
341    try {
342      var res = await fetch("/api/v1/root/" + ROOT_ID + "/query", {
343        method: "POST",
344        credentials: "include",
345        headers: { "Content-Type": "application/json" },
346        body: JSON.stringify({ message: msg }),
347      });
348
349      var text = await res.text();
350      var data;
351      try { data = JSON.parse(text); } catch (_) { data = {}; }
352
353      if (res.status === 429) {
354        loadingDiv.className = "message error";
355        loadingDiv.innerHTML = "<p>Rate limit reached. Please wait a few minutes before trying again.</p>";
356      } else if (!res.ok || data.status === "error") {
357        var errMsg = (data.data && data.data.answer) || (data.error && data.error.message) || data.error || data.message || "Error (HTTP " + res.status + ")";
358        loadingDiv.className = "message error";
359        loadingDiv.innerHTML = "<p>" + errMsg + "</p>";
360      } else {
361        var result = data.data || data;
362        loadingDiv.innerHTML = markdownToHtml(result.answer);
363      }
364    } catch (err) {
365      loadingDiv.className = "message error";
366      loadingDiv.innerHTML = "<p>Network error: " + err.message + "</p>";
367    }
368
369    sending = false;
370    btn.disabled = false;
371    btn.textContent = "Ask";
372    input.focus();
373  }`;
374
375  return page({
376    title: `${escapeHtml(treeName)} - Query`,
377    css,
378    body,
379    js,
380    bare: true,
381  });
382}
383
1import { page } from "../../html-rendering/html/layout.js";
2import { esc } from "../../html-rendering/html/utils.js";
3import { getLandUrl } from "../../../canopy/identity.js";
4
5export function renderShareToken({ userId, user, token, tokenQS, savedShareToken }) {
6  const css = `
7body {
8  display: flex;
9  align-items: center;
10  justify-content: center;
11}
12
13
14.container {
15  max-width: 600px;
16  width: 100%;
17  position: relative;
18  z-index: 1;
19}
20
21/* Glass Card */
22.card {
23  position: relative;
24  overflow: hidden;
25  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
26  backdrop-filter: blur(22px) saturate(140%);
27  -webkit-backdrop-filter: blur(22px) saturate(140%);
28  border-radius: 24px;
29  padding: 48px;
30  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2),
31    inset 0 1px 0 rgba(255, 255, 255, 0.25);
32  border: 1px solid rgba(255, 255, 255, 0.28);
33  color: white;
34  animation: fadeInUp 0.6s ease-out;
35}
36
37.card::before {
38  content: "";
39  position: absolute;
40  inset: -40%;
41  background: radial-gradient(
42    120% 60% at 0% 0%,
43    rgba(255, 255, 255, 0.35),
44    transparent 60%
45  );
46  opacity: 0;
47  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
48  pointer-events: none;
49}
50
51.card:hover::before {
52  opacity: 1;
53  transform: translateX(30%) translateY(10%);
54}
55
56/* Header */
57.header {
58  text-align: center;
59  margin-bottom: 32px;
60}
61
62.icon {
63  font-size: 64px;
64  margin-bottom: 20px;
65  display: inline-block;
66  filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.2));
67  animation: bounce 2s infinite;
68}
69
70@keyframes bounce {
71  0%, 100% {
72    transform: translateY(0);
73  }
74  50% {
75    transform: translateY(-10px);
76  }
77}
78
79h1 {
80  font-size: 32px;
81  font-weight: 600;
82  color: white;
83  margin-bottom: 8px;
84  letter-spacing: -0.5px;
85  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
86}
87
88.username {
89  font-size: 16px;
90  color: rgba(255, 255, 255, 0.85);
91  font-weight: 500;
92}
93
94/* Description */
95.description {
96  color: rgba(255, 255, 255, 0.9);
97  line-height: 1.6;
98  margin-bottom: 28px;
99  font-size: 15px;
100  text-align: center;
101}
102
103/* Welcome Box */
104.welcome-box {
105  background: rgba(255, 255, 255, 0.15);
106  backdrop-filter: blur(10px);
107  padding: 24px;
108  border-radius: 16px;
109  margin-bottom: 28px;
110  border: 1px solid rgba(255, 255, 255, 0.25);
111}
112
113.welcome-title {
114  font-size: 18px;
115  font-weight: 600;
116  color: white;
117  margin-bottom: 12px;
118  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
119}
120
121.welcome-text {
122  color: rgba(255, 255, 255, 0.9);
123  line-height: 1.6;
124  font-size: 15px;
125}
126
127/* Token Section */
128.token-section {
129  margin-bottom: 28px;
130}
131
132.token-label {
133  font-size: 13px;
134  font-weight: 600;
135  color: rgba(255, 255, 255, 0.85);
136  text-transform: uppercase;
137  letter-spacing: 0.5px;
138  margin-bottom: 10px;
139}
140
141.token-display {
142  display: flex;
143  align-items: center;
144  gap: 12px;
145  background: rgba(255, 255, 255, 0.15);
146  backdrop-filter: blur(10px);
147  padding: 16px 20px;
148  border-radius: 12px;
149  border: 1px solid rgba(255, 255, 255, 0.25);
150  transition: all 0.3s;
151}
152
153.token-display:hover {
154  border-color: rgba(255, 255, 255, 0.4);
155  background: rgba(255, 255, 255, 0.2);
156}
157
158.token-text {
159  flex: 1;
160  font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
161  font-size: 14px;
162  color: white;
163  word-break: break-all;
164  font-weight: 500;
165}
166
167.btn-copy {
168  padding: 8px 16px;
169  background: rgba(255, 255, 255, 0.25);
170  color: white;
171  border: 1px solid rgba(255, 255, 255, 0.3);
172  border-radius: 980px;
173  font-size: 13px;
174  font-weight: 600;
175  cursor: pointer;
176  transition: all 0.3s;
177  flex-shrink: 0;
178}
179
180.btn-copy:hover {
181  background: rgba(255, 255, 255, 0.35);
182  transform: translateY(-2px);
183  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
184}
185
186/* Form Section */
187.form-section {
188  background: rgba(255, 255, 255, 0.1);
189  backdrop-filter: blur(10px);
190  padding: 24px;
191  border-radius: 16px;
192  border: 1px solid rgba(255, 255, 255, 0.2);
193  margin-bottom: 24px;
194}
195
196.form-title {
197  font-size: 16px;
198  font-weight: 600;
199  color: white;
200  margin-bottom: 16px;
201  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
202}
203
204.form-row {
205  display: flex;
206  gap: 12px;
207}
208
209input {
210  flex: 1;
211  padding: 14px 18px;
212  border-radius: 12px;
213  border: 1px solid rgba(255, 255, 255, 0.25);
214  font-size: 15px;
215  font-family: 'SF Mono', Monaco, monospace;
216  transition: all 0.3s;
217  background: rgba(255, 255, 255, 0.15);
218  backdrop-filter: blur(10px);
219  color: white;
220  font-weight: 500;
221}
222
223input::placeholder {
224  color: rgba(255, 255, 255, 0.5);
225}
226
227input:focus {
228  outline: none;
229  border-color: rgba(255, 255, 255, 0.4);
230  background: rgba(255, 255, 255, 0.2);
231  box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.1);
232}
233
234.btn-submit {
235  padding: 14px 28px;
236  border-radius: 980px;
237  border: 1px solid rgba(255, 255, 255, 0.3);
238  background: rgba(255, 255, 255, 0.3);
239  backdrop-filter: blur(10px);
240  color: white;
241  font-weight: 600;
242  font-size: 15px;
243  cursor: pointer;
244  transition: all 0.3s;
245  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
246  flex-shrink: 0;
247  position: relative;
248  overflow: hidden;
249}
250
251.btn-submit::before {
252  content: "";
253  position: absolute;
254  inset: -40%;
255  background: radial-gradient(
256    120% 60% at 0% 0%,
257    rgba(255, 255, 255, 0.35),
258    transparent 60%
259  );
260  opacity: 0;
261  transform: translateX(-30%) translateY(-10%);
262  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
263  pointer-events: none;
264}
265
266.btn-submit:hover {
267  background: rgba(255, 255, 255, 0.4);
268  transform: translateY(-2px);
269  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
270}
271
272.btn-submit:hover::before {
273  opacity: 1;
274  transform: translateX(30%) translateY(10%);
275}
276
277/* Info Box */
278.info-box {
279  background: rgba(255, 255, 255, 0.1);
280  backdrop-filter: blur(10px);
281  padding: 14px 18px;
282  border-radius: 12px;
283  border-left: 3px solid rgba(255, 255, 255, 0.5);
284  margin-bottom: 24px;
285}
286
287.info-box-content {
288  font-size: 14px;
289  color: rgba(255, 255, 255, 0.85);
290  line-height: 1.6;
291}
292
293/* Back Links */
294.back-links {
295  display: flex;
296  flex-direction: column;
297  gap: 10px;
298}
299
300.back-link {
301  display: inline-flex;
302  align-items: center;
303  justify-content: center;
304  gap: 8px;
305  padding: 12px 20px;
306  text-decoration: none;
307  color: white;
308  font-weight: 600;
309  font-size: 14px;
310  background: rgba(255, 255, 255, 0.15);
311  backdrop-filter: blur(10px);
312  border-radius: 980px;
313  transition: all 0.3s;
314  border: 1px solid rgba(255, 255, 255, 0.25);
315}
316
317.back-link:hover {
318  background: rgba(255, 255, 255, 0.25);
319  transform: translateY(-2px);
320  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
321}
322
323/* Responsive */
324@media (max-width: 640px) {
325  body {
326    padding: 16px;
327    align-items: flex-start;
328    padding-top: 40px;
329  }
330
331  .card {
332    padding: 32px 24px;
333  }
334
335  h1 {
336    font-size: 28px;
337  }
338
339  .icon {
340    font-size: 56px;
341  }
342
343  .form-row {
344    flex-direction: column;
345  }
346
347  .btn-submit {
348    width: 100%;
349  }
350
351  .token-display {
352    flex-direction: column;
353    align-items: stretch;
354  }
355
356  .btn-copy {
357    width: 100%;
358  }
359}`;
360
361  const body = `
362  <div class="container">
363    <div class="card">
364      <!-- Header -->
365      <div class="header">
366        <div class="icon">\uD83D\uDD10</div>
367        <h1>Share Token</h1>
368        <div class="username">@${user.username}</div>
369      </div>
370
371      ${
372        savedShareToken
373          ? `
374          <!-- Existing Token View -->
375          <div class="description">
376            Share read-only access to your content.
377          </div>
378
379          <div class="token-section">
380            <div class="token-label">Your Token</div>
381            <div class="token-display">
382              <div class="token-text" id="tokenText">${esc(savedShareToken)}</div>
383              <button class="btn-copy" onclick="copyToken()">Copy</button>
384            </div>
385          </div>
386
387          <div class="info-box">
388            <div class="info-box-content">
389              Change your token anytime to revoke shared URL access.
390            </div>
391          </div>
392
393          <div class="form-section">
394            <div class="form-title">Update Token</div>
395            <form method="POST" action="/api/v1/user/${userId}/shareToken${tokenQS}">
396              <div class="form-row">
397                <input
398                  name="htmlShareToken"
399                  placeholder="Enter new token"
400                  required
401                />
402                <button type="submit" class="btn-submit">Update</button>
403              </div>
404            </form>
405          </div>
406        `
407          : `
408          <!-- First Time View -->
409          <div class="welcome-box">
410            <div class="welcome-title">Create a Share Token</div>
411            <div class="welcome-text">
412              Share read-only access to your trees and notes. Change it anytime to revoke old links.
413            </div>
414          </div>
415
416          <div class="form-section">
417            <div class="form-title">Choose Your Token</div>
418            <form method="POST" action="/api/v1/user/${userId}/shareToken${tokenQS}">
419              <div class="form-row">
420                <input
421                  name="htmlShareToken"
422                  placeholder="Enter a unique token"
423                  required
424                />
425                <button type="submit" class="btn-submit">Create</button>
426              </div>
427            </form>
428          </div>
429        `
430      }
431
432      <div class="back-links">
433        <a class="back-link" href="/api/v1/user/${userId}${tokenQS}">
434          \u2190 Back to Profile
435        </a>
436        <a class="back-link" target="_top" href="/">
437          \u2190 Back to ${new URL(getLandUrl()).hostname}
438        </a>
439      </div>
440    </div>
441  </div>`;
442
443  const js = `
444    function copyToken() {
445      const tokenText = document.getElementById('tokenText').textContent;
446      navigator.clipboard.writeText(tokenText).then(() => {
447        const btn = document.querySelector('.btn-copy');
448        const originalText = btn.textContent;
449        btn.textContent = '\u2713 Copied';
450        setTimeout(() => {
451          btn.textContent = originalText;
452        }, 2000);
453      });
454    }`;
455
456  return page({
457    title: `Share Token \u2014 @${esc(user.username)}`,
458    css,
459    body,
460    js,
461  });
462}
463
1/* ------------------------------------------------- */
2/* Tree Overview page (extracted from root.js)       */
3/* ------------------------------------------------- */
4
5import { page } from "../../html-rendering/html/layout.js";
6import { resolveSlots } from "../slots.js";
7import { escapeHtml, rainbow } from "../../html-rendering/html/utils.js";
8
9export function renderRootOverview({
10  allData,
11  rootMeta,
12  ancestors,
13  isOwner,
14  isDeleted,
15  isRoot,
16  isPublicAccess,
17  queryAvailable,
18  currentUserId,
19  queryString,
20  nodeId,
21  userId,
22  token,
23  deferredItems,
24  ownerConnections,
25  allRootSlots = [],
26}) {
27  const deferredHtml = deferredItems && deferredItems.length > 0
28    ? `<ul class="deferred-list">${deferredItems.map((d) => `<li class="deferred-item"><div class="deferred-content">${escapeHtml(d.content || d.text || JSON.stringify(d.data || ""))}</div><div class="deferred-meta" style="font-size:11px;opacity:0.6;margin-top:4px;">${d.status || "pending"}${d.createdAt ? " . " + new Date(d.createdAt).toLocaleDateString() : ""}</div></li>`).join("")}</ul>`
29    : '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.5);font-size:14px;">No short-term items</div>';
30
31  let rootNameColor = "rgba(255, 255, 255, 0.4)";
32  if (isDeleted) {
33    rootNameColor = "#b00020";
34  }
35
36  const _txMeta = rootMeta?.metadata?.transactions || (rootMeta?.metadata instanceof Map ? rootMeta?.metadata?.get("transactions") : null) || {};
37  const transactionPolicy = _txMeta.policy || "OWNER_ONLY";
38
39  const renderParents = (chain) => {
40    if (!chain || chain.length === 0) return "";
41    if (chain.length === 1 && chain[0].isCurrent) return "";
42
43    let html = '<div class="breadcrumb-constellation">';
44    chain.forEach((node, idx) => {
45      const isLast = idx === chain.length - 1;
46      const color = rainbow[idx % rainbow.length];
47
48      html += `
49        <div class="breadcrumb-node ${isLast ? "current-node" : ""}" data-depth="${idx}">
50          <div class="node-connector" style="background: linear-gradient(90deg, ${rainbow[(idx - 1 + rainbow.length) % rainbow.length]}, ${color});"></div>
51          <div class="node-bubble" style="border-color: ${color}; box-shadow: 0 0 20px ${color}40;" data-node-id="${node._id}" data-is-current="${node.isCurrent ? "true" : "false"}">
52            ${
53              node.isCurrent
54                ? `<a href="/api/v1/node/${node._id}${queryString}" class="node-link current">
55                    <span class="node-icon">●</span>
56                    <span class="node-name">${escapeHtml(node.name)}</span>
57                    <span class="node-badge">YOU ARE HERE</span>
58                  </a>`
59                : `<a href="/api/v1/root/${node._id}${queryString}" class="node-link">
60                    <span class="node-icon">○</span>
61                    <span class="node-name">${escapeHtml(node.name)}</span>
62                    <span class="depth-badge">Level ${idx + 1}</span>
63                  </a>`
64            }
65          </div>
66        </div>
67      `;
68    });
69    html += "</div>";
70    return html;
71  };
72
73  const renderTree = (node, depth = 0) => {
74    const color = rainbow[depth % rainbow.length];
75    let html = `
76    <li
77      class="tree-node"
78        data-node-id="${node._id}"
79
80      style="
81        border-left: 4px solid ${color};
82        padding-left: 12px;
83        margin: 6px 0;
84      "
85    >
86
87
88      <a href="/api/v1/node/${node._id}/${0}${queryString}">
89        ${escapeHtml(node.name)}
90      </a>
91  `;
92    if (node.children && node.children.length > 0) {
93      html += `<ul>`;
94      for (const c of node.children) {
95        html += renderTree(c, depth + 1);
96      }
97      html += `</ul>`;
98    }
99    html += `</li>`;
100    return html;
101  };
102
103  const inviteFormHtml = isOwner
104    ? `
105<form
106  method="POST"
107  action="/api/v1/root/${nodeId}/invite?token=${encodeURIComponent(token)}&html"
108  style="display:flex; gap:8px; max-width:420px; margin-top:12px;"
109>
110  <input
111    type="text"
112    name="userReceiving"
113    placeholder="username or user@other.land.com"
114    required
115  />
116
117  <button type="submit">
118    Invite
119  </button>
120</form>
121<div style="font-size:11px; color:rgba(255,255,255,0.5); margin-top:4px;">
122  Use username@domain to invite someone from another land.
123</div>
124`
125    : ``;
126
127  const policyHtml = isOwner
128    ? `
129
130<form
131  method="POST"
132  action="/api/v1/root/${nodeId}/transaction-policy?token=${encodeURIComponent(token)}&html"
133  style="max-width: 420px;"
134>
135  <select
136    name="policy"
137    style="
138      width:100%;
139      padding:10px;
140      border-radius:8px;
141      border:1px solid #ccc;
142      font-size:14px;
143    "
144  >
145    <option value="OWNER_ONLY" ${
146      transactionPolicy === "OWNER_ONLY" ? "selected" : ""
147    }>
148      Owner only
149    </option>
150    <option value="ANYONE" ${transactionPolicy === "ANYONE" ? "selected" : ""}>
151      Anyone (single approval)
152    </option>
153    <option value="MAJORITY" ${
154      transactionPolicy === "MAJORITY" ? "selected" : ""
155    }>
156      Majority of root members
157    </option>
158    <option value="ALL" ${transactionPolicy === "ALL" ? "selected" : ""}>
159      All root members
160    </option>
161  </select>
162
163  <button type="submit" style="margin-top:12px;">
164    Update Policy
165  </button>
166</form>
167`
168    : ``;
169
170  const ownerHtml = rootMeta?.rootOwner
171    ? `<ul class="contributors-list">
172  <li>
173    <a href="/api/v1/user/${rootMeta.rootOwner._id}${queryString}">
174      ${escapeHtml(rootMeta.rootOwner.username)}
175    </a>
176    <span style="font-size:12px;opacity:0.7;color:white;">Owner</span>
177  </li>
178</ul>`
179    : ``;
180
181  const contributorsHtml = rootMeta?.contributors?.length
182    ? `
183<ul class="contributors-list">
184${rootMeta.contributors
185  .map((u) => {
186    const isSelf = u._id.toString() === userId?.toString();
187
188    return `
189<li>
190<a href="/api/v1/user/${u._id}${queryString}">
191  ${escapeHtml(u.username)}
192</a>
193${u.isRemote ? '<span style="font-size:11px;opacity:0.5;color:white;">(remote)</span>' : ""}
194  <div class="contributors-actions">
195    ${
196      isOwner
197        ? `
198      <form
199        method="POST"
200        action="/api/v1/root/${nodeId}/transfer-owner?token=${encodeURIComponent(token)}&html"
201onsubmit="return confirm('Transfer ownership to ${escapeHtml(u.username)}?')"
202      >
203        <input type="hidden" name="userReceiving" value="${u._id}" />
204        <button type="submit">Transfer</button>
205      </form>
206      `
207        : ""
208    }
209
210    ${
211      isOwner || isSelf
212        ? `
213      <form
214        method="POST"
215        action="/api/v1/root/${nodeId}/remove-user?token=${encodeURIComponent(token)}&html"
216        onsubmit="return confirm('${
217          isSelf ? "Leave this root?" : `Remove ${escapeHtml(u.username)} from this root?`
218        }')"
219      >
220        <input type="hidden" name="userReceiving" value="${u._id}" />
221        <button type="submit">
222          ${isSelf ? "Leave" : "Remove"}
223        </button>
224      </form>
225      `
226        : ""
227    }
228  </div>
229</li>
230`;
231  })
232  .join("")}
233</ul>
234`
235    : ``;
236
237  const retireHtml = isOwner
238    ? `
239<form
240  method="POST"
241  action="/api/v1/root/${nodeId}/retire?token=${encodeURIComponent(token)}&html"
242  onsubmit="return confirm('This will retire the root. Continue?')"
243  style="margin-top:12px;"
244>
245  <button
246    type="submit"
247    style="
248      padding:8px 14px;
249      border-radius:8px;
250      border:1px solid rgba(239, 68, 68, 0.5);
251      background:rgba(239, 68, 68, 0.25);
252      color:white;
253      font-weight:600;
254      cursor:pointer;
255    "
256  >
257    Retire
258  </button>
259</form>
260`
261    : "";
262
263  // Tree AI Model section
264  let treeLlmHtml = "";
265  if (isOwner && rootMeta?.rootOwner && ownerConnections) {
266    const ownerProfile = rootMeta.rootOwner;
267    const llmSlots = [
268      { key: "default", label: "Default", isDefault: true },
269      ...allRootSlots
270        .filter(s => s !== "default")
271        .map(s => ({ key: s, label: s.charAt(0).toUpperCase() + s.slice(1) })),
272    ];
273
274    function buildSlotHtml(slot) {
275      // Read from llmDefault for "default" slot, metadata.llm.slots for extension slots
276      const current = slot.key === "default"
277        ? (rootMeta.llmDefault || null)
278        : (rootMeta.metadata?.llm?.slots?.[slot.key] || (rootMeta.metadata instanceof Map ? rootMeta.metadata.get("llm")?.slots?.[slot.key] : null) || null);
279      const optHtml = ownerConnections.map(function(c) {
280        return '<div class="custom-select-option' + (current === c._id ? ' selected' : '') + '" data-value="' + c._id + '">'
281          + escapeHtml(c.name) + ' (' + escapeHtml(c.model) + ')</div>';
282      }).join('');
283      let label;
284      if (current === "none") {
285        label = 'Off (no AI)';
286      } else if (current) {
287        const m = ownerConnections.find(function(c){return c._id === current;});
288        label = m ? escapeHtml(m.name) + ' (' + escapeHtml(m.model) + ')' : 'Account default';
289      } else {
290        label = slot.isDefault ? 'Account default' : 'Use default';
291      }
292      return `<p style="font-size:0.85em;opacity:0.6;margin-bottom:4px;margin-top:10px;">${slot.label}</p>
293  <div class="custom-select" data-slot="${slot.key}" style="margin-bottom:4px;">
294    <div class="custom-select-trigger">${label}</div>
295    <div class="custom-select-options">
296      <div class="custom-select-option${!current ? ' selected' : ''}" data-value="">${slot.isDefault ? 'Account default' : 'Use default'}</div>
297      ${optHtml}
298      ${slot.isDefault ? '<div class="custom-select-option' + (current === "none" ? ' selected' : '') + '" data-value="none" style="color:rgba(255,107,107,0.8);">Off (no AI)</div>' : ''}
299    </div>
300  </div>`;
301    }
302
303    treeLlmHtml = `
304<h3>AI Models</h3>
305<p style="font-size:0.85em;opacity:0.5;margin-bottom:8px;">Set a default LLM for the tree. All modes fall back to this. Per-mode overrides below. Set default to "Off" to disable AI entirely.</p>
306${ownerConnections.length === 0
307  ? '<p style="font-size:0.85em;opacity:0.5;">No custom connections -- <a href="/api/v1/user/${ownerProfile._id}${queryString ? queryString + "&" : "?"}html" style="color:inherit;">add one on your profile</a></p>'
308  : llmSlots.map(buildSlotHtml).join('\n') + '\n  <div class="llm-assign-status" style="font-size:0.8em;margin-top:4px;display:none;"></div>'
309}`;
310  }
311
312  const parentHtml = ancestors.length
313    ? renderParents([
314        ...ancestors.slice().reverse(),
315        {
316          _id: allData._id,
317          name: allData.name,
318          isCurrent: true,
319        },
320      ])
321    : ``;
322
323  const childrenInner = allData.children?.length
324    ? `<ul>${allData.children.map((c) => renderTree(c)).join("")}</ul>`
325    : ``;
326
327  const treeHtml = `
328      <ul class="tree-root" style="padding-left:0;">
329        <li class="tree-node root-entry"
330            data-node-id="${allData._id}"
331            style="border-left: 4px solid ${rootNameColor}; padding-left: 6px; margin: 6px 0;">
332          <a href="/api/v1/node/${allData._id}/0${queryString}">
333            ${escapeHtml(allData.name)}
334          </a>
335          ${childrenInner}
336        </li>
337      </ul>`;
338
339  const css = `
340    .current {
341    color: rgb(51, 66, 85);}
342
343    /* Glass Content Cards */
344    .content-card {
345      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
346      backdrop-filter: blur(22px) saturate(140%);
347      -webkit-backdrop-filter: blur(22px) saturate(140%);
348      border-radius: 16px;
349      padding: 28px;
350      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
351        inset 0 1px 0 rgba(255, 255, 255, 0.25);
352      border: 1px solid rgba(255, 255, 255, 0.28);
353      margin-bottom: 24px;
354      animation: fadeInUp 0.6s ease-out;
355      animation-fill-mode: both;
356       position: relative;
357    }
358
359    .content-card:nth-child(2) { animation-delay: 0.1s; }
360    .content-card:nth-child(3) { animation-delay: 0.15s; }
361    .content-card:nth-child(4) { animation-delay: 0.2s; }
362    .content-card:nth-child(5) { animation-delay: 0.25s; }
363
364
365
366  .content-card::before {
367  content: "";
368  position: absolute;
369  inset: 0;
370  border-radius: inherit;
371
372  background:
373    linear-gradient(
374      180deg,
375      rgba(255,255,255,0.18),
376      rgba(255,255,255,0.05)
377    );
378
379  pointer-events: none;
380}
381
382    /* Header Section */
383    .header-section {
384      margin-bottom: 24px;
385      padding-bottom: 20px;
386      border-bottom: 1px solid rgba(255, 255, 255, 0.2);
387    }
388
389    .section-header {
390      margin-bottom: 20px;
391      padding-bottom: 12px;
392      border-bottom: 1px solid rgba(255, 255, 255, 0.2);
393    }
394
395    .section-header h2 {
396      margin: 0;
397      font-size: 20px;
398      font-weight: 600;
399      color: white;
400      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
401      letter-spacing: -0.3px;
402    }
403
404    .section-header h2 a {
405      color: white;
406      text-decoration: none;
407      transition: all 0.2s;
408    }
409
410    .section-header h2 a:hover {
411      text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
412    }
413
414    /* ==========================================
415       CONSTELLATION BREADCRUMB NAVIGATION
416       ========================================== */
417
418.breadcrumb-constellation {
419  display: flex;
420  align-items: center;
421  gap: 0;
422  padding: 24px 16px;
423  overflow-x: auto;
424  overflow-y: hidden;
425  position: relative;
426  min-height: 100px;
427
428  /* Scrollbar styling */
429  scrollbar-width: thin;
430  scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
431}
432
433    .breadcrumb-constellation::-webkit-scrollbar {
434      height: 6px;
435    }
436
437    .breadcrumb-constellation::-webkit-scrollbar-track {
438      background: rgba(255, 255, 255, 0.1);
439      border-radius: 3px;
440    }
441
442    .breadcrumb-constellation::-webkit-scrollbar-thumb {
443      background: rgba(255, 255, 255, 0.3);
444      border-radius: 3px;
445    }
446
447    .breadcrumb-node {
448      display: flex;
449      align-items: center;
450      flex-shrink: 0;
451      animation: nodeSlideIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
452      animation-fill-mode: both;
453    }
454
455    .breadcrumb-node:nth-child(1) { animation-delay: 0s; }
456    .breadcrumb-node:nth-child(2) { animation-delay: 0.1s; }
457    .breadcrumb-node:nth-child(3) { animation-delay: 0.2s; }
458    .breadcrumb-node:nth-child(4) { animation-delay: 0.3s; }
459    .breadcrumb-node:nth-child(5) { animation-delay: 0.4s; }
460    .breadcrumb-node:nth-child(6) { animation-delay: 0.5s; }
461    .breadcrumb-node:nth-child(n+7) { animation-delay: 0.6s; }
462
463    @keyframes nodeSlideIn {
464      from {
465        opacity: 0;
466        transform: translateX(-30px) scale(0.8);
467      }
468      to {
469        opacity: 1;
470        transform: translateX(0) scale(1);
471      }
472    }
473
474    .node-connector {
475      width: 40px;
476      height: 3px;
477      border-radius: 2px;
478      position: relative;
479      overflow: hidden;
480    }
481
482    .node-connector::after {
483      content: '';
484      position: absolute;
485      top: 0;
486      left: -100%;
487      width: 100%;
488      height: 100%;
489      background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.6), transparent);
490      animation: shimmer 2s infinite;
491    }
492
493    @keyframes shimmer {
494      to {
495        left: 100%;
496      }
497    }
498
499    .breadcrumb-node:first-child .node-connector {
500      display: none;
501    }
502
503    .node-bubble {
504      background: rgba(255, 255, 255, 0.15);
505      backdrop-filter: blur(10px);
506      border: 2px solid;
507      border-radius: 12px;
508      padding: 12px 18px;
509      position: relative;
510      transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
511      cursor: pointer;
512    }
513
514    .bubble-scroll-zone {
515      position: absolute;
516      left: 0;
517      top: 0;
518      bottom: 0;
519      width: 35%;
520      z-index: 10;
521      cursor: pointer;
522    }
523
524    .bubble-scroll-zone:hover {
525      background: rgba(255, 255, 255, 0.1);
526      border-radius: 12px 0 0 12px;
527    }
528
529    .node-bubble:hover {
530      transform: scale(1.05) translateY(-3px);
531      background: rgba(255, 255, 255, 0.25);
532      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
533    }
534
535    .current-node .node-bubble {
536      background: rgba(255, 255, 255, 0.3);
537      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2),
538                  inset 0 0 30px rgba(255, 255, 255, 0.3);
539      animation: pulse 2s infinite;
540    }
541
542    @keyframes pulse {
543      0%, 100% {
544        box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2),
545                    inset 0 0 30px rgba(255, 255, 255, 0.3);
546      }
547      50% {
548        box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3),
549                    inset 0 0 40px rgba(255, 255, 255, 0.5);
550      }
551    }
552
553    .node-link {
554      display: flex;
555      align-items: center;
556      gap: 10px;
557      color: white;
558      text-decoration: none;
559      font-weight: 600;
560      font-size: 14px;
561      white-space: nowrap;
562      transition: all 0.2s;
563    }
564
565    .node-link:hover {
566      text-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
567    }
568
569    .node-link.current {
570      font-weight: 700;
571      font-size: 15px;
572    }
573
574    .node-icon {
575      font-size: 20px;
576      line-height: 1;
577      transition: transform 0.3s;
578    }
579
580    .node-link:hover .node-icon {
581      transform: scale(1.3);
582    }
583
584    .current-node .node-icon {
585      animation: glow 2s infinite;
586    }
587
588    @keyframes glow {
589      0%, 100% {
590        filter: drop-shadow(0 0 3px rgba(255, 255, 255, 0.6));
591      }
592      50% {
593        filter: drop-shadow(0 0 8px rgba(255, 255, 255, 1));
594      }
595    }
596
597    .node-name {
598      max-width: 150px;
599      overflow: hidden;
600      text-overflow: ellipsis;
601    }
602
603    .node-badge {
604      background: rgba(255, 255, 255, 0.3);
605      padding: 2px 8px;
606      border-radius: 8px;
607      font-size: 9px;
608      font-weight: 700;
609      letter-spacing: 0.5px;
610      text-transform: uppercase;
611      border: 1px solid rgba(255, 255, 255, 0.4);
612    }
613
614    .depth-badge {
615      background: rgba(0, 0, 0, 0.2);
616      padding: 2px 6px;
617      border-radius: 6px;
618      font-size: 10px;
619      font-weight: 600;
620      opacity: 0.7;
621    }
622
623    /* Mobile optimization */
624    @media (max-width: 640px) {
625      .breadcrumb-constellation {
626        padding: 16px 8px;
627        min-height: 80px;
628      }
629
630      .node-connector {
631        width: 24px;
632      }
633
634      .node-bubble {
635        padding: 8px 12px;
636      }
637
638      .node-link {
639        font-size: 12px;
640        gap: 6px;
641      }
642
643      .node-icon {
644        font-size: 16px;
645      }
646
647      .node-name {
648        max-width: 100px;
649      }
650
651      .node-badge {
652        font-size: 8px;
653        padding: 1px 6px;
654      }
655    }
656
657    /* Glass Action Buttons */
658   .action-button {
659  background: rgba(255,255,255,0.22);
660  border: 1px solid rgba(255,255,255,0.28);
661  box-shadow:
662    inset 0 1px 0 rgba(255,255,255,0.35),
663    0 4px 12px rgba(0,0,0,0.12);
664}
665
666
667    .action-button::before {
668      content: "";
669      position: absolute;
670      inset: -40%;
671      background: radial-gradient(
672        120% 60% at 0% 0%,
673        rgba(255, 255, 255, 0.35),
674        transparent 60%
675      );
676      opacity: 0;
677      transform: translateX(-30%) translateY(-10%);
678      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
679      pointer-events: none;
680    }
681
682    .action-button:hover {
683      background: rgba(255, 255, 255, 0.35);
684      transform: translateY(-2px);
685    }
686
687    .action-button:hover::before {
688      opacity: 1;
689      transform: translateX(30%) translateY(10%);
690    }
691
692    .owner-info {
693      font-size: 14px;
694      color: white;
695      font-weight: 600;
696      margin-bottom: 8px;
697      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
698    }
699
700    .owner-info a {
701      color: white;
702      text-decoration: none;
703      transition: all 0.2s;
704      border-bottom: 1px solid rgba(255, 255, 255, 0.3);
705    }
706
707    .owner-info a:hover {
708      border-bottom-color: white;
709      text-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
710    }
711
712    h1 {
713      font-size: 28px;
714      margin: 12px 0;
715      font-weight: 600;
716      line-height: 1.3;
717      letter-spacing: -0.5px;
718    }
719
720    h1 a {
721      color: white;
722      text-decoration: none;
723      transition: all 0.2s;
724      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
725    }
726
727    h1 a:hover {
728      text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
729      transform: translateX(4px);
730      display: inline-block;
731    }
732
733    code {
734      background: transparent;
735      padding: 0;
736      border-radius: 0;
737      font-size: 13px;
738      font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
739      color: white;
740      word-break: break-all;
741    }
742
743    /* Section Headers */
744    h2 {
745      font-size: 18px;
746      margin: 24px 0 16px 0;
747      font-weight: 600;
748      color: white;
749      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
750      letter-spacing: -0.3px;
751    }
752
753    h3 {
754      font-size: 16px;
755      margin: 20px 0 12px 0;
756      font-weight: 600;
757      color: white;
758      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
759    }
760
761    /* Filter Buttons */
762    #filterButtons {
763      display: flex;
764      flex-wrap: wrap;
765      gap: 8px;
766      margin: 16px 0;
767    }
768
769    #filterButtons a {
770      display: inline-flex;
771      align-items: center;
772      padding: 8px 16px;
773      font-size: 13px;
774      border-radius: 980px;
775      color: white;
776      font-weight: 600;
777transition:
778  transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
779  background-color 0.3s ease,
780  opacity 0.3s ease;         text-decoration: none;
781      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
782      border: 1px solid rgba(255, 255, 255, 0.3);
783      position: relative;
784      overflow: hidden;
785    }
786
787    #filterButtons a::before {
788      content: "";
789      position: absolute;
790      inset: -40%;
791      background: radial-gradient(
792        120% 60% at 0% 0%,
793        rgba(255, 255, 255, 0.35),
794        transparent 60%
795      );
796      opacity: 0;
797      transform: translateX(-30%) translateY(-10%);
798      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
799      pointer-events: none;
800    }
801
802    #filterButtons a:hover {
803      transform: translateY(-2px);
804    }
805
806    #filterButtons a:hover::before {
807      opacity: 1;
808      transform: translateX(30%) translateY(10%);
809    }
810
811    /* Tree Structure - Keep rainbow colors */
812    ul {
813      list-style: none;
814      padding-left: 16px;
815      margin: 12px 0;
816    }
817
818    li {
819      margin: 8px 0;
820      word-wrap: break-word;
821      overflow-wrap: break-word;
822    }
823
824    li a {
825      color: white;
826      text-decoration: none;
827      font-weight: 500;
828      transition: all 0.2s;
829      position: relative;
830      display: inline-block;
831    }
832
833    li a:hover {
834      text-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
835      transform: translateX(4px);
836    }
837
838    /* Parents/Children with colored borders - keep the rainbow */
839    li[style*="border-left"] {
840      padding-left: 12px !important;
841      margin: 6px 0 !important;
842      position: relative;
843      background: rgba(255, 255, 255, 0.05);
844      border-radius: 6px;
845      padding: 8px 12px !important;
846      transition: all 0.2s;
847    }
848
849    li[style*="border-left"]:hover {
850      background: rgba(255, 255, 255, 0.1);
851      transform: translateX(4px);
852    }
853
854    /* Glass Forms */
855    form {
856      margin: 16px 0;
857    }
858
859    input[type="text"],
860    select {
861      width: 100%;
862      padding: 12px 14px;
863      font-size: 15px;
864      border-radius: 10px;
865      border: 2px solid rgba(255, 255, 255, 0.3);
866      background: rgba(255, 255, 255, 0.15);
867      font-family: inherit;
868transition:
869  transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
870  background-color 0.3s ease,
871  opacity 0.3s ease;
872        color: white;
873      font-weight: 500;
874    }
875
876    input[type="text"]::placeholder {
877      color: rgba(255, 255, 255, 0.5);
878    }
879
880    input[type="text"]:focus,
881    select:focus {
882      outline: none;
883      border-color: rgba(255, 255, 255, 0.6);
884      background: rgba(255, 255, 255, 0.25);
885      box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
886      transform: translateY(-2px);
887    }
888
889    select option {
890      background: #1a1145;
891      color: white;
892    }
893
894    button[type="submit"] {
895      padding: 10px 18px;
896      border-radius: 980px;
897      border: 1px solid rgba(255, 255, 255, 0.3);
898      background: rgba(255, 255, 255, 0.25);
899      color: white;
900      cursor: pointer;
901      font-weight: 600;
902      font-size: 14px;
903transition:
904  transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
905  background-color 0.3s ease,
906  opacity 0.3s ease;
907        font-family: inherit;
908      position: relative;
909      overflow: hidden;
910      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
911    }
912
913    button[type="submit"]::before {
914      content: "";
915      position: absolute;
916      inset: -40%;
917
918      opacity: 0;
919      transform: translateX(-30%) translateY(-10%);
920      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
921      pointer-events: none;
922    }
923
924    button[type="submit"]:hover {
925      background: rgba(255, 255, 255, 0.35);
926      transform: translateY(-1px);
927    }
928
929    button[type="submit"]:hover::before {
930      opacity: 1;
931      transform: translateX(30%) translateY(10%);
932    }
933
934    /* Invite Form */
935    form[action*="/invite"] {
936      display: flex;
937      flex-direction: column;
938      gap: 10px;
939      max-width: 100%;
940    }
941
942    @media (min-width: 640px) {
943      form[action*="/invite"] {
944        flex-direction: row;
945        max-width: 500px;
946      }
947
948      form[action*="/invite"] input[type="text"] {
949        flex: 1;
950      }
951
952      form[action*="/invite"] button {
953        width: auto;
954      }
955    }
956
957    /* Contributors - Glass List Items */
958    .contributors-list {
959      list-style: none;
960      padding-left: 0;
961    }
962
963    .contributors-list li {
964      display: flex;
965      flex-direction: column;
966      gap: 8px;
967      padding: 14px 16px;
968      background: rgba(255, 255, 255, 0.12);
969      border-radius: 10px;
970      margin: 8px 0;
971      border: 1px solid rgba(255, 255, 255, 0.25);
972transition:
973  transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
974  background-color 0.3s ease,
975  opacity 0.3s ease;
976      }
977
978    .contributors-list li:hover {
979      background: rgba(255, 255, 255, 0.18);
980      transform: translateX(4px);
981    }
982
983    @media (min-width: 640px) {
984      .contributors-list li {
985        flex-direction: row;
986        align-items: center;
987        justify-content: space-between;
988      }
989    }
990
991    .contributors-list a {
992      font-weight: 600;
993      color: white;
994      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
995    }
996
997    .contributors-list form {
998      display: inline-block;
999      margin: 0;
1000    }
1001
1002    .contributors-list button {
1003      padding: 6px 12px;
1004      font-size: 13px;
1005      border-radius: 980px;
1006      border: 1px solid rgba(255, 255, 255, 0.3);
1007      cursor: pointer;
1008      transition: all 0.2s;
1009    }
1010
1011    .contributors-list button:hover {
1012      transform: translateY(-1px);
1013    }
1014
1015    .contributors-actions {
1016      display: flex;
1017      gap: 8px;
1018      flex-wrap: wrap;
1019    }
1020
1021    /* Retire Button */
1022    form[action*="/retire"] button {
1023      background: rgba(239, 68, 68, 0.3) !important;
1024      border: 1px solid rgba(239, 68, 68, 0.5) !important;
1025    }
1026
1027    form[action*="/retire"] button:hover {
1028      background: rgba(239, 68, 68, 0.5) !important;
1029      transform: translateY(-2px);
1030    }
1031
1032.glass-shadow {
1033  position: absolute;
1034  inset: 0;
1035  border-radius: inherit;
1036  box-shadow: 0 12px 32px rgba(0,0,0,0.18);
1037  opacity: 0;
1038  transition: opacity 0.3s ease;
1039  pointer-events: none;
1040}
1041  :hover > .glass-shadow {
1042  opacity: 1;
1043}
1044
1045    /* Responsive Design */
1046    @media (max-width: 640px) {
1047  ul {
1048    padding-left: 6px;
1049  }
1050}
1051  @media (max-width: 640px) {
1052  .tree-node {
1053    padding: 6px 8px;
1054  }
1055}
1056  @media (max-width: 640px) {
1057  .tree-node {
1058    margin-left: 0;
1059  }
1060
1061  li[style*="border-left"] {
1062    padding-left: 8px !important;
1063  }
1064}
1065@media (max-width: 640px) {
1066  .tree-node,
1067  .tree-node:hover,
1068  li[style*="border-left"],
1069  li[style*="border-left"]:hover {
1070    transform: none !important;
1071  }
1072}
1073@media (max-width: 640px) {
1074  li[style*="border-left"] {
1075    padding-left: 8px !important;
1076    margin-left: 0 !important;
1077  }
1078}
1079@media (hover: none) {
1080  .tree-node:hover::before {
1081    opacity: 0;
1082    transform: none;
1083  }
1084}
1085
1086    @media (max-width: 640px) {
1087      .content-card {
1088        padding: 20px;
1089      }
1090
1091      h1 {
1092        font-size: 24px;
1093      }
1094
1095      ul {
1096        padding-left: 8px;
1097      }
1098    }
1099
1100.tree-node {
1101  position: relative;
1102  padding: 8px 12px;
1103  border-radius: 8px;
1104  background: rgba(255, 255, 255, 0.12);
1105  border: 1px solid rgba(255, 255, 255, 0.22);
1106  box-shadow:
1107    inset 0 1px 0 rgba(255, 255, 255, 0.35),
1108    0 4px 12px rgba(0, 0, 0, 0.12);
1109
1110  transition:
1111    transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
1112    background-color 0.25s ease,
1113    box-shadow 0.25s ease;
1114}
1115
1116
1117
1118  /* Custom dropdown (replaces native <select> to avoid iframe glitch on mobile) */
1119  .custom-select { position: relative; width: 100%; max-width: 360px; }
1120  .custom-select-trigger {
1121    padding: 12px 14px; font-size: 15px; border-radius: 10px;
1122    border: 2px solid rgba(255, 255, 255, 0.3);
1123    background: rgba(255, 255, 255, 0.15); color: white;
1124    cursor: pointer; display: flex; align-items: center;
1125    justify-content: space-between; gap: 8px;
1126    font-weight: 500;
1127    -webkit-user-select: none; user-select: none;
1128    transition: all 0.3s ease;
1129  }
1130  .custom-select-trigger::after { content: "\u25BE"; font-size: 11px; opacity: 0.6; flex-shrink: 0; }
1131  .custom-select.open .custom-select-trigger {
1132    border-color: rgba(255, 255, 255, 0.6);
1133    background: rgba(255, 255, 255, 0.25);
1134    box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
1135  }
1136  .custom-select.open .custom-select-trigger::after { content: "\u25B4"; }
1137  .custom-select-options {
1138    display: none; position: absolute; left: 0; right: 0;
1139    bottom: calc(100% + 4px);
1140    background: rgba(102, 126, 234, 0.95);
1141    backdrop-filter: blur(12px);
1142    border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 10px;
1143    overflow: hidden; z-index: 100; max-height: 220px; overflow-y: auto;
1144    box-shadow: 0 -4px 16px rgba(0,0,0,0.2);
1145  }
1146  .custom-select.open .custom-select-options { display: block; }
1147  .custom-select-option {
1148    padding: 10px 14px; font-size: 14px; color: rgba(255, 255, 255, 0.85);
1149    cursor: pointer; transition: background 0.15s;
1150  }
1151  .custom-select-option:hover { background: rgba(255, 255, 255, 0.15); }
1152  .custom-select-option.selected { background: rgba(255, 255, 255, 0.2); color: white; font-weight: 600; }
1153
1154  /* Root entry in tree */
1155  .root-entry {
1156    background: rgba(255, 255, 255, 0.18) !important;
1157    border: 1px solid rgba(255, 255, 255, 0.30);
1158    border-left: 4px solid !important;
1159  }
1160  .root-entry > a {
1161    font-weight: 700;
1162    font-size: 16px;
1163  }
1164
1165  /* Settings groups inside ownership card */
1166  .settings-group {
1167    padding: 16px 0;
1168    border-bottom: 1px solid rgba(255, 255, 255, 0.15);
1169  }
1170  .settings-group:last-child {
1171    border-bottom: none;
1172    padding-bottom: 0;
1173  }
1174  .settings-group:first-child {
1175    padding-top: 0;
1176  }
1177  .settings-group h3 {
1178    margin-top: 0;
1179    margin-bottom: 12px;
1180    font-size: 15px;
1181    opacity: 0.85;
1182  }
1183  .settings-group h2 {
1184    margin-top: 0;
1185  }
1186`;
1187
1188  const body = `
1189  <div class="container">
1190    ${
1191      currentUserId
1192        ? `
1193    <!-- Back Navigation -->
1194    <div class="back-nav">
1195      <a href="/api/v1/user/${currentUserId}${queryString}" class="back-link">
1196        <- Back to Profile
1197      </a>
1198      <a href="/api/v1/root/${allData._id}/llm${queryString}" class="back-link">
1199        LLM
1200      </a>
1201      <a href="/api/v1/node/${allData._id}/metadata${queryString}" class="back-link">
1202        Metadata
1203      </a>
1204      ${resolveSlots("tree-quick-links", { rootId: allData._id, nodeId: allData._id, userId: currentUserId, queryString })}
1205    </div>
1206    `
1207        : ""
1208    }
1209    <!-- Navigation Path (only if not root) -->
1210    ${
1211      ancestors.length
1212        ? `
1213    <div class="content-card">
1214      <div class="section-header">
1215        <h2>Navigation Path</h2>
1216      </div>
1217      ${parentHtml}
1218    </div>
1219    `
1220        : ""
1221    }
1222
1223    <!-- Tree Card (root + children unified) -->
1224    <div class="content-card">
1225      <div class="section-header">
1226        <h2>Tree: <a href="/api/v1/node/${allData._id}/0${queryString}">${escapeHtml(allData.name)}</a></h2>
1227      </div>
1228      <div id="filterButtons"></div>
1229      ${treeHtml}
1230    </div>
1231
1232    ${isPublicAccess ? `
1233    <div style="text-align:center;padding:16px 20px;background:rgba(72,187,120,0.1);border:1px solid rgba(72,187,120,0.25);border-radius:12px;margin:16px 0;color:rgba(255,255,255,0.8);font-size:0.9rem;">
1234      Viewing public tree${queryAvailable ? ". You can query this tree using the API." : "."}
1235    </div>
1236    ` : ""}
1237
1238    ${!isPublicAccess ? resolveSlots("tree-holdings", { rootId: nodeId, nodeId, queryString, token, userId, deferredItems, deferredHtml }) : ""}
1239
1240    <!-- Tree Settings Section -->
1241${
1242  !isPublicAccess && (isOwner ||
1243  rootMeta?.contributors?.some(
1244    (c) => c._id.toString() === userId?.toString(),
1245  ))
1246    ? `
1247
1248  ${isOwner ? `
1249<div class="content-card">
1250  <div class="section-header">
1251    <h2>Visibility</h2>
1252  </div>
1253  <p style="color:rgba(255,255,255,0.7);font-size:0.85rem;margin:0 0 12px">
1254    Public trees can be browsed and queried by anyone without authentication.
1255    If an LLM is assigned to the placement slot, anonymous visitors can query the tree (you pay energy).
1256  </p>
1257  <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
1258    <select id="visibilitySelect"
1259      style="padding:8px 12px;border-radius:8px;border:1px solid rgba(255,255,255,0.15);
1260             background:rgba(255,255,255,0.06);color:#fff;font-size:0.95rem;min-width:140px">
1261      <option value="private" ${rootMeta.visibility || "private" === "private" ? "selected" : ""}>Private</option>
1262      <option value="public" ${rootMeta.visibility === "public" ? "selected" : ""}>Public</option>
1263    </select>
1264    <button onclick="saveVisibility()" style="padding:8px 14px;border-radius:8px;
1265      border:1px solid rgba(72,187,120,0.4);background:rgba(72,187,120,0.15);
1266      color:rgba(72,187,120,0.9);font-weight:600;cursor:pointer">Save</button>
1267    <span id="visibilityStatus" style="display:none;font-size:0.85rem"></span>
1268  </div>
1269</div>
1270
1271  ${resolveSlots("tree-dream", { rootId: nodeId, nodeId, queryString, token, userId, rootMeta })}
1272  ` : ""}
1273
1274  ${resolveSlots("tree-team", { rootId: nodeId, nodeId, queryString, token, userId, isOwner, rootMeta, ownerHtml, contributorsHtml, inviteFormHtml })}
1275
1276  ${resolveSlots("tree-transaction-policy", { rootId: nodeId, nodeId, queryString, token, userId, isOwner, policyHtml })}
1277
1278
1279  ${isOwner ? resolveSlots("tree-owner-sections", { rootId: nodeId, nodeId, queryString, token, userId }) : ""}
1280
1281  ${
1282    !isOwner && userId
1283      ? `
1284<div class="content-card">
1285  <div class="section-header">
1286    <h2>Leave Tree</h2>
1287  </div>
1288  <form
1289    method="POST"
1290    action="/api/v1/root/${nodeId}/remove-user?token=${encodeURIComponent(token)}&html"
1291    onsubmit="return confirm('Are you sure you want to leave this tree?')"
1292  >
1293    <input type="hidden" name="userReceiving" value="${userId}" />
1294    <button
1295      type="submit"
1296      style="
1297        padding:8px 14px;
1298        border-radius:8px;
1299        border:1px solid #900;
1300        background:rgba(239, 68, 68, 0.15);
1301        color:#ff6b6b;
1302        font-weight:600;
1303        cursor:pointer;
1304      "
1305    >
1306      Leave Tree
1307    </button>
1308  </form>
1309</div>
1310  `
1311      : ""
1312  }
1313
1314  ${
1315    retireHtml
1316      ? `
1317<div class="content-card">
1318  <div class="section-header">
1319    <h2>Retire Tree</h2>
1320  </div>
1321  ${retireHtml}
1322</div>
1323  `
1324      : ""
1325  }
1326`
1327    : ""
1328}
1329
1330  </div>
1331`;
1332
1333  const js = `
1334// VISIBILITY
1335async function saveVisibility() {
1336  var select = document.getElementById("visibilitySelect");
1337  var status = document.getElementById("visibilityStatus");
1338  if (!select) return;
1339  try {
1340    var res = await fetch("/api/v1/root/${nodeId}/visibility", {
1341      method: "POST",
1342      credentials: "include",
1343      headers: { "Content-Type": "application/json" },
1344      body: JSON.stringify({ visibility: select.value }),
1345    });
1346    if (res.ok) {
1347      if (status) {
1348        status.style.display = "inline";
1349        status.style.color = "rgba(72, 187, 120, 0.9)";
1350        status.textContent = select.value === "public" ? "Now public" : "Now private";
1351        setTimeout(function() { status.style.display = "none"; }, 3000);
1352      }
1353    } else {
1354      var data = await res.json().catch(function() { return {}; });
1355      if (status) {
1356        status.style.display = "inline";
1357        status.style.color = "rgba(255, 107, 107, 0.9)";
1358        status.textContent = (data.error && data.error.message) || data.error || "Failed";
1359      }
1360    }
1361  } catch (err) {
1362    if (status) {
1363      status.style.display = "inline";
1364      status.style.color = "rgba(255, 107, 107, 0.9)";
1365      status.textContent = "Error";
1366    }
1367  }
1368}
1369
1370// DREAM TIME
1371async function saveDreamTime() {
1372  var input = document.getElementById("dreamTimeInput");
1373  var status = document.getElementById("dreamTimeStatus");
1374  try {
1375    var res = await fetch("/api/v1/root/${nodeId}/dream-time", {
1376      method: "POST",
1377      credentials: "include",
1378      headers: { "Content-Type": "application/json" },
1379      body: JSON.stringify({ dreamTime: input.value || null }),
1380    });
1381    if (res.ok) {
1382      if (status) {
1383        status.style.display = "inline";
1384        status.style.color = "rgba(72, 187, 120, 0.9)";
1385        status.textContent = input.value ? "Saved" : "Disabled";
1386        setTimeout(function() { status.style.display = "none"; }, 3000);
1387      }
1388    } else {
1389      var data = await res.json().catch(function() { return {}; });
1390      if (status) {
1391        status.style.display = "inline";
1392        status.style.color = "rgba(255, 107, 107, 0.9)";
1393        status.textContent = (data.error && data.error.message) || data.error || "Failed";
1394      }
1395    }
1396  } catch (err) {
1397    if (status) {
1398      status.style.display = "inline";
1399      status.style.color = "rgba(255, 107, 107, 0.9)";
1400      status.textContent = "Network error";
1401    }
1402  }
1403}
1404async function clearDreamTime() {
1405  document.getElementById("dreamTimeInput").value = "";
1406  saveDreamTime();
1407}
1408
1409// ROOT LLM ASSIGNMENT
1410async function assignRootLlm(slot, connId) {
1411  var statusEl = document.querySelector(".llm-assign-status");
1412  try {
1413    var res = await fetch("/api/v1/root/${nodeId}/llm-assign", {
1414      method: "POST",
1415      credentials: "include",
1416      headers: { "Content-Type": "application/json" },
1417      body: JSON.stringify({ slot: slot, connectionId: connId || null }),
1418    });
1419    if (res.ok) {
1420      if (statusEl) {
1421        statusEl.style.display = "block";
1422        statusEl.style.color = "rgba(72, 187, 120, 0.9)";
1423        statusEl.textContent = connId ? "\\u2713 Assigned" : "\\u2713 Using default";
1424        setTimeout(function() { statusEl.style.display = "none"; }, 3000);
1425      }
1426    } else {
1427      var data = await res.json().catch(function() { return {}; });
1428      if (statusEl) {
1429        statusEl.style.display = "block";
1430        statusEl.style.color = "rgba(255, 107, 107, 0.9)";
1431        statusEl.textContent = "\\u2715 " + ((data.error && data.error.message) || data.error || "Failed");
1432      }
1433    }
1434  } catch (err) {
1435    if (statusEl) {
1436      statusEl.style.display = "block";
1437      statusEl.style.color = "rgba(255, 107, 107, 0.9)";
1438      statusEl.textContent = "\\u2715 Network error";
1439    }
1440  }
1441}
1442
1443// CUSTOM DROPDOWN HANDLER
1444(function() {
1445  document.querySelectorAll(".custom-select").forEach(function(sel) {
1446    var trigger = sel.querySelector(".custom-select-trigger");
1447    if (!trigger) return;
1448    trigger.addEventListener("click", function(e) {
1449      e.stopPropagation();
1450      var wasOpen = sel.classList.contains("open");
1451      document.querySelectorAll(".custom-select.open").forEach(function(s) { s.classList.remove("open"); });
1452      if (!wasOpen) sel.classList.add("open");
1453    });
1454    sel.querySelectorAll(".custom-select-option").forEach(function(opt) {
1455      opt.addEventListener("click", function(e) {
1456        e.stopPropagation();
1457        sel.querySelectorAll(".custom-select-option").forEach(function(o) { o.classList.remove("selected"); });
1458        opt.classList.add("selected");
1459        trigger.textContent = opt.textContent;
1460        sel.classList.remove("open");
1461        assignRootLlm(sel.getAttribute("data-slot") || "placement", opt.getAttribute("data-value"));
1462      });
1463    });
1464  });
1465  document.addEventListener("click", function() {
1466    document.querySelectorAll(".custom-select.open").forEach(function(s) { s.classList.remove("open"); });
1467  });
1468})();
1469
1470// AUTO-SCROLL BREADCRUMB TO RIGHT ON LOAD
1471window.addEventListener('load', () => {
1472  const breadcrumb = document.querySelector('.breadcrumb-constellation');
1473  if (breadcrumb) {
1474    breadcrumb.scrollLeft = breadcrumb.scrollWidth;
1475  }
1476});
1477
1478// HORIZONTAL SCROLL WITH MOUSE WHEEL - Breadcrumb
1479const breadcrumb = document.querySelector('.breadcrumb-constellation');
1480if (breadcrumb) {
1481  breadcrumb.addEventListener('wheel', (e) => {
1482    e.preventDefault();
1483    breadcrumb.scrollLeft += e.deltaY;
1484  });
1485}
1486
1487// Breadcrumb bubble click handling - links work normally
1488document.addEventListener('click', (e) => {
1489  const link = e.target.closest('.node-link');
1490  if (link && !e.defaultPrevented) {
1491    // Just let the link work normally
1492    return;
1493  }
1494});
1495
1496// Tree node click handling (existing)
1497document.addEventListener('click', (e) => {
1498  const node = e.target.closest('.tree-node');
1499  if (!node) return;
1500
1501  // Ignore real navigation
1502  if (e.target.closest('a, button')) return;
1503
1504  const link = node.querySelector(':scope > a');
1505  if (!link) return;
1506
1507  e.preventDefault();
1508
1509  const OFFSET = 50;
1510
1511  const rect = link.getBoundingClientRect();
1512  const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
1513
1514  const targetY = rect.top + scrollTop - OFFSET;
1515
1516  window.scrollTo({
1517    top: targetY,
1518    behavior: 'smooth'
1519  });
1520
1521  // Optional glow pulse
1522  link.animate(
1523    [
1524      { boxShadow: '0 0 0 rgba(255,255,255,0)' },
1525      { boxShadow: '0 0 24px rgba(255,255,255,0.6)' },
1526      { boxShadow: '0 0 0 rgba(255,255,255,0)' }
1527    ],
1528    { duration: 900, easing: 'ease-out' }
1529  );
1530});
1531
1532// Filter toggles
1533const params = new URLSearchParams(window.location.search);
1534
1535function paramIsOn(param, current) {
1536  if (current === "true") return true;
1537  if (current === "false") return false;
1538  if (param === "active" || param === "completed") return true;
1539  return false;
1540}
1541
1542function makeToggle(param) {
1543  const current = params.get(param);
1544  const isOn = paramIsOn(param, current);
1545  const nextValue = isOn ? "false" : "true";
1546
1547  const newParams = new URLSearchParams(params);
1548  newParams.set(param, nextValue);
1549
1550  const url = window.location.pathname + "?" + newParams.toString();
1551  const color = isOn ? "#4CAF50" : "#9E9E9E";
1552
1553  return (
1554    '<a href="' + url + '" ' +
1555    'style="background:' + color + ';">' +
1556      param +
1557    '</a>'
1558  );
1559}
1560
1561document.getElementById("filterButtons").innerHTML =
1562  makeToggle("active") +
1563  makeToggle("completed") +
1564  makeToggle("trimmed") +
1565  '<a href="#" id="copyNodeIdBtn" title="Copy Node ID" style="background:rgba(var(--glass-water-rgb),0.35);">\\ud83d\\udccb</a>';
1566
1567document.getElementById("copyNodeIdBtn").addEventListener("click", function(e) {
1568  e.preventDefault();
1569  navigator.clipboard.writeText("${allData._id}").then(function() {
1570    var b = document.getElementById("copyNodeIdBtn");
1571    b.textContent = "\\u2714\\ufe0f";
1572    setTimeout(function() { b.textContent = "\\ud83d\\udccb"; }, 900);
1573  });
1574});
1575`;
1576
1577  return page({
1578    title: `${escapeHtml(allData.name)} - TreeOS`,
1579    css,
1580    body,
1581    js,
1582  });
1583}
1584
1/* ------------------------------------------------------------------ */
2/* renderVersionDetail -- Version detail page with status, schedule    */
3/* ------------------------------------------------------------------ */
4
5import { page } from "../../html-rendering/html/layout.js";
6import { resolveSlots } from "../slots.js";
7
8/* ── page-specific CSS ── */
9
10const css = `
11
12/* =========================================================
13   UNIFIED GLASS BUTTON SYSTEM
14   ========================================================= */
15
16.glass-btn,
17button,
18.action-button,
19.back-link,
20.nav-links a,
21.meta-value button,
22.contributors-list button,
23button[type="submit"],
24.status-button,
25.primary-button {
26  position: relative;
27  overflow: hidden;
28
29  padding: 10px 20px;
30  border-radius: 980px;
31
32  display: inline-flex;
33  align-items: center;
34  justify-content: center;
35  white-space: nowrap;
36
37  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
38  backdrop-filter: blur(22px) saturate(140%);
39  -webkit-backdrop-filter: blur(22px) saturate(140%);
40
41  color: white;
42  text-decoration: none;
43  font-family: inherit;
44
45  font-size: 15px;
46  font-weight: 600;
47  letter-spacing: -0.2px;
48
49  border: 1px solid rgba(255, 255, 255, 0.28);
50
51  box-shadow:
52    0 8px 24px rgba(0, 0, 0, 0.12),
53    inset 0 1px 0 rgba(255, 255, 255, 0.25);
54
55  cursor: pointer;
56
57  transition:
58    background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
59    transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
60    box-shadow 0.3s ease;
61}
62
63/* Liquid light layer */
64.glass-btn::before,
65button::before,
66.action-button::before,
67.back-link::before,
68.nav-links a::before,
69.meta-value button::before,
70.contributors-list button::before,
71button[type="submit"]::before,
72.status-button::before,
73.primary-button::before {
74  content: "";
75  position: absolute;
76  inset: -40%;
77
78  background:
79    radial-gradient(
80      120% 60% at 0% 0%,
81      rgba(255, 255, 255, 0.35),
82      transparent 60%
83    ),
84    linear-gradient(
85      120deg,
86      transparent 30%,
87      rgba(255, 255, 255, 0.25),
88      transparent 70%
89    );
90
91  opacity: 0;
92  transform: translateX(-30%) translateY(-10%);
93  transition:
94    opacity 0.35s ease,
95    transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
96
97  pointer-events: none;
98}
99
100/* Hover motion */
101.glass-btn:hover,
102button:hover,
103.action-button:hover,
104.back-link:hover,
105.nav-links a:hover,
106.meta-value button:hover,
107.contributors-list button:hover,
108button[type="submit"]:hover,
109.status-button:hover,
110.primary-button:hover {
111  background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
112  transform: translateY(-2px);
113}
114
115.glass-btn:hover::before,
116button:hover::before,
117.action-button:hover::before,
118.back-link:hover::before,
119.nav-links a:hover::before,
120.meta-value button:hover::before,
121.contributors-list button:hover::before,
122button[type="submit"]:hover::before,
123.status-button:hover::before,
124.primary-button:hover::before {
125  opacity: 1;
126  transform: translateX(30%) translateY(10%);
127}
128
129/* Active press */
130.glass-btn:active,
131button:active,
132.status-button:active,
133.primary-button:active {
134  background: rgba(var(--glass-water-rgb), 0.45);
135  transform: translateY(0);
136}
137
138/* Emphasis variants */
139.primary-button {
140  --glass-water-rgb: 72, 187, 178;
141  --glass-alpha: 0.34;
142  --glass-alpha-hover: 0.46;
143  font-weight: 600;
144}
145
146.legacy-btn {
147  opacity: 0.85;
148}
149.legacy-btn:hover {
150  opacity: 1;
151}
152
153/* =========================================================
154   CONTENT CARDS - UPDATED TO MATCH ROOT ROUTE
155   ========================================================= */
156
157.header,
158.nav-section,
159.actions-section {
160  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
161  backdrop-filter: blur(22px) saturate(140%);
162  -webkit-backdrop-filter: blur(22px) saturate(140%);
163  border-radius: 16px;
164  padding: 28px;
165  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
166    inset 0 1px 0 rgba(255, 255, 255, 0.25);
167  border: 1px solid rgba(255, 255, 255, 0.28);
168  margin-bottom: 24px;
169  animation: fadeInUp 0.6s ease-out;
170  animation-fill-mode: both;
171  position: relative;
172  overflow: hidden;
173}
174
175.header {
176  animation-delay: 0.1s;
177}
178
179.nav-section {
180  animation-delay: 0.15s;
181}
182
183.actions-section {
184  animation-delay: 0.2s;
185}
186
187.header::before,
188.nav-section::before,
189.actions-section::before {
190  content: "";
191  position: absolute;
192  inset: 0;
193  border-radius: inherit;
194  background: linear-gradient(
195    180deg,
196    rgba(255, 255, 255, 0.18),
197    rgba(255, 255, 255, 0.05)
198  );
199  pointer-events: none;
200}
201
202.meta-card {
203  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
204  backdrop-filter: blur(22px) saturate(140%);
205  -webkit-backdrop-filter: blur(22px) saturate(140%);
206  border-radius: 12px;
207  padding: 16px 20px;
208  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
209    inset 0 1px 0 rgba(255, 255, 255, 0.25);
210  border: 1px solid rgba(255, 255, 255, 0.28);
211  color: white;
212  animation: fadeInUp 0.6s ease-out;
213  animation-fill-mode: both;
214  position: relative;
215  overflow: hidden;
216}
217
218.meta-card::before {
219  content: "";
220  position: absolute;
221  inset: 0;
222  border-radius: inherit;
223  background: linear-gradient(
224    180deg,
225    rgba(255, 255, 255, 0.18),
226    rgba(255, 255, 255, 0.05)
227  );
228  pointer-events: none;
229}
230
231/* Stagger meta-card animations */
232.meta-card:nth-child(1) { animation-delay: 0.2s; }
233.meta-card:nth-child(2) { animation-delay: 0.25s; }
234
235.header h1 {
236  font-size: 28px;
237  font-weight: 600;
238  letter-spacing: -0.5px;
239  line-height: 1.3;
240  margin-bottom: 8px;
241  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
242}
243
244.header h1 a {
245  color: white;
246  text-decoration: none;
247  transition: opacity 0.2s;
248}
249
250.header h1 a:hover {
251  opacity: 0.8;
252}
253
254.nav-section h2,
255.actions-section h3 {
256  font-size: 18px;
257  font-weight: 600;
258  color: white;
259  margin-bottom: 16px;
260  letter-spacing: -0.3px;
261  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
262}
263
264/* =========================================================
265   NAV + META
266   ========================================================= */
267
268.back-nav {
269  display: flex;
270  gap: 12px;
271  margin-bottom: 20px;
272  flex-wrap: wrap;
273  animation: fadeInUp 0.5s ease-out;
274}
275
276.version-badge {
277  display: inline-block;
278  padding: 6px 14px;
279  background: rgba(16, 185, 129, 0.25);
280  backdrop-filter: blur(10px);
281  color: white;
282  border-radius: 20px;
283  font-size: 14px;
284  font-weight: 600;
285  margin-top: 8px;
286  border: 1px solid rgba(16, 185, 129, 0.4);
287  position: relative;
288  overflow: hidden;
289  box-shadow: 0 4px 12px rgba(16, 185, 129, 0.15),
290    inset 0 1px 0 rgba(255, 255, 255, 0.3);
291}
292
293/* Version badge colors matching status */
294.version-badge.version-status-active {
295  background: rgba(16, 185, 129, 0.25);
296  border: 1px solid rgba(16, 185, 129, 0.4);
297  box-shadow: 0 4px 12px rgba(16, 185, 129, 0.15),
298    inset 0 1px 0 rgba(255, 255, 255, 0.3);
299}
300
301.version-badge.version-status-completed {
302  background: rgba(139, 92, 246, 0.25);
303  border: 1px solid rgba(139, 92, 246, 0.4);
304  box-shadow: 0 4px 12px rgba(139, 92, 246, 0.15),
305    inset 0 1px 0 rgba(255, 255, 255, 0.3);
306}
307
308.version-badge.version-status-trimmed {
309  background: rgba(220, 38, 38, 0.25);
310  border: 1px solid rgba(220, 38, 38, 0.4);
311  box-shadow: 0 4px 12px rgba(220, 38, 38, 0.15),
312    inset 0 1px 0 rgba(255, 255, 255, 0.3);
313}
314
315.version-badge::after {
316  content: "";
317  position: absolute;
318  inset: 0;
319
320  background: linear-gradient(
321    100deg,
322    transparent 40%,
323    rgba(255, 255, 255, 0.5),
324    transparent 60%
325  );
326
327  opacity: 0;
328  transform: translateX(-100%);
329  transition: transform 0.8s ease, opacity 0.3s ease;
330
331  animation: openAppHoverShimmerClone 1.6s ease forwards;
332  animation-delay: 0.5s;
333
334  pointer-events: none;
335}
336
337@keyframes openAppHoverShimmerClone {
338  0% {
339    opacity: 0;
340    transform: translateX(-100%);
341  }
342
343  100% {
344    opacity: 1;
345    transform: translateX(100%);
346  }
347}
348
349.created-date {
350  font-size: 13px;
351  color: rgba(255, 255, 255, 0.7);
352  margin-top: 10px;
353  font-weight: 500;
354}
355
356.node-id-container {
357  display: flex;
358  align-items: center;
359  gap: 8px;
360  margin-top: 12px;
361  flex-wrap: wrap;
362  padding: 10px 14px;
363  background: rgba(255, 255, 255, 0.15);
364  border-radius: 8px;
365  border: 1px solid rgba(255, 255, 255, 0.2);
366  width: 100%;
367}
368
369code {
370  background: transparent;
371  padding: 0;
372  border-radius: 0;
373  font-size: 13px;
374  font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
375  color: white;
376  word-break: break-all;
377  flex: 1;
378  min-width: 0;
379  overflow-wrap: break-word;
380}
381
382#copyNodeIdBtn {
383  background: rgba(255, 255, 255, 0.2);
384  border: 1px solid rgba(255, 255, 255, 0.3);
385  cursor: pointer;
386  padding: 6px 10px;
387  border-radius: 6px;
388  opacity: 1;
389  font-size: 16px;
390  transition: all 0.2s;
391  flex-shrink: 0;
392}
393
394#copyNodeIdBtn:hover {
395  background: rgba(255, 255, 255, 0.3);
396  transform: scale(1.1);
397}
398
399#copyNodeIdBtn::before {
400  display: none;
401}
402
403.meta-grid {
404  display: grid;
405  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
406  gap: 16px;
407  margin-bottom: 24px;
408}
409
410.meta-label {
411  font-size: 12px;
412  font-weight: 600;
413  text-transform: uppercase;
414  letter-spacing: 0.5px;
415  color: rgba(255, 255, 255, 0.7);
416  margin-bottom: 6px;
417}
418
419.meta-value {
420  font-size: 15px;
421  font-weight: 600;
422  color: white;
423  word-break: break-word;
424  overflow-wrap: break-word;
425}
426
427.status-badge {
428  display: inline-block;
429  padding: 6px 12px;
430  border-radius: 6px;
431  font-size: 13px;
432  font-weight: 600;
433  text-transform: capitalize;
434  background: rgba(255, 255, 255, 0.25);
435  color: white;
436  border: 1px solid rgba(255, 255, 255, 0.3);
437}
438
439/* Official status colors with glass effect - UPDATED COLORS */
440.status-badge.status-active {
441  background: rgba(16, 185, 129, 0.35);
442  border: 1px solid rgba(16, 185, 129, 0.5);
443  backdrop-filter: blur(10px);
444  box-shadow: 0 0 12px rgba(16, 185, 129, 0.2);
445}
446
447.status-badge.status-completed {
448  background: rgba(139, 92, 246, 0.35);
449  border: 1px solid rgba(139, 92, 246, 0.5);
450  backdrop-filter: blur(10px);
451  box-shadow: 0 0 12px rgba(139, 92, 246, 0.2);
452}
453
454.status-badge.status-trimmed {
455  background: rgba(220, 38, 38, 0.35);
456  border: 1px solid rgba(220, 38, 38, 0.5);
457  backdrop-filter: blur(10px);
458  box-shadow: 0 0 12px rgba(220, 38, 38, 0.2);
459}
460
461.nav-links {
462  display: grid;
463  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
464  gap: 12px;
465}
466
467.nav-links a {
468  padding: 14px 18px;
469  font-size: 15px;
470  text-align: center;
471}
472
473/* =========================================================
474   STATUS CARD WITH BUTTONS - UPDATED COLORS
475   ========================================================= */
476
477.status-controls {
478  display: flex;
479  align-items: center;
480  gap: 12px;
481  flex-wrap: wrap;
482  margin-top: 12px;
483}
484
485.status-controls button {
486  padding: 8px 16px;
487  font-size: 13px;
488  position: relative;
489}
490
491/* Faint glass colors for status buttons - UPDATED */
492.status-controls button[value="active"] {
493  --glass-water-rgb: 16, 185, 129; /* green */
494  --glass-alpha: 0.15;
495  --glass-alpha-hover: 0.25;
496}
497
498.status-controls button[value="completed"] {
499  --glass-water-rgb: 139, 92, 246; /* purple */
500  --glass-alpha: 0.15;
501  --glass-alpha-hover: 0.25;
502}
503
504.status-controls button[value="trimmed"] {
505  --glass-water-rgb: 220, 38, 38; /* red */
506  --glass-alpha: 0.15;
507  --glass-alpha-hover: 0.25;
508}
509
510/* =========================================================
511   SCHEDULE CARD
512   ========================================================= */
513
514.schedule-info {
515  display: flex;
516  flex-direction: column;
517  gap: 8px;
518  width: 100%;
519}
520
521.schedule-row {
522  display: flex;
523  align-items: flex-start;
524  gap: 12px;
525  width: 100%;
526}
527
528.schedule-text {
529  flex: 1;
530  min-width: 0;
531  overflow: hidden;
532}
533
534.schedule-text .meta-value {
535  word-break: break-word;
536  overflow-wrap: break-word;
537}
538
539.repeat-text {
540  font-size: 13px;
541  color: rgba(255, 255, 255, 0.8);
542  margin-top: 6px;
543}
544
545#editScheduleBtn {
546  flex-shrink: 0;
547}
548
549/* =========================================================
550   ACTIONS & FORMS
551   ========================================================= */
552
553.action-form {
554  margin-bottom: 24px;
555}
556
557.action-form:last-child {
558  margin-bottom: 0;
559}
560
561.button-group {
562  display: flex;
563  gap: 12px;
564  flex-wrap: wrap;
565}
566
567button[type="submit"],
568.status-button {
569  padding: 12px 20px;
570  font-size: 14px;
571}
572
573/* =========================================================
574   MODAL
575   ========================================================= */
576
577#scheduleModal {
578  display: none;
579  position: fixed;
580  inset: 0;
581  background: rgba(0, 0, 0, 0.5);
582  backdrop-filter: blur(8px);
583  align-items: center;
584  justify-content: center;
585  z-index: 1000;
586}
587
588#scheduleModal > div {
589  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
590  backdrop-filter: blur(22px) saturate(140%);
591  -webkit-backdrop-filter: blur(22px) saturate(140%);
592  padding: 28px;
593  border-radius: 16px;
594  width: 320px;
595  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2),
596    inset 0 1px 0 rgba(255, 255, 255, 0.25);
597  border: 1px solid rgba(255, 255, 255, 0.28);
598  position: relative;
599  overflow: hidden;
600}
601
602#scheduleModal > div::before {
603  content: "";
604  position: absolute;
605  inset: 0;
606  border-radius: inherit;
607  background: linear-gradient(
608    180deg,
609    rgba(255, 255, 255, 0.18),
610    rgba(255, 255, 255, 0.05)
611  );
612  pointer-events: none;
613}
614
615#scheduleModal label {
616  display: block;
617  margin-bottom: 12px;
618  color: white;
619  font-weight: 600;
620  font-size: 14px;
621  letter-spacing: -0.2px;
622  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
623  position: relative;
624}
625
626#scheduleModal input {
627  width: 100%;
628  margin-top: 6px;
629  padding: 12px 14px;
630  border-radius: 10px;
631  border: 2px solid rgba(255, 255, 255, 0.3);
632  background: rgba(255, 255, 255, 0.15);
633  font-size: 15px;
634  font-family: inherit;
635  font-weight: 500;
636  transition: all 0.2s;
637  color: white;
638  position: relative;
639}
640
641#scheduleModal input::placeholder {
642  color: rgba(255, 255, 255, 0.5);
643}
644
645#scheduleModal input:focus {
646  outline: none;
647  border-color: rgba(255, 255, 255, 0.6);
648  background: rgba(255, 255, 255, 0.25);
649  box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
650  transform: translateY(-2px);
651}
652
653#scheduleModal button {
654  padding: 10px 18px;
655  border-radius: 980px;
656  font-weight: 600;
657  font-size: 14px;
658  cursor: pointer;
659  font-family: inherit;
660  transition: all 0.2s;
661  border: 1px solid rgba(255, 255, 255, 0.28);
662  position: relative;
663}
664
665#scheduleModal button[type="button"] {
666  background: rgba(255, 255, 255, 0.15);
667  color: white;
668  border: 1px solid rgba(255, 255, 255, 0.28) !important;
669  box-shadow: none !important;
670}
671
672#scheduleModal button[type="button"]:hover {
673  background: rgba(255, 255, 255, 0.25);
674}
675
676#scheduleModal button[type="button"]::before {
677  display: none;
678}
679
680#scheduleModal > div > form > div {
681  display: flex;
682  gap: 10px;
683  justify-content: flex-end;
684  margin-top: 16px;
685}
686
687/* =========================================================
688   RESPONSIVE
689   ========================================================= */
690
691@media (max-width: 640px) {
692  .container {
693    max-width: 100%;
694  }
695
696  .header,
697  .nav-section,
698  .actions-section {
699    padding: 20px;
700  }
701
702  .meta-grid {
703    grid-template-columns: 1fr;
704    gap: 12px;
705  }
706
707  .meta-card {
708    padding: 14px 16px;
709  }
710
711  .nav-links {
712    grid-template-columns: 1fr;
713  }
714
715  .button-group {
716    flex-direction: column;
717  }
718
719  button,
720  .status-button,
721  .primary-button {
722    width: 100%;
723  }
724
725  .status-controls {
726    flex-direction: column;
727    align-items: stretch;
728  }
729
730  .status-controls button {
731    width: 100%;
732  }
733
734  code {
735    font-size: 12px;
736    word-break: break-all;
737  }
738
739  .schedule-row {
740    flex-direction: column;
741    align-items: stretch;
742    gap: 8px;
743  }
744
745  #editScheduleBtn {
746    width: 100%;
747    justify-content: center;
748  }
749
750  #scheduleModal > div {
751    width: calc(100% - 40px);
752    max-width: 320px;
753  }
754}
755
756@media (min-width: 641px) and (max-width: 1024px) {
757  .meta-grid {
758    grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
759  }
760}
761`;
762
763/* ── client-side JS ── */
764
765const jsCode = `
766    // Copy ID functionality
767    const btn = document.getElementById("copyNodeIdBtn");
768    const code = document.getElementById("nodeIdCode");
769
770    btn.addEventListener("click", () => {
771      navigator.clipboard.writeText(code.textContent).then(() => {
772        btn.textContent = "✔️";
773        setTimeout(() => (btn.textContent = "📋"), 900);
774      });
775    });
776
777    // Schedule modal
778    const editBtn = document.getElementById("editScheduleBtn");
779    const modal = document.getElementById("scheduleModal");
780    const cancelBtn = document.getElementById("cancelSchedule");
781
782    if (editBtn) {
783      editBtn.onclick = () => {
784        modal.style.display = "flex";
785      };
786    }
787
788    if (cancelBtn) {
789      cancelBtn.onclick = () => {
790        modal.style.display = "none";
791      };
792    }
793`;
794
795/* ================================================================== */
796/* renderVersionDetail                                                 */
797/* ================================================================== */
798
799export function renderVersionDetail({
800  node,
801  nodeId,
802  version,
803  data,
804  qs,
805  backUrl,
806  backTreeUrl,
807  createdDate,
808  scheduleHtml,
809  reeffectTime,
810  showPrestige,
811  ALL_STATUSES,
812  STATUS_LABELS,
813}) {
814  const body = `
815  <div class="container">
816    <!-- Back Navigation -->
817    <div class="back-nav">
818      ${backTreeUrl ? `<a href="${backTreeUrl}" class="back-link">← Back to Tree</a>` : ""}
819      <a href="${backUrl}" class="back-link">
820        View All Versions
821      </a>
822      <a href="/api/v1/node/${nodeId}/command-center${qs}" class="back-link">
823        Command Center
824      </a>
825    </div>
826
827    <!-- Header -->
828    <div class="header">
829      <h1
830        id="nodeNameDisplay"
831        style="cursor:pointer;"
832        title="Click to rename"
833        onclick="document.getElementById('nodeNameDisplay').style.display='none';document.getElementById('renameForm').style.display='flex';"
834      >${node.name}</h1>
835      <form
836        id="renameForm"
837        method="POST"
838        action="/api/v1/node/${nodeId}/${version}/editName${qs}"
839        style="display:none;align-items:center;gap:8px;margin-bottom:12px;"
840      >
841        <input
842          type="text"
843          name="name"
844          value="${node.name.replace(/"/g, '&quot;')}"
845          required
846          style="flex:1;font-size:20px;font-weight:700;padding:8px 12px;border-radius:12px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.1);color:white;"
847        />
848        <button type="submit" class="primary-button" style="padding:8px 16px;">Save</button>
849        <button
850          type="button"
851          class="warning-button"
852          style="padding:8px 16px;"
853          onclick="document.getElementById('renameForm').style.display='none';document.getElementById('nodeNameDisplay').style.display='';"
854        >Cancel</button>
855      </form>
856
857      <div class="meta-row" style="margin-top:4px;">
858        <div class="meta-item">
859          <div class="meta-label">Type</div>
860          <div class="meta-value">${node.type ?? "None"}</div>
861        </div>
862      </div>
863
864      ${resolveSlots("version-badge", { version, data }) || ""}
865
866      <div class="created-date">Created: ${createdDate}</div>
867
868      <div class="node-id-container">
869        <code id="nodeIdCode">${node._id}</code>
870        <button id="copyNodeIdBtn" title="Copy ID">📋</button>
871      </div>
872    </div>
873
874    <!-- Navigation Links -->
875    <div class="nav-section">
876      <h2>Quick Access</h2>
877      <div class="nav-links">
878        <a href="/api/v1/node/${nodeId}/${version}/notes${qs}">Notes</a>
879        <a href="/api/v1/node/${nodeId}/${version}/contributions${qs}">Contributions</a>
880        <a href="/api/v1/node/${nodeId}/${version}/chats${qs}">AI Chats</a>
881        ${resolveSlots("version-quick-links", { nodeId, version, qs })}
882      </div>
883    </div>
884
885    <!-- Metadata Grid -->
886    <div class="meta-grid">
887      <!-- Status Card with Controls -->
888      <div class="meta-card">
889        <div class="meta-label">Status</div>
890        <div class="meta-value">
891          <span class="status-badge status-${data.status}">${data.status}</span>
892        </div>
893        <form
894          method="POST"
895          action="/api/v1/node/${nodeId}/${version}/editStatus${qs}"
896          onsubmit="return confirm('This will apply to all children. Is that ok?')"
897          class="status-controls"
898        >
899          <input type="hidden" name="isInherited" value="true" />
900          ${ALL_STATUSES.filter((s) => s !== data.status)
901            .map(
902              (s) => `
903            <button type="submit" name="status" value="${s}" class="status-button">
904              ${STATUS_LABELS[s]}
905            </button>
906          `,
907            )
908            .join("")}
909        </form>
910      </div>
911
912      <!-- Extension meta cards (schedule, etc.) -->
913      ${resolveSlots("version-meta-cards", { nodeId, version, qs, scheduleHtml, reeffectTime, data })}
914    </div>
915
916    <!-- Extension sections (version control, etc.) -->
917    ${resolveSlots("version-detail-sections", { nodeId, version, qs, showPrestige, data })}
918  </div>
919
920  <!-- Schedule Modal -->
921  <div id="scheduleModal">
922    <div>
923      <form
924        method="POST"
925        action="/api/v1/node/${nodeId}/${version}/editSchedule${qs}"
926      >
927        <label>
928          TIME
929          <input
930            type="datetime-local"
931            name="newSchedule"
932            value="${
933              data.schedule
934                ? new Date(data.schedule).toISOString().slice(0, 16)
935                : ""
936            }"
937          />
938        </label>
939
940        <label>
941          REPEAT HOURS
942          <input
943            type="number"
944            name="reeffectTime"
945            min="0"
946            value="${data.reeffectTime ?? 0}"
947          />
948        </label>
949
950        <div style="display:flex;gap:10px;justify-content:flex-end;">
951          <button type="button" id="cancelSchedule">Cancel</button>
952          <button type="submit" class="primary-button">Save</button>
953        </div>
954      </form>
955    </div>
956  </div>
957`;
958
959  return page({
960    title: `${node.name} v${version}`,
961    css,
962    body,
963    js: jsCode,
964  });
965}
966
1/**
2 * Welcome Page ("/")
3 *
4 * Public landing for any visitor to this land.
5 * Shows the land name, login/register if anonymous, dashboard link if logged in.
6 */
7
8export function renderWelcome({ landName, landUrl, isLoggedIn, isAdmin, username, extensionCount, userCount, treeCount }) {
9  const esc = (s) => String(s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
10
11  return `<!DOCTYPE html>
12<html lang="en">
13<head>
14  <meta charset="UTF-8">
15  <meta name="viewport" content="width=device-width, initial-scale=1.0">
16  <meta name="theme-color" content="#0a0a0a">
17  <title>${esc(landName)}</title>
18  <style>
19    * { margin: 0; padding: 0; box-sizing: border-box; }
20    body {
21      background: #0a0a0a;
22      color: #e5e5e5;
23      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
24      min-height: 100vh;
25      display: flex;
26      flex-direction: column;
27      align-items: center;
28      justify-content: center;
29      -webkit-font-smoothing: antialiased;
30    }
31
32    .top-bar {
33      position: fixed;
34      top: 0;
35      right: 0;
36      padding: 20px 24px;
37      display: flex;
38      gap: 10px;
39      z-index: 10;
40    }
41
42    .btn {
43      padding: 10px 22px;
44      border-radius: 8px;
45      font-size: 14px;
46      font-weight: 600;
47      cursor: pointer;
48      border: none;
49      text-decoration: none;
50      transition: all 0.2s;
51    }
52    .btn-primary {
53      background: #fff;
54      color: #0a0a0a;
55    }
56    .btn-primary:hover { background: #e5e5e5; }
57    .btn-secondary {
58      background: transparent;
59      color: #e5e5e5;
60      border: 1px solid rgba(255,255,255,0.15);
61    }
62    .btn-secondary:hover {
63      border-color: rgba(255,255,255,0.3);
64      background: rgba(255,255,255,0.05);
65    }
66
67    .hero {
68      text-align: center;
69      padding: 0 24px;
70    }
71    .hero h1 {
72      font-size: 72px;
73      font-weight: 800;
74      letter-spacing: -2px;
75      color: #fff;
76      margin-bottom: 12px;
77    }
78    .hero p {
79      font-size: 18px;
80      color: rgba(255,255,255,0.4);
81      max-width: 480px;
82      margin: 0 auto 32px;
83      line-height: 1.6;
84    }
85    .hero-links {
86      display: flex;
87      gap: 12px;
88      justify-content: center;
89      flex-wrap: wrap;
90    }
91
92    .stats {
93      margin-top: 48px;
94      display: flex;
95      gap: 32px;
96      justify-content: center;
97    }
98    .stat {
99      text-align: center;
100    }
101    .stat-num {
102      font-size: 24px;
103      font-weight: 700;
104      color: #fff;
105    }
106    .stat-label {
107      font-size: 12px;
108      color: rgba(255,255,255,0.3);
109      text-transform: uppercase;
110      letter-spacing: 0.5px;
111      margin-top: 4px;
112    }
113
114    .footer {
115      position: fixed;
116      bottom: 0;
117      width: 100%;
118      text-align: center;
119      padding: 16px;
120      font-size: 12px;
121      color: rgba(255,255,255,0.15);
122    }
123    .footer a { color: inherit; text-decoration: none; }
124
125    @media (max-width: 600px) {
126      .hero h1 { font-size: 42px; }
127      .stats { gap: 20px; }
128    }
129  </style>
130</head>
131<body>
132  <div class="top-bar">
133    ${isLoggedIn
134      ? `<a class="btn btn-primary" href="/dashboard">Dashboard</a>
135         ${isAdmin ? `<a class="btn btn-secondary" href="/land">Admin</a>` : ""}`
136      : `<a class="btn btn-secondary" href="/login">Log In</a>
137         <a class="btn btn-primary" href="/register">Register</a>`
138    }
139  </div>
140
141  <div class="hero">
142    <h1>${esc(landName)}</h1>
143    <p>
144      ${isLoggedIn
145        ? `Welcome back, ${esc(username)}.`
146        : `A TreeOS land. Log in or register to start growing trees.`
147      }
148    </p>
149    <div class="hero-links">
150      ${isLoggedIn
151        ? `<a class="btn btn-primary" href="/dashboard">Go to Dashboard</a>`
152        : `<a class="btn btn-primary" href="/register">Get Started</a>`
153      }
154    </div>
155    <div class="stats">
156      <div class="stat"><div class="stat-num">${extensionCount}</div><div class="stat-label">Extensions</div></div>
157      <div class="stat"><div class="stat-num">${userCount}</div><div class="stat-label">Users</div></div>
158      <div class="stat"><div class="stat-num">${treeCount}</div><div class="stat-label">Trees</div></div>
159    </div>
160  </div>
161
162  <div class="footer">
163    Powered by <a href="https://treeos.ai">The Seed</a>
164  </div>
165</body>
166</html>`;
167}
168
1/**
2 * UI Slot Registry
3 *
4 * Extensions register HTML fragments for named slots during init().
5 * Pages resolve slots by name. Whatever's installed appears. Whatever's
6 * not installed doesn't. The page never names an extension.
7 *
8 * Same pattern as hooks, modes, tools. Extensions register. The resolver filters.
9 *
10 * Spatial scoping: if an extension is blocked at the current node position,
11 * its slot fragments don't render. Same resolution as tools and hooks.
12 *
13 * Usage in extensions:
14 *   const treeos = getExtension("treeos-base");
15 *   treeos?.exports?.registerSlot("user-profile", "energy", (ctx) => {
16 *     return `<div>Energy: ${ctx.energy}</div>`;
17 *   });
18 *
19 * Usage in pages:
20 *   const html = resolveSlots("user-profile", { user, nodeId });
21 */
22
23import log from "../../seed/log.js";
24
25// slotName -> [{ extName, render, priority }]
26const slots = new Map();
27
28/**
29 * Register a UI fragment for a named slot.
30 *
31 * @param {string} slotName - where the fragment appears (e.g. "user-profile", "node-detail", "welcome-stats")
32 * @param {string} extName - the extension registering this fragment
33 * @param {Function} renderFn - (context) => HTML string. Context is whatever the page passes.
34 * @param {object} [opts]
35 * @param {number} [opts.priority=50] - lower renders first. Default 50. Core treeos uses 10-30.
36 */
37export function registerSlot(slotName, extName, renderFn, opts = {}) {
38  if (typeof slotName !== "string" || !slotName) {
39    log.warn("Slots", `Invalid slot name from ${extName}. Ignored.`);
40    return false;
41  }
42  if (typeof renderFn !== "function") {
43    log.warn("Slots", `Slot "${slotName}" from ${extName} has non-function render. Ignored.`);
44    return false;
45  }
46
47  if (!slots.has(slotName)) slots.set(slotName, []);
48  const list = slots.get(slotName);
49
50  // Replace existing from same extension (no duplicates)
51  const idx = list.findIndex(s => s.extName === extName);
52  if (idx !== -1) list.splice(idx, 1);
53
54  list.push({
55    extName,
56    render: renderFn,
57    priority: opts.priority ?? 50,
58  });
59
60  // Sort by priority (lower first)
61  list.sort((a, b) => a.priority - b.priority);
62
63  log.verbose("Slots", `Registered "${slotName}" from ${extName} (priority ${opts.priority ?? 50})`);
64  return true;
65}
66
67/**
68 * Remove all slots registered by an extension.
69 * Called on extension uninstall.
70 */
71export function unregisterSlots(extName) {
72  for (const [name, list] of slots) {
73    const filtered = list.filter(s => s.extName !== extName);
74    if (filtered.length === 0) slots.delete(name);
75    else slots.set(name, filtered);
76  }
77}
78
79/**
80 * Resolve all fragments for a named slot.
81 * Filters by spatial scoping if nodeId is in context.
82 * Returns concatenated HTML string.
83 *
84 * @param {string} slotName
85 * @param {object} [context] - passed to each render function. May include { user, node, nodeId, ... }
86 * @returns {string} HTML
87 */
88export function resolveSlots(slotName, context = {}, opts = {}) {
89  const registered = slots.get(slotName);
90  if (!registered || registered.length === 0) return "";
91
92  const raw = opts.raw === true;
93  const parts = [];
94  for (const slot of registered) {
95    // Spatial scoping: if the extension is blocked at this position, skip its fragment
96    if (context._blockedExtensions && context._blockedExtensions.has(slot.extName)) continue;
97
98    try {
99      const html = slot.render(context);
100      if (html && typeof html === "string") {
101        parts.push(raw ? html : `<div data-slot="${slotName}" data-ext="${slot.extName}">${html}</div>`);
102      }
103    } catch (err) {
104      log.warn("Slots", `Slot "${slotName}" render from ${slot.extName} failed: ${err.message}`);
105    }
106  }
107
108  return parts.join("\n");
109}
110
111/**
112 * Emit a slot update over WebSocket.
113 * The client-side script replaces the matching data-slot container.
114 *
115 * @param {object} core - core services bundle (needs core.websocket)
116 * @param {string} userId - target user
117 * @param {string} slotName - which slot to update
118 * @param {string} extName - which extension's fragment to update
119 * @param {object} context - passed to the render function
120 */
121export function emitSlotUpdate(core, userId, slotName, extName, context = {}) {
122  const registered = slots.get(slotName);
123  if (!registered) return;
124
125  const slot = registered.find(s => s.extName === extName);
126  if (!slot) return;
127
128  try {
129    const html = slot.render(context);
130    if (html && typeof html === "string" && core.websocket?.emitToUser) {
131      core.websocket.emitToUser(userId, "slotUpdate", {
132        slotName,
133        extName,
134        html,
135      });
136    }
137  } catch (err) {
138    log.debug("Slots", `emitSlotUpdate "${slotName}" from ${extName} failed: ${err.message}`);
139  }
140}
141
142/**
143 * Resolve slots with async render functions.
144 * Same as resolveSlots but awaits each render.
145 */
146export async function resolveSlotsAsync(slotName, context = {}) {
147  const registered = slots.get(slotName);
148  if (!registered || registered.length === 0) return "";
149
150  const parts = [];
151  for (const slot of registered) {
152    if (context._blockedExtensions && context._blockedExtensions.has(slot.extName)) continue;
153
154    try {
155      const html = await slot.render(context);
156      if (html && typeof html === "string") parts.push(html);
157    } catch (err) {
158      log.debug("Slots", `Async slot "${slotName}" render from ${slot.extName} failed: ${err.message}`);
159    }
160  }
161
162  return parts.join("\n");
163}
164
165/**
166 * List all registered slot names (for debugging).
167 */
168export function listSlots() {
169  const result = {};
170  for (const [name, list] of slots) {
171    result[name] = list.map(s => ({ extName: s.extName, priority: s.priority }));
172  }
173  return result;
174}
175
1// TreeOS default MCP tool definitions. Modes reference these by name.
2
3const TOOL_DEFS = {
4  // ── READ ──────────────────────────────────────────────────────────────
5  "get-tree": {
6    type: "function",
7    function: {
8      name: "get-tree",
9      description:
10        "Fetch a tree's structure. Use filters to show active/trimmed/completed nodes.",
11      parameters: {
12        type: "object",
13        properties: {
14          nodeId: {
15            type: "string",
16            description: "Root node ID to fetch tree from",
17          },
18          filters: {
19            type: "object",
20            properties: {
21              active: { type: "boolean" },
22              trimmed: { type: "boolean" },
23              completed: { type: "boolean" },
24            },
25            description: "Status filters. Default shows active and completed.",
26          },
27        },
28        required: ["nodeId"],
29      },
30    },
31  },
32  // ── READ ──────────────────────────────────────────────────────────────
33  // ── READ ──────────────────────────────────────────────────────────────
34  "get-active-leaf-execution-frontier": {
35    type: "function",
36    function: {
37      name: "get-active-leaf-execution-frontier",
38      description:
39        "Get the next executable leaf node for BE mode. This function is authoritative and determines what step should be worked on next.",
40      parameters: {
41        type: "object",
42        properties: {
43          rootNodeId: {
44            type: "string",
45            description: "Root node ID of the active tree",
46          },
47        },
48        required: ["rootNodeId"],
49      },
50    },
51  },
52
53  "get-node": {
54    type: "function",
55    function: {
56      name: "get-node",
57      description: "Fetch detailed information for a specific node.",
58      parameters: {
59        type: "object",
60        properties: {
61          nodeId: { type: "string", description: "The node ID to fetch" },
62        },
63        required: ["nodeId"],
64      },
65    },
66  },
67
68  "get-node-notes": {
69    type: "function",
70    function: {
71      name: "get-node-notes",
72      description: "Get notes for a node.",
73      parameters: {
74        type: "object",
75        properties: {
76          nodeId: { type: "string" },
77          limit: { type: "number", description: "Max notes to return" },
78          startDate: { type: "string", description: "ISO date filter start" },
79          endDate: { type: "string", description: "ISO date filter end" },
80        },
81        required: ["nodeId"],
82      },
83    },
84  },
85
86  "get-node-contributions": {
87    type: "function",
88    function: {
89      name: "get-node-contributions",
90      description: "Get contribution history for a node.",
91      parameters: {
92        type: "object",
93        properties: {
94          nodeId: { type: "string" },
95          limit: { type: "number" },
96          startDate: { type: "string" },
97          endDate: { type: "string" },
98        },
99        required: ["nodeId"],
100      },
101    },
102  },
103
104  "get-unsearched-notes-by-user": {
105    type: "function",
106    function: {
107      name: "get-unsearched-notes-by-user",
108      description: "Get recent notes by the user (limit 20 max).",
109      parameters: {
110        type: "object",
111        properties: {
112          userId: { type: "string" },
113          limit: { type: "number" },
114          startDate: { type: "string" },
115          endDate: { type: "string" },
116        },
117        required: ["userId"],
118      },
119    },
120  },
121
122  "get-searched-notes-by-user": {
123    type: "function",
124    function: {
125      name: "get-searched-notes-by-user",
126      description: "Search user's notes by text content.",
127      parameters: {
128        type: "object",
129        properties: {
130          userId: { type: "string" },
131          query: { type: "string", description: "Search query" },
132          limit: { type: "number" },
133          startDate: { type: "string" },
134          endDate: { type: "string" },
135        },
136        required: ["userId", "query"],
137      },
138    },
139  },
140
141  "get-all-tags-for-user": {
142    type: "function",
143    function: {
144      name: "get-all-tags-for-user",
145      description: "Get notes where user was tagged (mail).",
146      parameters: {
147        type: "object",
148        properties: {
149          userId: { type: "string" },
150          limit: { type: "number" },
151          startDate: { type: "string" },
152          endDate: { type: "string" },
153        },
154        required: ["userId"],
155      },
156    },
157  },
158
159  "get-contributions-by-user": {
160    type: "function",
161    function: {
162      name: "get-contributions-by-user",
163      description: "Get user's contribution history.",
164      parameters: {
165        type: "object",
166        properties: {
167          userId: { type: "string" },
168          limit: { type: "number" },
169          startDate: { type: "string" },
170          endDate: { type: "string" },
171        },
172        required: ["userId"],
173      },
174    },
175  },
176
177  "get-raw-ideas-by-user": {
178    type: "function",
179    function: {
180      name: "get-raw-ideas-by-user",
181      description: "Get user's raw ideas inbox.",
182      parameters: {
183        type: "object",
184        properties: {
185          userId: { type: "string" },
186          limit: { type: "number" },
187          startDate: { type: "string" },
188          endDate: { type: "string" },
189        },
190        required: ["userId"],
191      },
192    },
193  },
194
195  "get-root-nodes": {
196    type: "function",
197    function: {
198      name: "get-root-nodes",
199      description: "Get all root trees owned by user.",
200      parameters: {
201        type: "object",
202        properties: {
203          userId: { type: "string" },
204        },
205        required: ["userId"],
206      },
207    },
208  },
209
210  // ── WRITE ─────────────────────────────────────────────────────────────
211
212  "edit-node-or-branch-status": {
213    type: "function",
214    function: {
215      name: "edit-node-or-branch-status",
216      description:
217        "Change node status. Use isInherited=true to apply to children.",
218      parameters: {
219        type: "object",
220        properties: {
221          nodeId: { type: "string" },
222          status: { type: "string", enum: ["active", "trimmed", "completed"] },
223          prestige: { type: "number" },
224          isInherited: {
225            type: "boolean",
226            description: "Apply to children recursively",
227          },
228          userId: { type: "string" },
229        },
230        required: ["nodeId", "status", "isInherited", "userId"],
231      },
232    },
233  },
234
235  "create-node-note": {
236    type: "function",
237    function: {
238      name: "create-node-note",
239      description: "Create a text note on a node. Confirm exact wording first.",
240      parameters: {
241        type: "object",
242        properties: {
243          content: { type: "string", description: "Note text content" },
244          nodeId: { type: "string" },
245          userId: { type: "string" },
246        },
247        required: ["content", "nodeId", "userId"],
248      },
249    },
250  },
251  "edit-node-note" : {
252  type: "function",
253  function: {
254    name: "edit-node-note",
255    description:
256      "Edit an existing text note. By default replaces all content. Optionally specify a line range to replace or insert at specific lines.",
257    parameters: {
258      type: "object",
259      properties: {
260        noteId: {
261          type: "string",
262          description: "The ID of the note to edit.",
263        },
264        content: {
265          type: "string",
266          description:
267            "New content. Replaces entire note, or replaces the specified line range.",
268        },
269        lineStart: {
270          type: "number",
271          description:
272            "Start line (0-indexed). If provided with lineEnd, replaces lines [start, end). If provided alone, inserts at that line.",
273        },
274        lineEnd: {
275          type: "number",
276          description:
277            "End line (0-indexed, exclusive). Lines from lineStart to lineEnd are replaced with the new content.",
278        },
279        nodeId: { type: "string" },
280          prestige: { type: "number" },
281      },
282      required: ["noteId", "content", "nodeId"],
283    },
284  },
285},
286
287  "transfer-node-note": {
288    type: "function",
289    function: {
290      name: "transfer-node-note",
291      description: "Transfer a note from its current node to a different node in the same tree.",
292      parameters: {
293        type: "object",
294        properties: {
295          noteId: { type: "string", description: "The ID of the note to transfer" },
296          targetNodeId: { type: "string", description: "The destination node ID" },
297          prestige: { type: "number", description: "Target version (defaults to latest)" },
298        },
299        required: ["noteId", "targetNodeId"],
300      },
301    },
302  },
303
304  "delete-node-note": {
305    type: "function",
306    function: {
307      name: "delete-node-note",
308      description: "Delete a note by ID.",
309      parameters: {
310        type: "object",
311        properties: {
312          noteId: { type: "string" },
313           nodeId: { type: "string" },
314          prestige: { type: "number" },
315        },
316        required: ["noteId", "nodeId"],
317      },
318    },
319  },
320
321  "create-new-node": {
322    type: "function",
323    function: {
324      name: "create-new-node",
325      description: "Create a single new node.",
326      parameters: {
327        type: "object",
328        properties: {
329          name: { type: "string" },
330          parentId: { type: "string", description: "ID of the parent node." },
331          userId: { type: "string" },
332          note: { type: "string", description: "Optional initial note." },
333          type: {
334            type: "string",
335            description:
336              "Optional semantic type. Core types: goal, plan, task, knowledge, resource, identity. Custom types valid.",
337          },
338        },
339        required: ["name", "parentId", "userId"],
340      },
341    },
342  },
343
344  "create-tree": {
345    type: "function",
346    function: {
347      name: "create-tree",
348      description: "Create a new tree by creating a root node.",
349      parameters: {
350        type: "object",
351        properties: {
352          name: {
353            type: "string",
354            description: "Name of the new tree (root node).",
355          },
356          note: {
357            type: "string",
358            description: "Optional initial note for the root node.",
359          },
360          type: {
361            type: "string",
362            description:
363              "Optional semantic type. Core types: goal, plan, task, knowledge, resource, identity.",
364          },
365          userId: {
366            type: "string",
367            description: "Injected by server. Ignore.",
368          },
369        },
370        required: ["name", "userId"],
371      },
372    },
373  },
374
375  "create-new-node-branch": {
376    type: "function",
377    function: {
378      name: "create-new-node-branch",
379      description: "Create a recursive tree structure.",
380      parameters: {
381        type: "object",
382        properties: {
383          nodeData: {
384            type: "object",
385            description: "Node with optional children array. Each node has name, optional type, note, and children.",
386            properties: {
387              name: { type: "string" },
388              note: { type: "string" },
389              type: {
390                type: "string",
391                description:
392                  "Optional semantic type. Core types: goal, plan, task, knowledge, resource, identity.",
393              },
394              children: { type: "array" },
395            },
396            required: ["name"],
397          },
398          parentId: { type: "string" },
399          userId: { type: "string" },
400        },
401        required: ["nodeData", "parentId", "userId"],
402      },
403    },
404  },
405  "delete-node-branch": {
406    type: "function",
407    function: {
408      name: "delete-node-branch",
409      description:
410        "Retire (delete) a node branch and detach it from its parent.",
411      parameters: {
412        type: "object",
413        properties: {
414          nodeId: {
415            type: "string",
416            description: "ID of the node branch to be retired.",
417          },
418          userId: {
419            type: "string",
420            description: "User performing the delete action.",
421          },
422        },
423        required: ["nodeId", "userId"],
424      },
425    },
426  },
427  "navigate-tree": {
428    type: "function",
429    function: {
430      name: "navigate-tree",
431      description:
432        "Return minimal structural context for navigating a tree (current node, parent, children, siblings, root).",
433      parameters: {
434        type: "object",
435        properties: {
436          nodeId: {
437            type: "string",
438            description: "The current node ID to navigate from.",
439          },
440         
441          search: {
442            type: "string",
443            description: "Search node names across the tree. Returns up to 10 matches with paths",
444          },
445          
446        },
447        required: ["nodeId"],
448      },
449    },
450  },
451 "get-tree-context" : {
452  type: "function",
453  function: {
454    name: "get-tree-context",
455    description:
456      "Reads node data with configurable scope. Returns notes, and optionally siblings, parent chain, scripts.",
457    parameters: {
458      type: "object",
459      properties: {
460        nodeId: {
461          type: "string",
462          description: "Node ID to read.",
463        },
464        includeNotes: {
465          type: "boolean",
466          description: "Include notes. Default true.",
467        },
468        includeSiblings: {
469          type: "boolean",
470          description: "Include sibling node names. Default false.",
471        },
472        includeParentChain: {
473          type: "boolean",
474          description: "Include full path from root. Default false.",
475        },
476        includeChildren: {
477          type: "boolean",
478          description: "Include children names. Default true.",
479        },
480},
481      required: ["nodeId"],
482    },
483  },
484},
485
486  "edit-node-name": {
487    type: "function",
488    function: {
489      name: "edit-node-name",
490      description: "Rename a node.",
491      parameters: {
492        type: "object",
493        properties: {
494          nodeId: { type: "string" },
495          newName: { type: "string" },
496          userId: { type: "string" },
497        },
498        required: ["nodeId", "newName", "userId"],
499      },
500    },
501  },
502
503  "edit-node-type": {
504    type: "function",
505    function: {
506      name: "edit-node-type",
507      description:
508        "Set or clear a node's semantic type. Core types: goal, plan, task, knowledge, resource, identity. Custom types valid. Use null to clear.",
509      parameters: {
510        type: "object",
511        properties: {
512          nodeId: { type: "string" },
513          newType: {
514            type: ["string", "null"],
515            description: "Type label or null to clear.",
516          },
517          userId: { type: "string" },
518        },
519        required: ["nodeId", "newType", "userId"],
520      },
521    },
522  },
523
524  "update-node-branch-parent-relationship": {
525    type: "function",
526    function: {
527      name: "update-node-branch-parent-relationship",
528      description: "Move a node to a new parent.",
529      parameters: {
530        type: "object",
531        properties: {
532          nodeChildId: { type: "string" },
533          nodeNewParentId: { type: "string" },
534          userId: { type: "string" },
535        },
536        required: ["nodeChildId", "nodeNewParentId", "userId"],
537      },
538    },
539  },
540
541  "transfer-raw-idea-to-note": {
542    type: "function",
543    function: {
544      name: "transfer-raw-idea-to-note",
545      description: "Convert a raw idea to a note on a node.",
546      parameters: {
547        type: "object",
548        properties: {
549          rawIdeaId: { type: "string" },
550          nodeId: { type: "string" },
551          userId: { type: "string" },
552        },
553        required: ["rawIdeaId", "nodeId", "userId"],
554      },
555    },
556  },
557
558    // understanding-list, understanding-create, understanding-process live in the understanding extension.
559};
560
561export default TOOL_DEFS;
562

Versions

Version Published Downloads
1.0.5 38d ago 0
1.0.4 38d ago 0
1.0.3 46d ago 0
1.0.2 47d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star treeos-base

Comments

Loading comments...

Post comments from the CLI: treeos ext comment treeos-base "your comment"
Max 3 comments per extension. One star and one flag per user.