magic-agent 2.1.5 → 2.3.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.
Files changed (52) hide show
  1. package/CLIENT-CONTRACT.md +103 -6
  2. package/client-ui/pc/app.css +3 -0
  3. package/client-ui/pc/index.html +5 -0
  4. package/client-ui/pc/js/05-task-list-render.js +28 -4
  5. package/client-ui/pc/js/07-chat-stream-render.js +19 -8
  6. package/client-ui/pc/js/93-rail.js +21 -1
  7. package/client-ui/shared/conv/03-chat-message.js +43 -0
  8. package/client-ui/shared/conv/06-ask-answer.js +30 -7
  9. package/client-ui/shared/conv/09-stream-apply.js +16 -0
  10. package/client-ui/shared/conv/19-attachments.js +75 -11
  11. package/client-ui/shared/conv/29-msg-variants.js +56 -3
  12. package/client-ui/shared/conv/README.md +6 -0
  13. package/client-ui/shared/conv/conv.css +16 -1
  14. package/client-ui/shared/conversation.css +42 -4
  15. package/client-ui/shared/conversation.js +655 -87
  16. package/client-ui/shared/transport-http.js +16 -0
  17. package/client-ui-mobile/index.html +401 -553
  18. package/client-ui-mobile/reqs.css +39 -0
  19. package/client-ui-mobile/reqs.js +507 -0
  20. package/cmd/magic-client/main.go +1 -1
  21. package/internal/cli/ask.go +6 -0
  22. package/internal/cli/root.go +5 -0
  23. package/internal/cli/session.go +16 -14
  24. package/internal/cli/suggest.go +98 -0
  25. package/internal/cli/suggest_test.go +54 -0
  26. package/internal/cli/team.go +111 -24
  27. package/internal/client/service.go +11 -1
  28. package/internal/client/suggest.go +131 -0
  29. package/internal/client/suggest_test.go +116 -0
  30. package/internal/session/delete.go +58 -11
  31. package/internal/session/delete_test.go +140 -0
  32. package/internal/session/session.go +25 -0
  33. package/internal/session/session_mark_test.go +62 -0
  34. package/internal/suggest/suggest.go +421 -0
  35. package/internal/suggest/suggest_test.go +218 -0
  36. package/internal/team/board.go +107 -15
  37. package/internal/team/execute.go +1 -1
  38. package/internal/team/team.go +28 -19
  39. package/internal/team/team_test.go +68 -15
  40. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  41. package/npm/apk/version.json +3 -3
  42. package/npm/dist/darwin-arm64/magic-agent +0 -0
  43. package/npm/dist/darwin-arm64/magic-client +0 -0
  44. package/npm/dist/darwin-x64/magic-agent +0 -0
  45. package/npm/dist/darwin-x64/magic-client +0 -0
  46. package/npm/dist/linux-arm64/magic-agent +0 -0
  47. package/npm/dist/linux-arm64/magic-client +0 -0
  48. package/npm/dist/linux-x64/magic-agent +0 -0
  49. package/npm/dist/linux-x64/magic-client +0 -0
  50. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  51. package/npm/dist/win32-x64/magic-client.exe +0 -0
  52. package/package.json +5 -2
@@ -32,6 +32,9 @@
32
32
  <!-- 对话内核那套 DOM(.qa-*)的**共用皮肤**:三端同一份(见 shared/conv/conv.css 的文件头)。
33
33
  里面的取值全走令牌,本页只负责给令牌(下面的 :root)—— 皮肤本身不定义颜色。 -->
34
34
  <link rel="stylesheet" href="shared/conv/conv.css">
35
+ <!-- 需求页(「需求」tab)是独立子页面:样式在 reqs.css、结构/逻辑在 reqs.js。
36
+ 本文件只给两个空容器与一次 mount,不再混入这一页的实现。 -->
37
+ <link rel="stylesheet" href="reqs.css">
35
38
  <style>
36
39
  /* ===== 令牌:ZCode 安卓端那一套,映射到共享组件的颜色契约 =====
37
40
  ⚠️ 默认 = 暗色:跟 ZCode 的 DarkColorScheme 逐字段对齐。 */
@@ -313,11 +316,25 @@ textarea{font:inherit}
313
316
  .srow__s{display:flex;align-items:center;gap:5px;min-width:0;font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden}
314
317
  .srow__stext{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
315
318
  .srow__time{flex:0 0 auto;font-size:12px;color:var(--muted)}
319
+ /* 运行中**自动临时置顶**的小图钉(2026-10-22):让「它凭什么在最上面」有据可查。
320
+ 手动置顶靠「置顶」分段表达,不重复画;跑完 statusOf 不再是 run,图钉随之消失。 */
321
+ .srow__auto{flex:0 0 auto;display:flex;align-items:center;color:var(--primary-action);opacity:.75}
316
322
  /* 子会话(团队模式拆出的子任务,2026-10-22):**缩进挂在主会话下面**,不占顶层一行。
317
323
  左侧一道竖线 + 小一档图标 —— 读起来是「这条主会话拆出去的活」;点开仍是它自己
318
- 那份完整过程(自己的日志)。行本身可点、可长按(重命名 / 删除同普通会话)。 */
324
+ 那份完整过程(自己的日志)。行本身可点、可长按(重命名 / 删除同普通会话)。
325
+ ⚠️ 默认**折叠**(2026-10-23 用户:「列表中默认不展开显示子会话」):父行只留一枚
326
+ 计数徽标(有几个子会话 / 是否有子任务在跑),点它才展开。 */
319
327
  .sblock{min-width:0}
320
328
  .sblock__kids{margin:0 8px 6px 30px;padding-left:8px;border-left:2px solid var(--border)}
329
+ .srow__kids{flex:0 0 auto;display:inline-flex;align-items:center;gap:3px;height:20px;padding:0 5px;
330
+ border:1px solid var(--border);border-radius:999px;background:var(--subtle);color:var(--muted);
331
+ font-size:11px;font-weight:600;line-height:1;cursor:pointer;user-select:none;-webkit-user-select:none}
332
+ .srow__kids:active{opacity:.7}
333
+ .srow__kids svg{transition:transform .15s ease}
334
+ .srow__kids.is-open svg{transform:rotate(90deg)}
335
+ /* 有子任务在跑:徽标转成运行色 + 一个小圆点(折叠时也能看出「下面有活在跑」)。 */
336
+ .srow__kids.is-run{color:var(--running);border-color:currentColor}
337
+ .srow__kids-dot{width:6px;height:6px;border-radius:50%;background:var(--running)}
321
338
  .srow--sub{padding:9px 8px 9px 4px;gap:10px}
322
339
  .srow--sub .srow__ico{width:32px;height:32px}
323
340
  .srow--sub .srow__ico svg{width:16px;height:16px}
@@ -365,12 +382,6 @@ textarea{font:inherit}
365
382
  .wsrow__open{flex:1;display:flex;align-items:center;gap:12px;min-width:0;padding:10px 8px;border-radius:14px;text-align:left}
366
383
  .wsrow__open:active{background:var(--subtle)}
367
384
  .wsrow__l{flex:1;min-width:0;font-size:15px;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
368
- /* 空间规模(几模块 · 几条):紧跟在空间名后面,中性色 —— 不是状态,别用徽标色。
369
- 需求页专属:空间名改成自适应宽度,meta 才会贴在名字后面;meta 的 margin-right:auto
370
- 把「待整理」徽标和箭头推到行尾。 */
371
- .wsrow__open--req .wsrow__l{flex:0 1 auto}
372
- .wsrow__open--req .wsrow__meta{margin-right:auto}
373
- .wsrow__meta{flex:0 1 auto;min-width:0;font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
374
385
  /* 串行等待中的任务数:空间组折叠时也要看得见(颜色与「等待中」行一致) */
375
386
  .wsrow__badge{flex:0 0 auto;padding:1px 7px;border-radius:var(--r-full);font-size:11px;line-height:16px;
376
387
  color:var(--queued);background:var(--tint-queued);white-space:nowrap}
@@ -378,33 +389,6 @@ textarea{font:inherit}
378
389
  .wsrow__chev.is-open{transform:rotate(90deg)}
379
390
  .wsrow__add{flex:0 0 auto;width:36px;height:36px;border-radius:var(--r-full);display:flex;align-items:center;justify-content:center;color:var(--muted)}
380
391
  .wsrow__add:active{background:var(--subtle)}
381
- /* 每个空间自己的「整理」:需求页会同时列出多个空间,顶部那一枚只认当前会话的空间。
382
- 把入口放到组头,用户点哪个空间就初始化/增量哪个空间,不再猜「当前空间」是谁。 */
383
- .wsrow__sync{flex:0 0 auto;height:32px;padding:0 10px;border-radius:var(--r-full);
384
- font-size:12px;font-weight:500;color:var(--primary-action);background:var(--subtle);white-space:nowrap}
385
- .wsrow__sync:active{opacity:.7}
386
- .wsrow__sync:disabled{color:var(--muted);opacity:.6}
387
- /* 「未初始化」与「待整理」同形不同色:前者是「还没按目录生成过基线」,
388
- 后者是「有对话等着归纳」,两种提示不能混成一个数字。 */
389
- .wsrow__badge--init{color:var(--warning);background:var(--tint-wait)}
390
-
391
- /* ══════════ 需求页:两级(空间→模块 → 需求列表)══════════
392
- 一级是**目录**:空间是行,模块缩进挂在空间下(树形),功能点一律不画在这里 ——
393
- 需求一多,一级就会被功能点撑成长列表,什么结构都看不出来。
394
- 二级才是内容:点某个模块进去,看**该空间下所有模块**的需求列表。 */
395
- .reqtree__mod{position:relative;display:flex;align-items:center;gap:8px;width:100%;
396
- padding:9px 10px 9px 30px;background:none;border:0;text-align:left;color:var(--ink)}
397
- .reqtree__mod:active{background:var(--subtle)}
398
- /* 连接线:从空间名垂下来的细线 + 每个模块前的一小截横线 —— 归属关系靠线说话,不靠缩进猜 */
399
- .reqtree__mod::before{content:"";position:absolute;left:14px;top:0;bottom:0;width:1px;background:var(--border)}
400
- .reqtree__mod::after{content:"";position:absolute;left:14px;top:50%;width:10px;height:1px;background:var(--border)}
401
- .reqtree__modname{flex:1;min-width:0;font-size:14px;font-weight:500;color:var(--ink);
402
- overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
403
- .reqtree__modcount{flex:0 0 auto;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
404
- .reqtree__modgo{flex:0 0 auto;color:var(--faint);display:flex;align-items:center}
405
- /* 二级视图的分组小节:当前所在模块高亮,其余弱化 */
406
- .reqdetail__sec--cur .sechead{color:var(--primary-action)}
407
- .reqdetail__sec--cur .sechead__t{font-weight:800}
408
392
  /* 长按会话行的浮动菜单 */
409
393
  .rowmenu{position:fixed;z-index:1201;min-width:184px;background:var(--raised);border:1px solid var(--border);
410
394
  border-radius:14px;box-shadow:0 14px 34px -12px var(--app-shadow);padding:6px}
@@ -722,6 +706,9 @@ button.setrow{font-family:inherit}
722
706
  .qa-todo-ico{width:14px;height:14px;border-width:1.5px}
723
707
  .qa-todo-ico svg{width:9px;height:9px}
724
708
  .qa-todo-meta{font-size:11px;max-width:52%}
709
+ .qa-todo-active{font-size:11px;max-width:56%}
710
+ .qa-todo-st{font-size:11.5px}
711
+ .qa-todo-dur{font-size:11.5px}
725
712
  .qa-todo-toggle{width:26px;height:26px}
726
713
  .qa-todo-toggle svg{width:14px;height:14px}
727
714
 
@@ -984,13 +971,22 @@ button.setrow{font-family:inherit}
984
971
  <button class="seg__b" data-v="serial">串行</button>
985
972
  </span>
986
973
  </div>
974
+ <!-- 会话托管(2026-10-23 用户:「托管模式下自动按照最佳建议提交到会话中执行,
975
+ 是否开启在我的 tab 中可配置」)。开关是**设备级**,落 localStorage;
976
+ 组件执行、这里只存与显示。给一行说明,避免只看到四个字不知道会发生什么。 -->
977
+ <div class="setrow">
978
+ <span class="setrow__main">
979
+ <span class="setrow__l">会话托管</span>
980
+ <span class="setrow__sub">每轮结束自动发送最合适的建议;提问卡自动选第一项继续(最多连续 12 轮)</span>
981
+ </span>
982
+ <button class="tgl" id="tglHosting" aria-label="会话托管"></button>
983
+ </div>
987
984
  <!-- 默认引擎 / 模型:新建任务的初值(会话里输入框上方的级联切换会回写这里)。
988
985
  两行点开的是同一个「引擎 → 模型」二列选择器,模型必须挂在引擎下。 -->
989
986
  <button class="setrow" id="btnDefaultEngine"><span class="setrow__l">默认引擎</span><span class="setrow__v" id="defaultEngine">自动(首个可用)</span></button>
990
987
  <button class="setrow" id="btnDefaultModel"><span class="setrow__l">默认模型</span><span class="setrow__v" id="defaultModel">跟随引擎</span></button>
991
- <div class="setrow"><span class="setrow__l">触感反馈</span><button class="tgl" id="tglHaptic" aria-label="触感反馈"></button></div>
988
+ <!-- 2026-10-22 用户:「触感反馈 / 语言」这两项不需要 —— 已移除(设备级偏好只剩主题/字体)。 -->
992
989
  <div class="setrow"><span class="setrow__l">网络代理</span><button class="tgl" id="tglVpn" aria-label="网络代理"></button></div>
993
- <div class="setrow"><span class="setrow__l">语言</span><span class="setrow__v">跟随系统</span></div>
994
990
  <button class="setrow" id="btnNotify"><span class="setrow__l">消息通知设置</span><span class="setrow__v" id="notifyState">—</span></button>
995
991
  </div>
996
992
  <!-- 团队模式(2026-10-21):开关**按会话**给,放会话页右上角的运行设置里(与权限模式并排),
@@ -1054,30 +1050,12 @@ button.setrow{font-family:inherit}
1054
1050
  </div>
1055
1051
 
1056
1052
  <!-- ══════════ 需求(会话自动聚合成的功能点文档)══════════
1057
- 数据全来自 GET /desk/requirements(core 的 --requirements,只读)。
1058
- **两级**(2026-10-22 用户定稿):
1059
- 一级 = 目录(空间 → 模块),**不画功能点** —— 一级只回答「这个空间有哪几块」;
1060
- 二级 = 点某个模块进去,看**该空间下所有模块**的需求列表(当前模块高亮)。
1061
- 点一行用既有的阅读器打开那篇 markdown。
1062
- ⚠️ 这里**不做筛选胶囊与搜索**:第一版只要「看得见 + 能点开」,多了反而没人用
1063
- (用户 2026-10-22 定稿)。 -->
1064
- <div class="history" id="reqs" hidden>
1065
- <div class="history__top">
1066
- <span class="history__title">需求</span>
1067
- <button class="history__disconnect" id="btnReqSync" type="button">全部整理</button>
1068
- </div>
1069
- <div class="history__list" id="reqList"></div>
1070
- </div>
1053
+ **独立子页面**:内容、样式、逻辑都在 reqs.js / reqs.css,本文件只留两个空容器
1054
+ (隐藏/显示由底部 tab 控制)。数据形状、两级口径见 reqs.js 文件头。 -->
1055
+ <div class="history" id="reqs" hidden></div>
1071
1056
 
1072
- <!-- 需求的二级视图:某空间下所有模块的需求列表(从一级目录点模块进来) -->
1073
- <div class="history" id="reqDetail" hidden>
1074
- <div class="history__top">
1075
- <button class="topbar__icon" id="btnReqDetailBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
1076
- <span class="history__title" id="reqDetailTitle">需求</span>
1077
- <button class="history__disconnect" id="btnReqDetailSync" type="button">整理</button>
1078
- </div>
1079
- <div class="history__list" id="reqDetailList"></div>
1080
- </div>
1057
+ <!-- 需求的二级视图:只看所选模块的需求列表(从一级目录点模块进来;内容由 reqs.js 渲染) -->
1058
+ <div class="history" id="reqDetail" hidden></div>
1081
1059
 
1082
1060
  <!-- ══════════ 发现(浏览器 / 应用 / 插件 / SKILL)══════════ -->
1083
1061
  <div class="history" id="discover" hidden>
@@ -1203,7 +1181,7 @@ button.setrow{font-family:inherit}
1203
1181
  <nav class="tabbar" id="tabbar" hidden>
1204
1182
  <button class="tab" type="button" data-tab="tasks" aria-label="任务"><span class="icw"><svg width="25" height="25"><use href="#i-task"/></svg></span></button>
1205
1183
  <!-- 需求 tab 常驻:不做能力位控制(用户 2026-10-22:「移动端永远显示」)。
1206
- 老 core 上点进来会由 loadRequirements 的 501 分支给出明确文案,
1184
+ 老 core 上点进来会由 reqs.js 的 501 分支给出明确文案,
1207
1185
  不会是一片空白 —— 入口固定比入口忽隐忽现更好理解。 -->
1208
1186
  <button class="tab" type="button" data-tab="reqs" aria-label="需求"><span class="icw"><svg width="25" height="25"><use href="#i-req"/></svg></span></button>
1209
1187
  <span class="tabfab">
@@ -1237,6 +1215,9 @@ button.setrow{font-family:inherit}
1237
1215
  <script src="shared/host-glue.js"></script>
1238
1216
  <script src="shared/conversation.js"></script>
1239
1217
  <script src="shared/transport-http.js"></script>
1218
+ <!-- 需求页(独立子页面模块):只在这里加载并注册 window.MagicReqs,
1219
+ 真正的 DOM/逻辑都在 reqs.js,主 IIFE 末尾 mount 时把宿主能力注入进去。 -->
1220
+ <script src="reqs.js"></script>
1240
1221
  <script>
1241
1222
  (function () {
1242
1223
  'use strict';
@@ -1266,13 +1247,9 @@ button.setrow{font-family:inherit}
1266
1247
  var segTheme = document.getElementById('segTheme');
1267
1248
  var segFont = document.getElementById('segFont');
1268
1249
  var segMode = document.getElementById('segMode');
1269
- var tglHaptic = document.getElementById('tglHaptic');
1270
1250
  var tabbarEl = document.getElementById('tabbar');
1271
1251
  var reqsEl = document.getElementById('reqs');
1272
- var reqListEl = document.getElementById('reqList');
1273
1252
  var reqDetailEl = document.getElementById('reqDetail');
1274
- var reqDetailTitle = document.getElementById('reqDetailTitle');
1275
- var reqDetailListEl = document.getElementById('reqDetailList');
1276
1253
  var discoverEl = document.getElementById('discover');
1277
1254
  var teamPageEl = document.getElementById('teamPage');
1278
1255
  var runSettingsPageEl = document.getElementById('runSettingsPage');
@@ -1643,8 +1620,8 @@ button.setrow{font-family:inherit}
1643
1620
  —— 内存态全部重新初始化,不留上一台机器的影子。
1644
1621
  ⚠️ 分桶键用 host:port 而不是 url|token:token 会随 App 重启变,按 token 分桶等于每次都丢现场。 */
1645
1622
  var ENV_SCOPE_KEYS = [
1646
- 'magic.ws', 'magic.taskmode', 'magic.engine', 'magic.model',
1647
- 'magic.theme', 'magic.font', 'magic.haptic', 'magic.notify',
1623
+ 'magic.ws', 'magic.taskmode', 'magic.hosting', 'magic.engine', 'magic.model',
1624
+ 'magic.theme', 'magic.font', 'magic.notify',
1648
1625
  'magic.pinned', 'magic.spaces', 'magic.browser.stars.v1',
1649
1626
  'magic.team.sessions.v1', 'magic.team.routes.v1', 'magic.team.rules.v1',
1650
1627
  'magic.team.parallel', 'magic.team.split', 'magic.team.splitcode', 'magic.team.adaptive'
@@ -1697,7 +1674,7 @@ button.setrow{font-family:inherit}
1697
1674
  所以这组键以 `/desk/prefs` 为准,localStorage 只当离线缓存:
1698
1675
  · 连上后拉一次,远端是权威 → 有差异就覆盖本地并重载一次;
1699
1676
  · 本地任何一处改了,防抖推回远端。
1700
- 纯设备偏好(主题/字体/触感/通知)**不同步** —— 那是手机自己的事。 */
1677
+ 纯设备偏好(主题/字体/通知)**不同步** —— 那是手机自己的事。 */
1701
1678
  var PREF_SYNC_KEYS = [
1702
1679
  'magic.engine', 'magic.model', 'magic.ws', 'magic.spaces', 'magic.pinned',
1703
1680
  'magic.team.sessions.v1', 'magic.team.routes.v1', 'magic.team.rules.v1',
@@ -2071,14 +2048,14 @@ button.setrow{font-family:inherit}
2071
2048
  loadSessions();
2072
2049
  }
2073
2050
  function showSession(sub) { hideAll(); sessionEl.hidden = false; tabbarEl.hidden = false; setActive('tasks'); subtitleEl.textContent = (sub != null ? sub : histSubtitle) || ''; }
2074
- /* 需求页:进页面即拉一次清单(GET /desk/requirements,只读、不调模型)。
2051
+ /* 需求页:内容与逻辑在 reqs.js(独立子页面模块),这里只负责切页。
2052
+ 进页面即拉一次清单(GET /desk/requirements,只读、不调模型);
2075
2053
  「整理」按钮才触发归纳(POST /desk/requirements/consolidate)——
2076
2054
  两者代价差三个数量级,绝不能混成一个动作。 */
2077
2055
  function showReqs() {
2078
2056
  hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false; setActive('reqs');
2079
- /* 从二级退回来时不要还卡在详情态:回到底栏「需求」这一页 = 回到一级目录。 */
2080
- REQ_DETAIL = null;
2081
- loadRequirements(false);
2057
+ /* 从二级退回来时不要还卡在详情态:reqs.js 的 show() 会回一级目录并拉清单。 */
2058
+ if (window.MagicReqs && window.MagicReqs.show) window.MagicReqs.show();
2082
2059
  }
2083
2060
  function showDiscover() { hideAll(); discoverEl.hidden = false; tabbarEl.hidden = false; setActive('discover'); }
2084
2061
  function showMine() {
@@ -2087,7 +2064,6 @@ button.setrow{font-family:inherit}
2087
2064
  var ns = document.getElementById('notifyState');
2088
2065
  if (ns) { var p = notifyPermState(); ns.textContent = p === 'plugin' ? '已接入' : (p === 'granted' ? '已开启' : '未开启'); }
2089
2066
  markSeg(segTheme, PREFS.theme); markSeg(segFont, PREFS.font); markSeg(segMode, TASK_MODE);
2090
- tglHaptic.classList.toggle('is-on', PREFS.haptic !== 'off');
2091
2067
  paintRunPref();
2092
2068
  refreshVpn();
2093
2069
  /* 版本号:先用已拉到的值立刻画(列表页的 /desk/health 顺手带回来的),
@@ -2634,15 +2610,14 @@ button.setrow{font-family:inherit}
2634
2610
  });
2635
2611
  }
2636
2612
 
2637
- /* ══════════ 偏好:主题 / 字体 / 触感(本地持久化)══════════ */
2613
+ /* ══════════ 偏好:主题 / 字体(本地持久化)══════════ */
2638
2614
  function loadPrefs() {
2639
2615
  try {
2640
2616
  return {
2641
2617
  theme: localStorage.getItem('magic.theme') || 'system',
2642
- font: localStorage.getItem('magic.font') || '1',
2643
- haptic: localStorage.getItem('magic.haptic') || 'on'
2618
+ font: localStorage.getItem('magic.font') || '1'
2644
2619
  };
2645
- } catch (e) { return { theme: 'system', font: '1', haptic: 'on' }; }
2620
+ } catch (e) { return { theme: 'system', font: '1' }; }
2646
2621
  }
2647
2622
  function savePref(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
2648
2623
  function applyThemePref(v) {
@@ -2651,24 +2626,113 @@ button.setrow{font-family:inherit}
2651
2626
  }
2652
2627
  /* 字体大小:组件尺寸是 px,改 root font-size 无效;用 body zoom 整体缩放(WebView 支持)。 */
2653
2628
  function applyFontPref(v) { try { document.body.style.zoom = (v && v !== '1') ? v : ''; } catch (e) {} }
2654
- function applyHapticPref(v) { window.MAGIC_HAPTICS = (v !== 'off'); }
2655
2629
  /* 任务模式:parallel(默认,全部并行)| serial(同一空间同时只跑一个新任务)。 */
2656
2630
  var TASK_MODE = 'parallel';
2657
2631
  try { TASK_MODE = localStorage.getItem('magic.taskmode') || 'parallel'; } catch (e) {}
2632
+ /* 会话托管(2026-10-23):默认关;跟着环境走(见 ENV_SCOPE_KEYS),
2633
+ 同一台手机切到另一台桌面时,两台各自的托管开关互不继承。 */
2634
+ var HOSTING = false;
2635
+ try { HOSTING = localStorage.getItem('magic.hosting') === '1'; } catch (e) {}
2658
2636
  var PREFS = loadPrefs();
2659
- applyThemePref(PREFS.theme); applyFontPref(PREFS.font); applyHapticPref(PREFS.haptic);
2637
+ applyThemePref(PREFS.theme); applyFontPref(PREFS.font);
2660
2638
  paintRunPref();
2661
2639
  paintTeamPref(); // 团队配置卡片(能力位要到连上桌面后才亮,见 loadSessions)
2662
2640
 
2663
2641
  /* 挂 conversation:传输层由宿主注入,组件不连后端(见 CLIENT-CONTRACT.md 3.2.1)。
2664
2642
  sid 非空 = 打开该会话(组件会走 readSession 回放历史);s = 该会话对象(标题取 prompt_head);空 = 新会话。 */
2643
+ /* 「同一个会话」要认 run_id ↔ session_id 的别名。
2644
+ 会话刚起(团队任务 / 新会话首轮)时清单里只有 run_id,session_id 是 core 首帧之后
2645
+ 才写回的;同一个会话因此会先后以两个 id 出现。只比字面会把「返回列表再点进来」
2646
+ 判成另一条会话 —— 组件被销毁、内存队列清空,排队的追问就整条消失
2647
+ (2026-10-22 用户:「排队中的任务重新从会话列表进入就看不到了」)。 */
2648
+ function sameConvId(a, b) {
2649
+ if (!a || !b) return false;
2650
+ a = String(a); b = String(b);
2651
+ if (a === b) return true;
2652
+ for (var i = 0; i < LAST_SESSIONS.length; i++) {
2653
+ var s = LAST_SESSIONS[i];
2654
+ var ids = [s.session_id, s.run_id].filter(Boolean).map(String);
2655
+ if (ids.indexOf(a) >= 0 && ids.indexOf(b) >= 0) return true;
2656
+ }
2657
+ return false;
2658
+ }
2659
+ /* 这条会话里「已落库、还没轮到发」的条目,能接回组件的那些:
2660
+ · owner==='conv' —— 组件内排队(见 queuePushed),主人就是组件;
2661
+ · owner==='host' —— 重启恢复(restoreQueue)交给宿主的,但宿主**还没开始发**
2662
+ (!p.dispatching):用户这时点进会话,让组件接管更符合预期 ——
2663
+ 否则画布上永远不显示「排队中」,只有列表角标。
2664
+ owner==='gate' 的绝不碰:那是串行门挡住的**当前这一轮**(气泡已由 fireTurn 画了),
2665
+ 由 beforeAsk 的 Promise 自己放行,接管会双发。 */
2666
+ function pendingOfConv(sid) {
2667
+ return PENDING.filter(function (p) {
2668
+ if (p.gone || p.stalled || p.dispatching) return false;
2669
+ if (p.owner !== 'conv' && p.owner !== 'host') return false;
2670
+ return sameConvId(p.session, sid);
2671
+ });
2672
+ }
2673
+ function adoptPendingFor(sid) {
2674
+ if (!conv || typeof conv.adoptQueue !== 'function' || !sid) return 0;
2675
+ /* ⚠️ 2026-10-23(用户:「排队的和自动建议发送串会话了」):`p.session` **空**时不许接。
2676
+ sameConvId('', sid) 这类「空串当通配」的匹配,正是把上一条会话的排队追问灌进
2677
+ 当前会话的入口 —— 新会话首句排队那一刻 state.session 还是空的,落库就是空串。
2678
+ 那种条目交给原主(enqueueDispatch 按 session 空 = 新会话下发),
2679
+ 不该被任何一条**已有 id 的会话**认领。 */
2680
+ var list = pendingOfConv(sid).filter(function (p) { return !!p.session; });
2681
+ if (!list.length) return 0;
2682
+ var items = list.map(function (p) {
2683
+ /* 接管:宿主那条 enqueueDispatch 在真正 fireQueued 之前会检查 owner,
2684
+ 看到它已归组件就放手(见 enqueueDispatch)—— 两边不会各发一次。 */
2685
+ p.owner = 'conv';
2686
+ /* 恢复出来的条目沿用**宿主持久化 id**:组件 take / drop 时回传的就是它,
2687
+ 宿主据此删对行(QPEND 里重新登记一次,值指向自己)。 */
2688
+ QPEND[p.id] = p.id;
2689
+ return {
2690
+ id: p.id, prompt: p.prompt,
2691
+ /* 归属会话要一并交给组件:它据此在发出前再核一次(见 flushQueue),
2692
+ 宿主这边的筛选只是第一道。 */
2693
+ session: p.session,
2694
+ attachments: (p.attachments || []).slice(),
2695
+ atts: (p.atts || []).slice()
2696
+ };
2697
+ });
2698
+ try { return conv.adoptQueue(items) || 0; } catch (e) { return 0; }
2699
+ }
2700
+ function adoptAfterHistory(sid, s) {
2701
+ var c = conv;
2702
+ if (!c) return;
2703
+ var p;
2704
+ try { p = c.setSession(sid, s); } catch (e) { p = null; }
2705
+ Promise.resolve(p).then(function () {
2706
+ /* 迟到的历史:这期间用户可能已经切走 / 又换了会话 —— 不许把旧队列灌进新画布。 */
2707
+ if (conv !== c || !sameConvId(sid, convSid)) return;
2708
+ adoptPendingFor(sid);
2709
+ });
2710
+ }
2711
+ /* 会话托管的开关落盘 + 同步「我的」里那枚 toggle。
2712
+ reason 非空 = 组件自己暂停的(失败 / 被停 / 到上限 / 用户点了「托管中」标记),
2713
+ 这里要如实弹一句;宿主自己拨开关时传空串,由调用处给更具体的提示。 */
2714
+ function setHostingPref(on, reason) {
2715
+ HOSTING = !!on;
2716
+ try { localStorage.setItem('magic.hosting', HOSTING ? '1' : '0'); } catch (e) {}
2717
+ var tgl = document.getElementById('tglHosting');
2718
+ if (tgl) tgl.classList.toggle('is-on', HOSTING);
2719
+ if (reason) toast('会话托管已暂停:' + reason);
2720
+ }
2721
+
2665
2722
  function mountConversation(sid, s) {
2666
2723
  /* 同一条会话、同一个空间:组件还活着,画面也还在 —— 直接把这一屏端回来。
2667
2724
  以前这里无条件 destroy() + create() + innerHTML='',于是「返回列表 → 再点进来」
2668
2725
  每次都要重拉全量历史、重新建 DOM、重新回放(长会话 = 又慢又费流量)。
2669
- 标题 / 引擎 / 模型交给组件的 setSession 同步(它对本会话是幂等的)。 */
2670
- if (conv && sid && sid === convSid && WS === convWs) {
2671
- try { conv.setSession(sid, s); } catch (e) {}
2726
+ 标题 / 引擎 / 模型交给组件的 setSession 同步(它对本会话是幂等的)。
2727
+ ⚠️ 判「同一条会话」要认 run_id ↔ session_id 的别名(见 sameConvId):
2728
+ 只比字面的话,团队任务/新会话在 session_id 回来前后拿到的是两个 id,
2729
+ 同一屏会被判成两条会话 → 销毁重建 → 内存里排队的追问连气泡一起消失
2730
+ (2026-10-22 用户:「排队中的任务重新从会话列表进入就看不到了」)。 */
2731
+ if (conv && sid && WS === convWs && sameConvId(sid, convSid)) {
2732
+ convSid = sid; // 列表那边可能已经从 run_id 切到 session_id
2733
+ /* same:true —— 上面已经确认是同一屏会话。组件按字面判 id 会把别名当成
2734
+ 「换了会话」→ clearQueue() 删掉排队追问(本次报障的病根之一)。 */
2735
+ try { conv.setSession(sid, s, { same: true }); } catch (e) {}
2672
2736
  window.magicConv = conv;
2673
2737
  return;
2674
2738
  }
@@ -2695,6 +2759,9 @@ button.setrow{font-family:inherit}
2695
2759
  onQueueTake: queueTaken,
2696
2760
  onQueueDrop: queueDropped,
2697
2761
  onPick: rememberRunPick,
2762
+ /* 会话托管:开关值由宿主给(权威在 localStorage),自动暂停时组件回调这里同步。 */
2763
+ hosting: HOSTING,
2764
+ onHostingChange: setHostingPref,
2698
2765
  /* 团队模式:开关初值按**这条会话**给;配置是设备偏好(每轮现读,改配置立刻生效)。
2699
2766
  features 也给函数:能力清单是异步拉到的(/desk/health),
2700
2767
  查到之后开关才会亮出来,不必重新挂载组件。 */
@@ -2726,7 +2793,10 @@ button.setrow{font-family:inherit}
2726
2793
  }
2727
2794
  }
2728
2795
  });
2729
- if (sid) conv.setSession(sid, s);
2796
+ /* 历史画完再把落库的排队条目接回画布(见 adoptPendingFor):抢在回放之前插气泡,
2797
+ 会被组件的 clearThread 一起清掉;而且那一刻「这一轮还在不在跑」已经由
2798
+ loadHistory / follow 落定,flush 的时机才是对的。 */
2799
+ if (sid) adoptAfterHistory(sid, s);
2730
2800
  window.magicConv = conv;
2731
2801
  } catch (e) {
2732
2802
  var d = document.createElement('div');
@@ -2906,7 +2976,9 @@ button.setrow{font-family:inherit}
2906
2976
  });
2907
2977
  }
2908
2978
  /* ── 会话列表:**置顶 / 任务 / 空间** 三段(对齐截图)──
2909
- 置顶:本地 pin(长按会话行切换);任务:最近会话(默认前 8,可「展开更多」);
2979
+ 置顶:本地 pin(长按会话行切换)+ **运行中自动临时置顶**(2026-10-22:跑着的那条
2980
+ 自己进「置顶」段,不写 PINNED,跑完自动归位;见 isAutoPinned);
2981
+ 任务:最近会话(默认前 8,可「展开更多」);
2910
2982
  空间:会话里出现过的 workspace(点行=按空间筛选任务,+=在该空间新建)。 */
2911
2983
  var LAST_SESSIONS = [];
2912
2984
  var SEC_COLLAPSED = {};
@@ -2960,6 +3032,10 @@ button.setrow{font-family:inherit}
2960
3032
  return !(pk && pk !== sidOf(s));
2961
3033
  });
2962
3034
  }
3035
+ /* 路径归一:只去尾部斜杠,不做 realpath —— 与 core 登记表里的 workspace 同一口径。
3036
+ 任务页 / 需求页共用(需求页已拆到 reqs.js,那边自带一份同名实现;这里留着
3037
+ 是因为 isRealSpaceDir / isTeamRunDir 还服务于任务页的空间分组)。 */
3038
+ function reqNormDir(p) { return String(p || '').replace(/\/+$/, ''); }
2963
3039
  /* 一个目录算不算「空间」:得是**用户认得的工作区**。
2964
3040
  根目录 / 不是空间(哪怕历史上有会话在那儿跑过)—— 需求页显示它只会让人
2965
3041
  以为「/ 是个项目」(用户 2026-10-22:「没有空间的不需要」)。
@@ -3045,6 +3121,30 @@ button.setrow{font-family:inherit}
3045
3121
 
3046
3122
  function sidOf(s) { return s.session_id || s.run_id; }
3047
3123
  function isPinned(s) { return PINNED.indexOf(sidOf(s)) >= 0; }
3124
+ /* 运行中会话**自动临时置顶**(2026-10-22 用户:「运行中的会话自动临时置顶」):
3125
+ 判据只看当前是不是在跑(`statusOf === 'run'`),**不写 PINNED、不落 prefs** ——
3126
+ 跑完下一次 renderSessions 自然归位,用户自己的手动置顶关系一点不动。
3127
+ 子会话在跑也算:子行默认折叠在父块里,只提子行的话首屏根本看不见,
3128
+ 所以把父块一起提到「置顶」段(父行本身不跑时不画图钉,计数徽标会亮运行色,见 sessionRow)。
3129
+ ⚠️ 与手动置顶分开判:手动置顶仍只认 PINNED,自动的只在排序 / 分段时叠加。 */
3130
+ function isAutoPinned(s) {
3131
+ if (!s) return false;
3132
+ if (statusOf(s) === 'run') return true;
3133
+ var kids = CHILD_OF[sidOf(s)] || [];
3134
+ for (var i = 0; i < kids.length; i++) { if (statusOf(kids[i]) === 'run') return true; }
3135
+ return false;
3136
+ }
3137
+ /* 列表分段用:手动置顶 ∪ 运行中自动置顶。 */
3138
+ function isListPinned(s) { return isPinned(s) || isAutoPinned(s); }
3139
+ /* 置顶段 / 搜索结果的排序:**人工置顶在前 → 运行中自动置顶 → 普通**,同类按 updated_at 倒序。
3140
+ 用户 2026-10-22 定稿:「应该在人工置顶的后面」—— 自动置顶只是临时把在跑的活提上来,
3141
+ 不能压过用户自己钉的会话;跑完 isAutoPinned 变 false,它回到普通分段。 */
3142
+ function pinnedOrder(a, b) {
3143
+ var ra = isPinned(a) ? 2 : (isAutoPinned(a) ? 1 : 0);
3144
+ var rb = isPinned(b) ? 2 : (isAutoPinned(b) ? 1 : 0);
3145
+ return (rb - ra)
3146
+ || String(b.updated_at || b.started_at || '').localeCompare(String(a.updated_at || a.started_at || ''));
3147
+ }
3048
3148
  function togglePin(s) {
3049
3149
  var id = sidOf(s), i = PINNED.indexOf(id);
3050
3150
  if (i >= 0) PINNED.splice(i, 1); else PINNED.unshift(id);
@@ -3090,10 +3190,46 @@ button.setrow{font-family:inherit}
3090
3190
  .then(function () { TITLES[id] = name; renderSessions(LAST_SESSIONS); })
3091
3191
  .catch(function (e) { window.alert('重命名失败:' + (e && e.message ? e.message : e)); });
3092
3192
  }
3093
- /* 删除:DELETE /desk/session/{id}(登记表记录 + 事件日志),然后刷新列表。 */
3193
+ /* 删除一棵会话树:删主会话时,挂在它下面的**子任务会话**也要从本地列表一起消失。
3194
+ 服务端(session.Delete)已经级联删了登记记录 / 日志 / 标题 / 状态;这里只是不让
3195
+ 用户在看到下一次轮询刷新之前,还点得进那些已经不存在了的子行。
3196
+ 认亲按 run_id / session_id 双向别名折算(与 renderSessions 的 KEY2SID 同口径):
3197
+ 子会话创建时主会话可能还没拿到 session_id,parent 指针里存的是 run_id。 */
3198
+ function deleteClosureOf(id, list) {
3199
+ var del = {};
3200
+ del[id] = 1;
3201
+ function mark(s) {
3202
+ if (!s) return false;
3203
+ var sid = sidOf(s);
3204
+ if (!sid) return false;
3205
+ var changed = !del[sid] || (s.run_id && !del[s.run_id]) || (s.session_id && !del[s.session_id]);
3206
+ del[sid] = 1;
3207
+ if (s.run_id) del[s.run_id] = 1;
3208
+ if (s.session_id) del[s.session_id] = 1;
3209
+ return changed;
3210
+ }
3211
+ (list || []).forEach(function (s) { if (del[sidOf(s)]) mark(s); });
3212
+ var changed = true;
3213
+ while (changed) {
3214
+ changed = false;
3215
+ (list || []).forEach(function (s) {
3216
+ var sid = sidOf(s);
3217
+ if (!sid || del[sid]) return;
3218
+ var pk = parentKeyOf(s);
3219
+ if (pk && del[pk]) changed = mark(s) || changed;
3220
+ });
3221
+ }
3222
+ return del;
3223
+ }
3224
+ /* 删除:DELETE /desk/session/{id}(登记表记录 + 事件日志 + 子任务会话),然后刷新列表。 */
3094
3225
  function deleteSessionRow(s) {
3095
3226
  var id = sidOf(s);
3096
- if (!window.confirm('删除这个会话?此操作不可恢复。')) return;
3227
+ /* 文案如实说明「会连子任务一起删」:这是物理删除,提示里不能只提主会话。 */
3228
+ var kidCount = (CHILD_OF[id] || []).length;
3229
+ var ask = kidCount
3230
+ ? '删除这个会话及其 ' + kidCount + ' 个子任务会话?此操作不可恢复。'
3231
+ : '删除这个会话?此操作不可恢复。';
3232
+ if (!window.confirm(ask)) return;
3097
3233
  var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
3098
3234
  fetch(BASE + '/desk/session/' + encodeURIComponent(id), { method: 'DELETE', headers: hdr })
3099
3235
  .then(function (r) {
@@ -3101,10 +3237,11 @@ button.setrow{font-family:inherit}
3101
3237
  return r.json().catch(function () { return {}; });
3102
3238
  })
3103
3239
  .then(function () {
3104
- var i = PINNED.indexOf(id); if (i >= 0) { PINNED.splice(i, 1); savePinned(); }
3105
- if (TITLES[id]) { delete TITLES[id]; }
3240
+ var del = deleteClosureOf(id, LAST_SESSIONS);
3241
+ PINNED = PINNED.filter(function (pid) { return !del[pid]; }); savePinned();
3242
+ Object.keys(TITLES).forEach(function (tid) { if (del[tid]) delete TITLES[tid]; });
3106
3243
  /* 本地移除 + 静默重绘:不走 loadSessions(那会先塞「读取中…」再重建 = 整页闪) */
3107
- LAST_SESSIONS = LAST_SESSIONS.filter(function (x) { return sidOf(x) !== id; });
3244
+ LAST_SESSIONS = LAST_SESSIONS.filter(function (x) { return !del[sidOf(x)]; });
3108
3245
  renderSessions(LAST_SESSIONS);
3109
3246
  })
3110
3247
  .catch(function (e) { window.alert('删除失败:' + (e && e.message ? e.message : e)); });
@@ -3215,20 +3352,30 @@ button.setrow{font-family:inherit}
3215
3352
  }
3216
3353
  /* 子会话(团队模式拆出的子任务,2026-10-22):core 在 /desk/sessions 里给出
3217
3354
  parent_session_id / parent_run_id(见 internal/session 的 Record)。子任务是**普通会话**
3218
- (自己的登记 + 自己的日志,点进去看完整过程),列表里只做两件事:
3355
+ (自己的登记 + 自己的日志,点进去看完整过程),列表里只做三件事:
3219
3356
  · 不占顶层一行 —— 缩进挂在主会话下面(CHILD_OF:父 key → 子会话数组);
3357
+ · 默认**折叠**(2026-10-23 用户:「列表中默认不展开显示子会话」):父行只留一枚
3358
+ 计数徽标,点它才展开;展开状态按会话 id 记在 KIDS_OPEN 里,刷新列表不收回;
3220
3359
  · 不进「任务 / 空间」分组(它们的 workspace 是主会话里的任务目录,不该自成一个空间)。
3221
3360
  ⚠️ 父会话不在列表里(被删 / 老 core 没给父字段)→ 回落成顶层一行,
3222
3361
  宁可多一行也不能让子会话凭空消失。
3223
3362
  ⚠️ 主会话可能还没拿到 session_id,parent_session_id 同时给了 parent_run_id 兜底。 */
3224
- var CHILD_OF = {}, CHILD_IDS = {};
3363
+ var CHILD_OF = {}, CHILD_IDS = {}, KIDS_OPEN = {};
3225
3364
  function parentKeyOf(s) { return (s && (s.parent_session_id || s.parent_run_id)) || ''; }
3365
+ function kidsOpenOf(id) { return !!KIDS_OPEN[id]; }
3366
+ function toggleKids(id) {
3367
+ if (!id) return;
3368
+ KIDS_OPEN[id] = !KIDS_OPEN[id];
3369
+ renderSessions(LAST_SESSIONS);
3370
+ }
3226
3371
  function sessionBlock(s) {
3227
3372
  var wrap = document.createElement('div');
3228
3373
  wrap.className = 'sblock';
3229
3374
  wrap.appendChild(s.pendingWait ? pendingRow(s) : sessionRow(s, !!CHILD_IDS[sidOf(s)]));
3230
- var kids = CHILD_OF[sidOf(s)] || [];
3231
- if (kids.length) {
3375
+ var sid = sidOf(s);
3376
+ var kids = CHILD_OF[sid] || [];
3377
+ /* 默认不展开:只有用户点过父行上的计数徽标(KIDS_OPEN[sid])才挂子行。 */
3378
+ if (kids.length && kidsOpenOf(sid)) {
3232
3379
  var box = document.createElement('div');
3233
3380
  box.className = 'sblock__kids';
3234
3381
  kids.forEach(function (k) { box.appendChild(sessionRow(k, true)); });
@@ -3252,6 +3399,49 @@ button.setrow{font-family:inherit}
3252
3399
  col.appendChild(subEl);
3253
3400
  /* 排队与团队标记都不在这里做:两者都做在图标里(见 sessionIcon),行尾不再挂角标。 */
3254
3401
  b.appendChild(col);
3402
+ /* 子会话计数徽标(默认折叠开关):点一下展开 / 收回,**不触发打开会话**。
3403
+ 没有子会话的普通行不画它,保持列表干净;有子任务在跑时亮运行色 + 圆点,
3404
+ 这样折叠状态下也能看出「父会话下面还有活在跑」。 */
3405
+ var kids = CHILD_OF[sid] || [];
3406
+ if (!sub && kids.length) {
3407
+ var kidsRunning = false;
3408
+ for (var ki = 0; ki < kids.length; ki++) {
3409
+ if (statusOf(kids[ki]) === 'run') { kidsRunning = true; break; }
3410
+ }
3411
+ var opened = kidsOpenOf(sid);
3412
+ var chip = elSpan('srow__kids' + (opened ? ' is-open' : '') + (kidsRunning ? ' is-run' : ''));
3413
+ chip.setAttribute('role', 'button');
3414
+ chip.setAttribute('tabindex', '0');
3415
+ chip.setAttribute('aria-expanded', opened ? 'true' : 'false');
3416
+ chip.setAttribute('aria-label', (opened ? '收起 ' : '展开 ') + kids.length + ' 个子会话');
3417
+ chip.title = kidsRunning
3418
+ ? kids.length + ' 个子会话 · 有任务在跑(点' + (opened ? '收起' : '展开') + ')'
3419
+ : kids.length + ' 个子会话(点' + (opened ? '收起' : '展开') + ')';
3420
+ if (kidsRunning) chip.appendChild(elSpan('srow__kids-dot'));
3421
+ chip.appendChild(elSpan('srow__kids-n', kids.length + ' 子会话'));
3422
+ chip.appendChild(svgUse('#i-chev-r', 12));
3423
+ var toggleKid = function (e) {
3424
+ if (e) { e.preventDefault(); e.stopPropagation(); }
3425
+ toggleKids(sid);
3426
+ };
3427
+ /* 父行有长按菜单与点击打开:徽标上的指针 / 点击事件都要截住,别让它们穿到父行。 */
3428
+ chip.addEventListener('pointerdown', function (e) { e.stopPropagation(); });
3429
+ chip.addEventListener('pointerup', function (e) { e.stopPropagation(); });
3430
+ chip.addEventListener('click', toggleKid);
3431
+ chip.addEventListener('keydown', function (e) {
3432
+ if (e.key === 'Enter' || e.key === ' ') toggleKid(e);
3433
+ });
3434
+ b.appendChild(chip);
3435
+ }
3436
+ /* 运行中自动置顶的行带一枚小图钉(手动置顶由「置顶」分段说明,不重复画):
3437
+ 只有**这行自己**在跑才画 —— 父行因某个子会话在跑而被提上来时,行上不画图钉
3438
+ (计数徽标会亮运行色),否则会误读成「父会话在跑」。 */
3439
+ if (run && !isPinned(s)) {
3440
+ var autoPin = elSpan('srow__auto');
3441
+ autoPin.title = '运行中 · 自动临时置顶(跑完自动归位)';
3442
+ autoPin.appendChild(svgUse('#i-pin', 14));
3443
+ b.appendChild(autoPin);
3444
+ }
3255
3445
  var timeEl = elSpan('srow__time', run ? elapsedText(s) : fmtRel(s.updated_at || s.started_at));
3256
3446
  b.appendChild(timeEl);
3257
3447
  if (run) { RUN_ROWS[sid] = { subEl: subEl, timeEl: timeEl, s: s }; refreshProgress(sid); }
@@ -3373,7 +3563,10 @@ button.setrow{font-family:inherit}
3373
3563
 
3374
3564
  /* 搜索态:不分组、不出「分组视图」行,命中项平铺(对齐截图 2)。 */
3375
3565
  if (searching) {
3376
- var matched = query ? rows.filter(taskMatches) : rows;
3566
+ var matched = (query ? rows.filter(taskMatches) : rows).slice();
3567
+ /* 与分组列表同一口径:运行中的自动临时置顶排最前(搜索是临时态,跑完同样归位)。 */
3568
+ /* 与分组列表同一口径:人工置顶在前,运行中的自动临时置顶紧随其后(搜索是临时态,跑完归位)。 */
3569
+ matched.sort(pinnedOrder);
3377
3570
  if (!matched.length && query) {
3378
3571
  var miss = document.createElement('div'); miss.className = 'history__hint';
3379
3572
  miss.textContent = '没有匹配「' + TASK_QUERY + '」的任务。';
@@ -3389,9 +3582,11 @@ button.setrow{font-family:inherit}
3389
3582
  return;
3390
3583
  }
3391
3584
 
3392
- /* 置顶:只在这里显示;置顶项不再出现在「任务 / 空间」里(去重) */
3393
- var pinned = rows.filter(isPinned);
3394
- var rest = rows.filter(function (s) { return !isPinned(s); });
3585
+ /* 置顶:手动 pin + **运行中自动临时置顶**(不写 PINNED,跑完归位);
3586
+ 只在这里显示,置顶项不再出现在「任务 / 空间」里(去重)。 */
3587
+ var pinned = rows.filter(isListPinned);
3588
+ var rest = rows.filter(function (s) { return !isListPinned(s); });
3589
+ pinned.sort(pinnedOrder);
3395
3590
 
3396
3591
  /* 任务:**不属于任何空间**的普通任务(且未置顶)+ 默认空间(不指定)的串行等待任务。
3397
3592
  显式选了空间的等待任务归到下面的「空间」组(用户 2026-10-20:「串行等待中的任务
@@ -3605,451 +3800,6 @@ button.setrow{font-family:inherit}
3605
3800
  if (e.key === 'Escape' && readerEl && !readerEl.hidden) { e.preventDefault(); closeReader(); }
3606
3801
  }, true);
3607
3802
 
3608
- /* ══════════ 需求页(会话自动聚合成的功能点文档)══════════
3609
- 数据形状见 CLIENT-CONTRACT §3.4.1:{ok, root, workspaces[{id,dir,name,pending,memory,
3610
- modules:[{name,features:[{module,name,file,desc,points,conclusions,conclusion,
3611
- sources,has_final,final_preview,updated_at}]}],feature_count,last_consolidated_at,last_error}]}
3612
-
3613
- 三个口径(照抄契约,别自己发明):
3614
- · 列表只读:`loadRequirements` 只打 GET /desk/requirements,**不触发归纳**;
3615
- · 「整理」才归纳:POST /desk/requirements/consolidate(可能跑几十秒,按钮态要变);
3616
- · 一篇文档就是一个绝对路径:点行直接进既有阅读器(markdown,不另做渲染)。 */
3617
- var REQ_DATA = null; // 最近一次清单(重画用,避免每次点都重新拉)
3618
- var REQ_BUSY = false; // 「整理」进行中(防重复点击)
3619
- var REQ_BUSY_LABEL = '全部整理'; // 顶栏按钮的进行中文案(重渲染后要能恢复)
3620
- var REQ_OPEN = {}; // 工作区展开状态(key = 工作区 id),与会话列表的 WS_OPEN 同构
3621
- var REQ_ALIVE = null; // 仍有会话的空间路径集合;null = 拿不到会话清单(不筛)
3622
- var REQ_DETAIL = null; // 二级视图:{key: 工作区 key, mod: 模块名, ws: 工作区对象}
3623
- /* 路径归一:只去尾部斜杠,不做 realpath —— 与 core 登记表里的 workspace 同一口径。 */
3624
- function reqNormDir(p) { return String(p || '').replace(/\/+$/, ''); }
3625
-
3626
- function reqFeatureRow(f) {
3627
- /* 一行 = 一个功能点,**照会话行的排布**做(图标 + 标题 / 副标题 / 右侧时间):
3628
- 标题(功能点名称)→ 归纳结论 / 需求描述 → 「N 需求点 · M 结论 · 来源 K」。
3629
- 为什么优先结论:这一页要回答的是「这个功能定了什么」,不是「记录了多少条」。 */
3630
- var b = hEl('button', 'srow srow--req');
3631
- b.type = 'button';
3632
- var ico = hEl('span', 'srow__ico srow__ico--idle');
3633
- ico.appendChild(svgUse('#i-doc', 20));
3634
- b.appendChild(ico);
3635
- var col = hEl('span', 'srow__col');
3636
- col.appendChild(hEl('span', 'srow__t', f.name || '(未命名)'));
3637
- /* 先结论,再一句话描述,最后才是定稿预览。定稿预览是 markdown(可能带 `**目标**:`),
3638
- 直接塞进这一行会把星号原样显示出来;有 desc 时它更适合当副标题。 */
3639
- var sub = f.conclusion || f.desc || f.final_preview || '';
3640
- if (sub) col.appendChild(hEl('span', 'srow__s', sub));
3641
- var meta = [];
3642
- if (f.points) meta.push(f.points + ' 需求点');
3643
- if (f.conclusions) meta.push(f.conclusions + ' 结论');
3644
- if (f.sources) meta.push('来源 ' + f.sources);
3645
- if (!f.has_final) meta.push('未定稿');
3646
- if (meta.length) col.appendChild(hEl('span', 'srow__s', meta.join(' · ')));
3647
- b.appendChild(col);
3648
- if (f.updated_at) {
3649
- var d = new Date(Number(f.updated_at) || 0);
3650
- if (!isNaN(d.getTime())) {
3651
- b.appendChild(hEl('span', 'srow__time',
3652
- (d.getMonth() + 1) + '-' + ('0' + d.getDate()).slice(-2)));
3653
- }
3654
- }
3655
- b.onclick = function () { openReader(f.file); };
3656
- return b;
3657
- }
3658
-
3659
- /* 一级:空间目录。空间是行,模块缩进挂在空间下(树形),**功能点一律不画在这里**。
3660
- 一级只回答「这个空间有哪几块、每块多少条」—— 需求一多时画功能点会把目录撑成长列表,
3661
- 结构反而看不见了(2026-10-22 用户定稿:两级,一级只显示空间和模块)。
3662
- idx = 排序后的位次(0 = 需求条数最多的那个)。 */
3663
- function reqWorkspaceBlock(w, idx) {
3664
- var wrap = hEl('div', '');
3665
- var head = hEl('div', 'wsrow__head');
3666
- var key = w.id || w.dir || w.name || '';
3667
- var count = Number(w.feature_count) || 0;
3668
- /* 默认只展开**第一个**(需求条数最多的那个),其余折叠(用户 2026-10-22):
3669
- 空间一多,全展开就是一屏拉到底的长列表,什么结构都看不出来。
3670
- 用户手动点过谁就按用户的来(REQ_OPEN 里有记录就不动它)。 */
3671
- var open = (REQ_OPEN[key] !== undefined) ? !!REQ_OPEN[key] : (idx === 0);
3672
- var tog = document.createElement('button');
3673
- tog.type = 'button'; tog.className = 'wsrow__open wsrow__open--req';
3674
- tog.appendChild(elSpan('wsrow__l', w.name || w.dir || w.id));
3675
- /* 模块数 + 功能点数:一行说清这个空间有多大,比只写空间名有用。
3676
- 用**中性色**(不是 wsrow__badge 那枚 queued 紫)—— 那是「等待中」的语义色,
3677
- 空间规模不是状态,套上会让人误以为这个空间在排队。 */
3678
- var mods = (w.modules || []).filter(function (m) { return (m.features || []).length; });
3679
- tog.appendChild(elSpan('wsrow__meta', mods.length + ' 模块 · ' + count + ' 条'));
3680
- /* 待整理条数做成徽标(与「等待中」同款):折叠起来也看得见这个空间在长新需求。 */
3681
- if (w.pending > 0) tog.appendChild(elSpan('wsrow__badge', w.pending + ' 待整理'));
3682
- /* 未初始化 = 还没按空间目录生成过基线。老 core 不带 initialized 字段(undefined),
3683
- 此时不提示 —— 不能把「不知道」画成「未初始化」。 */
3684
- if (w.initialized === false) tog.appendChild(elSpan('wsrow__badge wsrow__badge--init', '未初始化'));
3685
- var chev = elSpan('wsrow__chev' + (open ? ' is-open' : ''));
3686
- chev.appendChild(svgUse('#i-chev-r', 16));
3687
- tog.appendChild(chev);
3688
- tog.onclick = function () { REQ_OPEN[key] = !open; renderRequirements(); };
3689
- head.appendChild(tog);
3690
- /* 组头右侧的「整理」:只作用于这个空间(初始化它 / 归纳它的新会话)。 */
3691
- var sync = document.createElement('button');
3692
- sync.type = 'button'; sync.className = 'wsrow__sync'; sync.textContent = '整理';
3693
- sync.setAttribute('data-ws', key);
3694
- sync.onclick = function (ev) {
3695
- if (ev && ev.stopPropagation) ev.stopPropagation();
3696
- consolidateRequirements(w);
3697
- };
3698
- head.appendChild(sync);
3699
- wrap.appendChild(head);
3700
- if (!open) return wrap;
3701
-
3702
- if (w.last_error) {
3703
- var err = hEl('div', 'history__hint', '上次整理失败:' + w.last_error);
3704
- err.style.color = 'var(--warning)';
3705
- wrap.appendChild(err);
3706
- }
3707
- var shown = 0;
3708
- (w.modules || []).forEach(function (m) {
3709
- var fs = (m.features || []);
3710
- if (!fs.length) return;
3711
- shown += fs.length;
3712
- /* 模块 = 目录里的一行(缩进 + 连接线归属到空间),点它进二级看需求列表。
3713
- 未归类(name 为空)的老数据没有模块名可写 —— 这种情况直接不画这一行,
3714
- 功能点仍能在二级里看到(core 把空 module 排在最后)。 */
3715
- if (!m.name) return;
3716
- var row = document.createElement('button');
3717
- row.type = 'button'; row.className = 'reqtree__mod';
3718
- row.appendChild(elSpan('reqtree__modname', m.name));
3719
- row.appendChild(elSpan('reqtree__modcount', String(fs.length)));
3720
- var go = elSpan('reqtree__modgo');
3721
- go.appendChild(svgUse('#i-chev-r', 16));
3722
- row.appendChild(go);
3723
- row.onclick = function () { openReqDetail(w, m.name); };
3724
- wrap.appendChild(row);
3725
- });
3726
- /* 空间还在但还没有文档:别画成一片空白,给出这一组的下一步(整理)。 */
3727
- if (!shown) {
3728
- var tip = Number(w.pending) > 0
3729
- ? '有 ' + w.pending + ' 条对话待整理'
3730
- : (w.initialized === false ? '还没有需求文档,点右侧「整理」按空间生成' : '还没有需求文档');
3731
- var hint = hEl('div', 'history__hint', tip);
3732
- hint.style.padding = '6px 12px 12px';
3733
- wrap.appendChild(hint);
3734
- }
3735
- return wrap;
3736
- }
3737
-
3738
- /* 二级:某空间下**所有模块**的需求列表(用户 2026-10-22 定稿)。
3739
- 进来的那个模块高亮,滚到它那儿;其余模块照常列出来 —— 需求之间有交叉,
3740
- 只看当前模块会漏掉相关需求。 */
3741
- function reqDetailBlock(d) {
3742
- var w = d.ws;
3743
- var frag = document.createDocumentFragment();
3744
- (w.modules || []).forEach(function (m) {
3745
- var fs = (m.features || []);
3746
- if (!fs.length) return;
3747
- var sec = hEl('div', 'reqdetail__sec' + (m.name === d.mod ? ' reqdetail__sec--cur' : ''));
3748
- var mh = hEl('div', 'sechead');
3749
- mh.style.paddingLeft = '16px';
3750
- mh.appendChild(elSpan('sechead__t', (m.name || '未归类') + ' (' + fs.length + ')'));
3751
- sec.appendChild(mh);
3752
- fs.forEach(function (f) { sec.appendChild(reqFeatureRow(f)); });
3753
- frag.appendChild(sec);
3754
- });
3755
- if (!frag.childNodes.length) {
3756
- frag.appendChild(hEl('div', 'history__hint', '这个空间还没有需求文档'));
3757
- }
3758
- return frag;
3759
- }
3760
-
3761
- /* 进二级:记下是哪个空间的哪个模块,渲染后滚到那个模块。 */
3762
- function openReqDetail(w, mod) {
3763
- var key = w.id || w.dir || w.name || '';
3764
- REQ_DETAIL = { key: key, mod: mod, ws: w };
3765
- renderRequirements();
3766
- /* 空间在两次渲染之间被删掉了(会话全删 → 空间联动消失):
3767
- renderRequirements 会把 REQ_DETAIL 清空,这时别停在空白的二级页,退回目录。 */
3768
- if (!REQ_DETAIL) { hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false; return; }
3769
- hideAll(); reqDetailEl.hidden = false; tabbarEl.hidden = true;
3770
- if (reqDetailTitle) reqDetailTitle.textContent = (w.name || w.dir || w.id) + ' · ' + mod;
3771
- /* 滚到当前模块:它高亮,但用户进来第一眼要看到的是自己点的那一块。 */
3772
- if (reqDetailListEl) {
3773
- var cur = reqDetailListEl.querySelector('.reqdetail__sec--cur');
3774
- if (cur && cur.scrollIntoView) cur.scrollIntoView({ block: 'start' });
3775
- }
3776
- }
3777
-
3778
- /* 退二级回一级目录。 */
3779
- function closeReqDetail() {
3780
- REQ_DETAIL = null;
3781
- hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false;
3782
- renderRequirements();
3783
- }
3784
-
3785
- function renderRequirements() {
3786
- if (!reqListEl) return;
3787
- var data = REQ_DATA;
3788
- if (!data) return;
3789
- /* 空间清单与任务页对齐:**用与 collectSpaceKeys 同一套空间键过滤**
3790
- (用户 2026-10-22:「4个空间需求里显示的不一样」「会话中全部删除后需求中的空间还在
3791
- 也要联动删除」)。没有文档的空间不再整块藏掉,而是留一个组头 + 「整理」按钮 ——
3792
- 这样两页的空间数一致,也给了「这个空间还没生成需求」的入口。
3793
- 拿不到会话/目录清单时(REQ_ALIVE=null)不筛。 */
3794
- var all = (data && data.workspaces) || [];
3795
- var list = all.filter(function (w) {
3796
- var dir = reqNormDir(w && w.dir);
3797
- /* 根目录 / 归一后是空串 —— 「!dir 就放行」的老写法会把它漏进来,
3798
- 需求页于是出现一个叫「/」的假空间(用户 2026-10-22:「没有空间的不需要」)。
3799
- 这里先卡一道:不是真空间目录的直接不画。 */
3800
- if (!isRealSpaceDir(dir)) return false;
3801
- return !REQ_ALIVE || !dir || !!REQ_ALIVE[dir];
3802
- });
3803
- /* 排序:**需求条数多的排前面**(用户 2026-10-22)。
3804
- core 给的顺序是「最后归纳时间倒序」,但那是给「最近在动的」用的;
3805
- 需求页要回答的是「哪块需求最多」,条数多的才是用户要先看的那个。
3806
- 条数一样时按名字排 —— 同一条数下顺序要稳定,不能每次刷新都在跳。 */
3807
- list.sort(function (a, b) {
3808
- var ca = Number(a && a.feature_count) || 0;
3809
- var cb = Number(b && b.feature_count) || 0;
3810
- if (ca !== cb) return cb - ca;
3811
- return String((a && a.name) || '').localeCompare(String((b && b.name) || ''));
3812
- });
3813
- /* 二级视图:只画这个空间的需求列表。空间可能已被会话删除联动掉
3814
- (list 里没有它)—— 那就退回一级目录,不要留一页空白。 */
3815
- if (REQ_DETAIL) {
3816
- var hit = null;
3817
- for (var i = 0; i < list.length; i++) {
3818
- if ((list[i].id || list[i].dir || list[i].name || '') === REQ_DETAIL.key) { hit = list[i]; break; }
3819
- }
3820
- if (!hit) { REQ_DETAIL = null; }
3821
- else {
3822
- REQ_DETAIL.ws = hit;
3823
- if (reqDetailTitle) reqDetailTitle.textContent = (hit.name || hit.dir || hit.id) + ' · ' + REQ_DETAIL.mod;
3824
- if (reqDetailListEl) reqDetailListEl.replaceChildren(reqDetailBlock(REQ_DETAIL));
3825
- if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
3826
- return;
3827
- }
3828
- }
3829
- var frag = document.createDocumentFragment();
3830
- if (!list.length) {
3831
- /* 一个空间都没有(会话全删了 / 还没聊过)。 */
3832
- frag.appendChild(hEl('div', 'history__hint',
3833
- REQ_ALIVE
3834
- ? '还没有任何空间。在任务里聊几句,这里会按空间整理出功能点文档。'
3835
- : '还没有任何需求。在任务里聊几句,这里会自动整理出功能点文档。'));
3836
- reqListEl.replaceChildren(frag);
3837
- return;
3838
- }
3839
- /* 工作区顺序:上面已按需求条数排好(多的在前)。默认只展开第一个 ——
3840
- 见 reqWorkspaceBlock 的 open 判定。 */
3841
- list.forEach(function (w, i) { frag.appendChild(reqWorkspaceBlock(w, i)); });
3842
- reqListEl.replaceChildren(frag);
3843
- /* 整理进行中切走再回来:新 DOM 的按钮默认是可点的,这里按当前状态重新置忙。 */
3844
- if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
3845
- }
3846
-
3847
- function loadRequirements(quiet) {
3848
- if (!reqListEl) return;
3849
- if (!quiet && !REQ_DATA) reqListEl.innerHTML = '<div class="history__hint">读取中…</div>';
3850
- var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
3851
- var pReq = fetch(BASE + '/desk/requirements', { headers: hdr })
3852
- .then(function (r) {
3853
- if (r.status === 501) {
3854
- /* 老 core 没有这个能力位:**说清楚**,不要给一页空白
3855
- (空白看起来像「没有需求」,是最难查的一种)。 */
3856
- return r.json().then(function (e) { throw new Error((e && e.error) || '当前 core 不支持需求聚合'); });
3857
- }
3858
- if (!r.ok) throw new Error('HTTP ' + r.status);
3859
- return r.json();
3860
- });
3861
- /* 会话清单与空间目录:用来判断「这个空间在任务页还在不在」。
3862
- 两者都允许失败 —— 拿不到就不筛空间(见 renderRequirements)。 */
3863
- var pSess = fetch(BASE + '/desk/sessions', { headers: hdr })
3864
- .then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
3865
- var pDirs = fetch(BASE + '/desk/dirs', { headers: hdr })
3866
- .then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
3867
- Promise.all([pReq, pSess, pDirs])
3868
- .then(function (a) {
3869
- var sess = a[1], dirs = (a[2] && a[2].dirs) || null;
3870
- if (Array.isArray(sess) || Array.isArray(dirs)) {
3871
- /* 与任务页**同一套空间键**(用户 2026-10-22:「4个空间需求里显示的不一样」
3872
- 「会话中全部删除后需求中的空间还在 也要联动删除」「没有空间的不需要」):
3873
- ① 根目录下的 magic-* ② 自定义空间 ③ **顶层会话**的工作目录。
3874
- ⚠️ ③ 只能取**顶层**会话的 workspace:团队子代理的会话挂在父会话下面,
3875
- 它们的工作目录是 /…/run-xxx/a1 这种临时目录 —— 任务页不把它们列成空间,
3876
- 需求页照收就会多出一堆 a1/t1/tmp 假空间。
3877
- ⚠️ 根目录 / 与 /tmp 这类系统目录不是空间,哪怕历史上有会话在那儿跑过。 */
3878
- var alive = {};
3879
- (ROOT_DIRS || []).forEach(function (d) { if (d && d.path) alive[reqNormDir(d.path)] = 1; });
3880
- (dirs || []).forEach(function (d) {
3881
- if (d && d.path && String(d.name || '').indexOf('magic-') === 0) alive[reqNormDir(d.path)] = 1;
3882
- });
3883
- (CUSTOM_SPACES || []).forEach(function (p) { if (p) alive[reqNormDir(p)] = 1; });
3884
- topLevelSessions(sess).forEach(function (s) {
3885
- if (s && s.workspace && isRealSpaceDir(s.workspace)) alive[reqNormDir(s.workspace)] = 1;
3886
- });
3887
- REQ_ALIVE = alive;
3888
- } else {
3889
- REQ_ALIVE = null; // 清单没拿到:不筛,宁可多显示
3890
- }
3891
- REQ_DATA = a[0] || { workspaces: [] };
3892
- renderRequirements();
3893
- })
3894
- .catch(function (e) {
3895
- if (quiet && REQ_DATA) return; // 后台刷新失败不覆盖已有画面
3896
- reqListEl.innerHTML = '';
3897
- reqListEl.appendChild(hEl('div', 'history__hint', '读取需求失败:' + ((e && e.message) || e)));
3898
- });
3899
- }
3900
-
3901
- /* 「整理」:手动触发一轮(POST /desk/requirements/consolidate)。
3902
- 服务端按 state 判断走哪条路:
3903
- · 没初始化过 → 先按空间目录反推需求基线(首次可能跑几分钟,按钮要能等);
3904
- · 已初始化 → 只消费上次整理之后的新会话。
3905
- 入口有两个:
3906
- · 每个空间组头的「整理」——只整理这个空间(用户点哪个就是哪个,不猜);
3907
- · 右上角「全部整理」——按空间逐个串行跑一遍,汇总结果(一键触发全部)。
3908
- 两个入口都带「我的 → 默认引擎/模型」,服务端不再自动替换引擎。 */
3909
- function reqPostConsolidate(ws) {
3910
- var hdr = { 'Content-Type': 'application/json' };
3911
- if (TOKEN) hdr['X-Magic-Token'] = TOKEN;
3912
- return fetch(BASE + '/desk/requirements/consolidate', {
3913
- method: 'POST', headers: hdr,
3914
- body: JSON.stringify({
3915
- workspace: ws || '',
3916
- engine: RUN_PREF.engine || '',
3917
- model: (RUN_PREF.engine && RUN_PREF.model) || ''
3918
- })
3919
- })
3920
- .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
3921
- .then(function (x) {
3922
- var d = x.d || {};
3923
- if (!x.ok) throw new Error(d.error || ('HTTP ' + (x.d && x.d.status)));
3924
- if (d.error) throw new Error(d.error);
3925
- return d;
3926
- });
3927
- }
3928
-
3929
- /* 统一置忙:顶部主按钮 + 所有空间组头的「整理」一起禁用。
3930
- label 只改顶部;组头统一显示「整理中…」/「整理」,不各自变文案。
3931
- 二级页那枚「整理」也跟着禁用 —— 归纳期间它和组头一样不能重复点。 */
3932
- function reqSetBusy(on, label) {
3933
- if (on) { if (label !== undefined) REQ_BUSY_LABEL = label; }
3934
- else REQ_BUSY_LABEL = '全部整理';
3935
- var btn = document.getElementById('btnReqSync');
3936
- if (btn) {
3937
- btn.disabled = !!on;
3938
- if (label !== undefined) btn.textContent = label;
3939
- }
3940
- var dbtn = document.getElementById('btnReqDetailSync');
3941
- if (dbtn) {
3942
- dbtn.disabled = !!on;
3943
- dbtn.textContent = on ? '整理中…' : '整理';
3944
- }
3945
- Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
3946
- b.disabled = !!on;
3947
- b.textContent = on ? '整理中…' : '整理';
3948
- });
3949
- }
3950
-
3951
- function consolidateRequirements(target) {
3952
- if (REQ_BUSY || !target) return;
3953
- var ws = target.dir || target.id || '';
3954
- if (!ws) return;
3955
- var firstTime = target.initialized === false;
3956
- REQ_BUSY = true;
3957
- reqSetBusy(true, firstTime ? '初始化中…' : '整理中…');
3958
- var targetKey = target.id || target.dir || '';
3959
- var groupBtn = null;
3960
- Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
3961
- if (!groupBtn && targetKey && b.getAttribute('data-ws') === targetKey) groupBtn = b;
3962
- });
3963
- /* 点的是哪个空间,就只让哪个组头显示「初始化中…」;其它组头保持「整理中…」。 */
3964
- if (groupBtn) groupBtn.textContent = firstTime ? '初始化中…' : '整理中…';
3965
- reqPostConsolidate(ws)
3966
- .then(function (d) {
3967
- if (d.bootstrap) {
3968
- if (d.created || d.updated) toast('已按空间目录生成需求:新建 ' + (d.created || 0) + ' · 更新 ' + (d.updated || 0));
3969
- else toast('已按空间目录初始化:暂未识别到功能点');
3970
- }
3971
- else if (d.noop) toast('没有新的对话需要整理');
3972
- else if (d.updated || d.created) toast('整理完成:更新 ' + (d.updated || 0) + ' · 新建 ' + (d.created || 0));
3973
- else toast('整理完成');
3974
- loadRequirements(true); // 后台刷新(不闪「读取中」)
3975
- })
3976
- .catch(function (e) { toast('整理失败:' + ((e && e.message) || e)); })
3977
- .then(function () {
3978
- REQ_BUSY = false;
3979
- reqSetBusy(false, '全部整理');
3980
- });
3981
- }
3982
-
3983
- /* 全部整理:按空间串行跑,避免多个初始化同时读目录把机器拖垮。
3984
- 顺序 = 有待整理的 → 未初始化的 → 其它(最近动过的仍在前),先做便宜/该做的。
3985
- 每个空间单独一次 HTTP(各自 10 分钟超时);某个失败不影响后面的。 */
3986
- function reqAllWorkspaces() {
3987
- var seen = {};
3988
- var list = ((REQ_DATA && REQ_DATA.workspaces) || []).filter(function (w) {
3989
- var key = w.dir || w.id || '';
3990
- if (!key || seen[key]) return false;
3991
- /* 会话已全部删除的空间不参与「全部整理」:否则又会把它重新生成出来。 */
3992
- var dir = reqNormDir(w.dir);
3993
- if (REQ_ALIVE && dir && !REQ_ALIVE[dir]) return false;
3994
- seen[key] = true;
3995
- return true;
3996
- });
3997
- function rank(w) {
3998
- if (Number(w.pending) > 0) return 0;
3999
- if (w.initialized === false) return 1;
4000
- return 2;
4001
- }
4002
- return list.sort(function (a, b) { return rank(a) - rank(b); });
4003
- }
4004
-
4005
- function consolidateAllRequirements() {
4006
- if (REQ_BUSY) return;
4007
- var list = reqAllWorkspaces();
4008
- if (!list.length) {
4009
- toast('还没有可整理的空间');
4010
- return;
4011
- }
4012
- REQ_BUSY = true;
4013
- reqSetBusy(true, '整理中 0/' + list.length);
4014
- var i = 0, ok = 0, fail = 0, created = 0, updated = 0, firstErr = '';
4015
- function done() {
4016
- REQ_BUSY = false;
4017
- reqSetBusy(false, '全部整理');
4018
- loadRequirements(true);
4019
- if (fail) {
4020
- toast('全部整理完成:成功 ' + ok + ' · 失败 ' + fail + (firstErr ? '(' + firstErr + ')' : ''));
4021
- } else {
4022
- toast('全部整理完成:' + ok + ' 个空间 · 新建 ' + created + ' · 更新 ' + updated);
4023
- }
4024
- }
4025
- function step() {
4026
- if (i >= list.length) { done(); return; }
4027
- var w = list[i++];
4028
- REQ_BUSY_LABEL = '整理中 ' + i + '/' + list.length;
4029
- var btn = document.getElementById('btnReqSync');
4030
- if (btn) btn.textContent = REQ_BUSY_LABEL;
4031
- reqPostConsolidate(w.dir || w.id || '')
4032
- .then(function (d) {
4033
- ok++;
4034
- created += Number(d.created) || 0;
4035
- updated += Number(d.updated) || 0;
4036
- })
4037
- .catch(function (e) {
4038
- fail++;
4039
- if (!firstErr) firstErr = (e && e.message) || String(e);
4040
- })
4041
- .then(step);
4042
- }
4043
- step();
4044
- }
4045
- on('btnReqSync', function () { consolidateAllRequirements(); });
4046
- /* 二级视图的两个动作:返回一级目录 / 整理**当前这个空间**(不是全部空间 ——
4047
- 二级页的作用域就是这个空间,标成「全部整理」会让人以为还是全局动作)。 */
4048
- on('btnReqDetailBack', function () { closeReqDetail(); });
4049
- on('btnReqDetailSync', function () {
4050
- if (!REQ_DETAIL || !REQ_DETAIL.ws) return;
4051
- consolidateRequirements(REQ_DETAIL.ws);
4052
- });
4053
3803
 
4054
3804
  function openHostSheet(title, build) {
4055
3805
  closeHostSheet();
@@ -4174,6 +3924,16 @@ button.setrow{font-family:inherit}
4174
3924
  丢了它这条排队任务就会从「团队」静默降级成单任务。 */
4175
3925
  team: info.team || null,
4176
3926
  attachments: (info.attachments || []).slice(),
3927
+ /* atts:气泡缩略图的快照({name, remoteUrl},见 conversation.js 的 notifyQueue)。
3928
+ 重进会话时 adoptPendingFor 用它把排队气泡原样画回来 —— 只存路径的话恢复出来会丢图。 */
3929
+ atts: (info.atts || []).slice(),
3930
+ /* owner:谁负责把这一条真正发出去。
3931
+ 'gate' = 串行门(gateAsk,放行后由这一轮自己下发)
3932
+ 'host' = 宿主(restoreQueue / 重启恢复,见 enqueueDispatch)
3933
+ 'conv' = 会话组件(组件内排队,轮到它时组件自己 flush)
3934
+ 组件被销毁重建后,它的 owner='conv' 条目还留在 PENDING 里,由
3935
+ adoptPendingFor 接回新实例 —— 状态只能落在条目上,不能只存在组件内存。 */
3936
+ owner: info.owner || 'gate',
4177
3937
  hide: !!hide, dev: devKey(), at: Date.now() };
4178
3938
  PENDING.push(p); saveQueue();
4179
3939
  if (!hide) renderSessions(LAST_SESSIONS);
@@ -4194,11 +3954,12 @@ button.setrow{font-family:inherit}
4194
3954
  (2026-10-21 用户:「非团队的串行排队也没落库」)。
4195
3955
  这里把它接进同一个 PENDING:进队即写 magic.serial.queue.v1、轮到就删行、取消就清盘。
4196
3956
  重开 App 后由 restoreQueue 直接下发(不挂会话页面,事件仍进会话日志,点开可回放)。 */
4197
- var QPEND = {}; // 组件条目 id(q…)→ 本端 PENDING id(p…)
3957
+ var QPEND = {}; // 组件条目 id(q… / 重新接管后的 p…)→ 本端 PENDING id(p…)
4198
3958
  function queuePushed(info) {
4199
3959
  if (!info || !info.id || !info.prompt || QPEND[info.id]) return;
4200
3960
  /* hide:用户此刻就在会话里,气泡已经画出来了 —— 列表那条「等待中」延后
4201
3961
  PENDING_SHOW_MS 再出现(与 gateAsk 同一口径:空间本来就空闲时不会闪一条假排队)。 */
3962
+ info.owner = 'conv';
4202
3963
  var p = addPending(wsKey(info.workspace), info, true);
4203
3964
  QPEND[info.id] = p.id;
4204
3965
  setTimeout(function () {
@@ -4243,7 +4004,10 @@ button.setrow{font-family:inherit}
4243
4004
  session: p.session || '',
4244
4005
  prompt: p.prompt || '', engine: p.engine || '', model: p.model || '',
4245
4006
  permission: p.permission || '', team: p.team || null,
4246
- attachments: p.attachments || [],
4007
+ attachments: p.attachments || [], atts: p.atts || [],
4008
+ /* owner 刻意不落盘:重启后所有残留条目都由 restoreQueue 交给宿主
4009
+ (enqueueDispatch),语义与 'host' 一致 —— 存下来反而会让「上次是
4010
+ 组件排队」的那条永远等不到组件接管。 */
4247
4011
  dev: p.dev, at: p.at };
4248
4012
  });
4249
4013
  try { localStorage.setItem(QUEUE_KEY, JSON.stringify(others.concat(mineRows))); } catch (e) {}
@@ -4330,6 +4094,35 @@ button.setrow{font-family:inherit}
4330
4094
  check();
4331
4095
  });
4332
4096
  }
4097
+ /* 宿主下发的**追问**要等原会话这一轮收尾(同一条会话不许并发两轮)。
4098
+ 与 waitForSlot 的区别:那条按**空间**串行、且只看 TASK_MODE;这条按**会话**,
4099
+ 并行模式也生效 —— 恢复一条「排在上一轮后面的追问」时,正是它在挡住并发。
4100
+ sid 为空(新任务)直接放行;清单拿不到 / 会话已不在 / 等超上限也放行:
4101
+ 排队是**保护**不是**闸门**,不能因为探测失败把用户的任务永久卡住。 */
4102
+ var SESSION_WAIT_MAX_MS = 30 * 60 * 1000;
4103
+ function waitSessionIdle(sid) {
4104
+ if (!sid) return Promise.resolve();
4105
+ var t0 = Date.now();
4106
+ return new Promise(function (resolve) {
4107
+ function check() {
4108
+ var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
4109
+ fetch(BASE + '/desk/sessions', { headers: hdr })
4110
+ .then(function (r) { return r.ok ? r.json() : null; })
4111
+ .then(function (list) {
4112
+ if (!Array.isArray(list)) { resolve(); return; }
4113
+ var row = null;
4114
+ for (var i = 0; i < list.length; i++) {
4115
+ var s = list[i];
4116
+ if (sidOf(s) === sid || s.session_id === sid || s.run_id === sid) { row = s; break; }
4117
+ }
4118
+ if (!row || !serialBusy(row) || Date.now() - t0 >= SESSION_WAIT_MAX_MS) { resolve(); return; }
4119
+ setTimeout(check, SLOT_POLL_MS);
4120
+ })
4121
+ .catch(function () { resolve(); });
4122
+ }
4123
+ check();
4124
+ });
4125
+ }
4333
4126
  /* 排队行的出现时机:**进队即落库**(关 App 也不能丢,这是用户报障的正题);
4334
4127
  列表上那条「等待中」行则由**是否真的要等**决定可见性(见 gateAsk 的 markWaiting)。
4335
4128
  这个常量只剩一处用处:组件内排队(queuePushed,同一会话上一轮还没收尾)在
@@ -4452,6 +4245,9 @@ button.setrow{font-family:inherit}
4452
4245
  var list = loadQueue();
4453
4246
  if (!list.length) return;
4454
4247
  list.sort(function (a, b) { return (a.at || 0) - (b.at || 0); }); // 按当时进队顺序
4248
+ /* 重启恢复的条目一律归宿主下发:它们的主人(会话组件)已经不在了,
4249
+ 再标成 'conv' 就没人会认领(见 adoptPendingFor 的判据)。 */
4250
+ list.forEach(function (p) { p.owner = 'host'; });
4455
4251
  PENDING = list;
4456
4252
  renderSessions(LAST_SESSIONS);
4457
4253
  list.forEach(function (p) { enqueueDispatch(p); });
@@ -4461,11 +4257,20 @@ button.setrow{font-family:inherit}
4461
4257
  var q = serialQ(ws);
4462
4258
  /* 重新尝试(用户点「未能启动」那条):先把红字连原因一起摘掉,回到正常的排队态。 */
4463
4259
  if (p.stalled) { p.stalled = false; p.stalledReason = ''; renderSessions(LAST_SESSIONS); }
4464
- var mine = q.tail.then(function () { return waitForSlot(ws, q); }).then(function () {
4465
- /* 断线 / 还没配对:**留在队列里**,等下次连上再发 —— 不能当成「已下发」删掉。 */
4466
- if (!connected()) return false;
4467
- return fireQueued(p);
4468
- });
4260
+ var mine = q.tail.then(function () { return waitForSlot(ws, q); })
4261
+ /* 同一条会话同时只能跑一轮:宿主下发的「追问」必须等这一轮真的收尾。
4262
+ 并行模式(waitForSlot 直接放行)下尤其重要 —— 否则新进程和还在跑的那一轮
4263
+ 会同时往同一份会话日志里写,正文与思考区交错,是最难查的一类脏数据。 */
4264
+ .then(function () { return waitSessionIdle(p.session); })
4265
+ .then(function () {
4266
+ /* 断线 / 还没配对:**留在队列里**,等下次连上再发 —— 不能当成「已下发」删掉。 */
4267
+ if (!connected()) return false;
4268
+ /* 等待期间用户点进了这条会话,组件已经把它接管回去了(adoptPendingFor 把 owner
4269
+ 改回 'conv')→ 让组件自己 flush,宿主这里放手。不检查的话同一条会被发两次。 */
4270
+ if (p.owner !== 'host') return false;
4271
+ p.dispatching = true; // 已交给 fireQueued:此后不许组件再接管(防双发)
4272
+ return fireQueued(p);
4273
+ });
4469
4274
  q.tail = mine.catch(function () { return false; });
4470
4275
  return mine.then(function (sent) {
4471
4276
  if (!sent) return; // 没落盘:留在队列里(stalled),不删
@@ -5321,6 +5126,39 @@ button.setrow{font-family:inherit}
5321
5126
  document.addEventListener('click', function () {
5322
5127
  if (!runSettingsPageEl.hidden) paintRunSettings();
5323
5128
  });
5129
+ /* 需求页(reqs.js):内容与逻辑不在本文件,这里只注入宿主能力并挂载一次。
5130
+ getter 形式传 BASE/TOKEN/空间清单 —— 切环境后这些值会变,不能传快照。 */
5131
+ if (window.MagicReqs && window.MagicReqs.mount) {
5132
+ window.MagicReqs.mount({
5133
+ rootEl: reqsEl,
5134
+ detailEl: reqDetailEl,
5135
+ hEl: hEl,
5136
+ elSpan: elSpan,
5137
+ svgUse: svgUse,
5138
+ toast: toast,
5139
+ openReader: openReader,
5140
+ hideAll: hideAll,
5141
+ setActive: setActive,
5142
+ showRoot: function () {
5143
+ hideAll();
5144
+ reqsEl.hidden = false;
5145
+ tabbarEl.hidden = false;
5146
+ setActive('reqs');
5147
+ },
5148
+ showDetail: function () {
5149
+ hideAll();
5150
+ reqDetailEl.hidden = false;
5151
+ tabbarEl.hidden = true;
5152
+ },
5153
+ getBase: function () { return BASE; },
5154
+ getToken: function () { return TOKEN; },
5155
+ getRootDirs: function () { return ROOT_DIRS; },
5156
+ getCustomSpaces: function () { return CUSTOM_SPACES; },
5157
+ getRunPref: function () { return RUN_PREF; },
5158
+ isRealSpaceDir: isRealSpaceDir,
5159
+ topLevelSessions: topLevelSessions
5160
+ });
5161
+ }
5324
5162
  Array.prototype.forEach.call(tabbarEl.querySelectorAll('.tab'), function (b) {
5325
5163
  b.onclick = function () {
5326
5164
  var t = b.getAttribute('data-tab');
@@ -5362,11 +5200,21 @@ button.setrow{font-family:inherit}
5362
5200
  if (segTheme) takeSeg(segTheme, 'magic.theme', function (v) { PREFS.theme = v; applyThemePref(v); });
5363
5201
  if (segFont) takeSeg(segFont, 'magic.font', function (v) { PREFS.font = v; applyFontPref(v); });
5364
5202
  if (segMode) takeSeg(segMode, 'magic.taskmode', function (v) { TASK_MODE = v; });
5365
- if (tglHaptic) tglHaptic.onclick = function () {
5366
- PREFS.haptic = (PREFS.haptic === 'off') ? 'on' : 'off';
5367
- savePref('magic.haptic', PREFS.haptic); applyHapticPref(PREFS.haptic);
5368
- tglHaptic.classList.toggle('is-on', PREFS.haptic !== 'off');
5369
- };
5203
+
5204
+ /* 会话托管开关(我的 tab)。拨动只改「接下来的轮次」:
5205
+ 正在跑的这一轮不受影响,收尾后若打开就自动接上。 */
5206
+ var tglHosting = document.getElementById('tglHosting');
5207
+ if (tglHosting) {
5208
+ tglHosting.classList.toggle('is-on', HOSTING);
5209
+ tglHosting.onclick = function () {
5210
+ var next = !HOSTING;
5211
+ setHostingPref(next, '');
5212
+ if (conv) { try { conv.setHosting(next); } catch (e) {} }
5213
+ toast(next
5214
+ ? '会话托管已开启:每轮结束自动发送最合适的建议,提问卡自动选第一项继续'
5215
+ : '会话托管已关闭');
5216
+ };
5217
+ }
5370
5218
 
5371
5219
  /* ══════════ 网络代理(VPN 隧道)══════════
5372
5220
  把整机流量经隧道送到桌面端的 SOCKS5 出口:TUN → 内核 → 桌面端 → 互联网。