@co0ontty/wand 1.35.2 → 1.37.0

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.
@@ -326,6 +326,36 @@
326
326
  miniKeyboardVisible: false,
327
327
  shortcutsExpanded: false,
328
328
  modifiers: { ctrl: false, alt: false, shift: false },
329
+ // ── 终端悬浮摇杆遥控器(手机端 PTY 遥控)状态 ──
330
+ // joystickPos 持久化球球位置 {right, bottom}(localStorage wand-ball-pos)
331
+ joystickPos: (function() {
332
+ try {
333
+ var saved = localStorage.getItem("wand-ball-pos");
334
+ if (!saved) return null;
335
+ var parsed = JSON.parse(saved);
336
+ return parsed && typeof parsed === "object" ? parsed : null;
337
+ } catch (e) {
338
+ return null;
339
+ }
340
+ })(),
341
+ joystickPinnedOpen: false, // 钉住面板是否展开(不持久化,切会话复位)
342
+ joystickRootEl: null, // 以下均为运行期句柄,teardown 复位
343
+ joystickRingEl: null,
344
+ joystickPanelEl: null,
345
+ joystickBackdropEl: null,
346
+ joystickBallEl: null,
347
+ joystickPointerId: null,
348
+ joystickGesture: null, // null|'pending'|'ring'|'move'
349
+ joystickPressStart: null, // {x, y, t}
350
+ joystickCenter: null, // 手势开始时球球中心,用于径向命中
351
+ joystickLongPressTimer: null,
352
+ joystickRepeatTimer: null,
353
+ joystickRepeatKey: null,
354
+ joystickHoverOuter: null, // 外圈当前高亮键(松手发送)
355
+ joystickLastHoverKey: null, // 上一次悬停的扇区键(用于切换震动反馈)
356
+ joystickMoveHandler: null,
357
+ joystickUpHandler: null,
358
+ joystickResizeHandler: null,
329
359
  fileSearchQuery: "",
330
360
  fileExplorerLoading: false,
331
361
  allFiles: [],
@@ -334,7 +364,7 @@
334
364
  fileExplorerTotal: 0,
335
365
  claudeHistory: [],
336
366
  claudeHistoryLoaded: false,
337
- claudeHistoryExpanded: true,
367
+ claudeHistoryExpanded: false,
338
368
  claudeHistoryExpandedDirs: {},
339
369
  archivedExpanded: false,
340
370
  sessionsManageMode: false,
@@ -354,6 +384,81 @@
354
384
  })()
355
385
  };
356
386
 
387
+ // ── 前端 i18n(最小化)──
388
+ // 后端 config.language 是给 Claude 用的"回答语言"偏好("中文" / "English" / 任意字符串),
389
+ // 之前 frontend 完全没收 → UI label 一直 hardcoded 中文 + 个别英文("SUBAGENT" 那个 tag)。
390
+ // 用户设的是中文时,"SUBAGENT" 这类英文残留就和"配置语言不一致"。
391
+ //
392
+ // 设计取舍:
393
+ // - 只维护两套:中文(默认) + 英文。其它取值("日本語"、"Français"等)回退到英文,
394
+ // 因为 Claude 会按用户语言回答,UI 至少不卡在中文上让英语圈用户看不懂。
395
+ // - 不引入 i18n 库,几十个 key 用平铺对象,t(key, params) 是个十行 helper。
396
+ // - params 支持 "{name}" 占位符替换,避免在调用点拼字符串。
397
+ // - 缺 key 时回退到中文表,再没有就返回 key 本身(debug 友好)。
398
+ var I18N_DEFAULT_LANG = "中文";
399
+ var I18N = {
400
+ "中文": {
401
+ "subagent.tag": "子代理",
402
+ "subagent.handoff": "{parent} 让 {sub} 帮忙",
403
+ "subagent.handoff.with_desc": "{parent} 让 {sub} 帮忙:",
404
+ "subagent.continued": "继续输出",
405
+ "subagent.task.done": "任务完成",
406
+ "subagent.task.failed": "任务失败",
407
+ "subagent.running": "运行中",
408
+ "subagent.no_output": "(无输出)",
409
+ "subagent.helper_fallback_prefix": "协作猫·",
410
+ "subagent.title_aria": "点击展开 / 收起子代理输出",
411
+ "subagent.tag_title": "子代理 / subagent",
412
+ "ui.expand": "展开",
413
+ "ui.collapse": "收起",
414
+ "ui.expand_panel_aria": "展开子代理输出",
415
+ "ui.collapse_panel_aria": "收起子代理输出"
416
+ },
417
+ "English": {
418
+ "subagent.tag": "Subagent",
419
+ "subagent.handoff": "{parent} asked {sub} for help",
420
+ "subagent.handoff.with_desc": "{parent} asked {sub} for help with: ",
421
+ "subagent.continued": "continued",
422
+ "subagent.task.done": "Task complete",
423
+ "subagent.task.failed": "Task failed",
424
+ "subagent.running": "Running",
425
+ "subagent.no_output": "(no output)",
426
+ "subagent.helper_fallback_prefix": "Helper·",
427
+ "subagent.title_aria": "Click to expand / collapse subagent output",
428
+ "subagent.tag_title": "Subagent",
429
+ "ui.expand": "Expand",
430
+ "ui.collapse": "Collapse",
431
+ "ui.expand_panel_aria": "Expand subagent output",
432
+ "ui.collapse_panel_aria": "Collapse subagent output"
433
+ }
434
+ };
435
+ function getActiveLang() {
436
+ var raw = state.config && typeof state.config.language === "string" ? state.config.language.trim() : "";
437
+ if (!raw) return I18N_DEFAULT_LANG;
438
+ if (I18N[raw]) return raw;
439
+ // 模糊匹配:用户可能写 "english" / "en" / "ENG"
440
+ var lower = raw.toLowerCase();
441
+ if (lower === "english" || lower === "en" || lower.indexOf("english") === 0 || lower.indexOf("英") === 0) return "English";
442
+ if (lower === "中文" || lower === "zh" || lower.indexOf("zh") === 0 || lower.indexOf("中") === 0 || lower.indexOf("chinese") === 0) return "中文";
443
+ return "English"; // 其它语言走英文 fallback(Claude 会按 raw 回答,UI 至少英文不卡)
444
+ }
445
+ function t(key, params) {
446
+ var lang = getActiveLang();
447
+ var table = I18N[lang] || I18N[I18N_DEFAULT_LANG];
448
+ var template = table && key in table ? table[key] : null;
449
+ if (template == null) {
450
+ var def = I18N[I18N_DEFAULT_LANG];
451
+ template = def && key in def ? def[key] : key;
452
+ }
453
+ if (params && typeof template === "string") {
454
+ for (var k in params) {
455
+ if (!Object.prototype.hasOwnProperty.call(params, k)) continue;
456
+ template = template.split("{" + k + "}").join(params[k]);
457
+ }
458
+ }
459
+ return template;
460
+ }
461
+
357
462
  // ── 统一线性图标库 ──
358
463
  // 替代页面里散落的 emoji(🛡 / ⌨ / 📁 / 🔔 …)。这些 emoji 在系统字体里渲染成
359
464
  // 彩色卡通形态,与项目温暖米色 + 棕橙的复古主题视觉冲突明显。这里集中维护
@@ -1040,6 +1145,10 @@
1040
1145
  }
1041
1146
  case "tool-group":
1042
1147
  return el.getAttribute("data-expanded") === "true";
1148
+ case "subagent-reply":
1149
+ return el.getAttribute("data-expanded") === "true";
1150
+ case "subagent-panel":
1151
+ return el.getAttribute("data-expanded") === "true";
1043
1152
  default:
1044
1153
  return false;
1045
1154
  }
@@ -1088,6 +1197,35 @@
1088
1197
  if (chevron) chevron.style.transform = expanded ? "rotate(180deg)" : "";
1089
1198
  break;
1090
1199
  }
1200
+ case "subagent-reply": {
1201
+ el.setAttribute("data-expanded", expanded ? "true" : "false");
1202
+ var subLabel = el.querySelector(".subagent-reply-toggle-label");
1203
+ if (subLabel) subLabel.textContent = expanded ? "收起" : "展开";
1204
+ var subToggleBtn = el.querySelector(".subagent-reply-toggle");
1205
+ if (subToggleBtn) {
1206
+ subToggleBtn.setAttribute("aria-expanded", expanded ? "true" : "false");
1207
+ subToggleBtn.setAttribute("aria-label", expanded ? "收起子代理回复" : "展开子代理回复全文");
1208
+ }
1209
+ break;
1210
+ }
1211
+ case "subagent-panel": {
1212
+ el.setAttribute("data-expanded", expanded ? "true" : "false");
1213
+ // 头/尾两个按钮都得同步——label、aria-expanded、aria-label
1214
+ var panelBtns = el.querySelectorAll(".subagent-panel-toggle");
1215
+ for (var pbi = 0; pbi < panelBtns.length; pbi++) {
1216
+ var pb = panelBtns[pbi];
1217
+ pb.setAttribute("aria-expanded", expanded ? "true" : "false");
1218
+ pb.setAttribute("aria-label", expanded ? "收起子代理输出" : "展开子代理输出");
1219
+ var pblbl = pb.querySelector(".subagent-panel-toggle-label");
1220
+ if (pblbl) pblbl.textContent = expanded ? "收起" : "展开";
1221
+ }
1222
+ // 展开时把 body 滚到顶,避免延续上次的滚动位置造成"展开后看到一半"
1223
+ if (expanded) {
1224
+ var pbody = el.querySelector(".subagent-panel-body");
1225
+ if (pbody) pbody.scrollTop = 0;
1226
+ }
1227
+ break;
1228
+ }
1091
1229
  }
1092
1230
  }
1093
1231
 
@@ -1685,6 +1823,7 @@
1685
1823
  '<div class="sessions-list" id="sessions-list">' + renderSessionsListContent() + '</div>' +
1686
1824
  '</div>' +
1687
1825
  '</div>' +
1826
+ '<div id="sidebar-history-region" class="sidebar-history-region">' + renderClaudeHistoryRegion() + '</div>' +
1688
1827
  '<div class="sidebar-footer">' +
1689
1828
  '<button id="drawer-new-session-button" class="btn btn-primary btn-block"><span>+</span> 新会话</button>' +
1690
1829
  '<div class="sidebar-footer-actions">' +
@@ -3361,6 +3500,9 @@
3361
3500
  }
3362
3501
 
3363
3502
  function renderSessions() {
3503
+ // Claude history is no longer inlined here — it lives in its own
3504
+ // collapsible region between .sidebar-body and .sidebar-footer, so
3505
+ // the scrolling sessions list focuses on recent / archived sessions.
3364
3506
  var archivedSessions = state.sessions.filter(function(session) { return session.archived; });
3365
3507
  var groups = [];
3366
3508
  groups.push(renderSessionManageBar());
@@ -3373,9 +3515,8 @@
3373
3515
  if (archivedSessions.length > 0) {
3374
3516
  groups.push(renderArchivedGroup(archivedSessions));
3375
3517
  }
3376
- groups.push(renderClaudeHistorySection());
3377
3518
  if (recentEntries.length === 0 && archivedSessions.length === 0) {
3378
- return renderSessionManageBar() + '<div class="empty-state"><strong>还没有会话记录</strong><br>点击上方「新对话」开始你的第一次对话。</div>' + renderClaudeHistorySection();
3519
+ return renderSessionManageBar() + '<div class="empty-state"><strong>还没有会话记录</strong><br>点击上方「新对话」开始你的第一次对话。</div>';
3379
3520
  }
3380
3521
  return groups.join("");
3381
3522
  }
@@ -3482,37 +3623,62 @@
3482
3623
  return html;
3483
3624
  }
3484
3625
 
3485
- function renderClaudeHistorySection() {
3486
- // Exclude recent 24h items from history section
3626
+ // Compute the items eligible for the history region (older than 24h —
3627
+ // the recent-24h ones already show in the recent group above).
3628
+ function getClaudeHistoryRegionItems() {
3487
3629
  var cutoff = Date.now() - 24 * 60 * 60 * 1000;
3488
- var visibleHistory = getVisibleClaudeHistorySessions().filter(function(s) {
3630
+ return getVisibleClaudeHistorySessions().filter(function(s) {
3489
3631
  return !s.timestamp || new Date(s.timestamp).getTime() <= cutoff;
3490
3632
  });
3491
- var chevron = state.claudeHistoryExpanded ? "&#9662;" : "&#9656;";
3492
- var countBadge = state.claudeHistoryLoaded && visibleHistory.length > 0
3493
- ? ' <span class="history-count">' + visibleHistory.length + '</span>'
3494
- : '';
3495
- var clearAllButton = state.claudeHistoryExpanded && state.claudeHistoryLoaded && visibleHistory.length > 0
3496
- ? '<button class="btn btn-danger btn-xs session-history-clear" data-action="clear-all-history" type="button">清空</button>'
3497
- : '';
3498
- var header = '<div class="session-group-title claude-history-toggle" id="claude-history-toggle">' +
3499
- '<span class="chevron">' + chevron + '</span> Claude 历史' + countBadge +
3500
- '</div>' + clearAllButton;
3633
+ }
3501
3634
 
3502
- if (!state.claudeHistoryExpanded) {
3503
- return '<section class="session-group">' + header + '</section>';
3635
+ // Render the docked Claude-history region that lives between
3636
+ // `.sidebar-body` and `.sidebar-footer`. Collapsed by default — only
3637
+ // shows a slim header ("历史消息" + count bubble). Expanded reveals the
3638
+ // grouped-by-cwd list inside a scroll cap.
3639
+ function renderClaudeHistoryRegion() {
3640
+ var visibleHistory = getClaudeHistoryRegionItems();
3641
+ var expanded = !!state.claudeHistoryExpanded;
3642
+ var loaded = !!state.claudeHistoryLoaded;
3643
+ var count = loaded ? visibleHistory.length : 0;
3644
+
3645
+ var badgeCls = "history-bubble";
3646
+ var badgeContent;
3647
+ if (!loaded) {
3648
+ badgeCls += " loading";
3649
+ badgeContent = "···";
3650
+ } else if (count === 0) {
3651
+ badgeCls += " empty";
3652
+ badgeContent = "0";
3653
+ } else {
3654
+ badgeContent = count > 999 ? "999+" : String(count);
3504
3655
  }
3656
+ var badge = '<span class="' + badgeCls + '">' + badgeContent + '</span>';
3657
+
3658
+ // Chevron rotates: collapsed → up (▲, suggests "expand upward"),
3659
+ // expanded → down (▼, suggests "collapse downward").
3660
+ var chevronSvg = '<svg class="sidebar-history-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 15 12 9 18 15"/></svg>';
3661
+
3662
+ var headerCls = "sidebar-history-header" + (expanded ? " expanded" : "");
3663
+ var header = '<button type="button" class="' + headerCls + '" id="claude-history-toggle" aria-expanded="' + expanded + '" aria-controls="sidebar-history-body" title="' + (expanded ? "收起历史消息" : "展开历史消息") + '">' +
3664
+ '<span class="sidebar-history-label">历史消息</span>' +
3665
+ '<span class="sidebar-history-right">' + badge + chevronSvg + '</span>' +
3666
+ '</button>';
3667
+
3668
+ var body = expanded
3669
+ ? '<div class="sidebar-history-body" id="sidebar-history-body">' + renderClaudeHistoryBodyContent(visibleHistory) + '</div>'
3670
+ : '';
3671
+
3672
+ return header + body;
3673
+ }
3505
3674
 
3675
+ function renderClaudeHistoryBodyContent(visibleHistory) {
3506
3676
  if (!state.claudeHistoryLoaded) {
3507
- return '<section class="session-group">' + header +
3508
- '<div class="claude-history-loading">扫描历史会话中…</div></section>';
3677
+ return '<div class="claude-history-loading">扫描历史会话中…</div>';
3509
3678
  }
3510
-
3511
3679
  if (visibleHistory.length === 0) {
3512
- return '<section class="session-group">' + header +
3513
- '<div class="claude-history-loading">没有更早的 Claude 历史会话</div></section>';
3680
+ return '<div class="claude-history-loading">没有更早的 Claude 历史会话</div>';
3514
3681
  }
3515
-
3516
3682
  var groups = {};
3517
3683
  var groupOrder = [];
3518
3684
  visibleHistory.forEach(function(s) {
@@ -3522,18 +3688,27 @@
3522
3688
  }
3523
3689
  groups[s.cwd].push(s);
3524
3690
  });
3525
-
3526
- var html = '';
3691
+ var toolbar = '<div class="sidebar-history-toolbar">' +
3692
+ '<button class="btn btn-ghost btn-xs sidebar-history-clear" data-action="clear-all-history" type="button">清空全部</button>' +
3693
+ '</div>';
3694
+ var listHtml = '';
3527
3695
  groupOrder.forEach(function(cwd) {
3528
3696
  var cwdShort = cwd.split("/").filter(Boolean).slice(-3).join("/");
3529
3697
  var isDirExpanded = !!state.claudeHistoryExpandedDirs[cwd];
3530
- html += renderClaudeHistoryDirectoryHeader(cwd, cwdShort, groups[cwd].length, isDirExpanded);
3698
+ listHtml += renderClaudeHistoryDirectoryHeader(cwd, cwdShort, groups[cwd].length, isDirExpanded);
3531
3699
  if (isDirExpanded) {
3532
- html += groups[cwd].map(function(session) { return renderClaudeHistoryItem(session, "history"); }).join("");
3700
+ listHtml += groups[cwd].map(function(session) { return renderClaudeHistoryItem(session, "history"); }).join("");
3533
3701
  }
3534
3702
  });
3703
+ return toolbar + '<div class="sidebar-history-scroll">' + listHtml + '</div>';
3704
+ }
3535
3705
 
3536
- return '<section class="session-group">' + header + html + '</section>';
3706
+ // Re-render only the docked history region in place. Called by
3707
+ // updateSessionsList() so existing callers (load complete, delete, etc.)
3708
+ // keep working without changes.
3709
+ function updateClaudeHistoryRegion() {
3710
+ var region = document.getElementById("sidebar-history-region");
3711
+ if (region) region.innerHTML = renderClaudeHistoryRegion();
3537
3712
  }
3538
3713
 
3539
3714
  function getVisibleClaudeHistorySessions() {
@@ -5591,35 +5766,28 @@
5591
5766
  }
5592
5767
  persistElementExpandState(el, "thinking");
5593
5768
  };
5594
- // Toggle function for subagent reply bubbles — cycles previewexpanded → collapsed.
5595
- // 三态循环(preview 默认 ~5 行可滚 / expanded 大区可滚 / collapsed 完全收起)。
5596
- window.__subagentReplyCycle = function(e, btn) {
5769
+ // Toggle function for subagent reply bubbles — simple two-state preview/expanded.
5770
+ // 参考 opencode 的折叠面板:默认固定高度预览(含底部渐隐 mask),点击切到全文展开。
5771
+ // 状态写在 data-expanded 上,配套 CSS 控制 max-height + mask;用 data-expand-key
5772
+ // 走通用持久化通道(applyPersistedExpandState 会自动恢复用户上次的选择)。
5773
+ window.__subagentReplyToggle = function(e, target) {
5597
5774
  if (e) { e.preventDefault(); e.stopPropagation(); }
5598
- var bubble = btn.closest(".subagent-reply");
5775
+ var bubble = target && target.closest ? target.closest(".subagent-reply") : null;
5599
5776
  if (!bubble) return;
5600
- var modes = ["preview", "expanded", "collapsed"];
5601
- var current = bubble.getAttribute("data-collapse-mode") || "preview";
5602
- var idx = modes.indexOf(current);
5603
- if (idx < 0) idx = 0;
5604
- var next = modes[(idx + 1) % modes.length];
5605
- bubble.setAttribute("data-collapse-mode", next);
5606
- var label = btn.querySelector(".subagent-reply-cycle-label");
5607
- var icon = btn.querySelector(".subagent-reply-cycle-icon");
5608
- if (label) {
5609
- label.textContent = next === "preview" ? "展开"
5610
- : next === "expanded" ? "收起"
5611
- : "预览";
5612
- }
5613
- if (icon) {
5614
- icon.textContent = next === "collapsed" ? "▸"
5615
- : next === "expanded" ? "▴"
5616
- : "▾";
5617
- }
5618
- btn.setAttribute("aria-label",
5619
- next === "preview" ? "点击展开全部" :
5620
- next === "expanded" ? "点击完全收起" :
5621
- "点击切回预览"
5622
- );
5777
+ var expanded = bubble.getAttribute("data-expanded") === "true";
5778
+ applyExpandedState(bubble, "subagent-reply", !expanded);
5779
+ persistElementExpandState(bubble, "subagent-reply");
5780
+ };
5781
+ // Toggle function for whole subagent panel (handoff + body + footer)。
5782
+ // 头部 / 尾部按钮、整条 header 都绑这个;data-expanded 一变,CSS 切 body max-height
5783
+ // + 旋转 chevron,applyExpandedState 走通用通道写持久化。
5784
+ window.__subagentPanelToggle = function(e, target) {
5785
+ if (e) { e.preventDefault(); e.stopPropagation(); }
5786
+ var panel = target && target.closest ? target.closest(".subagent-panel") : null;
5787
+ if (!panel) return;
5788
+ var expanded = panel.getAttribute("data-expanded") === "true";
5789
+ applyExpandedState(panel, "subagent-panel", !expanded);
5790
+ persistElementExpandState(panel, "subagent-panel");
5623
5791
  };
5624
5792
  // Toggle function for inline tool rows (Read, Glob, Grep, etc.)
5625
5793
  window.__inlineToolToggle = function(el) {
@@ -5858,6 +6026,15 @@
5858
6026
  sessionsList.addEventListener("mouseout", handleCollapsedTileLeave);
5859
6027
  initSwipeToDelete(sessionsList);
5860
6028
  }
6029
+ // The docked history region lives outside #sessions-list now, but it
6030
+ // still wants the same delegated handlers (toggle button, directory
6031
+ // expand/collapse, history-item clicks, clear-all, etc.). Reuse the
6032
+ // same callbacks so behavior stays identical.
6033
+ var historyRegion = document.getElementById("sidebar-history-region");
6034
+ if (historyRegion) {
6035
+ historyRegion.addEventListener("click", handleSessionItemClick);
6036
+ historyRegion.addEventListener("keydown", handleSessionItemKeydown);
6037
+ }
5861
6038
  window.addEventListener("scroll", hideCollapsedTileBubble, true);
5862
6039
  window.addEventListener("resize", hideCollapsedTileBubble);
5863
6040
 
@@ -5942,7 +6119,8 @@
5942
6119
  state.selectedId = null;
5943
6120
  persistSelectedId();
5944
6121
  resetChatRenderCache();
5945
- closeSessionsDrawer();
6122
+ // 回到首页是导航语义,不是「收侧栏」。桌面常驻栏保留;手机只把 overlay 收掉。
6123
+ dismissDrawerIfOverlay();
5946
6124
  render();
5947
6125
  });
5948
6126
  var refreshBtn = document.getElementById("sidebar-refresh-btn");
@@ -6188,7 +6366,8 @@
6188
6366
  if (autoApproveToggle) autoApproveToggle.addEventListener("click", toggleAutoApprove);
6189
6367
  var sendBtn = document.getElementById("send-input-button");
6190
6368
  if (sendBtn) sendBtn.addEventListener("click", function() {
6191
- closeSessionsDrawer();
6369
+ // 与 input focus 同理:手机 drawer 盖在上面才收起,桌面常驻栏保持原状。
6370
+ dismissDrawerIfOverlay();
6192
6371
  sendOrStart();
6193
6372
  });
6194
6373
  var stopBtn = document.getElementById("stop-button");
@@ -6241,8 +6420,10 @@
6241
6420
  if (state.terminalInteractive) handleInteractiveTextInput(inputBox);
6242
6421
  });
6243
6422
  inputBox.addEventListener("focus", function() {
6244
- // Close drawer when user focuses input to avoid backdrop blocking clicks
6245
- closeSessionsDrawer();
6423
+ // 只在手机 drawer 真的盖在输入区上面时才收起,避免 backdrop 挡点击。
6424
+ // 桌面 pinned/窄条形态下 drawer 是常驻并列布局,不会挡输入,调
6425
+ // closeSessionsDrawer 会把 sidebarPinned 一起清掉、侧栏整个不见。
6426
+ dismissDrawerIfOverlay();
6246
6427
  handleInputBoxFocus({ target: inputBox });
6247
6428
  });
6248
6429
  inputBox.addEventListener("blur", handleInputBoxBlur);
@@ -6960,9 +7141,11 @@
6960
7141
  } else {
6961
7142
  selectSession(sessionId);
6962
7143
  }
6963
- if (!state.sidebarPinned || isMobileLayout()) {
6964
- closeSessionsDrawer();
6965
- }
7144
+ // 桌面常驻栏与窄条形态都保留;只在手机端真的有 overlay drawer 时才收。
7145
+ // (旧条件 !sidebarPinned || isMobileLayout() 在桌面 not-pinned 状态下也会
7146
+ // 调 closeSessionsDrawer,靠内部 early-return 才不至于出错——含义不清晰,
7147
+ // 统一走 dismissDrawerIfOverlay 反过来表达"只收 overlay 不撤常驻"。)
7148
+ dismissDrawerIfOverlay();
6966
7149
  }
6967
7150
 
6968
7151
  function handleSessionItemClick(event) {
@@ -7097,7 +7280,8 @@
7097
7280
  state.drafts[data.id] = "";
7098
7281
  loadSessions().then(function() {
7099
7282
  selectSession(data.id);
7100
- closeSessionsDrawer();
7283
+ // 桌面常驻/窄条形态不要撤掉,只把手机端 overlay 收掉。
7284
+ dismissDrawerIfOverlay();
7101
7285
  });
7102
7286
  }
7103
7287
  });
@@ -7131,7 +7315,8 @@
7131
7315
  state.drafts[data.id] = "";
7132
7316
  loadSessions().then(function() {
7133
7317
  selectSession(data.id);
7134
- closeSessionsDrawer();
7318
+ // 桌面常驻/窄条形态不要撤掉,只把手机端 overlay 收掉。
7319
+ dismissDrawerIfOverlay();
7135
7320
  });
7136
7321
  }
7137
7322
  });
@@ -8197,6 +8382,7 @@
8197
8382
  container.addEventListener("click", state.terminalClickHandler);
8198
8383
  updateTerminalJumpToBottomButton();
8199
8384
  initTerminalResizeHandle();
8385
+ initTerminalJoystick();
8200
8386
  observeTerminalResize();
8201
8387
  startTerminalHealthCheck();
8202
8388
  // Container may have been hidden / zero-width at construction
@@ -9225,6 +9411,10 @@
9225
9411
  var countEl = document.getElementById("session-count");
9226
9412
  if (listEl) listEl.innerHTML = renderSessionsListContent();
9227
9413
  if (countEl) countEl.textContent = String(state.sessions.length);
9414
+ // The docked history region lives outside #sessions-list — refresh it
9415
+ // too so callers that mutate state.claudeHistory (load complete,
9416
+ // delete, clear) don't need to know about it.
9417
+ updateClaudeHistoryRegion();
9228
9418
  if (typeof hideCollapsedTileBubble === "function") hideCollapsedTileBubble();
9229
9419
  updateShellChrome();
9230
9420
  // Re-render cross-session queue (container may have been destroyed by DOM rebuild)
@@ -9519,6 +9709,19 @@
9519
9709
  updateLayoutState();
9520
9710
  }
9521
9711
 
9712
+ // 把"浮在内容上的 drawer/backdrop"关掉,但保留桌面常驻栏与窄条形态。
9713
+ // 用法:从 input focus / send 按钮 / 选中会话 / 新建会话回调里调,这些场
9714
+ // 景只想避免遮罩挡住内容,并不想撤掉用户主动开启的常驻侧栏。
9715
+ //
9716
+ // 直接调 closeSessionsDrawer() 会在桌面把 state.sidebarPinned 置 false,
9717
+ // 进而让 .pinned/.collapsed 这两个类一起脱落,窄条整体消失 —— 这是
9718
+ // sidebar-collapsed-tile 点击后侧栏整个不见的根因。
9719
+ function dismissDrawerIfOverlay() {
9720
+ if (isMobileLayout() && state.sessionsDrawerOpen) {
9721
+ closeSessionsDrawer();
9722
+ }
9723
+ }
9724
+
9522
9725
  // 桌面 padding-left transition 结束后重新拟合终端尺寸。
9523
9726
  // 抽出来给 toggleSessionsDrawer / closeSessionsDrawer / toggleSidebarCollapsed 复用。
9524
9727
  function scheduleTerminalRefitAfterPaddingTransition() {
@@ -11336,7 +11539,9 @@
11336
11539
  .then(function(data) {
11337
11540
  saveWorkingDir(cwd);
11338
11541
  closeSessionModal();
11339
- closeSessionsDrawer();
11542
+ // 桌面常驻栏要保留:用户刚建完会话,希望左侧栏继续看到列表里的新条目,
11543
+ // 不能因为模态关闭顺手把 sidebarPinned 抹掉让侧栏整体消失。
11544
+ dismissDrawerIfOverlay();
11340
11545
  return data;
11341
11546
  })
11342
11547
  .then(function() { focusInputBox(true); })
@@ -11382,7 +11587,8 @@
11382
11587
  state.drafts[data.id] = "";
11383
11588
  resetChatRenderCache();
11384
11589
  closeSessionModal();
11385
- closeSessionsDrawer();
11590
+ // 同 structured 路径:模态关闭后只收手机端的 overlay,保留桌面常驻侧栏。
11591
+ dismissDrawerIfOverlay();
11386
11592
  return refreshAll();
11387
11593
  })
11388
11594
  .then(function() {
@@ -13610,6 +13816,48 @@
13610
13816
  "_": 31
13611
13817
  };
13612
13818
 
13819
+ // ── 终端悬浮摇杆遥控器常量与布局表 ──
13820
+ var JOYSTICK_LONG_PRESS_MS = 400; // 按住不动多久进入移动模式
13821
+ var JOYSTICK_MOVE_THRESHOLD = 10; // px:区分"拖动选键"与"静止长按"
13822
+ var JOYSTICK_TAP_THRESHOLD = 8; // px:快速点击的最大位移
13823
+ var JOYSTICK_REPEAT_MS = 130; // 内圈方向键连发间隔
13824
+ var JOYSTICK_R0 = 24; // 扇形中心空洞 = 死区半径
13825
+ var JOYSTICK_R1 = 60; // 内圈(方向)/外圈(功能)分界半径
13826
+ var JOYSTICK_R2 = 104; // 外圈外缘半径
13827
+ var JOYSTICK_DEADZONE_R = 24; // 命中死区(= R0)
13828
+ var JOYSTICK_RING_SPLIT_R = 60; // 命中分界(= R1):< 内圈方向,>= 外圈功能
13829
+ var JOYSTICK_MOVE_OUT_R = 140; // 拖出此半径(超出外圈区域)→ 切"正在移动"
13830
+ var JOYSTICK_BALL_SIZE = 52; // 球球直径(与 CSS 一致)
13831
+ var JOYSTICK_EDGE_MARGIN = 8; // 球球钳进视口的留白
13832
+ var JOYSTICK_RING_RADIUS = JOYSTICK_R2 + 8; // 环整体半径(含标签外延),用于把圆心钳进视口
13833
+ var JOYSTICK_RING_VIEW_PAD = 6; // 环外缘与视口边的最小留白
13834
+ var JOYSTICK_SECTOR_GAP_DEG = 2; // 外圈扇区之间的角度细缝(°),读成独立按钮
13835
+ // 内圈 4 方向:i=0 上、1 右、2 下、3 左(与渲染角 -90° 起顺时针一致)
13836
+ var JOYSTICK_INNER_KEYS = [
13837
+ { key: "up", label: "↑" },
13838
+ { key: "right", label: "→" },
13839
+ { key: "down", label: "↓" },
13840
+ { key: "left", label: "←" }
13841
+ ];
13842
+ // 外圈 8 功能:i=0 正上方起顺时针,与八分扇区 idx 对应
13843
+ var JOYSTICK_OUTER_KEYS = [
13844
+ { key: "enter", label: "Enter" },
13845
+ { key: "escape", label: "Esc" },
13846
+ { key: "tab", label: "Tab" },
13847
+ { key: "shift_tab", label: "Shift+Tab" },
13848
+ { key: "ctrl_c", label: "Ctrl+C" },
13849
+ { key: "ctrl_z", label: "Ctrl+Z" },
13850
+ { key: "ctrl_d", label: "Ctrl+D" },
13851
+ { key: "ctrl_l", label: "Ctrl+L" }
13852
+ ];
13853
+ // 钉住面板四角翻页键
13854
+ var JOYSTICK_CORNER_KEYS = [
13855
+ { key: "pageup", label: "PgUp" },
13856
+ { key: "home", label: "Home" },
13857
+ { key: "pagedown", label: "PgDn" },
13858
+ { key: "end", label: "End" }
13859
+ ];
13860
+
13613
13861
  var ignoredInteractiveTargetIds = new Set([
13614
13862
  "mini-keyboard-fab",
13615
13863
  "mini-keyboard-toggle",
@@ -13783,6 +14031,7 @@
13783
14031
  }
13784
14032
  var container = document.getElementById("output");
13785
14033
  if (container) container.classList.toggle("interactive", !structured && state.terminalInteractive);
14034
+ updateJoystickVisibility();
13786
14035
  }
13787
14036
 
13788
14037
  // COPY-2/COPY-4: 是否存在落在终端输出区(#output)内的活动文本选区。用于:
@@ -14411,7 +14660,8 @@
14411
14660
  persistSelectedId();
14412
14661
  state.drafts[data.id] = "";
14413
14662
  activateSession(data).then(function() {
14414
- closeSessionsDrawer();
14663
+ // 桌面常驻/窄条形态不要撤掉,仅手机端 overlay 需要收。
14664
+ dismissDrawerIfOverlay();
14415
14665
  });
14416
14666
  }
14417
14667
  })
@@ -15450,6 +15700,588 @@
15450
15700
  document.addEventListener("touchend", state.resizeTouchEnd);
15451
15701
  }
15452
15702
 
15703
+ // ====== 终端悬浮摇杆遥控器(手机端 PTY 遥控) ======
15704
+ // 纯前端覆盖层:fixed 挂到 body,绕开 #output 的 overflow:hidden 裁切。
15705
+ // 用 Pointer Events 统一鼠标/触摸;球球 touch-action:none 让 preventDefault 稳定。
15706
+ // 不改动终端背景的 touch/scroll/wheel —— 单指空白处仍是原生滚动看历史。
15707
+
15708
+ function isJoystickAvailable() {
15709
+ // 触屏与桌面网页端都显示(球球用 Pointer Events,鼠标拖拽同样可用)
15710
+ if (state.currentView !== "terminal") return false;
15711
+ var session = getSelectedSession();
15712
+ if (!session) return false;
15713
+ if (isStructuredSession(session)) return false;
15714
+ return true;
15715
+ }
15716
+
15717
+ function clampJoystickPos(pos) {
15718
+ var maxRight = Math.max(JOYSTICK_EDGE_MARGIN, window.innerWidth - JOYSTICK_BALL_SIZE - JOYSTICK_EDGE_MARGIN);
15719
+ var maxBottom = Math.max(JOYSTICK_EDGE_MARGIN, window.innerHeight - JOYSTICK_BALL_SIZE - JOYSTICK_EDGE_MARGIN);
15720
+ return {
15721
+ right: Math.min(Math.max(JOYSTICK_EDGE_MARGIN, pos.right), maxRight),
15722
+ bottom: Math.min(Math.max(JOYSTICK_EDGE_MARGIN, pos.bottom), maxBottom)
15723
+ };
15724
+ }
15725
+
15726
+ function applyJoystickPosition() {
15727
+ if (!state.joystickBallEl) return;
15728
+ var pos = clampJoystickPos(state.joystickPos || { right: 18, bottom: 96 });
15729
+ state.joystickBallEl.style.right = pos.right + "px";
15730
+ state.joystickBallEl.style.bottom = pos.bottom + "px";
15731
+ }
15732
+
15733
+ function saveJoystickPosition(right, bottom) {
15734
+ var pos = clampJoystickPos({ right: right, bottom: bottom });
15735
+ state.joystickPos = pos;
15736
+ try {
15737
+ localStorage.setItem("wand-ball-pos", JSON.stringify(pos));
15738
+ } catch (e) {
15739
+ // Ignore localStorage errors
15740
+ }
15741
+ }
15742
+
15743
+ function renderJoystickPanel() {
15744
+ function keyBtn(key, label, cls) {
15745
+ return '<button type="button" class="wjp-key' + (cls ? " " + cls : "") +
15746
+ '" data-key="' + key + '">' + label + "</button>";
15747
+ }
15748
+ var dpad =
15749
+ '<div class="wjp-dpad">' +
15750
+ '<div class="wjp-dpad-row">' + keyBtn("up", "↑", "wjp-dir") + "</div>" +
15751
+ '<div class="wjp-dpad-row">' +
15752
+ keyBtn("left", "←", "wjp-dir") + keyBtn("down", "↓", "wjp-dir") + keyBtn("right", "→", "wjp-dir") +
15753
+ "</div>" +
15754
+ "</div>";
15755
+ var fnRow = "";
15756
+ var i;
15757
+ for (i = 0; i < JOYSTICK_OUTER_KEYS.length; i++) {
15758
+ fnRow += keyBtn(JOYSTICK_OUTER_KEYS[i].key, JOYSTICK_OUTER_KEYS[i].label, "");
15759
+ }
15760
+ var cornerRow = "";
15761
+ for (i = 0; i < JOYSTICK_CORNER_KEYS.length; i++) {
15762
+ cornerRow += keyBtn(JOYSTICK_CORNER_KEYS[i].key, JOYSTICK_CORNER_KEYS[i].label, "");
15763
+ }
15764
+ var modRow = keyBtn("ctrl", "Ctrl", "wjp-mod") + keyBtn("alt", "Alt", "wjp-mod");
15765
+ return '<div class="wjp-title">遥控面板</div>' +
15766
+ dpad +
15767
+ '<div class="wjp-grid wjp-fnkeys">' + fnRow + "</div>" +
15768
+ '<div class="wjp-grid wjp-corners">' + cornerRow + "</div>" +
15769
+ '<div class="wjp-grid wjp-mods">' + modRow + "</div>";
15770
+ }
15771
+
15772
+ function joystickPolar(r, deg) {
15773
+ var a = deg * Math.PI / 180;
15774
+ return { x: +(r * Math.cos(a)).toFixed(2), y: +(r * Math.sin(a)).toFixed(2) };
15775
+ }
15776
+
15777
+ // 环形扇区(annular sector)路径:外弧顺时针、内弧逆时针闭合
15778
+ function joystickSectorPath(rIn, rOut, startDeg, endDeg) {
15779
+ var a = joystickPolar(rOut, startDeg), b = joystickPolar(rOut, endDeg);
15780
+ var c = joystickPolar(rIn, endDeg), d = joystickPolar(rIn, startDeg);
15781
+ return "M" + a.x + " " + a.y +
15782
+ "A" + rOut + " " + rOut + " 0 0 1 " + b.x + " " + b.y +
15783
+ "L" + c.x + " " + c.y +
15784
+ "A" + rIn + " " + rIn + " 0 0 0 " + d.x + " " + d.y + "Z";
15785
+ }
15786
+
15787
+ // 标签渲染:组合键(含 "+")拆成两行,前缀在上、+键在下,省横向空间更清晰。
15788
+ function joystickLabelMarkup(label, x, y) {
15789
+ var plus = label.indexOf("+");
15790
+ if (plus > 0) {
15791
+ var top = label.slice(0, plus);
15792
+ var bot = "+" + label.slice(plus + 1);
15793
+ return '<text class="wjr-2line" x="' + x + '" y="' + y + '">' +
15794
+ '<tspan x="' + x + '" dy="-0.42em">' + top + '</tspan>' +
15795
+ '<tspan x="' + x + '" dy="0.95em">' + bot + '</tspan></text>';
15796
+ }
15797
+ return '<text x="' + x + '" y="' + y + '">' + label + "</text>";
15798
+ }
15799
+
15800
+ // 构建两圈扇形 pie 菜单 SVG:内圈 4×90° 方向、外圈 8×45° 功能 + 中心选中提示。
15801
+ // 扇区角度与 joystickHitTest 完全对应(正上=0、顺时针)。
15802
+ function buildJoystickRingSvg() {
15803
+ var size = (JOYSTICK_R2 + 6) * 2;
15804
+ var half = size / 2;
15805
+ var gap = JOYSTICK_SECTOR_GAP_DEG / 2; // 每个扇区起止各内缩半个细缝
15806
+ var svg = '<svg class="wjr-svg" width="' + size + '" height="' + size +
15807
+ '" viewBox="' + (-half) + " " + (-half) + " " + size + " " + size + '">';
15808
+ // 底盘:所有扇区之下的一整块玻璃圆盘,细缝/外缘透出它作分隔与外圈光环
15809
+ svg += '<circle class="wjr-base" cx="0" cy="0" r="' + (JOYSTICK_R2 + 4) + '"/>';
15810
+ var i, k, center, lp;
15811
+ for (i = 0; i < JOYSTICK_INNER_KEYS.length; i++) {
15812
+ k = JOYSTICK_INNER_KEYS[i];
15813
+ center = -90 + i * 90;
15814
+ lp = joystickPolar((JOYSTICK_R0 + JOYSTICK_R1) / 2, center);
15815
+ svg += '<g class="wjr-sector wjr-inner" data-key="' + k.key + '">' +
15816
+ '<path d="' + joystickSectorPath(JOYSTICK_R0, JOYSTICK_R1, center - 45 + gap, center + 45 - gap) + '"/>' +
15817
+ joystickLabelMarkup(k.label, lp.x, lp.y) + "</g>";
15818
+ }
15819
+ for (i = 0; i < JOYSTICK_OUTER_KEYS.length; i++) {
15820
+ k = JOYSTICK_OUTER_KEYS[i];
15821
+ center = -90 + i * 45;
15822
+ lp = joystickPolar((JOYSTICK_R1 + JOYSTICK_R2) / 2, center);
15823
+ svg += '<g class="wjr-sector wjr-outer" data-key="' + k.key + '">' +
15824
+ '<path d="' + joystickSectorPath(JOYSTICK_R1, JOYSTICK_R2, center - 22.5 + gap, center + 22.5 - gap) + '"/>' +
15825
+ joystickLabelMarkup(k.label, lp.x, lp.y) + "</g>";
15826
+ }
15827
+ svg += '<circle class="wjr-hub" cx="0" cy="0" r="' + (JOYSTICK_R0 - 1) + '"/>';
15828
+ svg += '<text class="wjr-hub-label" x="0" y="0"></text>';
15829
+ return svg + "</svg>";
15830
+ }
15831
+
15832
+ function joystickLabelForKey(key) {
15833
+ var i;
15834
+ for (i = 0; i < JOYSTICK_INNER_KEYS.length; i++) {
15835
+ if (JOYSTICK_INNER_KEYS[i].key === key) return JOYSTICK_INNER_KEYS[i].label;
15836
+ }
15837
+ for (i = 0; i < JOYSTICK_OUTER_KEYS.length; i++) {
15838
+ if (JOYSTICK_OUTER_KEYS[i].key === key) return JOYSTICK_OUTER_KEYS[i].label;
15839
+ }
15840
+ return "";
15841
+ }
15842
+
15843
+ function setJoystickCenterLabel(text) {
15844
+ if (!state.joystickRingEl) return;
15845
+ var el = state.joystickRingEl.querySelector(".wjr-hub-label");
15846
+ if (el) el.textContent = text || "";
15847
+ }
15848
+
15849
+ function joystickHaptic(ms) {
15850
+ try { if (navigator.vibrate) navigator.vibrate(ms); } catch (e) {}
15851
+ }
15852
+
15853
+ function initTerminalJoystick() {
15854
+ if (state.joystickRootEl) return; // 已存在不重复建(触屏/桌面均构建)
15855
+
15856
+ var root = document.createElement("div");
15857
+ root.className = "wand-joystick-root";
15858
+
15859
+ var backdrop = document.createElement("div");
15860
+ backdrop.className = "wand-joystick-backdrop";
15861
+ root.appendChild(backdrop);
15862
+
15863
+ // 环形菜单容器(圆心运行期对齐球球中心)—— 扇形 pie 菜单(SVG,带文字 + 中心提示)
15864
+ var ring = document.createElement("div");
15865
+ ring.className = "wand-joystick-ring";
15866
+ ring.innerHTML = buildJoystickRingSvg();
15867
+ root.appendChild(ring);
15868
+
15869
+ // 钉住面板
15870
+ var panel = document.createElement("div");
15871
+ panel.className = "wand-joystick-panel";
15872
+ panel.innerHTML = renderJoystickPanel();
15873
+ panel.addEventListener("click", onJoystickPanelClick);
15874
+ root.appendChild(panel);
15875
+
15876
+ // 球球本体
15877
+ var ball = document.createElement("div");
15878
+ ball.className = "wand-joystick-ball";
15879
+ ball.setAttribute("role", "button");
15880
+ ball.setAttribute("aria-label", "终端摇杆遥控");
15881
+ ball.innerHTML = '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" ' +
15882
+ 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
15883
+ '<circle cx="12" cy="12" r="3"/><path d="M12 5V3M12 21v-2M5 12H3M21 12h-2"/></svg>';
15884
+ root.appendChild(ball);
15885
+
15886
+ document.body.appendChild(root);
15887
+
15888
+ state.joystickRootEl = root;
15889
+ state.joystickBackdropEl = backdrop;
15890
+ state.joystickRingEl = ring;
15891
+ state.joystickPanelEl = panel;
15892
+ state.joystickBallEl = ball;
15893
+
15894
+ applyJoystickPosition();
15895
+
15896
+ ball.addEventListener("pointerdown", onJoystickPointerDown);
15897
+ backdrop.addEventListener("pointerdown", function(e) {
15898
+ // 钉住面板开着且无进行中手势时,点遮罩收起面板
15899
+ if (state.joystickPinnedOpen && state.joystickGesture == null) {
15900
+ e.preventDefault();
15901
+ closeJoystickPanel();
15902
+ }
15903
+ });
15904
+
15905
+ // 旋转/窗口尺寸变化时重新钳制球球位置
15906
+ state.joystickResizeHandler = function() { applyJoystickPosition(); };
15907
+ window.addEventListener("resize", state.joystickResizeHandler);
15908
+ window.addEventListener("orientationchange", state.joystickResizeHandler);
15909
+
15910
+ updateJoystickVisibility();
15911
+ }
15912
+
15913
+ function getJoystickCenter() {
15914
+ if (!state.joystickBallEl) return { x: 0, y: 0 };
15915
+ var r = state.joystickBallEl.getBoundingClientRect();
15916
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
15917
+ }
15918
+
15919
+ function onJoystickPointerDown(e) {
15920
+ if (!isJoystickAvailable()) return;
15921
+ if (state.joystickPointerId !== null) return; // 已有手势在进行
15922
+ e.preventDefault();
15923
+ e.stopPropagation();
15924
+ state.joystickPointerId = e.pointerId;
15925
+ state.joystickPressStart = { x: e.clientX, y: e.clientY, t: Date.now() };
15926
+ state.joystickGesture = "pending";
15927
+ state.joystickHoverOuter = null;
15928
+ state.joystickCenter = getJoystickCenter();
15929
+ try { state.joystickBallEl.setPointerCapture(e.pointerId); } catch (err) {}
15930
+ // 起长按定时器:不动到 400ms → 移动模式
15931
+ state.joystickLongPressTimer = setTimeout(function() {
15932
+ if (state.joystickGesture === "pending") enterJoystickMoveMode();
15933
+ }, JOYSTICK_LONG_PRESS_MS);
15934
+ state.joystickMoveHandler = onJoystickPointerMove;
15935
+ state.joystickUpHandler = onJoystickPointerUp;
15936
+ document.addEventListener("pointermove", state.joystickMoveHandler);
15937
+ document.addEventListener("pointerup", state.joystickUpHandler);
15938
+ document.addEventListener("pointercancel", state.joystickUpHandler);
15939
+ }
15940
+
15941
+ function enterJoystickMoveMode() {
15942
+ state.joystickGesture = "move";
15943
+ if (state.joystickPinnedOpen) closeJoystickPanel();
15944
+ if (state.joystickBallEl) state.joystickBallEl.classList.add("dragging");
15945
+ if (state.joystickBackdropEl) state.joystickBackdropEl.classList.add("active");
15946
+ }
15947
+
15948
+ function moveJoystickBallTo(clientX, clientY) {
15949
+ if (!state.joystickBallEl) return;
15950
+ var pos = clampJoystickPos({
15951
+ right: window.innerWidth - clientX - JOYSTICK_BALL_SIZE / 2,
15952
+ bottom: window.innerHeight - clientY - JOYSTICK_BALL_SIZE / 2
15953
+ });
15954
+ state.joystickBallEl.style.right = pos.right + "px";
15955
+ state.joystickBallEl.style.bottom = pos.bottom + "px";
15956
+ }
15957
+
15958
+ // 环形手势里把手指往外拖出外圈区域时调用:收起环,切到"正在移动"状态,
15959
+ // 球球立刻挪到手指下,之后跟手慢慢移动,松手保存位置。
15960
+ function switchJoystickToMoveMode(e) {
15961
+ stopJoystickRepeat();
15962
+ state.joystickHoverOuter = null;
15963
+ closeJoystickRing();
15964
+ state.joystickGesture = "move";
15965
+ if (state.joystickBallEl) state.joystickBallEl.classList.add("dragging");
15966
+ if (state.joystickBackdropEl) state.joystickBackdropEl.classList.add("active");
15967
+ joystickHaptic(18);
15968
+ moveJoystickBallTo(e.clientX, e.clientY);
15969
+ }
15970
+
15971
+ function onJoystickPointerMove(e) {
15972
+ if (e.pointerId !== state.joystickPointerId) return;
15973
+ if (!state.joystickBallEl) return;
15974
+ e.preventDefault();
15975
+ var dxStart = e.clientX - state.joystickPressStart.x;
15976
+ var dyStart = e.clientY - state.joystickPressStart.y;
15977
+ if (state.joystickGesture === "pending") {
15978
+ if (Math.sqrt(dxStart * dxStart + dyStart * dyStart) > JOYSTICK_MOVE_THRESHOLD) {
15979
+ // 先动 → 选键手势
15980
+ if (state.joystickLongPressTimer) {
15981
+ clearTimeout(state.joystickLongPressTimer);
15982
+ state.joystickLongPressTimer = null;
15983
+ }
15984
+ if (state.joystickPinnedOpen) closeJoystickPanel();
15985
+ state.joystickGesture = "ring";
15986
+ state.joystickCenter = getJoystickCenter();
15987
+ openJoystickRing();
15988
+ } else {
15989
+ return;
15990
+ }
15991
+ }
15992
+ if (state.joystickGesture === "ring") {
15993
+ var c = state.joystickCenter || getJoystickCenter();
15994
+ var rdx = e.clientX - c.x;
15995
+ var rdy = e.clientY - c.y;
15996
+ // 往外拖超出外圈区域 → 切到"正在移动"状态,球球开始跟手
15997
+ if (Math.sqrt(rdx * rdx + rdy * rdy) > JOYSTICK_MOVE_OUT_R) {
15998
+ switchJoystickToMoveMode(e);
15999
+ return;
16000
+ }
16001
+ applyJoystickRingHit(joystickHitTest(rdx, rdy));
16002
+ return;
16003
+ }
16004
+ if (state.joystickGesture === "move") {
16005
+ moveJoystickBallTo(e.clientX, e.clientY);
16006
+ return;
16007
+ }
16008
+ }
16009
+
16010
+ function onJoystickPointerUp(e) {
16011
+ if (e.pointerId !== state.joystickPointerId) return;
16012
+ if (state.joystickLongPressTimer) {
16013
+ clearTimeout(state.joystickLongPressTimer);
16014
+ state.joystickLongPressTimer = null;
16015
+ }
16016
+ var gesture = state.joystickGesture;
16017
+ if (gesture === "ring") {
16018
+ stopJoystickRepeat();
16019
+ if (state.joystickHoverOuter) {
16020
+ joystickHaptic(18);
16021
+ sendJoystickKey(state.joystickHoverOuter);
16022
+ }
16023
+ } else if (gesture === "pending") {
16024
+ var dx = e.clientX - state.joystickPressStart.x;
16025
+ var dy = e.clientY - state.joystickPressStart.y;
16026
+ if (Math.sqrt(dx * dx + dy * dy) <= JOYSTICK_TAP_THRESHOLD) toggleJoystickPanel();
16027
+ } else if (gesture === "move") {
16028
+ var r = state.joystickBallEl ? state.joystickBallEl.getBoundingClientRect() : null;
16029
+ if (r) saveJoystickPosition(window.innerWidth - r.right, window.innerHeight - r.bottom);
16030
+ }
16031
+ endJoystickGesture();
16032
+ }
16033
+
16034
+ function endJoystickGesture() {
16035
+ stopJoystickRepeat();
16036
+ if (state.joystickLongPressTimer) {
16037
+ clearTimeout(state.joystickLongPressTimer);
16038
+ state.joystickLongPressTimer = null;
16039
+ }
16040
+ if (state.joystickBallEl && state.joystickPointerId !== null) {
16041
+ try { state.joystickBallEl.releasePointerCapture(state.joystickPointerId); } catch (err) {}
16042
+ }
16043
+ if (state.joystickMoveHandler) {
16044
+ document.removeEventListener("pointermove", state.joystickMoveHandler);
16045
+ state.joystickMoveHandler = null;
16046
+ }
16047
+ if (state.joystickUpHandler) {
16048
+ document.removeEventListener("pointerup", state.joystickUpHandler);
16049
+ document.removeEventListener("pointercancel", state.joystickUpHandler);
16050
+ state.joystickUpHandler = null;
16051
+ }
16052
+ closeJoystickRing();
16053
+ if (state.joystickBallEl) state.joystickBallEl.classList.remove("dragging");
16054
+ // 钉住面板若仍开着则保留遮罩,否则移除
16055
+ if (state.joystickBackdropEl && !state.joystickPinnedOpen) {
16056
+ state.joystickBackdropEl.classList.remove("active");
16057
+ }
16058
+ state.joystickPointerId = null;
16059
+ state.joystickGesture = null;
16060
+ state.joystickHoverOuter = null;
16061
+ state.joystickLastHoverKey = null;
16062
+ state.joystickPressStart = null;
16063
+ state.joystickCenter = null;
16064
+ }
16065
+
16066
+ function joystickHitTest(dx, dy) {
16067
+ var r = Math.sqrt(dx * dx + dy * dy);
16068
+ if (r < JOYSTICK_DEADZONE_R) return { zone: "dead", key: null };
16069
+ if (r < JOYSTICK_RING_SPLIT_R) {
16070
+ // 内圈:主轴象限(往上滑 dy<0 = up)
16071
+ if (Math.abs(dy) >= Math.abs(dx)) return { zone: "inner", key: dy < 0 ? "up" : "down" };
16072
+ return { zone: "inner", key: dx < 0 ? "left" : "right" };
16073
+ }
16074
+ // 外圈:8 等分扇区,正上方为 0,顺时针递增;+π/8 让扇区中心对准按钮
16075
+ var ang = Math.atan2(dx, -dy);
16076
+ if (ang < 0) ang += Math.PI * 2;
16077
+ var idx = Math.floor((ang + Math.PI / 8) / (Math.PI / 4)) % 8;
16078
+ return { zone: "outer", key: JOYSTICK_OUTER_KEYS[idx].key };
16079
+ }
16080
+
16081
+ function applyJoystickRingHit(hit) {
16082
+ if (!state.joystickRingEl) return;
16083
+ var key = hit.zone === "dead" ? null : hit.key;
16084
+ if (key !== state.joystickLastHoverKey) { // 切换扇区 → 轻震反馈
16085
+ state.joystickLastHoverKey = key;
16086
+ joystickHaptic(8);
16087
+ }
16088
+ if (hit.zone === "inner") {
16089
+ state.joystickHoverOuter = null;
16090
+ setJoystickOuterHighlight(null);
16091
+ startJoystickRepeat(hit.key);
16092
+ setJoystickInnerHighlight(hit.key);
16093
+ setJoystickCenterLabel(joystickLabelForKey(hit.key));
16094
+ } else if (hit.zone === "outer") {
16095
+ stopJoystickRepeat();
16096
+ setJoystickInnerHighlight(null);
16097
+ state.joystickHoverOuter = hit.key;
16098
+ setJoystickOuterHighlight(hit.key);
16099
+ setJoystickCenterLabel(joystickLabelForKey(hit.key));
16100
+ } else {
16101
+ stopJoystickRepeat();
16102
+ setJoystickInnerHighlight(null);
16103
+ state.joystickHoverOuter = null;
16104
+ setJoystickOuterHighlight(null);
16105
+ setJoystickCenterLabel("取消");
16106
+ }
16107
+ }
16108
+
16109
+ function setJoystickInnerHighlight(key) {
16110
+ if (!state.joystickRingEl) return;
16111
+ var btns = state.joystickRingEl.querySelectorAll(".wjr-inner");
16112
+ for (var i = 0; i < btns.length; i++) {
16113
+ btns[i].classList.toggle("is-repeating", btns[i].getAttribute("data-key") === key);
16114
+ }
16115
+ }
16116
+
16117
+ function setJoystickOuterHighlight(key) {
16118
+ if (!state.joystickRingEl) return;
16119
+ var btns = state.joystickRingEl.querySelectorAll(".wjr-outer");
16120
+ for (var i = 0; i < btns.length; i++) {
16121
+ btns[i].classList.toggle("is-hover", btns[i].getAttribute("data-key") === key);
16122
+ }
16123
+ }
16124
+
16125
+ // 把环圆心钳进视口,保证整圈(含标签)不被屏幕边裁掉。视口比环还小则回退到正中。
16126
+ function clampJoystickRingCenter(c) {
16127
+ var pad = JOYSTICK_RING_RADIUS + JOYSTICK_RING_VIEW_PAD;
16128
+ var vw = window.innerWidth, vh = window.innerHeight;
16129
+ return {
16130
+ x: vw < pad * 2 ? vw / 2 : Math.min(Math.max(pad, c.x), vw - pad),
16131
+ y: vh < pad * 2 ? vh / 2 : Math.min(Math.max(pad, c.y), vh - pad)
16132
+ };
16133
+ }
16134
+
16135
+ function openJoystickRing() {
16136
+ if (!state.joystickRingEl) return;
16137
+ // 圆心钳进视口后写回 state.joystickCenter:球球此刻已 is-ringing(opacity:0),
16138
+ // 圆心内移不露馅,且命中测试与可见环始终对齐。
16139
+ var c = clampJoystickRingCenter(state.joystickCenter || getJoystickCenter());
16140
+ state.joystickCenter = c;
16141
+ state.joystickRingEl.style.left = c.x + "px";
16142
+ state.joystickRingEl.style.top = c.y + "px";
16143
+ state.joystickRingEl.classList.add("active");
16144
+ state.joystickLastHoverKey = null;
16145
+ setJoystickCenterLabel("取消"); // 初始在死区,提示松手取消
16146
+ if (state.joystickBallEl) state.joystickBallEl.classList.add("is-ringing"); // 隐球球露中心
16147
+ if (state.joystickBackdropEl) state.joystickBackdropEl.classList.add("active");
16148
+ joystickHaptic(10);
16149
+ }
16150
+
16151
+ function closeJoystickRing() {
16152
+ if (state.joystickRingEl) state.joystickRingEl.classList.remove("active");
16153
+ if (state.joystickBallEl) state.joystickBallEl.classList.remove("is-ringing");
16154
+ setJoystickInnerHighlight(null);
16155
+ setJoystickOuterHighlight(null);
16156
+ }
16157
+
16158
+ function startJoystickRepeat(key) {
16159
+ if (state.joystickRepeatKey === key) return; // 同方向不重启,保持节奏
16160
+ stopJoystickRepeat();
16161
+ state.joystickRepeatKey = key;
16162
+ sendJoystickKey(key); // 立即发一次
16163
+ state.joystickRepeatTimer = setInterval(function() {
16164
+ if (state.joystickRepeatKey) sendJoystickKey(state.joystickRepeatKey);
16165
+ }, JOYSTICK_REPEAT_MS);
16166
+ }
16167
+
16168
+ function stopJoystickRepeat() {
16169
+ if (state.joystickRepeatTimer) {
16170
+ clearInterval(state.joystickRepeatTimer);
16171
+ state.joystickRepeatTimer = null;
16172
+ }
16173
+ state.joystickRepeatKey = null;
16174
+ }
16175
+
16176
+ function sendJoystickKey(key) {
16177
+ if (key === "ctrl" || key === "alt" || key === "shift") {
16178
+ state.modifiers[key] = !state.modifiers[key];
16179
+ updateJoystickPanelUI();
16180
+ return;
16181
+ }
16182
+ var seq = buildPtySequence(key, {
16183
+ ctrl: state.modifiers.ctrl,
16184
+ alt: state.modifiers.alt,
16185
+ shift: state.modifiers.shift
16186
+ });
16187
+ if (seq) sendTerminalSequence(seq, key);
16188
+ clearModifiers(); // 发后自动清修饰键(应用到下一个发送的键)
16189
+ updateJoystickPanelUI();
16190
+ scheduleShortcutResync();
16191
+ }
16192
+
16193
+ function toggleJoystickPanel() {
16194
+ if (state.joystickPinnedOpen) closeJoystickPanel();
16195
+ else openJoystickPanel();
16196
+ }
16197
+
16198
+ function openJoystickPanel() {
16199
+ if (!state.joystickPanelEl || !state.joystickBallEl) return;
16200
+ state.joystickPinnedOpen = true;
16201
+ var r = state.joystickBallEl.getBoundingClientRect();
16202
+ // 面板锚定在球球上方(球球在右下→面板往左上展开),贴右/底边对齐
16203
+ state.joystickPanelEl.style.right = Math.max(JOYSTICK_EDGE_MARGIN, window.innerWidth - r.right) + "px";
16204
+ state.joystickPanelEl.style.bottom = Math.max(JOYSTICK_EDGE_MARGIN, window.innerHeight - r.top + 10) + "px";
16205
+ state.joystickPanelEl.classList.add("active");
16206
+ if (state.joystickBackdropEl) state.joystickBackdropEl.classList.add("active");
16207
+ updateJoystickPanelUI();
16208
+ }
16209
+
16210
+ function closeJoystickPanel() {
16211
+ state.joystickPinnedOpen = false;
16212
+ if (state.joystickPanelEl) state.joystickPanelEl.classList.remove("active");
16213
+ if (state.joystickBackdropEl && state.joystickGesture == null) {
16214
+ state.joystickBackdropEl.classList.remove("active");
16215
+ }
16216
+ }
16217
+
16218
+ function updateJoystickPanelUI() {
16219
+ if (!state.joystickPanelEl) return;
16220
+ ["ctrl", "alt"].forEach(function(name) {
16221
+ var btn = state.joystickPanelEl.querySelector('.wjp-mod[data-key="' + name + '"]');
16222
+ if (btn) btn.classList.toggle("active", !!state.modifiers[name]);
16223
+ });
16224
+ }
16225
+
16226
+ function onJoystickPanelClick(e) {
16227
+ var btn = e.target && e.target.closest ? e.target.closest(".wjp-key") : null;
16228
+ if (!btn) return;
16229
+ e.preventDefault();
16230
+ e.stopPropagation();
16231
+ var key = btn.getAttribute("data-key");
16232
+ if (key) sendJoystickKey(key);
16233
+ }
16234
+
16235
+ function updateJoystickVisibility() {
16236
+ var root = state.joystickRootEl;
16237
+ if (!root) return;
16238
+ var available = isJoystickAvailable();
16239
+ root.classList.toggle("visible", available);
16240
+ if (!available) {
16241
+ // 不可用:强制收手势 + 收面板 + 停连发 + 清修饰键,杜绝残留
16242
+ if (state.joystickPointerId !== null || state.joystickGesture) endJoystickGesture();
16243
+ stopJoystickRepeat();
16244
+ if (state.joystickPinnedOpen) closeJoystickPanel();
16245
+ if (state.joystickBackdropEl) state.joystickBackdropEl.classList.remove("active");
16246
+ }
16247
+ }
16248
+
16249
+ function teardownJoystick() {
16250
+ stopJoystickRepeat();
16251
+ if (state.joystickLongPressTimer) {
16252
+ clearTimeout(state.joystickLongPressTimer);
16253
+ state.joystickLongPressTimer = null;
16254
+ }
16255
+ if (state.joystickMoveHandler) {
16256
+ document.removeEventListener("pointermove", state.joystickMoveHandler);
16257
+ state.joystickMoveHandler = null;
16258
+ }
16259
+ if (state.joystickUpHandler) {
16260
+ document.removeEventListener("pointerup", state.joystickUpHandler);
16261
+ document.removeEventListener("pointercancel", state.joystickUpHandler);
16262
+ state.joystickUpHandler = null;
16263
+ }
16264
+ if (state.joystickResizeHandler) {
16265
+ window.removeEventListener("resize", state.joystickResizeHandler);
16266
+ window.removeEventListener("orientationchange", state.joystickResizeHandler);
16267
+ state.joystickResizeHandler = null;
16268
+ }
16269
+ if (state.joystickRootEl && state.joystickRootEl.parentNode) {
16270
+ state.joystickRootEl.parentNode.removeChild(state.joystickRootEl);
16271
+ }
16272
+ state.joystickRootEl = null;
16273
+ state.joystickRingEl = null;
16274
+ state.joystickPanelEl = null;
16275
+ state.joystickBackdropEl = null;
16276
+ state.joystickBallEl = null;
16277
+ state.joystickPointerId = null;
16278
+ state.joystickGesture = null;
16279
+ state.joystickPressStart = null;
16280
+ state.joystickHoverOuter = null;
16281
+ state.joystickCenter = null;
16282
+ state.joystickPinnedOpen = false;
16283
+ }
16284
+
15453
16285
  function observeTerminalResize() {
15454
16286
  var output = document.getElementById("output");
15455
16287
  if (!output) return;
@@ -15644,6 +16476,7 @@
15644
16476
  // 尺寸下创建的会话时,若新算出的 cols/rows 恰好等于上次值会被去重跳过,导致
15645
16477
  // 后端该会话列宽停在旧值、整段折行。teardown 重置后新会话首次 resize 必发出。
15646
16478
  state.lastResize = { cols: 0, rows: 0 };
16479
+ teardownJoystick();
15647
16480
  }
15648
16481
 
15649
16482
  function sendTerminalResize(cols, rows) {
@@ -18173,8 +19006,8 @@
18173
19006
  var agentType = sub.agentType || "";
18174
19007
  if (agentType && SUBAGENT_NAME_MAP[agentType]) return SUBAGENT_NAME_MAP[agentType];
18175
19008
  if (agentType) return agentType;
18176
- var tail = (sub.taskId || "").slice(-4) || "未知";
18177
- return "协作猫·" + tail;
19009
+ var tail = (sub.taskId || "").slice(-4) || (getActiveLang() === "English" ? "?" : "未知");
19010
+ return t("subagent.helper_fallback_prefix") + tail;
18178
19011
  }
18179
19012
  function getSubagentPalette(sub) {
18180
19013
  // 哈希优先用 agentType,让同类型 agent 跨 turn 颜色稳定;没有 agentType 时
@@ -18192,33 +19025,35 @@
18192
19025
  '</div>';
18193
19026
  }
18194
19027
 
18195
- // subagent tool_result 独立 reply 气泡(markdown 渲染)。出错时显示红色错误体,
18196
- // 没文本时显示打字指示器(subagent 还在跑)。
19028
+ // subagent 最终回复(父 Task tool_result)——现在外层 .subagent-panel 已经
19029
+ // 负责整段折叠 / 滚动,这里只需把"任务完成 / 失败"做个轻量标记块,markdown
19030
+ // 内容平铺,让 panel 的 body 滚动条统一接管。
18197
19031
  function renderSubagentReplyBubble(block, role) {
18198
19032
  if (!block || block.type !== "tool_result") return "";
18199
19033
  var text = extractToolResultText(block.content);
18200
19034
  var isError = block.is_error === true;
18201
-
18202
- if (isError) {
18203
- return '<div class="subagent-reply error">' +
18204
- '<span class="subagent-reply-icon">✗</span>' +
18205
- '<div class="subagent-reply-body">' + (text ? renderMarkdown(text) : escapeHtml("(无输出)")) + '</div>' +
19035
+ var rawText = typeof text === "string" ? text : (text == null ? "" : String(text));
19036
+
19037
+ // pending:subagent 还在跑,没收到结果。在 panel body 里画一个 typing 指示器
19038
+ // 占位,告诉用户"还在跑"
19039
+ if (!isError && !rawText.trim()) {
19040
+ return '<div class="subagent-reply pending">' +
19041
+ '<span class="subagent-reply-marker pending">' + escapeHtml(t("subagent.running")) + '</span>' +
19042
+ '<span class="typing-indicator"><span></span><span></span><span></span></span>' +
18206
19043
  '</div>';
18207
19044
  }
18208
19045
 
18209
- if (!text || !String(text).trim()) {
18210
- return '<div class="subagent-reply pending"><span class="typing-indicator"><span></span><span></span><span></span></span></div>';
18211
- }
19046
+ var displayText = rawText.trim() ? rawText : t("subagent.no_output");
19047
+ var bodyHtml = rawText.trim() ? renderMarkdown(displayText) : escapeHtml(displayText);
19048
+ var markerLabel = isError ? t("subagent.task.failed") : t("subagent.task.done");
19049
+ var markerSymbol = isError ? "✗" : "✓";
18212
19050
 
18213
- // 三态折叠:preview(默认 ~5 行预览,内部可滚)→ expanded(高一些上限,可滚)→
18214
- // collapsed(完全收起,只剩工具条)→ preview。按钮一直可见在右下,状态写在
18215
- // data-collapse-mode 上,配套 CSS 控制 max-height。
18216
- return '<div class="subagent-reply collapsible" data-collapse-mode="preview">' +
18217
- '<div class="subagent-reply-scroll">' + renderMarkdown(text) + '</div>' +
18218
- '<button type="button" class="subagent-reply-cycle" onclick="__subagentReplyCycle(event, this)" title="展开 / 收起">' +
18219
- '<span class="subagent-reply-cycle-label">展开</span>' +
18220
- '<span class="subagent-reply-cycle-icon" aria-hidden="true">▾</span>' +
18221
- '</button>' +
19051
+ return '<div class="subagent-reply final' + (isError ? ' error' : '') + '">' +
19052
+ '<div class="subagent-reply-marker ' + (isError ? 'error' : 'done') + '">' +
19053
+ '<span class="subagent-reply-marker-icon" aria-hidden="true">' + markerSymbol + '</span>' +
19054
+ '<span class="subagent-reply-marker-label">' + escapeHtml(markerLabel) + '</span>' +
19055
+ '</div>' +
19056
+ '<div class="subagent-reply-content">' + bodyHtml + '</div>' +
18222
19057
  '</div>';
18223
19058
  }
18224
19059
  var PIXEL_AVATAR = {
@@ -18569,23 +19404,12 @@
18569
19404
  // 跳过整段。否则会渲染出"只有头像没内容"的空气泡。
18570
19405
  if (!segHtml || !segHtml.trim()) continue;
18571
19406
  if (seg.subagent) {
18572
- var subPalette = getSubagentPalette(seg.subagent);
18573
- if (showHandoff && lastSubId !== seg.subagent.taskId) {
18574
- var subName = getSubagentDisplayName(seg.subagent);
18575
- var desc = seg.subagent.taskDescription
18576
- ? ':<span class="chat-handoff-desc">' + escapeHtml(seg.subagent.taskDescription) + '</span>'
18577
- : '';
18578
- html += '<div class="chat-handoff" style="--agent-color:' + subPalette.primary + '">' +
18579
- '<span class="chat-handoff-arrow">↳</span> ' +
18580
- escapeHtml(parentPersonaName) + ' 让 <strong>' + escapeHtml(subName) + '</strong>' +
18581
- '<span class="chat-handoff-tag" title="子代理 / subagent">subagent</span>' +
18582
- '帮忙' + desc +
18583
- '</div>';
18584
- }
18585
- html += '<div class="chat-message-segment subagent" data-agent-id="' + escapeHtml(seg.subagent.taskId) + '" style="--agent-color:' + subPalette.primary + '">' +
18586
- subagentAvatarHtml(seg.subagent) +
18587
- '<div class="chat-message-content">' + segHtml + '</div>' +
18588
- '</div>';
19407
+ // 整段 subagent 输出包成一个统一的可折叠面板:
19408
+ // 头部 = handoff title + 展开按钮;body = segHtml(所有工具卡、文本、最终回复
19409
+ // 都在 body 里);footer = 同款按钮。同一 taskId 的连续段(极少出现的
19410
+ // parent→sub→parent→sub 交错)只在第一次露 handoff title。
19411
+ var includeHandoff = showHandoff && lastSubId !== seg.subagent.taskId;
19412
+ html += buildSubagentPanelHtml(seg, parentPersonaName, segHtml, messageKey, includeHandoff);
18589
19413
  lastSubId = seg.subagent.taskId;
18590
19414
  } else {
18591
19415
  html += '<div class="chat-message-segment parent">' +
@@ -18598,6 +19422,85 @@
18598
19422
  return html;
18599
19423
  }
18600
19424
 
19425
+ // 渲染整段 subagent 输出为一个可折叠面板:
19426
+ // ┌─ subagent-panel ──────────────────────────────────┐
19427
+ // │ [🐱] ↳ 勤劳初二 让 协作猫 帮忙:xxx [展开 ▾] │ ← header (always visible)
19428
+ // ├───────────────────────────────────────────────────┤
19429
+ // │ <tool 卡 1> │
19430
+ // │ <text> │ ← body
19431
+ // │ <tool 卡 2> │ 默认 max-height 22em
19432
+ // │ <... 最终回复 ...> │ + 内部 overflow-y:auto
19433
+ // ├───────────────────────────────────────────────────┤
19434
+ // │ [展开 ▾] │ ← footer (always visible)
19435
+ // └───────────────────────────────────────────────────┘
19436
+ // 状态写在 .subagent-panel[data-expanded],按 messageKey + taskId 持久化。
19437
+ // 默认折叠(preview);用户点头/尾按钮或头部标题条都能切。
19438
+ function buildSubagentPanelHtml(seg, parentPersonaName, segHtml, messageKey, includeHandoff) {
19439
+ var sub = seg.subagent;
19440
+ var subPalette = getSubagentPalette(sub);
19441
+ var subName = getSubagentDisplayName(sub);
19442
+ var taskId = sub.taskId || "";
19443
+ var avatarSvg = buildPixelSvg(buildCatGrid(subPalette));
19444
+
19445
+ var titleHtml;
19446
+ if (includeHandoff) {
19447
+ // 用 i18n 模板拼 handoff title。{parent}/{sub} 用占位符避免在调用点拼字符串导致
19448
+ // 顺序错乱(中文 "X 让 Y 帮忙",英文 "X asked Y for help",词序完全不同)。
19449
+ // tag 是单独的彩色 chip,所以把模板里的 {sub} 替换成空 span 占位,然后再 splice
19450
+ // 进 <strong> + chip——保证模板可读、调用点不脏。
19451
+ var subInlineHtml = '<strong class="subagent-panel-name">' + escapeHtml(subName) + '</strong>' +
19452
+ '<span class="subagent-panel-tag" title="' + escapeHtml(t("subagent.tag_title")) + '">' + escapeHtml(t("subagent.tag")) + '</span>';
19453
+ var hasDesc = !!(sub.taskDescription && String(sub.taskDescription).trim());
19454
+ var handoffTpl = hasDesc ? t("subagent.handoff.with_desc", { parent: escapeHtml(parentPersonaName), sub: subInlineHtml })
19455
+ : t("subagent.handoff", { parent: escapeHtml(parentPersonaName), sub: subInlineHtml });
19456
+ var descSpan = hasDesc
19457
+ ? '<span class="subagent-panel-task-desc">' + escapeHtml(sub.taskDescription) + '</span>'
19458
+ : '';
19459
+ titleHtml = '<span class="subagent-panel-arrow" aria-hidden="true">↳</span>' +
19460
+ '<span class="subagent-panel-attribution">' + handoffTpl + descSpan + '</span>';
19461
+ } else {
19462
+ titleHtml = '<span class="subagent-panel-attribution">' +
19463
+ '<strong class="subagent-panel-name">' + escapeHtml(subName) + '</strong>' +
19464
+ '<span class="subagent-panel-task-desc"> ' + escapeHtml(t("subagent.continued")) + '</span>' +
19465
+ '</span>';
19466
+ }
19467
+
19468
+ var expandKey = buildExpandKey("subagent-panel", [messageKey, taskId]);
19469
+ var persisted = getPersistedExpandState(expandKey);
19470
+ var expanded = persisted === null ? false : !!persisted;
19471
+
19472
+ function toggleBtnHtml(position) {
19473
+ return '<button type="button" class="subagent-panel-toggle" ' +
19474
+ 'data-position="' + position + '" ' +
19475
+ 'onclick="__subagentPanelToggle(event, this)" ' +
19476
+ 'aria-expanded="' + (expanded ? "true" : "false") + '" ' +
19477
+ 'aria-label="' + escapeHtml(expanded ? t("ui.collapse_panel_aria") : t("ui.expand_panel_aria")) + '">' +
19478
+ '<span class="subagent-panel-toggle-label">' + escapeHtml(expanded ? t("ui.collapse") : t("ui.expand")) + '</span>' +
19479
+ '<svg class="subagent-panel-toggle-icon" width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">' +
19480
+ '<path d="M2.5 3.75L5 6.25L7.5 3.75" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>' +
19481
+ '</svg>' +
19482
+ '</button>';
19483
+ }
19484
+
19485
+ return '<div class="subagent-panel" ' +
19486
+ 'data-expand-kind="subagent-panel" ' +
19487
+ 'data-expand-key="' + escapeHtml(expandKey) + '" ' +
19488
+ 'data-agent-id="' + escapeHtml(taskId) + '" ' +
19489
+ 'data-expanded="' + (expanded ? "true" : "false") + '" ' +
19490
+ 'style="--agent-color:' + subPalette.primary + '">' +
19491
+ '<div class="subagent-panel-header" onclick="__subagentPanelToggle(event, this)" role="button" tabindex="0" aria-label="' + escapeHtml(t("subagent.title_aria")) + '">' +
19492
+ '<span class="subagent-panel-avatar" aria-hidden="true">' + avatarSvg + '</span>' +
19493
+ titleHtml +
19494
+ toggleBtnHtml("top") +
19495
+ '</div>' +
19496
+ '<div class="subagent-panel-body">' + segHtml + '</div>' +
19497
+ '<div class="subagent-panel-footer">' +
19498
+ '<span class="subagent-panel-footer-hint" aria-hidden="true">— ' + escapeHtml(subName) + ' —</span>' +
19499
+ toggleBtnHtml("bottom") +
19500
+ '</div>' +
19501
+ '</div>';
19502
+ }
19503
+
18601
19504
  function renderStructuredMessage(msg, roundUsage, messageIndex, legacyTaskMap) {
18602
19505
  var role = msg.role;
18603
19506
  var messageKey = getMessageKey(msg, messageIndex);