clay-server 2.39.0-beta.2 → 2.39.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/daemon.js CHANGED
@@ -1248,11 +1248,114 @@ var ipc = createIPCServer(socketPath(), function (msg) {
1248
1248
  }
1249
1249
  });
1250
1250
 
1251
+ // --- Cleanup stale SDK warmup CLI session files ---
1252
+ //
1253
+ // On adapter init the SDK is given a dummy "hi" prompt to discover models,
1254
+ // skills, and capabilities. Even though we abort before any response
1255
+ // arrives, the SDK creates a real CLI session jsonl on disk. Going forward
1256
+ // we delete those inline (see lib/yoke/adapters/claude.js and
1257
+ // claude-worker.js), but installs from before that fix can have a long
1258
+ // tail of "hi"-only sessions polluting the Resume CLI session dialog.
1259
+ //
1260
+ // On every daemon startup we scan REAL_HOME/.claude/projects/*/ and unlink
1261
+ // files that match a strict warmup signature so legitimate "hi" sessions
1262
+ // from real users are never touched. All checks must pass:
1263
+ //
1264
+ // 1. file size < 64 KB (real sessions grow past this fast;
1265
+ // threshold is generous because warmup files include the SDK's
1266
+ // skill_listing / deferred_tools attachments which can run into the
1267
+ // tens of KB on its own)
1268
+ // 2. mtime older than 60 seconds (avoid racing an in-flight warmup)
1269
+ // 3. exactly one user message, content text equals literal "hi"
1270
+ // 4. zero assistant messages (real users virtually always get a reply)
1271
+ //
1272
+ // Multi-user OS-isolated installs have per-user ~/.claude trees that the
1273
+ // daemon process can't traverse; the inline cleanup in claude-worker.js
1274
+ // covers those.
1275
+ function cleanupSdkWarmupSessions() {
1276
+ var projectsRoot = path.join(REAL_HOME, ".claude", "projects");
1277
+ var projectDirs;
1278
+ try { projectDirs = fs.readdirSync(projectsRoot, { withFileTypes: true }); }
1279
+ catch (e) { return; }
1280
+
1281
+ var deleted = 0;
1282
+ var nowMs = Date.now();
1283
+ var MAX_BYTES = 64 * 1024;
1284
+ var MIN_AGE_MS = 60 * 1000;
1285
+
1286
+ for (var di = 0; di < projectDirs.length; di++) {
1287
+ if (!projectDirs[di].isDirectory()) continue;
1288
+ var projDir = path.join(projectsRoot, projectDirs[di].name);
1289
+ var files;
1290
+ try { files = fs.readdirSync(projDir); } catch (e) { continue; }
1291
+
1292
+ for (var fi = 0; fi < files.length; fi++) {
1293
+ var f = files[fi];
1294
+ if (!f.endsWith(".jsonl")) continue;
1295
+ var fp = path.join(projDir, f);
1296
+
1297
+ var st;
1298
+ try { st = fs.statSync(fp); } catch (e) { continue; }
1299
+ if (st.size >= MAX_BYTES) continue;
1300
+ if (nowMs - st.mtimeMs < MIN_AGE_MS) continue;
1301
+
1302
+ var raw;
1303
+ try { raw = fs.readFileSync(fp, "utf8"); } catch (e) { continue; }
1304
+ var lines = raw.split("\n");
1305
+ var userCount = 0;
1306
+ var assistantCount = 0;
1307
+ var userText = null;
1308
+ var parseOk = true;
1309
+ for (var li = 0; li < lines.length; li++) {
1310
+ var line = lines[li];
1311
+ if (!line) continue;
1312
+ var ev;
1313
+ try { ev = JSON.parse(line); } catch (e) { parseOk = false; break; }
1314
+ if (!ev || typeof ev !== "object") continue;
1315
+ if (ev.type === "user") {
1316
+ userCount++;
1317
+ if (userCount > 1) break;
1318
+ var msg = ev.message;
1319
+ var content = msg && msg.content;
1320
+ if (typeof content === "string") {
1321
+ userText = content;
1322
+ } else if (Array.isArray(content)) {
1323
+ var parts = [];
1324
+ for (var ci = 0; ci < content.length; ci++) {
1325
+ if (content[ci] && content[ci].type === "text" && typeof content[ci].text === "string") {
1326
+ parts.push(content[ci].text);
1327
+ }
1328
+ }
1329
+ userText = parts.join("");
1330
+ }
1331
+ } else if (ev.type === "assistant") {
1332
+ assistantCount++;
1333
+ if (assistantCount > 0) break;
1334
+ }
1335
+ }
1336
+ if (!parseOk) continue;
1337
+ if (userCount !== 1) continue;
1338
+ if (assistantCount !== 0) continue;
1339
+ if (userText !== "hi") continue;
1340
+
1341
+ try { fs.unlinkSync(fp); deleted++; } catch (e) {}
1342
+ }
1343
+ }
1344
+
1345
+ if (deleted > 0) {
1346
+ console.log("[daemon] Removed " + deleted + " stale SDK warmup session file(s)");
1347
+ }
1348
+ }
1349
+
1251
1350
  // --- Start listening (with retry for port-in-use during update handoff) ---
1252
1351
  var listenRetries = 0;
1253
1352
  var MAX_LISTEN_RETRIES = 15;
1254
1353
 
1255
1354
  function startListening() {
1355
+ // One-shot cleanup of stale SDK warmup CLI session files from previous
1356
+ // daemon runs (best-effort, never fatal).
1357
+ try { cleanupSdkWarmupSessions(); } catch (e) {}
1358
+
1256
1359
  relay.server.listen(config.port, listenHost, function () {
1257
1360
  var protocol = tlsOptions ? "https" : "http";
1258
1361
  console.log("[daemon] Listening on " + protocol + "://" + listenHost + ":" + config.port);
@@ -157,6 +157,21 @@ function attachConnection(ctx) {
157
157
  var _comVal = _comUid ? usersModule.getClaudeOpenMode(_comUid) : "tui";
158
158
  sendTo(ws, { type: "claude_open_mode_changed", claudeOpenMode: _comVal || "tui" });
159
159
  }
160
+
161
+ // What's New: push the full entries list (for the home-page feed)
162
+ // plus the subset of unseen ids (for the auto-pop carousel). Content
163
+ // lives in lib/whats-new-content.js so adding an entry doesn't touch
164
+ // this file.
165
+ try {
166
+ var _wn = require("./whats-new");
167
+ var _wnUid = (wsUser && wsUser.id) || null;
168
+ var _wnState = _wnUid ? _wn.getStateForUser(_wnUid) : { entries: _wn.listEntries(), unseenIds: [] };
169
+ if (_wnState.entries.length > 0) {
170
+ sendTo(ws, { type: "whats_new_state", entries: _wnState.entries, unseenIds: _wnState.unseenIds });
171
+ }
172
+ } catch (e) {
173
+ if (debug) console.error("[project] whats_new send failed:", e && e.message);
174
+ }
160
175
  _loop.sendConnectionState(ws);
161
176
  if (_mcp) _mcp.sendConnectionState(ws);
162
177
  if (_notifications) _notifications.sendConnectionState(ws, sendTo);
@@ -265,12 +265,21 @@ function attachSessions(ctx) {
265
265
  return null;
266
266
  }
267
267
 
268
- // In-place conversion of a TUI session to GUI when the user's preference
269
- // is 'gui'. Reads the jsonl transcript through the existing import path
270
- // (same code Import CLI uses), populates session.history, flips the mode
271
- // to 'gui', and kills the PTY. Subsequent clicks render via the SDK chat.
272
- function convertTuiSessionToGui(session) {
268
+ // Prepare a born-TUI session to be rendered via the SDK GUI chat for the
269
+ // current click. Reads the CLI jsonl transcript (cli-sessions.js),
270
+ // populates session.history, and tears
271
+ // down the PTY. The born-TUI marker (session.mode === 'tui') is kept on
272
+ // disk so that flipping claudeOpenMode back to 'tui' later restores the
273
+ // embedded-terminal experience instead of locking the session as GUI.
274
+ //
275
+ // Idempotent: if the PTY is already gone and history is populated, this
276
+ // is a no-op (avoids re-reading jsonl and re-writing the session file on
277
+ // every click while the user is reading the session in GUI mode).
278
+ function prepareTuiSessionForGuiView(session) {
273
279
  if (!session || session.cliSessionId == null) return;
280
+ var alreadyHydrated = (typeof session.terminalId !== "number") &&
281
+ Array.isArray(session.history) && session.history.length > 0;
282
+ if (alreadyHydrated) return;
274
283
  var cliSess;
275
284
  try { cliSess = require("./cli-sessions"); } catch (e) { return; }
276
285
  var history = null;
@@ -278,7 +287,6 @@ function attachSessions(ctx) {
278
287
  if (Array.isArray(history)) {
279
288
  session.history = history;
280
289
  }
281
- session.mode = "gui";
282
290
  if (typeof session.terminalId === "number" && tm) {
283
291
  try { tm.close(session.terminalId); } catch (e) {}
284
292
  }
@@ -494,101 +502,10 @@ function attachSessions(ctx) {
494
502
  return true;
495
503
  }
496
504
 
497
- if (msg.type === "resume_session") {
498
- if (!msg.cliSessionId) return true;
499
- var cliSess = require("./cli-sessions");
500
-
501
- // If Clay already has a persisted meta file for this cliSessionId, read
502
- // its vendor so resumeSession doesn't silently default to the project's
503
- // primary vendor (which would break codex sessions after server restart).
504
- var persistedVendor = null;
505
- try {
506
- var _fsResume = require("fs");
507
- var _pathResume = require("path");
508
- var metaPath = _pathResume.join(sm.sessionsDir, msg.cliSessionId + ".jsonl");
509
- if (_fsResume.existsSync(metaPath)) {
510
- var firstLine = _fsResume.readFileSync(metaPath, "utf8").split("\n", 1)[0];
511
- try {
512
- var metaObj = JSON.parse(firstLine);
513
- if (metaObj && metaObj.type === "meta" && metaObj.vendor) persistedVendor = metaObj.vendor;
514
- } catch (e) {}
515
- }
516
- } catch (e) {}
517
-
518
- // Try SDK for title first, then fall back to manual parsing
519
- var titlePromise = adapter.getSessionInfo(msg.cliSessionId, { dir: cwd }).then(function(info) {
520
- return (info && info.summary) ? info.summary.substring(0, 100) : null;
521
- }).catch(function() { return null; });
522
-
523
- Promise.all([
524
- cliSess.readCliSessionHistory(cwd, msg.cliSessionId),
525
- titlePromise
526
- ]).then(function(results) {
527
- var history = results[0];
528
- var sdkTitle = results[1];
529
- var title = sdkTitle || "Resumed session";
530
- if (!sdkTitle) {
531
- for (var i = 0; i < history.length; i++) {
532
- if (history[i].type === "user_message" && history[i].text) {
533
- title = history[i].text.substring(0, 50);
534
- break;
535
- }
536
- }
537
- }
538
- var resumed = sm.resumeSession(msg.cliSessionId, { history: history, title: title, vendor: persistedVendor || undefined }, ws);
539
- if (resumed) ws._clayActiveSession = resumed.localId;
540
- }).catch(function() {
541
- var resumed = sm.resumeSession(msg.cliSessionId, persistedVendor ? { vendor: persistedVendor } : undefined, ws);
542
- if (resumed) ws._clayActiveSession = resumed.localId;
543
- });
544
- return true;
545
- }
546
-
547
- if (msg.type === "list_cli_sessions") {
548
- var _fs = require("fs");
549
- // Collect session IDs already in relay (in-memory + persisted on disk)
550
- var relayIds = {};
551
- sm.sessions.forEach(function (s) {
552
- if (s.cliSessionId) relayIds[s.cliSessionId] = true;
553
- });
554
- try {
555
- var sessDir = sm.sessionsDir;
556
- var diskFiles = _fs.readdirSync(sessDir);
557
- for (var fi = 0; fi < diskFiles.length; fi++) {
558
- if (diskFiles[fi].endsWith(".jsonl")) {
559
- relayIds[diskFiles[fi].replace(".jsonl", "")] = true;
560
- }
561
- }
562
- } catch (e) {}
563
-
564
- adapter.listSessions({ dir: cwd }).then(function(sdkSessions) {
565
- var filtered = sdkSessions.filter(function(s) {
566
- return !relayIds[s.sessionId];
567
- }).map(function(s) {
568
- return {
569
- sessionId: s.sessionId,
570
- firstPrompt: s.summary || s.firstPrompt || "",
571
- model: null,
572
- gitBranch: s.gitBranch || null,
573
- startTime: s.createdAt ? new Date(s.createdAt).toISOString() : null,
574
- lastActivity: s.lastModified ? new Date(s.lastModified).toISOString() : null,
575
- };
576
- });
577
- sendTo(ws, { type: "cli_session_list", sessions: filtered });
578
- }).catch(function() {
579
- // Fallback to manual parsing if SDK fails
580
- var cliSessions = require("./cli-sessions");
581
- cliSessions.listCliSessions(cwd).then(function(sessions) {
582
- var filtered = sessions.filter(function(s) {
583
- return !relayIds[s.sessionId];
584
- });
585
- sendTo(ws, { type: "cli_session_list", sessions: filtered });
586
- }).catch(function() {
587
- sendTo(ws, { type: "cli_session_list", sessions: [] });
588
- });
589
- });
590
- return true;
591
- }
505
+ // Note: the old `resume_session` and `list_cli_sessions` WS handlers
506
+ // were removed when CLI sessions started being auto-adopted into the
507
+ // unified session list on server start (see sessions.js
508
+ // adoptOrphanedCliSessions). Click a session in the sidebar instead.
592
509
 
593
510
  if (msg.type === "switch_session") {
594
511
  if (msg.id && sm.sessions.has(msg.id)) {
@@ -598,21 +515,24 @@ function attachSessions(ctx) {
598
515
  // - born-GUI viewed under TUI pref: spawn a transient PTY running
599
516
  // `claude --resume <cliSessionId>`; the session record stays GUI
600
517
  // so a later pref flip back to GUI just hides the runtime link.
601
- // - born-TUI viewed under GUI pref: in-place convert via the
602
- // Import CLI code path (jsonl history -> session.history,
603
- // session.mode -> 'gui', kill PTY). This is destructive but
604
- // matches "TUI->GUI is what Import CLI was always for."
518
+ // - born-TUI viewed under GUI pref: hydrate session.history from
519
+ // the jsonl transcript and tear down the PTY so the session
520
+ // renders via the SDK chat. The born-TUI marker stays on the
521
+ // record so a later pref flip back to TUI restores the
522
+ // embedded-terminal experience.
605
523
  //
606
524
  // runtimeMode / runtimeTerminalId are set on the session record so
607
525
  // the session_switched and session_list broadcasts surface them to
608
526
  // the client without sessions.js needing to know about the pref.
609
527
  var xmTarget = sm.sessions.get(msg.id);
610
528
  if (xmTarget && (xmTarget.vendor === "claude" || !xmTarget.vendor)) {
529
+ var xmPref = getClaudeOpenModeForWs(ws);
611
530
  // Born-TUI session whose PTY is gone (typically after a daemon
612
- // restart). Respawn via `claude --resume <cliSessionId>` before
613
- // the cross-mode logic runs - the rest of the pipeline assumes
614
- // terminalId points at a live PTY for TUI sessions.
615
- if (xmTarget.mode === "tui" && xmTarget.cliSessionId && tm &&
531
+ // restart) AND the user wants TUI rendering this click. Respawn
532
+ // via `claude --resume <cliSessionId>` so the rest of the pipeline
533
+ // has a live PTY to point at. Skipped when the user's pref is GUI
534
+ // - we'd just tear it back down two lines later.
535
+ if (xmTarget.mode === "tui" && xmTarget.cliSessionId && tm && xmPref === "tui" &&
616
536
  (typeof xmTarget.terminalId !== "number" || !tm.has(xmTarget.terminalId))) {
617
537
  var rsSid = xmTarget.cliSessionId;
618
538
  var rsLocalId = xmTarget.localId;
@@ -632,11 +552,15 @@ function attachSessions(ctx) {
632
552
  if (rsTerm) xmTarget.terminalId = rsTerm.id;
633
553
  startTitleWatcher(xmTarget);
634
554
  }
635
- var xmPref = getClaudeOpenModeForWs(ws);
636
555
  var xmRuntime = computeRuntimeMode(xmTarget, xmPref);
637
556
  if (xmRuntime === "gui" && xmTarget.mode === "tui") {
638
- convertTuiSessionToGui(xmTarget);
639
- xmTarget.runtimeMode = null;
557
+ // Born-TUI session under GUI pref. Hydrate history + drop PTY
558
+ // without flipping session.mode, so flipping back to TUI later
559
+ // restores the embedded-terminal rendering. runtimeMode is set
560
+ // to 'gui' explicitly so the client doesn't fall back to
561
+ // session.mode (which is still 'tui').
562
+ prepareTuiSessionForGuiView(xmTarget);
563
+ xmTarget.runtimeMode = "gui";
640
564
  xmTarget.runtimeTerminalId = null;
641
565
  } else if (xmRuntime === "tui" && xmTarget.mode === "gui" && xmTarget.cliSessionId) {
642
566
  var xmRid = spawnRuntimeTuiPty(xmTarget, ws);
@@ -1754,6 +1678,24 @@ function attachSessions(ctx) {
1754
1678
  return true;
1755
1679
  }
1756
1680
 
1681
+ if (msg.type === "whats_new_seen") {
1682
+ // Persist that the current user dismissed a What's New entry so it
1683
+ // is not shown again on future connects.
1684
+ var wnUserId = ws._clayUser ? ws._clayUser.id : null;
1685
+ if (!wnUserId) {
1686
+ sendTo(ws, { type: "whats_new_seen_result", ok: false, error: "no_user" });
1687
+ return true;
1688
+ }
1689
+ var wnSvc = require("./whats-new");
1690
+ var wnResult = wnSvc.markSeen(wnUserId, msg.id);
1691
+ if (wnResult && wnResult.ok) {
1692
+ sendTo(ws, { type: "whats_new_seen_result", ok: true, id: msg.id });
1693
+ } else {
1694
+ sendTo(ws, { type: "whats_new_seen_result", ok: false, error: (wnResult && wnResult.error) || "unknown" });
1695
+ }
1696
+ return true;
1697
+ }
1698
+
1757
1699
  if (msg.type === "set_claude_open_mode") {
1758
1700
  // Per-user preference: when Clay opens a Claude session, render it as
1759
1701
  // the SDK-driven custom chat ("gui") or as an embedded `claude` TUI
package/lib/public/app.js CHANGED
@@ -5,7 +5,7 @@ import { renderMarkdown, highlightCodeBlocks, renderMermaidBlocks, closeMermaidM
5
5
  import { initSidebar, updatePageTitle, spawnDustParticles } from './modules/sidebar.js';
6
6
  import {
7
7
  renderSessionList, handleSearchResults, updateSessionPresence,
8
- updateSessionBadge, populateCliSessionList
8
+ updateSessionBadge
9
9
  } from './modules/sidebar-sessions.js';
10
10
  import {
11
11
  renderIconStrip, getEmojiCategories, updateProjectBadge
@@ -63,6 +63,8 @@ import { initDebateUi, showDebateConcludeConfirm as _debShowDebateConcludeConfir
63
63
  import { initLoopUi, updateLoopInputVisibility as _loopUpdateLoopInputVisibility, updateLoopButton as _loopUpdateLoopButton, showLoopBanner as _loopShowLoopBanner, updateLoopBanner as _loopUpdateLoopBanner, updateRalphBars as _loopUpdateRalphBars, showRalphCraftingBar as _loopShowRalphCraftingBar, showRalphApprovalBar as _loopShowRalphApprovalBar, updateRalphApprovalStatus as _loopUpdateRalphApprovalStatus, openRalphPreviewModal as _loopOpenRalphPreviewModal, showExecModal as _loopShowExecModal, closeExecModal as _loopCloseExecModal, updateExecModalStatus as _loopUpdateExecModalStatus } from './modules/app-loop-ui.js';
64
64
  import { initLoopWizard, openRalphWizard as _loopOpenRalphWizard, closeRalphWizard as _loopCloseRalphWizard, getWizardSource as _loopGetWizardSource } from './modules/app-loop-wizard.js';
65
65
  import { initAppNotifications, handleNotificationsState as _notifHandleState, handleNotificationCreated as _notifHandleCreated, handleNotificationDismissed as _notifHandleDismissed, handleNotificationDismissedAll as _notifHandleDismissedAll } from './modules/app-notifications.js';
66
+ import { initWhatsNew, handleWhatsNewState as _wnHandleState, handleWhatsNewSeenResult as _wnHandleSeenResult } from './modules/whats-new.js';
67
+ import { initWhatsNewArticle, openArticle as openWhatsNewArticle } from './modules/whats-new-article.js';
66
68
  import { createStore, store } from './modules/store.js';
67
69
  import { initPanels, updateConfigChip as _panUpdateConfigChip, getModelEffortLevels as _panGetModelEffortLevels, accumulateUsage as _panAccumulateUsage, updateUsagePanel as _panUpdateUsagePanel, resetUsage as _panResetUsage, toggleUsagePanel as _panToggleUsagePanel, formatTokens as _panFormatTokens, updateStatusPanel as _panUpdateStatusPanel, requestProcessStats as _panRequestProcessStats, toggleStatusPanel as _panToggleStatusPanel, accumulateContext as _panAccumulateContext, updateContextPanel as _panUpdateContextPanel, resetContext as _panResetContext, resetContextData as _panResetContextData, minimizeContext as _panMinimizeContext, expandContext as _panExpandContext, toggleContextPanel as _panToggleContextPanel, getContextView as _panGetContextView, renderCtxPopover as _panRenderCtxPopover, hideCtxPopover as _panHideCtxPopover, formatBytes as _panFormatBytes, formatUptime as _panFormatUptime, getModelSupportsEffort as _panGetModelSupportsEffort, getSessionUsage, setSessionUsage, getContextData, setContextData, setContextView as _panSetContextView, applyContextView as _panApplyContextView } from './modules/app-panels.js';
68
70
  import { initProjects, updateProjectList as _projUpdateProjectList, renderProjectList as _projRenderProjectList, renderTopbarPresence as _projRenderTopbarPresence, switchProject as _projSwitchProject, resetClientState as _projResetClientState, confirmRemoveProject as _projConfirmRemoveProject, handleRemoveProjectCheckResult as _projHandleRemoveProjectCheckResult, handleRemoveProjectResult as _projHandleRemoveProjectResult, openAddProjectModal as _projOpenAddProjectModal, closeAddProjectModal as _projCloseAddProjectModal, handleBrowseDirResult as _projHandleBrowseDirResult, handleAddProjectResult as _projHandleAddProjectResult, handleCloneProgress as _projHandleCloneProgress, showUpdateAvailable as _projShowUpdateAvailable, getCachedProjects, setCachedProjects, getCachedProjectCount, getCachedRemovedProjects, setCachedRemovedProjects } from './modules/app-projects.js';
@@ -97,7 +99,6 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
97
99
  var hamburgerBtn = $("hamburger-btn");
98
100
  var sidebarToggleBtn = $("sidebar-toggle-btn");
99
101
  var sidebarExpandBtn = $("sidebar-expand-btn");
100
- var resumeSessionBtn = $("resume-session-btn");
101
102
  var imagePreviewBar = $("image-preview-bar");
102
103
  var connectOverlay = $("connect-overlay");
103
104
 
@@ -376,7 +377,6 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
376
377
  sidebarExpandBtn: sidebarExpandBtn,
377
378
  hamburgerBtn: hamburgerBtn,
378
379
  newSessionBtn: newSessionBtn,
379
- resumeSessionBtn: resumeSessionBtn,
380
380
  headerTitleEl: headerTitleEl,
381
381
  showConfirm: showConfirm,
382
382
  onFilesTabOpen: function () { loadRootDirectory(); },
@@ -590,6 +590,17 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
590
590
  // --- Notifications module ---
591
591
  initAppNotifications();
592
592
 
593
+ // --- What's New viewer ---
594
+ initWhatsNewArticle();
595
+ initWhatsNew({
596
+ // "Read more" in the carousel opens the dedicated article viewer
597
+ // for the chosen entry, jumping straight to the body content
598
+ // (skipping the home list).
599
+ onReadMore: function (entryId) {
600
+ if (entryId) openWhatsNewArticle(entryId);
601
+ },
602
+ });
603
+
593
604
  // --- Panels module ---
594
605
  initPanels();
595
606