@co0ontty/wand 1.2.0 → 1.2.3

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/dist/server.js CHANGED
@@ -56,11 +56,11 @@ import { ensureAvatarSeed, getAvatarSvg } from "./avatar.js";
56
56
  import { createSession, revokeSession, setAuthStorage, validateSession } from "./auth.js";
57
57
  import { ensureCertificates } from "./cert.js";
58
58
  import { isExecutionMode, resolveConfigDir, saveConfig } from "./config.js";
59
- import { ProcessManager, SessionInputError } from "./process-manager.js";
59
+ import { ProcessManager } from "./process-manager.js";
60
+ import { generatePwaManifest, generateServiceWorker } from "./pwa.js";
61
+ import { registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
60
62
  import { resolveDatabasePath, WandStorage } from "./storage.js";
61
63
  import { renderApp } from "./web-ui/index.js";
62
- import { parseMessages } from "./message-parser.js";
63
- import { generatePwaManifest, generateServiceWorker } from "./pwa.js";
64
64
  import { WsBroadcastManager } from "./ws-broadcast.js";
65
65
  import { checkRateLimit, recordFailedLogin, resetRateLimit } from "./middleware/rate-limit.js";
66
66
  import { isPathWithinBase, isBlockedFolderPath, normalizeFolderPath } from "./middleware/path-safety.js";
@@ -68,35 +68,6 @@ import { isPathWithinBase, isBlockedFolderPath, normalizeFolderPath } from "./mi
68
68
  function getErrorMessage(error, fallback) {
69
69
  return error instanceof Error ? error.message : fallback;
70
70
  }
71
- function getInputErrorResponse(error, sessionId) {
72
- if (error instanceof SessionInputError) {
73
- const statusCode = error.code === "SESSION_NOT_FOUND" ? 404 : 409;
74
- return {
75
- statusCode,
76
- payload: {
77
- error: error.message,
78
- errorCode: error.code,
79
- sessionId,
80
- sessionStatus: error.sessionStatus ?? null,
81
- },
82
- };
83
- }
84
- return {
85
- statusCode: 400,
86
- payload: {
87
- error: getErrorMessage(error, "会话已结束,请启动新会话。"),
88
- errorCode: "INPUT_SEND_FAILED",
89
- sessionId,
90
- sessionStatus: null,
91
- },
92
- };
93
- }
94
- function getInputDebugMeta(error) {
95
- if (error instanceof Error) {
96
- return { name: error.name, message: error.message, stack: error.stack };
97
- }
98
- return { error };
99
- }
100
71
  // ── Git helpers ──
101
72
  async function isGitRepo(dirPath) {
102
73
  try {
@@ -509,152 +480,8 @@ export async function startServer(config, configPath) {
509
480
  updateInFlight = false;
510
481
  }
511
482
  });
512
- app.get("/api/sessions", (_req, res) => {
513
- res.json(processes.list());
514
- });
515
- app.get("/api/claude-history", (_req, res) => {
516
- try {
517
- const sessions = processes.listClaudeHistorySessions();
518
- const hidden = getHiddenClaudeSessionIds(storage);
519
- const filtered = hidden.size > 0
520
- ? sessions.filter((s) => !s.claudeSessionId || !hidden.has(s.claudeSessionId))
521
- : sessions;
522
- res.json(filtered);
523
- }
524
- catch (error) {
525
- res.status(500).json({ error: getErrorMessage(error, "无法扫描 Claude 历史会话。") });
526
- }
527
- });
528
- app.delete("/api/claude-history/:claudeSessionId", (req, res) => {
529
- const claudeSessionId = req.params.claudeSessionId?.trim();
530
- if (!claudeSessionId) {
531
- res.status(400).json({ error: "会话 ID 不能为空。" });
532
- return;
533
- }
534
- const session = processes.listClaudeHistorySessions()
535
- .find((s) => s.claudeSessionId === claudeSessionId);
536
- if (session) {
537
- processes.deleteClaudeHistoryFiles([{ claudeSessionId, cwd: session.cwd }]);
538
- removeFromHiddenClaudeSessionIds(storage, [claudeSessionId]);
539
- }
540
- else {
541
- const hidden = getHiddenClaudeSessionIds(storage);
542
- if (!hidden.has(claudeSessionId)) {
543
- hidden.add(claudeSessionId);
544
- saveHiddenClaudeSessionIds(storage, hidden);
545
- }
546
- }
547
- res.json({ ok: true });
548
- });
549
- app.delete("/api/claude-history", (req, res) => {
550
- const cwd = typeof req.query.cwd === "string" ? req.query.cwd.trim() : "";
551
- if (!cwd) {
552
- res.status(400).json({ error: "目录不能为空。" });
553
- return;
554
- }
555
- try {
556
- const sessions = processes.listClaudeHistorySessions();
557
- const toDelete = [];
558
- for (const session of sessions) {
559
- if (session.claudeSessionId && session.cwd === cwd) {
560
- toDelete.push({ claudeSessionId: session.claudeSessionId, cwd: session.cwd });
561
- }
562
- }
563
- const deleted = processes.deleteClaudeHistoryFiles(toDelete);
564
- removeFromHiddenClaudeSessionIds(storage, toDelete.map((s) => s.claudeSessionId));
565
- res.json({ ok: true, deleted });
566
- }
567
- catch (error) {
568
- res.status(500).json({ error: getErrorMessage(error, "无法删除该目录下的历史会话。") });
569
- }
570
- });
571
- app.post("/api/claude-history/batch-delete", express.json(), (req, res) => {
572
- const claudeSessionIds = Array.isArray(req.body?.claudeSessionIds)
573
- ? req.body.claudeSessionIds.filter((value) => typeof value === "string" && value.trim().length > 0)
574
- : [];
575
- if (claudeSessionIds.length === 0) {
576
- res.status(400).json({ error: "至少提供一个历史会话 ID。" });
577
- return;
578
- }
579
- try {
580
- const allSessions = processes.listClaudeHistorySessions();
581
- const sessionMap = new Map();
582
- for (const s of allSessions) {
583
- if (s.claudeSessionId)
584
- sessionMap.set(s.claudeSessionId, s.cwd);
585
- }
586
- const toDelete = [];
587
- const toHide = [];
588
- for (const id of claudeSessionIds) {
589
- const cwd = sessionMap.get(id);
590
- if (cwd) {
591
- toDelete.push({ claudeSessionId: id, cwd });
592
- }
593
- else {
594
- toHide.push(id);
595
- }
596
- }
597
- const deleted = processes.deleteClaudeHistoryFiles(toDelete);
598
- removeFromHiddenClaudeSessionIds(storage, toDelete.map((s) => s.claudeSessionId));
599
- if (toHide.length > 0) {
600
- const hidden = getHiddenClaudeSessionIds(storage);
601
- let added = 0;
602
- for (const id of toHide) {
603
- if (!hidden.has(id)) {
604
- hidden.add(id);
605
- added++;
606
- }
607
- }
608
- if (added > 0)
609
- saveHiddenClaudeSessionIds(storage, hidden);
610
- }
611
- res.json({ ok: true, deleted: deleted + toHide.length });
612
- }
613
- catch (error) {
614
- res.status(500).json({ error: getErrorMessage(error, "无法批量删除历史会话。") });
615
- }
616
- });
617
- app.post("/api/sessions/batch-delete", express.json(), (req, res) => {
618
- const sessionIds = Array.isArray(req.body?.sessionIds)
619
- ? req.body.sessionIds.filter((value) => typeof value === "string" && value.trim().length > 0)
620
- : [];
621
- if (sessionIds.length === 0) {
622
- res.status(400).json({ error: "至少提供一个会话 ID。" });
623
- return;
624
- }
625
- let deleted = 0;
626
- const failed = [];
627
- for (const sessionId of sessionIds) {
628
- try {
629
- processes.delete(sessionId);
630
- deleted += 1;
631
- }
632
- catch {
633
- failed.push(sessionId);
634
- }
635
- }
636
- if (deleted === 0 && failed.length > 0) {
637
- res.status(400).json({ error: "无法批量删除会话。", failed });
638
- return;
639
- }
640
- res.json({ ok: true, deleted, failed });
641
- });
642
- app.get("/api/sessions/:id", (req, res) => {
643
- const snapshot = processes.get(req.params.id);
644
- if (!snapshot) {
645
- res.status(404).json({ error: "未找到该会话,可能已被删除。" });
646
- return;
647
- }
648
- if (req.query.format === "chat") {
649
- const messages = snapshot.messages && snapshot.messages.length > 0
650
- ? snapshot.messages
651
- : parseMessages(snapshot.output);
652
- res.json({ ...snapshot, messages });
653
- }
654
- else {
655
- res.json(snapshot);
656
- }
657
- });
483
+ registerSessionRoutes(app, processes, storage, config.defaultMode);
484
+ registerClaudeHistoryRoutes(app, processes, storage);
658
485
  // ── Path suggestion ──
659
486
  app.get("/api/path-suggestions", async (req, res) => {
660
487
  const query = typeof req.query.q === "string" ? req.query.q : "";
@@ -982,156 +809,6 @@ export async function startServer(config, configPath) {
982
809
  res.status(400).json({ error: getErrorMessage(error, "无法启动命令。请检查命令是否安装。") });
983
810
  }
984
811
  });
985
- app.post("/api/sessions/:id/resume", (req, res) => {
986
- const sessionId = req.params.id;
987
- const body = req.body;
988
- try {
989
- const existingSession = processes.get(sessionId) || storage.getSession(sessionId);
990
- if (!existingSession) {
991
- res.status(404).json({ error: "会话不存在。" });
992
- return;
993
- }
994
- const claudeSessionId = existingSession.claudeSessionId;
995
- if (!claudeSessionId) {
996
- res.status(400).json({ error: "此会话没有 Claude 会话 ID,无法恢复。" });
997
- return;
998
- }
999
- const command = existingSession.command.trim();
1000
- if (!/^claude\b/.test(command)) {
1001
- res.status(400).json({ error: "只有 Claude 命令支持恢复功能。" });
1002
- return;
1003
- }
1004
- const newMode = body.mode
1005
- ? normalizeMode(body.mode, config.defaultMode)
1006
- : normalizeMode(existingSession.mode, config.defaultMode);
1007
- const resumeCommand = `${command} --resume ${claudeSessionId}`;
1008
- const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { resumedFromSessionId: sessionId });
1009
- storage.saveSession({ ...existingSession, resumedToSessionId: newSnapshot.id });
1010
- res.status(201).json({ resumedFromSessionId: sessionId, ...newSnapshot });
1011
- }
1012
- catch (error) {
1013
- res.status(400).json({ error: getErrorMessage(error, "无法恢复会话。") });
1014
- }
1015
- });
1016
- app.post("/api/claude-sessions/:claudeSessionId/resume", (req, res) => {
1017
- const claudeSessionId = String(req.params.claudeSessionId || "").trim();
1018
- const body = req.body;
1019
- try {
1020
- if (!claudeSessionId) {
1021
- res.status(400).json({ error: "Claude 会话 ID 不能为空。" });
1022
- return;
1023
- }
1024
- const existingSession = storage.getLatestSessionByClaudeSessionId(claudeSessionId);
1025
- if (existingSession) {
1026
- const command = existingSession.command.trim();
1027
- if (!/^claude\b/.test(command)) {
1028
- res.status(400).json({ error: "只有 Claude 命令支持按 Claude Session ID 恢复。" });
1029
- return;
1030
- }
1031
- if (!existingSession.cwd || !processes.hasClaudeSessionFile(existingSession.cwd, claudeSessionId)) {
1032
- res.status(400).json({ error: "对应的 Claude 历史会话文件不存在,无法恢复。" });
1033
- return;
1034
- }
1035
- const newMode = body.mode
1036
- ? normalizeMode(body.mode, config.defaultMode)
1037
- : normalizeMode(existingSession.mode, config.defaultMode);
1038
- const resumeCommand = `${command} --resume ${claudeSessionId}`;
1039
- const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { resumedFromSessionId: existingSession.id });
1040
- storage.saveSession({ ...existingSession, resumedToSessionId: newSnapshot.id });
1041
- res.status(201).json({ resumedFromSessionId: existingSession.id, resumedClaudeSessionId: claudeSessionId, ...newSnapshot });
1042
- }
1043
- else {
1044
- // No existing wand session — resume directly with cwd from request body
1045
- const cwd = body.cwd?.trim();
1046
- if (!cwd) {
1047
- res.status(400).json({ error: "未找到对应的会话记录,请提供工作目录 (cwd)。" });
1048
- return;
1049
- }
1050
- const newMode = normalizeMode(body.mode, config.defaultMode);
1051
- const resumeCommand = `claude --resume ${claudeSessionId}`;
1052
- const newSnapshot = processes.start(resumeCommand, cwd, newMode);
1053
- res.status(201).json({ resumedClaudeSessionId: claudeSessionId, ...newSnapshot });
1054
- }
1055
- }
1056
- catch (error) {
1057
- res.status(400).json({ error: getErrorMessage(error, "无法按 Claude 会话 ID 恢复会话。") });
1058
- }
1059
- });
1060
- app.post("/api/sessions/:id/input", (req, res) => {
1061
- const body = req.body;
1062
- const sessionId = req.params.id;
1063
- const input = body.input ?? "";
1064
- const view = body.view;
1065
- const shortcutKey = body.shortcutKey;
1066
- console.error("[wand] Input request received", { sessionId, inputLength: input.length, view: view ?? "chat" });
1067
- try {
1068
- const snapshot = processes.sendInput(sessionId, input, view, shortcutKey);
1069
- console.error("[wand] Input request succeeded", { sessionId, status: snapshot.status, inputLength: input.length, view: view ?? "chat" });
1070
- res.json(snapshot);
1071
- }
1072
- catch (error) {
1073
- const response = getInputErrorResponse(error, sessionId);
1074
- console.error("[wand] Input request failed", {
1075
- sessionId, inputLength: input.length, view: view ?? "chat",
1076
- responseStatus: response.statusCode, responsePayload: response.payload,
1077
- error: getInputDebugMeta(error),
1078
- });
1079
- res.status(response.statusCode).json(response.payload);
1080
- }
1081
- });
1082
- app.post("/api/sessions/:id/resize", (req, res) => {
1083
- const body = req.body;
1084
- try {
1085
- const snapshot = processes.resize(req.params.id, body.cols ?? 0, body.rows ?? 0);
1086
- res.json(snapshot);
1087
- }
1088
- catch (error) {
1089
- res.status(400).json({ error: getErrorMessage(error, "无法调整终端大小。") });
1090
- }
1091
- });
1092
- app.post("/api/sessions/:id/approve-permission", (req, res) => {
1093
- try {
1094
- res.json(processes.approvePermission(req.params.id));
1095
- }
1096
- catch (error) {
1097
- res.status(400).json({ error: getErrorMessage(error, "无法批准该授权请求。") });
1098
- }
1099
- });
1100
- app.post("/api/sessions/:id/deny-permission", (req, res) => {
1101
- try {
1102
- res.json(processes.denyPermission(req.params.id));
1103
- }
1104
- catch (error) {
1105
- res.status(400).json({ error: getErrorMessage(error, "无法拒绝该授权请求。") });
1106
- }
1107
- });
1108
- app.post("/api/sessions/:id/escalations/:requestId/resolve", (req, res) => {
1109
- try {
1110
- const { requestId } = req.params;
1111
- const body = req.body;
1112
- res.json(processes.resolveEscalation(req.params.id, requestId, body.resolution));
1113
- }
1114
- catch (error) {
1115
- res.status(400).json({ error: getErrorMessage(error, "无法处理该授权请求。") });
1116
- }
1117
- });
1118
- app.post("/api/sessions/:id/stop", (req, res) => {
1119
- try {
1120
- res.json(processes.stop(req.params.id));
1121
- }
1122
- catch (error) {
1123
- res.status(400).json({ error: getErrorMessage(error, "无法停止会话。") });
1124
- }
1125
- });
1126
- app.delete("/api/sessions/:id", (req, res) => {
1127
- try {
1128
- processes.delete(req.params.id);
1129
- res.json({ ok: true });
1130
- }
1131
- catch (error) {
1132
- res.status(400).json({ error: getErrorMessage(error, "无法删除会话。") });
1133
- }
1134
- });
1135
812
  // ── WebSocket broadcast layer ──
1136
813
  const server = useHttps
1137
814
  ? (() => {
@@ -349,22 +349,25 @@
349
349
  }
350
350
  }
351
351
 
352
+ function renderShortcutKeys() {
353
+ return '<button class="shortcut-key' + (state.modifiers.ctrl ? ' active' : '') + '" data-key="ctrl" type="button">Ctrl</button>' +
354
+ '<button class="shortcut-key' + (state.modifiers.alt ? ' active' : '') + '" data-key="alt" type="button">Alt</button>' +
355
+ '<span class="shortcut-sep">·</span>' +
356
+ '<button class="shortcut-key shortcut-dir" data-key="up" type="button">↑</button>' +
357
+ '<button class="shortcut-key shortcut-dir" data-key="down" type="button">↓</button>' +
358
+ '<button class="shortcut-key shortcut-dir" data-key="left" type="button">←</button>' +
359
+ '<button class="shortcut-key shortcut-dir" data-key="right" type="button">→</button>' +
360
+ '<span class="shortcut-sep">·</span>' +
361
+ '<button class="shortcut-key" data-key="enter" type="button">↵</button>' +
362
+ '<button class="shortcut-key" data-key="ctrl_enter" type="button">C-↵</button>' +
363
+ '<button class="shortcut-key" data-key="escape" type="button">Esc</button>';
364
+ }
365
+
352
366
  function renderInlineKeyboard() {
353
367
  if (!state.selectedId) return "";
354
368
  var isTerminal = state.currentView === "terminal";
355
369
  if (!isTerminal) return "";
356
- var keys =
357
- '<button class="shortcut-key' + (state.modifiers.ctrl ? ' active' : '') + '" data-key="ctrl" type="button">Ctrl</button>' +
358
- '<button class="shortcut-key' + (state.modifiers.alt ? ' active' : '') + '" data-key="alt" type="button">Alt</button>' +
359
- '<span class="shortcut-sep">·</span>' +
360
- '<button class="shortcut-key shortcut-dir" data-key="up" type="button">↑</button>' +
361
- '<button class="shortcut-key shortcut-dir" data-key="down" type="button">↓</button>' +
362
- '<button class="shortcut-key shortcut-dir" data-key="left" type="button">←</button>' +
363
- '<button class="shortcut-key shortcut-dir" data-key="right" type="button">→</button>' +
364
- '<span class="shortcut-sep">·</span>' +
365
- '<button class="shortcut-key" data-key="enter" type="button">↵</button>' +
366
- '<button class="shortcut-key" data-key="ctrl_enter" type="button">C-↵</button>' +
367
- '<button class="shortcut-key" data-key="escape" type="button">Esc</button>';
370
+ var keys = renderShortcutKeys();
368
371
  var arrow = state.shortcutsExpanded ? '›' : '‹';
369
372
  return '<div class="inline-shortcuts-wrap' + (state.shortcutsExpanded ? ' expanded' : '') + '">' +
370
373
  '<button class="shortcuts-toggle' + (state.shortcutsExpanded ? ' active' : '') + '" type="button" title="快捷键">' + arrow + '</button>' +
@@ -373,9 +376,11 @@
373
376
  '</div>';
374
377
  }
375
378
 
376
- function renderMiniKeyboard() {
377
- // Mini keyboard is now inline, rendered in input-composer-right
378
- return "";
379
+ function renderExpandedShortcutsRow() {
380
+ if (!state.selectedId) return "";
381
+ var isTerminal = state.currentView === "terminal";
382
+ if (!isTerminal) return "";
383
+ return '<div class="inline-shortcuts-expanded-row' + (state.shortcutsExpanded ? ' visible' : '') + '">' + renderShortcutKeys() + '</div>';
379
384
  }
380
385
 
381
386
  function renderLogin() {
@@ -573,6 +578,7 @@
573
578
  '</button>' +
574
579
  '</div>' +
575
580
  '</div>' +
581
+ renderExpandedShortcutsRow() +
576
582
  // Session info bar at bottom
577
583
  '<div class="input-session-info-bar">' +
578
584
  '<span id="session-cwd-display" class="session-cwd-display">' + (selectedSession && selectedSession.cwd ? escapeHtml(selectedSession.cwd) : '未设置目录') + '</span>' +
@@ -1985,6 +1991,8 @@
1985
1991
  // Inline shortcuts click handler
1986
1992
  var inlineShortcutsWrap = document.querySelector(".inline-shortcuts-wrap");
1987
1993
  if (inlineShortcutsWrap) inlineShortcutsWrap.addEventListener("click", handleInlineKeyboardClick);
1994
+ var expandedShortcutsRow = document.querySelector(".inline-shortcuts-expanded-row");
1995
+ if (expandedShortcutsRow) expandedShortcutsRow.addEventListener("click", handleInlineKeyboardClick);
1988
1996
  // Shortcuts toggle (mobile fold/unfold)
1989
1997
  var shortcutsToggleBtn = document.querySelector(".shortcuts-toggle");
1990
1998
  if (shortcutsToggleBtn) shortcutsToggleBtn.addEventListener("click", function(e) {
@@ -1992,7 +2000,9 @@
1992
2000
  state.shortcutsExpanded = !state.shortcutsExpanded;
1993
2001
  var wrap = document.querySelector(".inline-shortcuts-wrap");
1994
2002
  var toggle = document.querySelector(".shortcuts-toggle");
2003
+ var row = document.querySelector(".inline-shortcuts-expanded-row");
1995
2004
  if (wrap) wrap.classList.toggle("expanded", state.shortcutsExpanded);
2005
+ if (row) row.classList.toggle("visible", state.shortcutsExpanded);
1996
2006
  if (toggle) {
1997
2007
  toggle.classList.toggle("active", state.shortcutsExpanded);
1998
2008
  toggle.textContent = state.shortcutsExpanded ? "\u203a" : "\u2039";
@@ -2002,9 +2012,12 @@
2002
2012
  document.addEventListener("click", function(e) {
2003
2013
  if (!state.shortcutsExpanded) return;
2004
2014
  var wrap = document.querySelector(".inline-shortcuts-wrap");
2005
- if (wrap && !wrap.contains(e.target)) {
2015
+ var expandedRow = document.querySelector(".inline-shortcuts-expanded-row");
2016
+ var clickedInsideRow = expandedRow && expandedRow.contains(e.target);
2017
+ if (wrap && !wrap.contains(e.target) && !clickedInsideRow) {
2006
2018
  state.shortcutsExpanded = false;
2007
2019
  wrap.classList.remove("expanded");
2020
+ if (expandedRow) expandedRow.classList.remove("visible");
2008
2021
  var toggle = document.querySelector(".shortcuts-toggle");
2009
2022
  if (toggle) {
2010
2023
  toggle.classList.remove("active");
@@ -3310,6 +3323,58 @@
3310
3323
  }
3311
3324
  }
3312
3325
 
3326
+ function subscribeToSession(sessionId) {
3327
+ if (!sessionId || !state.ws || state.ws.readyState !== WebSocket.OPEN) return;
3328
+ state.ws.send(JSON.stringify({ type: "subscribe", sessionId: sessionId }));
3329
+ }
3330
+
3331
+ function mergeServerSession(localSession, serverSession) {
3332
+ if (!localSession) return serverSession;
3333
+
3334
+ var merged = Object.assign({}, localSession, serverSession);
3335
+ var localOutput = localSession.output || "";
3336
+ var serverOutput = serverSession.output || "";
3337
+ var keepLocalOutput = localOutput.length > serverOutput.length;
3338
+
3339
+ if (keepLocalOutput) {
3340
+ merged.output = localOutput;
3341
+ }
3342
+
3343
+ if (localSession.id === state.selectedId) {
3344
+ if (localSession.permissionBlocked && serverSession.permissionBlocked === false) {
3345
+ // server explicitly resolved it; keep resolved state
3346
+ } else if (localSession.permissionBlocked && !serverSession.permissionBlocked) {
3347
+ merged.permissionBlocked = true;
3348
+ }
3349
+
3350
+ if (localSession.pendingEscalation && !serverSession.pendingEscalation && serverSession.permissionBlocked !== false) {
3351
+ merged.pendingEscalation = localSession.pendingEscalation;
3352
+ }
3353
+
3354
+ if (localSession.messages && localSession.messages.length > 0 && (!serverSession.messages || serverSession.messages.length === 0)) {
3355
+ merged.messages = localSession.messages;
3356
+ }
3357
+ }
3358
+
3359
+ return merged;
3360
+ }
3361
+
3362
+ function getPreferredMessages(session, fallbackOutput, allowFallback) {
3363
+ if (session && session.messages && session.messages.length > 0) {
3364
+ return session.messages;
3365
+ }
3366
+ if (!allowFallback) {
3367
+ return [];
3368
+ }
3369
+ var output = typeof fallbackOutput === "string"
3370
+ ? fallbackOutput
3371
+ : (session && session.output) || "";
3372
+ if (!output) {
3373
+ return [];
3374
+ }
3375
+ return parseMessages(output, session && session.command);
3376
+ }
3377
+
3313
3378
  function getPreferredSessionId(sessions) {
3314
3379
  if (!sessions || !sessions.length) return null;
3315
3380
  // Keep currently selected session as long as it still exists
@@ -3344,10 +3409,7 @@
3344
3409
 
3345
3410
  state.sessions = serverSessions.map(function(serverSession) {
3346
3411
  var localSession = state.sessions.find(function(s) { return s.id === serverSession.id; });
3347
- if (localSession && localSession.output && localSession.output.length > (serverSession.output || '').length) {
3348
- return localSession;
3349
- }
3350
- return serverSession;
3412
+ return mergeServerSession(localSession, serverSession);
3351
3413
  });
3352
3414
 
3353
3415
  state.selectedId = getPreferredSessionId(state.sessions);
@@ -3471,7 +3533,7 @@
3471
3533
  updateShellChrome();
3472
3534
 
3473
3535
  var selectedSession = state.sessions.find(function(s) { return s.id === id; });
3474
- state.currentMessages = data.messages || [];
3536
+ state.currentMessages = getPreferredMessages(selectedSession, data.output, false);
3475
3537
 
3476
3538
  if (state.terminal) {
3477
3539
  syncTerminalBuffer(id, data.output || "", { mode: "replace" });
@@ -3506,6 +3568,7 @@
3506
3568
  refreshFileExplorer();
3507
3569
  }
3508
3570
  loadOutput(id).then(function() { focusInputBox(true); });
3571
+ subscribeToSession(id);
3509
3572
  }
3510
3573
 
3511
3574
  function updateDrawerState() {
@@ -4553,9 +4616,7 @@
4553
4616
  switchToSessionView(data.id);
4554
4617
  updateSessionSnapshot(data);
4555
4618
  updateSessionsList();
4556
- if (state.ws && state.ws.readyState === WebSocket.OPEN) {
4557
- state.ws.send(JSON.stringify({ type: "subscribe", sessionId: data.id }));
4558
- }
4619
+ subscribeToSession(data.id);
4559
4620
  loadOutput(data.id).then(function() {
4560
4621
  focusInputBox(true);
4561
4622
  });
@@ -4616,9 +4677,7 @@
4616
4677
  updateSessionSnapshot(data);
4617
4678
  updateSessionsList();
4618
4679
  // Subscribe to new session via WebSocket
4619
- if (state.ws && state.ws.readyState === WebSocket.OPEN) {
4620
- state.ws.send(JSON.stringify({ type: 'subscribe', sessionId: data.id }));
4621
- }
4680
+ subscribeToSession(data.id);
4622
4681
  return loadOutput(data.id);
4623
4682
  })
4624
4683
  .catch(function(error) {
@@ -4791,9 +4850,7 @@
4791
4850
  updateSessionSnapshot(data);
4792
4851
  updateSessionsList();
4793
4852
  switchToSessionView(data.id);
4794
- if (state.ws && state.ws.readyState === WebSocket.OPEN) {
4795
- state.ws.send(JSON.stringify({ type: "subscribe", sessionId: data.id }));
4796
- }
4853
+ subscribeToSession(data.id);
4797
4854
  return loadOutput(data.id).then(function() {
4798
4855
  focusInputBox(true);
4799
4856
  return data;
@@ -5506,9 +5563,7 @@
5506
5563
  switchToSessionView(data.id);
5507
5564
  updateSessionSnapshot(data);
5508
5565
  updateSessionsList();
5509
- if (state.ws && state.ws.readyState === WebSocket.OPEN) {
5510
- state.ws.send(JSON.stringify({ type: "subscribe", sessionId: data.id }));
5511
- }
5566
+ subscribeToSession(data.id);
5512
5567
  return loadOutput(data.id).then(function() {
5513
5568
  focusInputBox(true);
5514
5569
  });
@@ -6722,9 +6777,7 @@
6722
6777
  state.ws = ws;
6723
6778
  state.wsConnected = true;
6724
6779
  // Subscribe to current session if any
6725
- if (state.selectedId) {
6726
- ws.send(JSON.stringify({ type: 'subscribe', sessionId: state.selectedId }));
6727
- }
6780
+ subscribeToSession(state.selectedId);
6728
6781
  // Flush pending messages after reconnection
6729
6782
  flushPendingMessages();
6730
6783
  };
@@ -6774,9 +6827,7 @@
6774
6827
  }
6775
6828
  updateSessionSnapshot(snapshot);
6776
6829
  if (msg.sessionId === state.selectedId) {
6777
- if (msg.data.messages) {
6778
- state.currentMessages = msg.data.messages;
6779
- }
6830
+ state.currentMessages = getPreferredMessages(snapshot, msg.data.output, false);
6780
6831
  updateTaskDisplay();
6781
6832
  scheduleChatRender();
6782
6833
  }
@@ -7119,12 +7170,7 @@
7119
7170
  // Re-parse messages from the latest session output (fallback for edge cases)
7120
7171
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
7121
7172
  if (selectedSession) {
7122
- // Prefer structured messages from JSON chat mode
7123
- if (selectedSession.messages && selectedSession.messages.length > 0) {
7124
- state.currentMessages = selectedSession.messages;
7125
- } else if (selectedSession.output) {
7126
- state.currentMessages = parseMessages(selectedSession.output, selectedSession.command);
7127
- }
7173
+ state.currentMessages = getPreferredMessages(selectedSession, selectedSession.output, true);
7128
7174
  }
7129
7175
  renderChat();
7130
7176
  }, 30);