63b01dee7350313c4b050b31b1a1fe1d268236845cb3a5bd4ce56b0695c7a44d2 packages depend on this
| Package | Type | Relationship |
|---|---|---|
| tree-orchestrator v1.0.5 | extension | needs |
| treeos v1.0.1 | os | standalone |
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(/ /g, ' ');
2346 html = html.replace(/&/g, '&');
2347 html = html.replace(/</g, '<');
2348 html = html.replace(/>/g, '>');
2349 html = html.replace(/\\u00A0/g, ' ');
2350 html = html.replace(/–/g, '-');
2351 html = html.replace(/—/g, '--');
2352
2353 html = html.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
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, '"') + '">' +
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, '"') + '">' +
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(/^>\\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, '"') + '">' +
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, '"') + '">' +
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, '"') + '">' +
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;
32591// 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, "&")
21 .replace(/</g, "<")
22 .replace(/>/g, ">")
23 .replace(/"/g, """)
24 .replace(/'/g, "'");
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()">✕</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(/ /g, ' ');
911 html = html.replace(/&/g, '&');
912 html = html.replace(/</g, '<');
913 html = html.replace(/>/g, '>');
914 html = html.replace(/\\u00A0/g, ' ');
915
916 html = html.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
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(/^>\\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, '"') + '">' +
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;
19091// 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">×</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">×</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">← All Trees</button>
576 <span class="dash-tree-title" id="dashTreeTitle">Tree</span>
577 <button class="dash-close-btn" id="dashCloseBtn2" title="Close dashboard">×</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">←</button>
600 <button class="dash-chat-close" id="dashChatRefresh" title="Refresh">↻</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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
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}
12981// 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;
501/* ─────────────────────────────────────────────── */
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">🌱</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 = "🌳";
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}
3581// 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}
14671/**
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}
13501import 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}
2661export 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};
481// 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};
1301// 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};
491// 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};
1081// 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};
1341// 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};
1361// 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};
811// 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}
1461/**
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}
1421/* ------------------------------------------------- */
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}
10261/* ------------------------------------------------- */
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}
5061/* ------------------------------------------------- */
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 & 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}
4241/* --------------------------------------------------------- */
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, "&")
20 .replace(/</g, "<")
21 .replace(/>/g, ">")
22 .replace(/"/g, """);
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,"&").replace(/</g,"<").replace(/>/g,">"); }
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}
14601/**
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
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}
3491/**
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
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}
2341/* ------------------------------------------------------------------ */
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"><- 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}
8181/* ------------------------------------------------------------------ */
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, '"')}"
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}
7701/**
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
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}
1411/**
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}
2441/* --------------------------------------------------------- */
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}
6401/* --------------------------------------------------------- */
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}
10731import { 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}
9981/* ------------------------------------------------- */
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, "&")
294 .replace(/</g, "<")
295 .replace(/>/g, ">");
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,"&").replace(/</g,"<").replace(/>/g,">").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}
3831import { 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}
4631/* ------------------------------------------------- */
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}
15841/* ------------------------------------------------------------------ */
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, '"')}"
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}
9661/**
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
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}
1681/**
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}
1751// 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
| 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 |
treeos ext star treeos-base
Post comments from the CLI: treeos ext comment treeos-base "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...