magic-agent 1.0.111 → 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 (55) hide show
  1. package/CLIENT-CONTRACT.md +117 -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 +1052 -595
  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 +2 -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/apk.go +135 -0
  28. package/internal/client/apk_test.go +99 -0
  29. package/internal/client/prefs.go +99 -0
  30. package/internal/client/service.go +202 -1
  31. package/internal/client/suggest.go +131 -0
  32. package/internal/client/suggest_test.go +116 -0
  33. package/internal/session/delete.go +58 -11
  34. package/internal/session/delete_test.go +140 -0
  35. package/internal/session/session.go +25 -0
  36. package/internal/session/session_mark_test.go +62 -0
  37. package/internal/suggest/suggest.go +421 -0
  38. package/internal/suggest/suggest_test.go +218 -0
  39. package/internal/team/board.go +107 -15
  40. package/internal/team/execute.go +1 -1
  41. package/internal/team/team.go +28 -19
  42. package/internal/team/team_test.go +68 -15
  43. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  44. package/npm/apk/version.json +5 -0
  45. package/npm/dist/darwin-arm64/magic-agent +0 -0
  46. package/npm/dist/darwin-arm64/magic-client +0 -0
  47. package/npm/dist/darwin-x64/magic-agent +0 -0
  48. package/npm/dist/darwin-x64/magic-client +0 -0
  49. package/npm/dist/linux-arm64/magic-agent +0 -0
  50. package/npm/dist/linux-arm64/magic-client +0 -0
  51. package/npm/dist/linux-x64/magic-agent +0 -0
  52. package/npm/dist/linux-x64/magic-client +0 -0
  53. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  54. package/npm/dist/win32-x64/magic-client.exe +0 -0
  55. package/package.json +6 -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 逐字段对齐。 */
@@ -206,6 +209,10 @@ textarea{font:inherit}
206
209
  .field label{display:block;font-size:12px;font-weight:500;color:var(--muted);margin-bottom:4px}
207
210
  .field textarea{display:block;width:100%;border:0;outline:0;resize:none;background:none;
208
211
  color:var(--ink);font:15px/20px var(--mono);min-height:40px}
212
+ /* 名称栏(2026-10-22 用户:「添加连接也没有输入名字的地方」):单行输入,与地址栏同样式 */
213
+ .field input{display:block;width:100%;height:40px;border:0;outline:0;background:none;
214
+ color:var(--ink);font-family:inherit;font-size:15px;line-height:20px;-webkit-appearance:none}
215
+ .field+.field{margin-top:12px}
209
216
  .field__err{color:var(--error-text);font-size:12px;line-height:16px;margin-top:6px}
210
217
  /* Button(filled)/ OutlinedButton:40dp 高 · 全圆角 */
211
218
  .btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;height:40px;
@@ -309,11 +316,25 @@ textarea{font:inherit}
309
316
  .srow__s{display:flex;align-items:center;gap:5px;min-width:0;font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden}
310
317
  .srow__stext{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
311
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}
312
322
  /* 子会话(团队模式拆出的子任务,2026-10-22):**缩进挂在主会话下面**,不占顶层一行。
313
323
  左侧一道竖线 + 小一档图标 —— 读起来是「这条主会话拆出去的活」;点开仍是它自己
314
- 那份完整过程(自己的日志)。行本身可点、可长按(重命名 / 删除同普通会话)。 */
324
+ 那份完整过程(自己的日志)。行本身可点、可长按(重命名 / 删除同普通会话)。
325
+ ⚠️ 默认**折叠**(2026-10-23 用户:「列表中默认不展开显示子会话」):父行只留一枚
326
+ 计数徽标(有几个子会话 / 是否有子任务在跑),点它才展开。 */
315
327
  .sblock{min-width:0}
316
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)}
317
338
  .srow--sub{padding:9px 8px 9px 4px;gap:10px}
318
339
  .srow--sub .srow__ico{width:32px;height:32px}
319
340
  .srow--sub .srow__ico svg{width:16px;height:16px}
@@ -368,15 +389,6 @@ textarea{font:inherit}
368
389
  .wsrow__chev.is-open{transform:rotate(90deg)}
369
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)}
370
391
  .wsrow__add:active{background:var(--subtle)}
371
- /* 每个空间自己的「整理」:需求页会同时列出多个空间,顶部那一枚只认当前会话的空间。
372
- 把入口放到组头,用户点哪个空间就初始化/增量哪个空间,不再猜「当前空间」是谁。 */
373
- .wsrow__sync{flex:0 0 auto;height:32px;padding:0 10px;border-radius:var(--r-full);
374
- font-size:12px;font-weight:500;color:var(--primary-action);background:var(--subtle);white-space:nowrap}
375
- .wsrow__sync:active{opacity:.7}
376
- .wsrow__sync:disabled{color:var(--muted);opacity:.6}
377
- /* 「未初始化」与「待整理」同形不同色:前者是「还没按目录生成过基线」,
378
- 后者是「有对话等着归纳」,两种提示不能混成一个数字。 */
379
- .wsrow__badge--init{color:var(--warning);background:var(--tint-wait)}
380
392
  /* 长按会话行的浮动菜单 */
381
393
  .rowmenu{position:fixed;z-index:1201;min-width:184px;background:var(--raised);border:1px solid var(--border);
382
394
  border-radius:14px;box-shadow:0 14px 34px -12px var(--app-shadow);padding:6px}
@@ -405,7 +417,13 @@ textarea{font:inherit}
405
417
  font-size:11px;font-weight:600;line-height:17px;color:var(--primary-action);background:var(--subtle)}
406
418
  .envmenu__s{font-size:12px;line-height:16px;color:var(--muted);
407
419
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
408
- .envmenu__check{flex:0 0 auto;color:var(--primary-action)}
420
+ /* 当前连接用**底色**区分,不再挂一枚 ✓(2026-10-22 用户:「不需要选中这个标识 用底色区分」)。
421
+ 底色要能压过同一行的 :active 态,所以写在它后面。 */
422
+ .envmenu__item.is-active{background:var(--subtle)}
423
+ /* 每行的修改入口:铅笔比「›」更明确是「编辑」,且不与整行的「切换」动作打架 */
424
+ .envmenu__edit{flex:0 0 auto;width:36px;height:36px;border-radius:50%;
425
+ display:flex;align-items:center;justify-content:center;color:var(--muted)}
426
+ .envmenu__edit:active{background:var(--subtle);color:var(--ink)}
409
427
  .envmenu__sep{display:block;height:1px;margin:6px 4px;background:var(--border)}
410
428
  .envmenu__add{display:flex;align-items:center;gap:10px;width:100%;padding:12px;border-radius:12px;
411
429
  font-size:15px;font-weight:500;color:var(--primary-action);text-align:left}
@@ -462,6 +480,8 @@ button.setrow{font-family:inherit}
462
480
  .setrow+.setrow{border-top:1px solid var(--border)}
463
481
  .setrow__l{flex:1;min-width:0;font-size:15px;color:var(--ink)}
464
482
  .setrow__v{flex:0 0 auto;font-size:14px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:50%}
483
+ /* 有新版本时这一格变成可点的「升级」提示(2026-10-22 用户:「版本现在也没点击升级的地方」) */
484
+ .setrow__v.is-upgradable{color:var(--primary-action);font-weight:600}
465
485
  .setrow--danger{color:var(--error-text)}
466
486
  .setrow--danger .setrow__l,.setrow--danger .setrow__v{color:var(--error-text)}
467
487
  /* 环境编辑(我的 → 环境):名称 / 地址 / 令牌三栏。
@@ -472,6 +492,8 @@ button.setrow{font-family:inherit}
472
492
  .envfield:focus{border-color:var(--primary-action)}
473
493
  .envfield::placeholder{color:var(--faint)}
474
494
  .envlabel{display:block;margin-top:14px;font-size:12px;font-weight:700;color:var(--muted)}
495
+ /* 链接栏下方的「扫码填入」:文字按钮,不抢「保存」的位置 */
496
+ .envscan{display:block;margin-top:8px;padding:6px 0;font-size:13px;font-weight:600;color:var(--primary-action)}
475
497
  .envhint{margin-top:10px;font-size:12px;line-height:1.6;color:var(--faint)}
476
498
  .envacts{display:flex;gap:8px;margin-top:16px}
477
499
  .envacts .btn{flex:1;margin-top:0}
@@ -491,6 +513,14 @@ button.setrow{font-family:inherit}
491
513
  .teamedit__acts{display:flex;gap:8px;margin-bottom:10px}
492
514
  /* 团队行尾箭头:点开才有的配置必须看得出「这里能点」。 */
493
515
  .setrow__chev{flex:0 0 auto;color:var(--faint);font-size:16px;line-height:1}
516
+ /* 团队行 = **一行两个点击区**(用户 2026-10-22:「两个触发就行了」):
517
+ 左边名字 + 规则描述 → 只改规则;右边引擎值 → 只选引擎。外层是 div(button 不能套 button)。 */
518
+ .setrow--split{padding:0;gap:4px}
519
+ .setrow__mainbtn{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;
520
+ padding:14px 2px 14px 0;text-align:left}
521
+ .setrow__vbtn{flex:0 0 auto;display:flex;align-items:center;gap:4px;max-width:52%;
522
+ padding:14px 0 14px 10px;color:var(--muted);text-align:right}
523
+ .setrow__vbtn .setrow__v{max-width:100%}
494
524
  .seg{display:inline-flex;background:var(--subtle);border-radius:var(--r-full);padding:2px;gap:2px}
495
525
  .seg__b{height:28px;padding:0 12px;border-radius:var(--r-full);font-size:13px;color:var(--muted)}
496
526
  .seg__b.is-on{background:var(--raised);color:var(--ink);font-weight:600}
@@ -505,18 +535,20 @@ button.setrow{font-family:inherit}
505
535
  从栏背滚过去,靠半透明 + 18px 模糊透出来。这样栏高不进布局,会话页的输入框也不用被它顶上去。
506
536
 
507
537
  代价是**每一页自己留出底部安全距离**,否则最后一行会被压在栏下面:
508
- · `.history__list` 已有 96px(够 74 + 34 safe-area)
538
+ · `.history__list` 已有 96px(够 56 + 34 safe-area,且给凸出的 + 留了一截)
509
539
  · 会话页由 composer 的下内边距负责,见下面的 `#conv .composer`
510
540
  · 启动页不显示底栏(showLauncher 里 hidden),无需留白
511
541
 
512
542
  min-height 而不是 height:底栏还要吞 safe-area(34px),
513
543
  border-box 下固定 height 会让内部 tab 被压到 26px 高。
514
- **56**(2026-10-22 定稿「底栏只保留图标不需要文字了」):
515
- 去掉标签后只剩 25px 图标 + 44×34 选中浅底,56 给到浅底上下各 11px,
516
- 栏体也矮下来 —— 对话区因此多出 18px。 */
544
+ **56 必须写成 `56 + safe-area`**(2026-10-22 实机:「手机上现在 tab 上下没间距」):
545
+ `*{box-sizing:border-box}` 下 min-height 含 padding,56 里先被 safe-area 吃掉 34,
546
+ 内容只剩 22 —— 44×34 的选中浅底直接顶到了栏的上下边。桌面上 env()=0 看不出这个问题。
547
+ **56**(「底栏只保留图标不需要文字了」):去掉标签后只剩 25px 图标 + 44×34 浅底,
548
+ 56 给到浅底上下各 11px,栏体也矮下来。 */
517
549
  .tabbar{position:absolute;left:0;right:0;bottom:0;z-index:60;
518
550
  display:flex;align-items:stretch;
519
- min-height:56px;
551
+ min-height:calc(56px + env(safe-area-inset-bottom));
520
552
  background:color-mix(in srgb,var(--raised) 86%,transparent);
521
553
  -webkit-backdrop-filter:blur(18px) saturate(1.4);backdrop-filter:blur(18px) saturate(1.4);
522
554
  box-shadow:0 -1px 0 var(--border),0 -14px 30px -26px var(--app-shadow);
@@ -548,15 +580,16 @@ button.setrow{font-family:inherit}
548
580
  于是看起来像按钮自带发光轮廓。
549
581
 
550
582
  实现:
551
- ① **洞**:`::before` 是一块悬在栏顶边界内的半圆(宽 74 / 高 37,下两角半径 37),
552
- 填槽底 `--fab-well`,在底栏上边缘切出一个半圆缺口。
553
- 高度只到 37,**不侵入上方内容**(试过整圆深度,会盖住列表底部)。
554
- ② **嵌**:按钮直径 68,圆心落在栏顶边线上(上露 34 / 下沉 34),
555
- 于是下半实打实坐进洞里。圆心压边线而不是抬高,是「嵌」与「放」的分界。
583
+ ① **洞**:`::before` 是一块 74×74 的圆(半径 37 = 按钮 34 + 3 接触缝),圆心与按钮同心,
584
+ **再用 `clip-path` 切掉落在栏顶边线以上的部分** —— 这样槽的形状永远跟着按钮走,
585
+ 按钮下沉多少,露出的就是那一段圆弧,不用为每种下沉量另算半圆尺寸。
586
+ 填槽底 `--fab-well`,于是在底栏上边缘切出一道弧。
587
+ ② **嵌**:按钮直径 68,圆心落在栏顶边线**下方 10px**(上露 24 / 下沉 44,距栏底还剩 12)。
588
+ 不是齐边切一半(2026-10-22 用户:「不需要突出半圆 出来一部分即可 再下沉一些 注意保留间距」)。
556
589
  ③ **接触影**:`radial-gradient` 把暗环**锚定在 r=34(按钮半径)**上,
557
- 从 33.5px 起最暗、到 37px 淡完。这是唯一能精确控制「暗在哪一圈」的写法 ——
590
+ 从 33.5px 起最暗、到 37px 淡完,圆心同样与按钮同心。这是唯一能精确控制「暗在哪一圈」的写法 ——
558
591
  `box-shadow: inset` 的暗边永远从元素边界向内,管不到元素外的那道槽。
559
- 几何全用相对量(`bottom:calc(100% - 34px)`、`top:0`),安全区变化时洞和按钮一起走。
592
+ 几何全用相对量(`bottom:calc(100% - 44px)`、`top:-27px`),安全区变化时洞和按钮一起走。
560
593
 
561
594
  **两页一致**:会话页不再把「+」收进栏内(2026-10-22 用户:「圆没有突出了 …两个效果要一致」)。
562
595
  凸出的圆在列表页和对话页长得完全一样,只用 composer 的下内边距给它让出空间。 */
@@ -567,22 +600,21 @@ button.setrow{font-family:inherit}
567
600
  接触影必须画在槽底**之上**,且暗环两端用硬锚点,
568
601
  避免模糊半径把亮度峰值推到按钮外面(那正是前几版读成光晕的原因)。
569
602
 
570
- **露出的槽宽只有 3px**(洞半径 37 − 按钮半径 34)。曾经用更大:那圈灰色槽底读作「按钮下面垫了个盘子」,
571
- 正是用户否掉的形态。3px 只够出一条接触缝 —— 看得见槽、看不清槽底,就等于"切出来的"。
572
- 洞的高也取 37 而不是更深:槽只要在栏顶边界内切出缺口,深了只是浪费。 */
603
+ **露出的槽宽只有 3px**(槽半径 37 − 按钮半径 34)。曾经用更大:那圈灰色槽底读作「按钮下面垫了个盘子」,
604
+ 正是用户否掉的形态。3px 只够出一条接触缝 —— 看得见槽、看不清槽底,就等于"切出来的"。 */
573
605
  .tabbar::before{content:"";position:absolute;z-index:1;pointer-events:none;
574
- left:50%;top:0;transform:translateX(-50%);
575
- width:74px;height:37px;
576
- border-radius:0 0 37px 37px;
606
+ left:50%;top:-27px;transform:translateX(-50%);
607
+ width:74px;height:74px;border-radius:50%;
608
+ clip-path:inset(27px 0 0 0); /* 圆心在栏顶下方 10px:切掉露在外面的那一截,只留栏内的弧 */
577
609
  background:
578
- radial-gradient(circle 37px at 50% 0,
610
+ radial-gradient(circle 37px at 50% 50%,
579
611
  var(--fab-notch) 33.5px,
580
612
  var(--fab-notch) 34.5px,
581
613
  color-mix(in srgb,var(--fab-notch) 35%,transparent) 36px,
582
614
  transparent 37px),
583
615
  var(--fab-well)}
584
616
  .tabfab__b{position:absolute;z-index:2;
585
- left:50%;bottom:calc(100% - 34px); /* 圆心钉在栏顶边线上:上露 34 / 下沉 34 */
617
+ left:50%;bottom:calc(100% - 44px); /* 上露 24 / 下沉 44,距栏底 12 */
586
618
  transform:translateX(-50%);
587
619
  width:68px;height:68px;
588
620
  display:flex;align-items:center;justify-content:center;
@@ -597,14 +629,14 @@ button.setrow{font-family:inherit}
597
629
  .tabfab__b:active{transform:translateX(-50%) translateY(2px);
598
630
  box-shadow:inset 0 -6px 12px -7px rgba(15,23,42,.56), inset 0 2px 3px -2px rgba(255,255,255,.26)}
599
631
  .tabfab__b svg{display:block;stroke-width:2.4;transform:translateY(-1px)}
600
- /* 会话页(2026-10-22 用户:「不要挤压对话框」「两个效果要一致」「圆可以更大」):
601
- 底栏浮起后不占位,「+」在**两页都凸出 34**(68 直径的一半),所以 composer 只需让出
602
- 「上探 34 + 呼吸 12 + 栏高 56」—— 输入框落在凸出圆的头顶 12px,不遮不压。
632
+ /* 会话页(2026-10-22 用户:「不要挤压对话框」「两个效果要一致」「圆可以更大」「再下沉一些 注意保留间距」):
633
+ 底栏浮起后不占位,「+」在**两页都上露 24**(68 直径、圆心在栏顶下方 10),所以 composer 只需让出
634
+ 「上露 24 + 呼吸 12 + 栏高 56」—— 输入框落在凸出圆弧上方 12px,不遮不压。
603
635
  原来 composer 下留 60(= 上探 30 + 呼吸 30)再叠占位的 68 栏,屏幕底到输入框 162;
604
- 现在 56 + 34 + 12 = 102,对话区多出 60px。
605
- 56 必须与 .tabbar 的 min-height 对齐(改栏高要一起改)。
636
+ 现在 56 + 24 + 12 = 92,对话区多出 70px。
637
+ 56 必须与 .tabbar 的 min-height 对齐、24 必须与 .tabfab__b 的下沉量对齐(改一处要一起改)。
606
638
  **只叠一次**:safe-area 由这里一次性算进来,别再给 .composer 的基础 padding 加一遍。 */
607
- #conv .composer{padding-bottom:calc(12px + 34px + 56px + env(safe-area-inset-bottom))}
639
+ #conv .composer{padding-bottom:calc(12px + 24px + 56px + env(safe-area-inset-bottom))}
608
640
 
609
641
  /* 组件挂载点:占满剩余高度 */
610
642
  #conv{flex:1;min-height:0;display:flex}
@@ -674,6 +706,9 @@ button.setrow{font-family:inherit}
674
706
  .qa-todo-ico{width:14px;height:14px;border-width:1.5px}
675
707
  .qa-todo-ico svg{width:9px;height:9px}
676
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}
677
712
  .qa-todo-toggle{width:26px;height:26px}
678
713
  .qa-todo-toggle svg{width:14px;height:14px}
679
714
 
@@ -852,8 +887,13 @@ button.setrow{font-family:inherit}
852
887
  <svg width="18" height="18"><use href="#i-back"/></svg><span>返回</span>
853
888
  </button>
854
889
  <h1 class="launcher__title">magic-agent · 远程控制</h1>
855
- <p class="launcher__desc">打开桌面配对页里的连接地址,远程继续你的会话。</p>
890
+ <p class="launcher__desc">填一个名字和桌面地址,或扫码配对。连过的桌面会保存下来,可在任务页标题处切换。</p>
856
891
 
892
+ <div class="field" id="nameField">
893
+ <label for="connName">名称(可选)</label>
894
+ <input id="connName" type="text" placeholder="例如 公司 Mac"
895
+ autocomplete="off" autocapitalize="off" spellcheck="false">
896
+ </div>
857
897
  <div class="field" id="field">
858
898
  <label for="addr">桌面地址</label>
859
899
  <textarea id="addr" rows="2" placeholder="http://192.168.1.5:8791/?token=…"
@@ -911,8 +951,6 @@ button.setrow{font-family:inherit}
911
951
  <div class="history__list">
912
952
  <div class="setcard">
913
953
  <div class="setrow"><span class="setrow__l">设备</span><span class="setrow__v" id="mineDevice">—</span></div>
914
- <!-- 环境(多连接):显式配置每台桌面的名字 / IP / 令牌,并在这里切换 -->
915
- <button class="setrow" id="btnEnvPage"><span class="setrow__l">环境</span><span class="setrow__v" id="mineEnv">—</span></button>
916
954
  <div class="setrow"><span class="setrow__l">主题</span>
917
955
  <span class="seg" id="segTheme">
918
956
  <button class="seg__b" data-v="system">系统</button>
@@ -933,13 +971,22 @@ button.setrow{font-family:inherit}
933
971
  <button class="seg__b" data-v="serial">串行</button>
934
972
  </span>
935
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>
936
984
  <!-- 默认引擎 / 模型:新建任务的初值(会话里输入框上方的级联切换会回写这里)。
937
985
  两行点开的是同一个「引擎 → 模型」二列选择器,模型必须挂在引擎下。 -->
938
986
  <button class="setrow" id="btnDefaultEngine"><span class="setrow__l">默认引擎</span><span class="setrow__v" id="defaultEngine">自动(首个可用)</span></button>
939
987
  <button class="setrow" id="btnDefaultModel"><span class="setrow__l">默认模型</span><span class="setrow__v" id="defaultModel">跟随引擎</span></button>
940
- <div class="setrow"><span class="setrow__l">触感反馈</span><button class="tgl" id="tglHaptic" aria-label="触感反馈"></button></div>
988
+ <!-- 2026-10-22 用户:「触感反馈 / 语言」这两项不需要 —— 已移除(设备级偏好只剩主题/字体)。 -->
941
989
  <div class="setrow"><span class="setrow__l">网络代理</span><button class="tgl" id="tglVpn" aria-label="网络代理"></button></div>
942
- <div class="setrow"><span class="setrow__l">语言</span><span class="setrow__v">跟随系统</span></div>
943
990
  <button class="setrow" id="btnNotify"><span class="setrow__l">消息通知设置</span><span class="setrow__v" id="notifyState">—</span></button>
944
991
  </div>
945
992
  <!-- 团队模式(2026-10-21):开关**按会话**给,放会话页右上角的运行设置里(与权限模式并排),
@@ -949,12 +996,15 @@ button.setrow{font-family:inherit}
949
996
  <button class="setrow" id="btnTeamPage"><span class="setrow__l">团队模式</span><span class="setrow__v" id="teamSummary">跟随主引擎</span></button>
950
997
  </div>
951
998
  <div class="setcard">
952
- <!-- 版本号(2026-10-22 用户:「移动端我的tab中显示版本号」)。
953
- 值由 /desk/health 的 version 给(= core 的 `--version`)—— 移动端界面是
954
- 桌面端 /m/ 远程加载的、本身不带版本,用户要看的是「连着的这份是哪一版」。
955
- 老 core 不给 version 时保持「—」,不写死。 -->
956
- <div class="setrow"><span class="setrow__l">版本</span><span class="setrow__v" id="mineVersion">—</span></div>
957
- <button class="setrow" id="btnUpgrade"><span class="setrow__l">检查更新</span><span class="setrow__v" id="upgradeState">重新加载</span></button>
999
+ <!-- 版本号 + 升级(2026-10-22 用户:「移动端版本跟着npm走」+「在版本号的地方
1000
+ 可以显示版本也可检测最新版本号并触发升级」)。
1001
+ 显示的是 **APK 自己的版本**(构建时从 magic-agent/package.json 注入,闸门页
1002
+ 通过 ?appv= 带进来);浏览器预览没有 appv 时才回落到桌面端 health.version。
1003
+ 点这一行就去问桌面端内置的 APK 是哪一版,新了就直接触发下载安装。 -->
1004
+ <button class="setrow" id="btnMineVersion" type="button">
1005
+ <span class="setrow__l">版本</span><span class="setrow__v" id="mineVersion">—</span>
1006
+ </button>
1007
+ <button class="setrow" id="btnUpgrade"><span class="setrow__l">重新加载界面</span><span class="setrow__v" id="upgradeState">重新加载</span></button>
958
1008
  </div>
959
1009
  <div class="setcard">
960
1010
  <button class="setrow setrow--danger" id="btnLogout">断开连接</button>
@@ -966,40 +1016,18 @@ button.setrow{font-family:inherit}
966
1016
  从「我的」那一行点进来。这里配的全都是**设备级偏好**(开关本身按会话,在会话页运行设置里)。
967
1017
  **只有一套划分**:计划 + 7 个角色(writer / researcher / reviewer / coder / designer /
968
1018
  director / builder),不再并列「6 个类别」那一层。
969
- 2026-10-22 用户:「配引擎和配规则要分开配置 不要在 1 个页面」——
970
- 于是**两张卡、两个入口**:上面「交给谁」点开只有引擎/模型;下面「规则」点开只有规则
971
- (计划 = 拆解口径,角色 = 这类任务适合什么)。两者互不掺杂。 -->
1019
+ 2026-10-22 用户:「两个触发就行了」—— 一行里**两个点击区**:
1020
+ 左边(名字 + 规则描述)点开只改规则;右边(引擎值)点开只选引擎。
1021
+ 不拆两张卡,也不把规则和引擎塞进同一个抽屉。 -->
972
1022
  <div class="history" id="teamPage" hidden>
973
1023
  <div class="history__top">
974
1024
  <button class="topbar__icon" id="btnTeamBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
975
1025
  <span class="history__title">团队模式</span>
976
1026
  </div>
977
1027
  <div class="history__list">
978
- <div class="history__group">交给谁(引擎 / 模型)</div>
1028
+ <div class="history__group">计划与 7 类子任务</div>
979
1029
  <div class="setcard" id="teamRouteCard"></div>
980
- <div class="history__group">规则(只影响计划怎么拆)</div>
981
- <div class="setcard" id="teamRuleCard"></div>
982
- <div class="history__hint" style="text-align:left">两张卡分开配:上面只选引擎,下面只改规则。留空 = 跟随主引擎 / 使用内置规则。</div>
983
- </div>
984
- </div>
985
-
986
- <!-- ══════════ 我的 → 环境(多连接:名字 / IP / 令牌)══════════
987
- 2026-10-22 用户:「环境没有地方配置 ip 和名字啊」。
988
- 环境不能只靠「连过才记得」:这里显式新建 / 改名 / 改地址 / 删,点一行就编辑。
989
- 地址填 IP:端口即可(不带 http:// 自动补),令牌可以写在地址的 ?token= 里,
990
- 也可以单独填下面那栏(显式令牌优先)。 -->
991
- <div class="history" id="envPage" hidden>
992
- <div class="history__top">
993
- <button class="topbar__icon" id="btnEnvBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
994
- <span class="history__title">环境</span>
995
- </div>
996
- <div class="history__list">
997
- <div class="history__group">已配置的桌面</div>
998
- <div class="setcard" id="envList"></div>
999
- <div class="history__actions" style="padding:10px 0 0">
1000
- <button class="newtask" id="btnEnvAdd" type="button"><svg width="18" height="18"><use href="#i-plus"/></svg><span>新建环境</span></button>
1001
- </div>
1002
- <div class="history__hint" style="text-align:left">环境 = 一台跑着 magic-client 的桌面。名字随你起(例如「公司 Mac」),地址填 IP:端口,例如 192.168.1.5:8791。保存后可在这里或任务页标题处切换。</div>
1030
+ <div class="history__hint" style="text-align:left">左边点规则,右边点引擎。规则留空 = 内置规则;引擎留空 = 跟随主引擎。</div>
1003
1031
  </div>
1004
1032
  </div>
1005
1033
 
@@ -1022,17 +1050,12 @@ button.setrow{font-family:inherit}
1022
1050
  </div>
1023
1051
 
1024
1052
  <!-- ══════════ 需求(会话自动聚合成的功能点文档)══════════
1025
- 数据全来自 GET /desk/requirements(core 的 --requirements,只读)。
1026
- 按「工作区 → 模块 → 功能点」两级分组;点一行用既有的阅读器打开那篇 markdown。
1027
- ⚠️ 这里**不做筛选胶囊与搜索**:第一版只要「看得见 + 能点开」,多了反而没人用
1028
- (用户 2026-10-22 定稿)。 -->
1029
- <div class="history" id="reqs" hidden>
1030
- <div class="history__top">
1031
- <span class="history__title">需求</span>
1032
- <button class="history__disconnect" id="btnReqSync" type="button">全部整理</button>
1033
- </div>
1034
- <div class="history__list" id="reqList"></div>
1035
- </div>
1053
+ **独立子页面**:内容、样式、逻辑都在 reqs.js / reqs.css,本文件只留两个空容器
1054
+ (隐藏/显示由底部 tab 控制)。数据形状、两级口径见 reqs.js 文件头。 -->
1055
+ <div class="history" id="reqs" hidden></div>
1056
+
1057
+ <!-- 需求的二级视图:只看所选模块的需求列表(从一级目录点模块进来;内容由 reqs.js 渲染) -->
1058
+ <div class="history" id="reqDetail" hidden></div>
1036
1059
 
1037
1060
  <!-- ══════════ 发现(浏览器 / 应用 / 插件 / SKILL)══════════ -->
1038
1061
  <div class="history" id="discover" hidden>
@@ -1158,7 +1181,7 @@ button.setrow{font-family:inherit}
1158
1181
  <nav class="tabbar" id="tabbar" hidden>
1159
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>
1160
1183
  <!-- 需求 tab 常驻:不做能力位控制(用户 2026-10-22:「移动端永远显示」)。
1161
- 老 core 上点进来会由 loadRequirements 的 501 分支给出明确文案,
1184
+ 老 core 上点进来会由 reqs.js 的 501 分支给出明确文案,
1162
1185
  不会是一片空白 —— 入口固定比入口忽隐忽现更好理解。 -->
1163
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>
1164
1187
  <span class="tabfab">
@@ -1192,6 +1215,9 @@ button.setrow{font-family:inherit}
1192
1215
  <script src="shared/host-glue.js"></script>
1193
1216
  <script src="shared/conversation.js"></script>
1194
1217
  <script src="shared/transport-http.js"></script>
1218
+ <!-- 需求页(独立子页面模块):只在这里加载并注册 window.MagicReqs,
1219
+ 真正的 DOM/逻辑都在 reqs.js,主 IIFE 末尾 mount 时把宿主能力注入进去。 -->
1220
+ <script src="reqs.js"></script>
1195
1221
  <script>
1196
1222
  (function () {
1197
1223
  'use strict';
@@ -1204,6 +1230,7 @@ button.setrow{font-family:inherit}
1204
1230
  var subtitleEl = document.getElementById('subtitle');
1205
1231
  var fieldEl = document.getElementById('field');
1206
1232
  var addrEl = document.getElementById('addr');
1233
+ var connNameEl = document.getElementById('connName');
1207
1234
  var errEl = document.getElementById('err');
1208
1235
  var recentEl = document.getElementById('recent');
1209
1236
  var recentListEl = document.getElementById('recentList');
@@ -1216,17 +1243,13 @@ button.setrow{font-family:inherit}
1216
1243
  var taskSearchInput = document.getElementById('taskSearchInput');
1217
1244
  var mineEl = document.getElementById('mine');
1218
1245
  var mineDevice = document.getElementById('mineDevice');
1219
- var mineEnv = document.getElementById('mineEnv');
1220
- var envPageEl = document.getElementById('envPage');
1221
- var envListEl = document.getElementById('envList');
1222
1246
  var mineVersion = document.getElementById('mineVersion');
1223
1247
  var segTheme = document.getElementById('segTheme');
1224
1248
  var segFont = document.getElementById('segFont');
1225
1249
  var segMode = document.getElementById('segMode');
1226
- var tglHaptic = document.getElementById('tglHaptic');
1227
1250
  var tabbarEl = document.getElementById('tabbar');
1228
1251
  var reqsEl = document.getElementById('reqs');
1229
- var reqListEl = document.getElementById('reqList');
1252
+ var reqDetailEl = document.getElementById('reqDetail');
1230
1253
  var discoverEl = document.getElementById('discover');
1231
1254
  var teamPageEl = document.getElementById('teamPage');
1232
1255
  var runSettingsPageEl = document.getElementById('runSettingsPage');
@@ -1259,7 +1282,7 @@ button.setrow{font-family:inherit}
1259
1282
  /* 工作空间要跨重启记住(与引擎 / 模型同一口径):只放内存的话,App 重启后
1260
1283
  「任务运行设置」里选的空间就回到默认,用户会以为没保存。 */
1261
1284
  try { WS = localStorage.getItem('magic.ws') || ''; } catch (e) {}
1262
- function saveWs() { try { localStorage.setItem('magic.ws', WS || ''); } catch (e) {} }
1285
+ function saveWs() { try { localStorage.setItem('magic.ws', WS || ''); } catch (e) {} schedulePushPrefs(); }
1263
1286
  /* 默认引擎 / 模型(「我的」可改;函数与选择器见下面「默认引擎 / 模型」段)。
1264
1287
  提前到这里声明:首屏 paintRunPref 会读它,晚于 PREFS 初始化赋值就会踩 undefined。 */
1265
1288
  var RUN_PREF = loadRunPref();
@@ -1366,6 +1389,7 @@ button.setrow{font-family:inherit}
1366
1389
  try { localStorage.setItem('magic.team.split', TEAM.split || 'standard'); } catch (e) {}
1367
1390
  try { localStorage.setItem('magic.team.splitcode', TEAM.splitCode ? '1' : '0'); } catch (e) {}
1368
1391
  try { localStorage.setItem('magic.team.adaptive', TEAM.adaptive === false ? '0' : '1'); } catch (e) {}
1392
+ schedulePushPrefs();
1369
1393
  paintTeamPref();
1370
1394
  }
1371
1395
  /* 这条会话的开关:本地记过就用本地;没记过 → 跟会话自己的运行模式(core 的 `mode:"team"`)。
@@ -1394,6 +1418,21 @@ button.setrow{font-family:inherit}
1394
1418
  var LS_KEY = 'magic.remote.recent.v1'; // 旧版「最近连接」:只在首次升级时搬一次
1395
1419
  var ACTIVE_ID = '';
1396
1420
  var CONN_SEQ = 0; // 每连一次 +1:上一台桌面迟到的响应不许画到这一台上面
1421
+ /* APK 自己的版本(2026-10-22):闸门页从构建时注入的版本号带进来(?appv=)。
1422
+ 为什么接口里还要 SERVER_VERSION:浏览器预览 / 旧版闸门页没有 appv,此时只能显示
1423
+ 连着的桌面端版本;两个都有时以 App 版本为准(用户问的「移动端版本」)。 */
1424
+ var APP_VERSION = (function () {
1425
+ try {
1426
+ var v = new URL(location.href).searchParams.get('appv');
1427
+ if (v && /^\d+\.\d+\.\d+$/.test(v)) return v;
1428
+ } catch (e) {}
1429
+ /* 兜底:本地 ui.html(桌面端没有 /m/ 时)由 boot.js 注入。 */
1430
+ try {
1431
+ var b = String(window.MAGIC_APP_VERSION || '');
1432
+ if (/^\d+\.\d+\.\d+$/.test(b)) return b;
1433
+ } catch (e) {}
1434
+ return '';
1435
+ })();
1397
1436
 
1398
1437
  function connHost(url) {
1399
1438
  try { return new URL(url).host; }
@@ -1438,28 +1477,44 @@ button.setrow{font-family:inherit}
1438
1477
  var next = {
1439
1478
  id: id, url: t.url || '', token: t.token || '',
1440
1479
  name: (t.name != null && t.name !== '') ? t.name : (old.name || ''),
1441
- host: connHost(t.url), app: old.app || '', at: Date.now()
1480
+ host: connHost(t.url), app: old.app || '', at: Date.now(),
1481
+ seed: !!old.seed // 种出来的默认环境:改名/改地址后仍然认出它
1442
1482
  };
1443
1483
  list.unshift(next);
1444
1484
  saveConns(list);
1445
1485
  return next;
1446
1486
  }
1447
- function removeConn(id) { saveConns(loadConns().filter(function (c) { return c.id !== id; })); }
1487
+ function removeConn(id) {
1488
+ var list = loadConns();
1489
+ var hit = null;
1490
+ list.forEach(function (c) { if (c && c.id === id) hit = c; });
1491
+ /* 用户主动删掉种出来的默认环境:记一笔,下次启动别再自动加回来。 */
1492
+ if (hit && hit.seed) { try { localStorage.setItem('magic.remote.seedoff.v1', '1'); } catch (e) {} }
1493
+ saveConns(list.filter(function (c) { return c.id !== id; }));
1494
+ }
1448
1495
  function clearConns() {
1449
1496
  saveConns([]);
1450
- try { localStorage.removeItem(LS_KEY); } catch (e) {}
1497
+ try {
1498
+ localStorage.removeItem(LS_KEY);
1499
+ localStorage.setItem('magic.remote.seedoff.v1', '1'); // 清空 = 明确不要默认环境
1500
+ } catch (e) {}
1451
1501
  }
1452
1502
 
1453
1503
  /* 当前连接:先按地址 + 令牌找;App 壳注入的连接还没落盘时给一条临时记录,
1454
- 这样标题与菜单里仍然看得见「现在连的是谁」。 */
1504
+ 这样标题与菜单里仍然看得见「现在连的是谁」。
1505
+ ⚠️ 壳里 BASE 可能是空串(同源):这时用 location.origin 当它的地址 ——
1506
+ 否则这条「未命名」没有 URL,既存不下来也就没法编辑(2026-10-22 用户:
1507
+ 「现在已有的未命名都编辑不了」)。 */
1508
+ function connUrlNow() { return BASE || (TOKEN ? location.origin : ''); }
1455
1509
  function activeConn() {
1510
+ var url = connUrlNow();
1456
1511
  var list = loadConns(), i;
1457
1512
  for (i = 0; i < list.length; i++) {
1458
- if (list[i].url === BASE && String(list[i].token || '') === String(TOKEN || '')) return list[i];
1513
+ if (list[i].url === url && String(list[i].token || '') === String(TOKEN || '')) return list[i];
1459
1514
  }
1460
1515
  for (i = 0; i < list.length; i++) if (ACTIVE_ID && list[i].id === ACTIVE_ID) return list[i];
1461
- if (BASE || TOKEN) {
1462
- return { id: connIdOf({ url: BASE, token: TOKEN }), url: BASE, token: TOKEN, name: '', host: connHost(BASE), app: '' };
1516
+ if (url || TOKEN) {
1517
+ return { id: connIdOf({ url: url, token: TOKEN }), url: url, token: TOKEN, name: '', host: connHost(url), app: '' };
1463
1518
  }
1464
1519
  return null;
1465
1520
  }
@@ -1468,7 +1523,219 @@ button.setrow{font-family:inherit}
1468
1523
  if (!c) return '';
1469
1524
  return c.name || c.app || c.host || connHost(c.url);
1470
1525
  }
1471
- function envLabel() { return connLabel(activeConn()) || histSubtitle || ''; }
1526
+ function envLabel() {
1527
+ var c = activeConn();
1528
+ if (!c) return histSubtitle || '';
1529
+ /* 壳注入的同源连接(还没有名字):标题仍显示「本机」,别把 127.0.0.1:端口 顶到标题上。 */
1530
+ if (!c.name && !c.app && String(c.url) === String(location.origin)) return histSubtitle || '本机';
1531
+ return connLabel(c) || histSubtitle || '';
1532
+ }
1533
+
1534
+ /* ── 默认环境:把「当前正在用的这条连接」落成一条可编辑的环境 ──
1535
+ 2026-10-22 用户:「再添加一个现在远程的地址,名字就叫公司远程」。
1536
+ 壳注入的连接(URL 里的 ?token=)如果不落盘,列表里就只有一条「未命名」的临时记录,
1537
+ 既改不了名也留不住。这里在首屏把它存成环境:本机回环叫「公司本地」,
1538
+ 局域网地址叫「公司远程」,都可在铅笔里改。
1539
+ ⚠️ App 每次启动会换令牌:按 seed 标记 + 地址更新那一条,不要每次多出一条。 */
1540
+ var SEED_NAME = '公司远程';
1541
+ var SEED_LOCAL = '公司本地';
1542
+ function isLoopUrl(u) {
1543
+ try {
1544
+ var h = String(u || '');
1545
+ return /^https?:\/\/(127\.|localhost|\[::1\]|0\.0\.0\.0)/i.test(h);
1546
+ } catch (e) { return false; }
1547
+ }
1548
+ function seedMark(rec) {
1549
+ var l2 = loadConns();
1550
+ for (var j = 0; j < l2.length; j++) if (l2[j].id === rec.id) l2[j].seed = true;
1551
+ saveConns(l2);
1552
+ }
1553
+ function seedCurrentConn() {
1554
+ var url = connUrlNow();
1555
+ if (!url || !TOKEN) return;
1556
+ try { if (localStorage.getItem('magic.remote.seedoff.v1') === '1') return; } catch (e) {}
1557
+ var name = isLoopUrl(url) ? SEED_LOCAL : SEED_NAME;
1558
+ var list = loadConns();
1559
+ for (var i = 0; i < list.length; i++) {
1560
+ var c = list[i];
1561
+ if (!c) continue;
1562
+ if (c.url === url && String(c.token || '') === String(TOKEN || '')) {
1563
+ /* 早先版本把回环连接也叫「公司远程」(同地址种子没带名字):按地址纠正,
1564
+ 但不碰用户自己改过的名字。 */
1565
+ if (c.seed && isLoopUrl(url) && c.name === SEED_NAME) { c.name = SEED_LOCAL; saveConns(list); }
1566
+ return; // 已经有这条
1567
+ }
1568
+ if (c.seed && c.url === url) {
1569
+ c.token = TOKEN;
1570
+ c.id = connIdOf({ url: url, token: TOKEN });
1571
+ c.at = Date.now();
1572
+ saveConns(list);
1573
+ return;
1574
+ }
1575
+ }
1576
+ seedMark(upsertConn({ url: url, token: TOKEN, name: name }));
1577
+ }
1578
+ /* 桌面 health 上报的远程入口:优先公网(cloudflared,不在同一 Wi-Fi 也能连),
1579
+ 没有再退回局域网。用户不用手抄地址。同地址只留一条;令牌跟着当前连接走。 */
1580
+ function seedRemoteConns(urls) {
1581
+ if (!urls || !urls.length || !TOKEN) return;
1582
+ try { if (localStorage.getItem('magic.remote.seedoff.v1') === '1') return; } catch (e) {}
1583
+ var url = String(urls[0] || '').replace(/\/+$/, '');
1584
+ if (!url) return;
1585
+ if (url === String(connUrlNow()).replace(/\/+$/, '')) return;
1586
+ var list = loadConns();
1587
+ /* 隧道换域名(cloudflared 每次重启都换):把上一枚**默认命名**的种子远程记录换到新地址。
1588
+ 用户改过名字的不动 —— 那条是他自己配的环境。 */
1589
+ var kept = [];
1590
+ for (var k = 0; k < list.length; k++) {
1591
+ var o = list[k];
1592
+ if (o && o.seed && o.name === SEED_NAME && o.url !== url) continue;
1593
+ kept.push(o);
1594
+ }
1595
+ if (kept.length !== list.length) saveConns(kept);
1596
+ list = loadConns();
1597
+ for (var i = 0; i < list.length; i++) {
1598
+ var c = list[i];
1599
+ if (!c) continue;
1600
+ if (c.url === url && String(c.token || '') === String(TOKEN)) {
1601
+ if (c.seed && c.name === SEED_LOCAL) { c.name = SEED_NAME; saveConns(list); }
1602
+ return;
1603
+ }
1604
+ if (c.seed && c.url === url) {
1605
+ c.token = TOKEN;
1606
+ c.id = connIdOf({ url: url, token: TOKEN });
1607
+ c.at = Date.now();
1608
+ saveConns(list);
1609
+ return;
1610
+ }
1611
+ }
1612
+ seedMark(upsertConn({ url: url, token: TOKEN, name: SEED_NAME }));
1613
+ }
1614
+
1615
+ /* ── 设备现场(按环境隔离)──
1616
+ 2026-10-22 用户:「切换环境后 所有东西都要跟着变 包括会话 需求 我的这些 是选择环境上的东西」。
1617
+ 会话与需求本来就来自桌面端(切完重拉);这里解决的是**留在本机的那部分状态**:
1618
+ 主题 / 字体 / 任务模式 / 默认引擎与模型 / 团队配置 / 当前空间 / 置顶 / 通知 / 浏览器收藏。
1619
+ 做法:切环境时把这一组键快照到「按地址分桶」的槽里,再把目标环境的槽写回来,然后整页重载
1620
+ —— 内存态全部重新初始化,不留上一台机器的影子。
1621
+ ⚠️ 分桶键用 host:port 而不是 url|token:token 会随 App 重启变,按 token 分桶等于每次都丢现场。 */
1622
+ var ENV_SCOPE_KEYS = [
1623
+ 'magic.ws', 'magic.taskmode', 'magic.hosting', 'magic.engine', 'magic.model',
1624
+ 'magic.theme', 'magic.font', 'magic.notify',
1625
+ 'magic.pinned', 'magic.spaces', 'magic.browser.stars.v1',
1626
+ 'magic.team.sessions.v1', 'magic.team.routes.v1', 'magic.team.rules.v1',
1627
+ 'magic.team.parallel', 'magic.team.split', 'magic.team.splitcode', 'magic.team.adaptive'
1628
+ ];
1629
+ var ENV_SCOPE_PREFIX = 'magic.envscope.v1.';
1630
+ var ACTIVE_ENV_KEY = 'magic.remote.active.v1'; // 上次停在哪个环境(url);重载后据此恢复
1631
+ /* 正在切环境:此时全局键已经被 applyEnvScope 换成**目标环境**的值,
1632
+ pagehide(reload 会触发)绝对不能再把这份值写回「旧环境」的槽 —— 那会把旧现场冲成空。 */
1633
+ var ENV_SWITCHING = false;
1634
+ function envScopeHash(s) {
1635
+ var h = 5381, str = String(s || '');
1636
+ for (var i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) >>> 0;
1637
+ return h.toString(36);
1638
+ }
1639
+ function envScopeIdOf(conn) {
1640
+ var host = connHost((conn && conn.url) || connUrlNow());
1641
+ return host ? envScopeHash(host) : '';
1642
+ }
1643
+ function envScopeId() { return envScopeIdOf(activeConn()); }
1644
+ /* 把当前这组键存进 id 的槽(切走之前调用)。 */
1645
+ function saveEnvScope(id) {
1646
+ if (!id) return;
1647
+ var o = {};
1648
+ for (var i = 0; i < ENV_SCOPE_KEYS.length; i++) {
1649
+ var v = null;
1650
+ try { v = localStorage.getItem(ENV_SCOPE_KEYS[i]); } catch (e) {}
1651
+ if (v !== null) o[ENV_SCOPE_KEYS[i]] = v;
1652
+ }
1653
+ try { localStorage.setItem(ENV_SCOPE_PREFIX + id, JSON.stringify(o)); } catch (e) {}
1654
+ }
1655
+ /* 把 id 的槽写回全局键;槽里没有的键删掉 —— 那台机器没改过的项回到默认,不继承上一台。 */
1656
+ function applyEnvScope(id) {
1657
+ var o = {};
1658
+ if (id) {
1659
+ try { o = JSON.parse(localStorage.getItem(ENV_SCOPE_PREFIX + id) || '{}') || {}; } catch (e) { o = {}; }
1660
+ }
1661
+ for (var i = 0; i < ENV_SCOPE_KEYS.length; i++) {
1662
+ var k = ENV_SCOPE_KEYS[i];
1663
+ try {
1664
+ if (o[k] === undefined || o[k] === null) localStorage.removeItem(k);
1665
+ else localStorage.setItem(k, o[k]);
1666
+ } catch (e) {}
1667
+ }
1668
+ }
1669
+
1670
+ /* ── 环境配置的远端存储(2026-10-22 用户:「环境的配置应该在远程上 不是浏览器缓存
1671
+ 否则同一个环境多个客户端配置还不一致」)──
1672
+ 这台桌面怎么干活(默认引擎/模型、团队配置、空间选择、置顶)属于**环境**,
1673
+ 不属于某台手机:两个客户端连同一台桌面必须看到同一套配置。
1674
+ 所以这组键以 `/desk/prefs` 为准,localStorage 只当离线缓存:
1675
+ · 连上后拉一次,远端是权威 → 有差异就覆盖本地并重载一次;
1676
+ · 本地任何一处改了,防抖推回远端。
1677
+ 纯设备偏好(主题/字体/通知)**不同步** —— 那是手机自己的事。 */
1678
+ var PREF_SYNC_KEYS = [
1679
+ 'magic.engine', 'magic.model', 'magic.ws', 'magic.spaces', 'magic.pinned',
1680
+ 'magic.team.sessions.v1', 'magic.team.routes.v1', 'magic.team.rules.v1',
1681
+ 'magic.team.parallel', 'magic.team.split', 'magic.team.splitcode', 'magic.team.adaptive'
1682
+ ];
1683
+ var PREFS_PUSH_TIMER = 0, PREFS_PULLED = false, PREFS_PULLING = false;
1684
+ function collectPrefs() {
1685
+ var o = {};
1686
+ for (var i = 0; i < PREF_SYNC_KEYS.length; i++) {
1687
+ var v = null;
1688
+ try { v = localStorage.getItem(PREF_SYNC_KEYS[i]); } catch (e) {}
1689
+ if (v !== null) o[PREF_SYNC_KEYS[i]] = v;
1690
+ }
1691
+ return o;
1692
+ }
1693
+ function pushPrefs() {
1694
+ if (!TOKEN) return;
1695
+ try {
1696
+ fetch(BASE + '/desk/prefs', {
1697
+ method: 'PUT',
1698
+ headers: { 'X-Magic-Token': TOKEN, 'Content-Type': 'application/json' },
1699
+ body: JSON.stringify(collectPrefs())
1700
+ }).catch(function () {});
1701
+ } catch (e) {}
1702
+ }
1703
+ function schedulePushPrefs() {
1704
+ if (!TOKEN) return;
1705
+ if (PREFS_PUSH_TIMER) clearTimeout(PREFS_PUSH_TIMER);
1706
+ PREFS_PUSH_TIMER = setTimeout(function () { PREFS_PUSH_TIMER = 0; pushPrefs(); }, 700);
1707
+ }
1708
+ /* 拉一次远端配置。远端是空对象 = 这台桌面还没存过 → 把本地这份推上去(迁移),
1709
+ 不反过来把本地清空 —— 老用户第一次升级时不能丢配置。 */
1710
+ function pullPrefs() {
1711
+ if (!TOKEN || PREFS_PULLING) return;
1712
+ PREFS_PULLING = true;
1713
+ var seq = CONN_SEQ;
1714
+ fetch(BASE + '/desk/prefs', { headers: { 'X-Magic-Token': TOKEN } })
1715
+ .then(function (r) { return r.ok ? r.json() : null; })
1716
+ .then(function (v) {
1717
+ PREFS_PULLING = false;
1718
+ if (seq !== CONN_SEQ) return;
1719
+ if (!v || typeof v !== 'object' || Array.isArray(v)) { PREFS_PULLED = true; return; }
1720
+ if (!Object.keys(v).length) { PREFS_PULLED = true; pushPrefs(); return; }
1721
+ var changed = false;
1722
+ for (var i = 0; i < PREF_SYNC_KEYS.length; i++) {
1723
+ var k = PREF_SYNC_KEYS[i];
1724
+ var cur = null;
1725
+ try { cur = localStorage.getItem(k); } catch (e) {}
1726
+ var want = Object.prototype.hasOwnProperty.call(v, k) ? String(v[k]) : null;
1727
+ if (want === cur) continue;
1728
+ try {
1729
+ if (want === null) localStorage.removeItem(k);
1730
+ else localStorage.setItem(k, want);
1731
+ } catch (e) {}
1732
+ changed = true;
1733
+ }
1734
+ PREFS_PULLED = true;
1735
+ if (changed) { try { location.reload(); } catch (e) {} }
1736
+ })
1737
+ .catch(function () { PREFS_PULLING = false; PREFS_PULLED = true; });
1738
+ }
1472
1739
 
1473
1740
  /* 桌面 health 回来的应用名(app)补进当前连接:它比 host 更像「这台设备是谁」。 */
1474
1741
  function noteActiveMeta(meta) {
@@ -1524,23 +1791,24 @@ button.setrow{font-family:inherit}
1524
1791
  }
1525
1792
  /* 换环境 = 换一台桌面:对话、轮询、运行态都属于旧连接,必须一起收掉。
1526
1793
  排队任务按令牌分别落库(见 QUEUE_KEY),旧环境那批留在原地,切回来再接着跑。 */
1794
+ /* 换环境 = 换一台桌面:会话 / 需求 / 我的设置全都属于那台机器。
1795
+ 做法分两步:① 把当前这台的本机状态快照进它的槽,再把目标环境的槽写回来;
1796
+ ② 记住「停在哪个环境」并整页重载 —— 重载后内存态从零初始化,按目标环境的
1797
+ 连接与设置重建界面(会话、需求都是重载后从目标桌面现拉的)。
1798
+ ⚠️ 不在这里手写「清哪些变量」:那种清单永远会漏(漏一个就带着上一台的数据)。 */
1527
1799
  function switchEnv(c) {
1528
1800
  if (!c) return;
1529
- if (c.url === BASE && String(c.token || '') === String(TOKEN || '')) {
1801
+ if (c.url === connUrlNow() && String(c.token || '') === String(TOKEN || '')) {
1530
1802
  toast('已在「' + (connLabel(c) || '当前环境') + '」');
1531
1803
  return;
1532
1804
  }
1533
- if (conv) { try { conv.destroy(); } catch (e) {} conv = null; }
1534
- convEl.innerHTML = '';
1535
- stopPoll();
1536
- PENDING = []; ASK_WAITING = []; SERIAL = {}; QUEUE_RESTORED = false;
1537
- SERVER_VERSION = ''; CORE_FEATURES = []; TEAM_ROLES = []; ENGINES = []; ENGINES_STALE_AT = '';
1538
- TITLES = {}; LAST_SESSIONS = []; DW = ''; DW_READY = false; ROOT_DIRS = [];
1539
- /* 工作空间属于某台机器的目录树:沿用旧路径会落到不存在的目录,一律回到默认。 */
1540
- WS = '';
1541
- saveWs();
1542
- connect({ url: c.url, token: c.token, name: c.name, host: c.host || connHost(c.url) });
1543
- toast('已切换到「' + (connLabel(c) || connHost(c.url)) + '」');
1805
+ ENV_SWITCHING = true;
1806
+ saveEnvScope(envScopeId()); // 旧环境的现场
1807
+ applyEnvScope(envScopeIdOf(c)); // 新环境的现场
1808
+ var rec = upsertConn(c); // 保证这条在册(临时记录也能落盘)
1809
+ try { localStorage.setItem(ACTIVE_ENV_KEY, rec.url || c.url || ''); } catch (e) {}
1810
+ toast('正在切换到「' + (connLabel(c) || connHost(c.url)) + '」');
1811
+ try { location.reload(); } catch (e) { location.href = location.href; }
1544
1812
  }
1545
1813
  function openEnvMenu() {
1546
1814
  if (envMenuEl) { closeEnvMenu(); return; }
@@ -1557,7 +1825,7 @@ button.setrow{font-family:inherit}
1557
1825
  var active = !!(cur && c.id === cur.id);
1558
1826
  var b = document.createElement('button');
1559
1827
  b.type = 'button';
1560
- b.className = 'envmenu__item';
1828
+ b.className = 'envmenu__item' + (active ? ' is-active' : '');
1561
1829
  var ico = document.createElement('span');
1562
1830
  ico.className = 'envmenu__ico';
1563
1831
  ico.appendChild(svgUse('#i-laptop', 20));
@@ -1575,29 +1843,29 @@ button.setrow{font-family:inherit}
1575
1843
  var csub = (cname && cname !== chost) ? chost : '';
1576
1844
  col.appendChild(elSpan('envmenu__s', (csub ? csub + ' · ' : '') + (active ? '当前连接' : '点击切换')));
1577
1845
  b.appendChild(col);
1578
- if (active) {
1579
- var ck = document.createElement('span');
1580
- ck.className = 'envmenu__check';
1581
- ck.appendChild(svgUse('#i-check', 18));
1582
- b.appendChild(ck);
1583
- }
1846
+ /* 每行一枚「修改」:改名 / 改链接不用绕去「我的」。行本身仍是切换。 */
1847
+ var ed = document.createElement('span');
1848
+ ed.className = 'envmenu__edit';
1849
+ ed.setAttribute('role', 'button');
1850
+ ed.setAttribute('aria-label', '编辑环境');
1851
+ ed.appendChild(svgUse('#i-edit', 18));
1852
+ ed.onclick = function (e) { e.stopPropagation(); closeEnvMenu(); openEnvEditor(c); };
1853
+ b.appendChild(ed);
1584
1854
  b.onclick = function () { closeEnvMenu(); switchEnv(c); };
1585
1855
  envMenuEl.appendChild(b);
1586
1856
  });
1587
1857
  envMenuEl.appendChild(elSpan('envmenu__sep', ''));
1588
- /* 两个入口必须分开:新建环境 = 手填名字 + IP;扫码/粘贴 = 用配对页给的链接直接连。
1589
- 以前只有后者,「环境没有地方配置 ip 和名字」(2026-10-22 用户)。 */
1590
- function menuAct(label, fn) {
1858
+ /* 只留一个「添加环境」:扫码在启动页,改名改链接走每行的铅笔图标。 */
1859
+ function menuAct(icon, label, fn) {
1591
1860
  var b = document.createElement('button');
1592
1861
  b.type = 'button';
1593
1862
  b.className = 'envmenu__add';
1594
- b.appendChild(svgUse('#i-plus', 18));
1863
+ b.appendChild(svgUse(icon, 18));
1595
1864
  b.appendChild(elSpan('', label));
1596
1865
  b.onclick = function () { closeEnvMenu(); fn(); };
1597
1866
  envMenuEl.appendChild(b);
1598
1867
  }
1599
- menuAct('环境设置(名字 / IP)', function () { showEnvPage('tasks'); });
1600
- menuAct('扫码或粘贴连接地址', function () { showLauncher(); });
1868
+ menuAct('#i-plus', '添加环境', function () { openEnvEditor(null); });
1601
1869
  document.body.appendChild(envScrimEl);
1602
1870
  document.body.appendChild(envMenuEl);
1603
1871
  var btn = document.getElementById('btnEnvSwitch');
@@ -1608,53 +1876,25 @@ button.setrow{font-family:inherit}
1608
1876
  btn.classList.add('is-open');
1609
1877
  }
1610
1878
 
1611
- /* ── 环境管理(我的 → 环境):显式配置名字 / IP / 令牌 ──
1612
- 2026-10-22 用户:「环境没有地方配置 ip 和名字啊」——
1613
- 扫码只是「连上并记住」,不是配置入口。这里补上增删改。 */
1614
- function showEnvPage(from) {
1615
- hideAll(); envPageEl.hidden = false; tabbarEl.hidden = true;
1616
- ENV_BACK = from === 'tasks' ? 'tasks' : 'mine';
1617
- renderEnvPage();
1618
- }
1619
- var ENV_BACK = 'mine'; // 环境页的返回目标:从任务页标题进来就回任务页,从「我的」进来就回「我的」
1620
- function renderEnvPage() {
1621
- if (!envListEl) return;
1622
- envListEl.innerHTML = '';
1623
- var list = loadConns();
1624
- if (!list.length) {
1625
- envListEl.appendChild(hEl('div', 'history__hint', '还没有配置环境。点下面的「新建环境」,填名字和 IP 即可。'));
1626
- return;
1627
- }
1628
- var cur = activeConn();
1629
- list.forEach(function (c) {
1630
- var b = hEl('button', 'setrow');
1631
- b.type = 'button';
1632
- var main = hEl('span', 'setrow__main');
1633
- main.appendChild(hEl('span', 'setrow__l', connLabel(c) || '未命名'));
1634
- main.appendChild(hEl('span', 'setrow__sub',
1635
- (c.host || connHost(c.url)) + (cur && c.id === cur.id ? ' · 当前连接' : '')));
1636
- b.appendChild(main);
1637
- b.appendChild(hEl('span', 'setrow__v', '编辑'));
1638
- b.onclick = function () { openEnvEditor(c); };
1639
- envListEl.appendChild(b);
1640
- });
1641
- }
1642
- /* conn 为空 = 新建;否则编辑(名字、地址、令牌都能改,也能删)。 */
1879
+ /* ── 环境编辑(任务页标题菜单里的铅笔 / 添加环境):名字 / 链接 / 令牌 ──
1880
+ 2026-10-22 用户:「我的中的环境配置就不需要了」—— 管理入口只留在标题菜单里,
1881
+ 不再单独做一页。 */
1882
+ /* conn 为空 = 新建;否则编辑(名字、链接、令牌都能改,也能删)。 */
1643
1883
  function openEnvEditor(conn) {
1644
1884
  var isNew = !conn;
1645
- openHostSheet(isNew ? '新建环境' : '编辑环境', function (body) {
1885
+ openHostSheet(isNew ? '添加环境' : '编辑环境', function (body) {
1646
1886
  var nameEl = hEl('input', 'envfield');
1647
1887
  nameEl.type = 'text';
1648
1888
  nameEl.placeholder = '例如 公司 Mac';
1649
1889
  nameEl.value = (conn && conn.name) || '';
1650
1890
  var addrEl = hEl('input', 'envfield');
1651
1891
  addrEl.type = 'text';
1652
- addrEl.placeholder = '192.168.1.5:8791';
1892
+ /* 「链接」= 桌面配对页给的那条地址,可整条粘进来(含 ?token=…)。 */
1893
+ addrEl.placeholder = 'http://192.168.1.5:8791/?token=…';
1653
1894
  addrEl.setAttribute('autocapitalize', 'off');
1654
1895
  addrEl.setAttribute('autocomplete', 'off');
1655
1896
  addrEl.setAttribute('spellcheck', 'false');
1656
- /* 地址栏显示不带 http:// 的短形态(用户脑子里就是 IP:端口);https 原样保留。 */
1657
- addrEl.value = conn && conn.url ? String(conn.url).replace(/^http:\/\//i, '') : '';
1897
+ addrEl.value = (conn && conn.url) ? String(conn.url) : '';
1658
1898
  var tokEl = hEl('input', 'envfield');
1659
1899
  tokEl.type = 'text';
1660
1900
  tokEl.placeholder = '可选,配对令牌';
@@ -1664,15 +1904,26 @@ button.setrow{font-family:inherit}
1664
1904
  tokEl.value = (conn && conn.token) || '';
1665
1905
  body.appendChild(hEl('label', 'envlabel', '名称'));
1666
1906
  body.appendChild(nameEl);
1667
- body.appendChild(hEl('label', 'envlabel', '地址(IP:端口)'));
1907
+ body.appendChild(hEl('label', 'envlabel', '链接'));
1668
1908
  body.appendChild(addrEl);
1909
+ /* 扫码填入:启动页不再出现在主流程里之后,这里是唯一的扫码入口。 */
1910
+ var scanB = hEl('button', 'envscan', '扫码填入');
1911
+ scanB.type = 'button';
1912
+ scanB.onclick = function () {
1913
+ startScan(function (raw) {
1914
+ addrEl.value = raw;
1915
+ var t = parseTarget(raw);
1916
+ if (t && t.token) tokEl.value = t.token;
1917
+ }, function (m) { err.textContent = m; err.hidden = false; });
1918
+ };
1919
+ body.appendChild(scanB);
1669
1920
  body.appendChild(hEl('label', 'envlabel', '令牌'));
1670
1921
  body.appendChild(tokEl);
1671
1922
  var err = hEl('div', 'field__err');
1672
1923
  err.hidden = true;
1673
1924
  body.appendChild(err);
1674
1925
  body.appendChild(hEl('div', 'envhint',
1675
- '地址也可以带 ?token=…;下面这栏单独填时优先。填好保存后可在这里或任务页标题处切换。'));
1926
+ '链接从桌面配对页复制,可以带 ?token=…;令牌栏单独填时优先。'));
1676
1927
  var acts = hEl('div', 'envacts');
1677
1928
  var saveB = hEl('button', 'btn btn--filled', '保存');
1678
1929
  var goB = hEl('button', 'btn btn--outlined', '保存并连接');
@@ -1690,7 +1941,7 @@ button.setrow{font-family:inherit}
1690
1941
  err.hidden = true;
1691
1942
  var raw = String(addrEl.value || '').trim();
1692
1943
  var t = parseTarget(raw);
1693
- if (!t) { err.textContent = '请填地址,例如 192.168.1.5:8791'; err.hidden = false; return null; }
1944
+ if (!t) { err.textContent = '请填链接,例如 http://192.168.1.5:8791/?token=…'; err.hidden = false; return null; }
1694
1945
  var tok = String(tokEl.value || '').trim();
1695
1946
  var hadParam = /[?&]token=/.test(raw);
1696
1947
  if (tok) t.token = tok;
@@ -1702,7 +1953,7 @@ button.setrow{font-family:inherit}
1702
1953
  return t;
1703
1954
  }
1704
1955
  function persist(t) {
1705
- var wasActive = !!(conn && conn.url === BASE && String(conn.token || '') === String(TOKEN || ''));
1956
+ var wasActive = !!(conn && conn.url === connUrlNow() && String(conn.token || '') === String(TOKEN || ''));
1706
1957
  var next = upsertConn(t);
1707
1958
  /* 改了地址/令牌 = 换了一条记录:旧的那条要删掉,别留一条连不上的僵尸。 */
1708
1959
  if (conn && conn.id !== next.id) removeConn(conn.id);
@@ -1719,9 +1970,7 @@ button.setrow{font-family:inherit}
1719
1970
  switchEnv(next); // 改的正是当前这台:按新地址/令牌重连
1720
1971
  return;
1721
1972
  }
1722
- renderEnvPage();
1723
1973
  if (!historyEl.hidden) historyTitle.textContent = envLabel() || '任务';
1724
- if (!mineEl.hidden && mineEnv) mineEnv.textContent = envLabel() || '—';
1725
1974
  toast(isNew ? ('已添加「' + (next.name || next.host) + '」') : '已保存');
1726
1975
  }
1727
1976
  saveB.onclick = function () { var t = collect(); if (t) persist(t); };
@@ -1731,14 +1980,13 @@ button.setrow{font-family:inherit}
1731
1980
  persist(t);
1732
1981
  /* persist 内部遇到「改的是当前连接」已经自己重连了;这里只处理新增/改别的环境。 */
1733
1982
  var c = findConn(connIdOf(t)) || findConn(connIdOf({ url: t.url, token: t.token }));
1734
- if (c && !(c.url === BASE && String(c.token || '') === String(TOKEN || ''))) switchEnv(c);
1983
+ if (c && !(c.url === connUrlNow() && String(c.token || '') === String(TOKEN || ''))) switchEnv(c);
1735
1984
  };
1736
1985
  if (delB) {
1737
1986
  delB.onclick = function () {
1738
1987
  if (!window.confirm('删除环境「' + (connLabel(conn) || conn.host || '') + '」?')) return;
1739
1988
  removeConn(conn.id);
1740
1989
  closeHostSheet();
1741
- renderEnvPage();
1742
1990
  toast('已删除');
1743
1991
  };
1744
1992
  }
@@ -1773,7 +2021,7 @@ button.setrow{font-family:inherit}
1773
2021
  }
1774
2022
 
1775
2023
  /* ══════════ 视图:启动页 → 会话列表 → 会话 ══════════ */
1776
- function hideAll() { launcherEl.hidden = true; historyEl.hidden = true; mineEl.hidden = true; reqsEl.hidden = true; discoverEl.hidden = true; teamPageEl.hidden = true; runSettingsPageEl.hidden = true; pluginsEl.hidden = true; pluginDetailEl.hidden = true; sessionEl.hidden = true; envPageEl.hidden = true; }
2024
+ function hideAll() { launcherEl.hidden = true; historyEl.hidden = true; mineEl.hidden = true; reqsEl.hidden = true; reqDetailEl.hidden = true; discoverEl.hidden = true; teamPageEl.hidden = true; runSettingsPageEl.hidden = true; pluginsEl.hidden = true; pluginDetailEl.hidden = true; sessionEl.hidden = true; }
1777
2025
  function setActive(name) {
1778
2026
  Array.prototype.forEach.call(tabbarEl.querySelectorAll('.tab'), function (b) {
1779
2027
  b.classList.toggle('is-on', b.getAttribute('data-tab') === name);
@@ -1789,64 +2037,147 @@ button.setrow{font-family:inherit}
1789
2037
  hideAll(); historyEl.hidden = false; tabbarEl.hidden = false; setActive('tasks');
1790
2038
  if (address != null) histSubtitle = address;
1791
2039
  historyTitle.textContent = envLabel() || '任务';
2040
+ /* 一条环境都没有(首次安装 / 刚断开):直接进任务页的空态。
2041
+ 2026-10-22 用户:「这个界面不需要了 直接切换环境就行」—— 不再用独立的启动页挡在前面。 */
2042
+ if (!TOKEN && !BASE) {
2043
+ historyTitle.textContent = '未连接';
2044
+ historyList.innerHTML = '<div class="history__hint" style="text-align:left">还没有连接桌面端。'
2045
+ + '点上面的标题 →「添加环境」,把桌面给的地址粘进来(也可以在里面扫码)。</div>';
2046
+ return;
2047
+ }
1792
2048
  loadSessions();
1793
2049
  }
1794
2050
  function showSession(sub) { hideAll(); sessionEl.hidden = false; tabbarEl.hidden = false; setActive('tasks'); subtitleEl.textContent = (sub != null ? sub : histSubtitle) || ''; }
1795
- /* 需求页:进页面即拉一次清单(GET /desk/requirements,只读、不调模型)。
2051
+ /* 需求页:内容与逻辑在 reqs.js(独立子页面模块),这里只负责切页。
2052
+ 进页面即拉一次清单(GET /desk/requirements,只读、不调模型);
1796
2053
  「整理」按钮才触发归纳(POST /desk/requirements/consolidate)——
1797
2054
  两者代价差三个数量级,绝不能混成一个动作。 */
1798
2055
  function showReqs() {
1799
2056
  hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false; setActive('reqs');
1800
- loadRequirements(false);
2057
+ /* 从二级退回来时不要还卡在详情态:reqs.js 的 show() 会回一级目录并拉清单。 */
2058
+ if (window.MagicReqs && window.MagicReqs.show) window.MagicReqs.show();
1801
2059
  }
1802
2060
  function showDiscover() { hideAll(); discoverEl.hidden = false; tabbarEl.hidden = false; setActive('discover'); }
1803
2061
  function showMine() {
1804
2062
  hideAll(); mineEl.hidden = false; tabbarEl.hidden = false; setActive('mine');
1805
2063
  mineDevice.textContent = histSubtitle || '本机';
1806
- if (mineEnv) mineEnv.textContent = envLabel() || '—';
1807
2064
  var ns = document.getElementById('notifyState');
1808
2065
  if (ns) { var p = notifyPermState(); ns.textContent = p === 'plugin' ? '已接入' : (p === 'granted' ? '已开启' : '未开启'); }
1809
2066
  markSeg(segTheme, PREFS.theme); markSeg(segFont, PREFS.font); markSeg(segMode, TASK_MODE);
1810
- tglHaptic.classList.toggle('is-on', PREFS.haptic !== 'off');
1811
2067
  paintRunPref();
1812
2068
  refreshVpn();
1813
2069
  /* 版本号:先用已拉到的值立刻画(列表页的 /desk/health 顺手带回来的),
1814
2070
  再补一次请求 —— 用户可能一连上就直接点「我的」,那时列表那次还没回来。 */
1815
2071
  paintMineVersion();
1816
2072
  refreshMineVersion();
2073
+ /* 顺带问一次「桌面端的包里带的是哪一版 APK」:有更新就把这一格显示成「升级 vX」。 */
2074
+ probeAppUpdate();
1817
2075
  }
1818
2076
 
1819
- /* ══════════ 版本号(「我的」)══════════
1820
- 来源:桌面端 `GET /desk/health` 的 `version`(= core 的 `--version`,ldflags 注入)。
1821
- 为什么不是移动端自己写死一个常量:这份界面是桌面端 `/m/` 远程加载的,页面文件跟着
1822
- 桌面端一起升级;只有服务端报的版本才是「用户此刻连着的这份」。
1823
- health 没有 version(插件是旧构建、但磁盘上的 core 已经是新的)时,再问一次
1824
- `/desk/contract` —— 它原样代理 core 的 envelope,新 core 里带 version。这样
1825
- **不必重启正在跑的服务**就能显示真实版本。仍然没有(老 core)就保持「—」,不编。 */
2077
+ /* ══════════ 版本号 + 升级(「我的」)══════════
2078
+ 显示:优先 APK 自己的版本(构建时从 magic-agent/package.json 注入,闸门页用 ?appv=
2079
+ 带进来);浏览器预览没有 appv 时才回落到桌面端 `GET /desk/health` 的 `version`。
2080
+ 升级:点这一行 → `GET /desk/app` 问桌面端「你包里带的 APK 是哪一版」。桌面端把
2081
+ 随 npm 包发布的 release APK 放在 npm/apk/ 下,由 magic-client 直接递过来;新版本
2082
+ 就触发下载(APK 壳里走原生 MagicUpdate.open 交给系统浏览器/安装器)。
2083
+ 老插件没有 /desk/app(404)→ 提示检查失败,不把「—」当成最新版本。 */
1826
2084
  var SERVER_VERSION = '';
2085
+ var APK_LATEST = ''; // 桌面端 npm 包里带的 APK 版本(/desk/app);空 = 没包或还没查
2086
+ function shownVersion() { return APP_VERSION || SERVER_VERSION; }
1827
2087
  function paintMineVersion() {
1828
2088
  if (!mineVersion) return;
1829
- mineVersion.textContent = SERVER_VERSION ? ('v' + SERVER_VERSION) : '—';
2089
+ var v = shownVersion();
2090
+ var txt = v ? ('v' + v) : '—';
2091
+ /* 有新版本就直接在这一格写「升级」,别让用户去猜这一行能不能点。 */
2092
+ if (v && APK_LATEST && cmpVersion(APK_LATEST, v) > 0) {
2093
+ mineVersion.textContent = txt + ' · 升级 v' + APK_LATEST;
2094
+ mineVersion.classList.add('is-upgradable');
2095
+ } else {
2096
+ mineVersion.textContent = txt;
2097
+ mineVersion.classList.remove('is-upgradable');
2098
+ }
2099
+ /* 两个版本不一致时把桌面端版本放进 title:排查「App 是新的、桌面还是旧的」用得上。 */
2100
+ mineVersion.title = (APP_VERSION && SERVER_VERSION && APP_VERSION !== SERVER_VERSION)
2101
+ ? ('App v' + APP_VERSION + ' · 桌面 v' + SERVER_VERSION) : '';
2102
+ }
2103
+ /* 进入「我的」时静默问一次桌面端带的是哪一版 APK:有更新就把版本格变成「升级」。
2104
+ 失败(老插件没有 /desk/app)就不动,不打扰。 */
2105
+ function probeAppUpdate() {
2106
+ if (!TOKEN) return;
2107
+ fetch(BASE + '/desk/app', { headers: { 'X-Magic-Token': TOKEN } })
2108
+ .then(function (r) { return r.ok ? r.json() : null; })
2109
+ .then(function (a) { APK_LATEST = (a && a.version) || ''; paintMineVersion(); })
2110
+ .catch(function () {});
1830
2111
  }
1831
2112
  function refreshMineVersion() {
1832
2113
  if (!TOKEN) { paintMineVersion(); return; }
1833
2114
  var hdr = { 'X-Magic-Token': TOKEN };
1834
- var seq = CONN_SEQ; // 切环境后旧桌面的 health 不许写到新连接上
1835
- fetch(BASE + '/desk/health', { headers: hdr })
2115
+ var seq = CONN_SEQ; // 切环境后旧桌面的响应不许写到新连接上
2116
+ /* ⚠️ /desk/health.version 是 magic-client **启动时**缓存的 core 版本:客户端比 core 旧
2117
+ (升级了 CLI 但没重启服务)时会一直报旧值(实测 v1.0.111 / 实际 2.1.3)。
2118
+ /desk/contract 每次都重新问 core,才是「此刻真正的 magic-agent 版本」。
2119
+ 两个并行拿:contract 权威,拿不到(老插件)才退回 health;health 还带 app 身份。 */
2120
+ var pContract = fetch(BASE + '/desk/contract', { headers: hdr })
1836
2121
  .then(function (r) { return r.ok ? r.json() : null; })
1837
- .then(function (h) {
1838
- if (seq !== CONN_SEQ) return;
1839
- if (h && h.app) noteActiveMeta({ app: h.app });
1840
- if (h && typeof h.version === 'string' && h.version) { SERVER_VERSION = h.version; return; }
1841
- return fetch(BASE + '/desk/contract', { headers: hdr })
1842
- .then(function (r) { return r.ok ? r.json() : null; })
1843
- .then(function (c) {
1844
- if (c && typeof c.version === 'string' && c.version) SERVER_VERSION = c.version;
1845
- })
1846
- .catch(function () {});
2122
+ .catch(function () { return null; });
2123
+ var pHealth = fetch(BASE + '/desk/health', { headers: hdr })
2124
+ .then(function (r) { return r.ok ? r.json() : null; })
2125
+ .catch(function () { return null; });
2126
+ Promise.all([pContract, pHealth]).then(function (a) {
2127
+ if (seq !== CONN_SEQ) return;
2128
+ var c = a[0], h = a[1];
2129
+ if (h && h.app) noteActiveMeta({ app: h.app });
2130
+ if (c && typeof c.version === 'string' && c.version) SERVER_VERSION = c.version;
2131
+ else if (h && typeof h.version === 'string' && h.version) SERVER_VERSION = h.version;
2132
+ paintMineVersion();
2133
+ });
2134
+ }
2135
+ function cmpVersion(a, b) {
2136
+ var pa = String(a || '0').split('.'), pb = String(b || '0').split('.');
2137
+ for (var i = 0; i < 3; i++) {
2138
+ var x = parseInt(pa[i], 10) || 0, y = parseInt(pb[i], 10) || 0;
2139
+ if (x !== y) return x - y;
2140
+ }
2141
+ return 0;
2142
+ }
2143
+ /* 下载走系统能力:APK 壳里 MainActivity 挂了 MagicUpdate.open(Intent.ACTION_VIEW),
2144
+ 浏览器预览退化为直接导航到下载地址(浏览器会下载,不会把页面留在 APK 上)。 */
2145
+ function openApkDownload(info) {
2146
+ var rel = (info && info.url) || '/desk/app.apk';
2147
+ var url;
2148
+ if (/^https?:/i.test(rel)) {
2149
+ url = rel;
2150
+ } else if (BASE) {
2151
+ url = BASE + rel;
2152
+ } else {
2153
+ /* 远程界面与 /desk/ 同源,BASE 是空串;交给原生 Intent 的必须是绝对地址。 */
2154
+ try { url = new URL(rel, location.href).toString(); } catch (e) { url = rel; }
2155
+ }
2156
+ url += (url.indexOf('?') >= 0 ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN);
2157
+ try {
2158
+ if (window.MagicUpdate && typeof window.MagicUpdate.open === 'function') {
2159
+ window.MagicUpdate.open(url);
2160
+ return;
2161
+ }
2162
+ } catch (e) {}
2163
+ location.href = url;
2164
+ }
2165
+ function checkAppUpdate() {
2166
+ var cur = shownVersion();
2167
+ if (!TOKEN) { toast('请先连接桌面端'); return; }
2168
+ if (!cur) { toast('版本还没拿到,等列表加载完再试'); return; }
2169
+ mineVersion.textContent = '检测中…';
2170
+ fetch(BASE + '/desk/app', { headers: { 'X-Magic-Token': TOKEN } })
2171
+ .then(function (r) { return r.ok ? r.json() : null; })
2172
+ .then(function (a) {
2173
+ paintMineVersion();
2174
+ if (!a || !a.version) { toast('桌面端没有内置安装包'); return; }
2175
+ if (cmpVersion(a.version, cur) <= 0) { toast('已是最新版本 v' + cur); return; }
2176
+ if (!confirm('发现新版本 v' + a.version + '(当前 v' + cur + '),现在下载升级?')) return;
2177
+ openApkDownload(a);
2178
+ toast('正在下载 v' + a.version + ',完成后点安装');
1847
2179
  })
1848
- .then(paintMineVersion)
1849
- .catch(function () { paintMineVersion(); }); // 网络抖动:保留旧值,不把「—」又闪回来
2180
+ .catch(function () { paintMineVersion(); toast('检查更新失败,请确认桌面端已升级'); });
1850
2181
  }
1851
2182
  /* 「我的」→ 团队模式:整块配置进子页面(与插件列表同一套子页面范式)。 */
1852
2183
  function showTeamPage() {
@@ -2279,15 +2610,14 @@ button.setrow{font-family:inherit}
2279
2610
  });
2280
2611
  }
2281
2612
 
2282
- /* ══════════ 偏好:主题 / 字体 / 触感(本地持久化)══════════ */
2613
+ /* ══════════ 偏好:主题 / 字体(本地持久化)══════════ */
2283
2614
  function loadPrefs() {
2284
2615
  try {
2285
2616
  return {
2286
2617
  theme: localStorage.getItem('magic.theme') || 'system',
2287
- font: localStorage.getItem('magic.font') || '1',
2288
- haptic: localStorage.getItem('magic.haptic') || 'on'
2618
+ font: localStorage.getItem('magic.font') || '1'
2289
2619
  };
2290
- } catch (e) { return { theme: 'system', font: '1', haptic: 'on' }; }
2620
+ } catch (e) { return { theme: 'system', font: '1' }; }
2291
2621
  }
2292
2622
  function savePref(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
2293
2623
  function applyThemePref(v) {
@@ -2296,24 +2626,113 @@ button.setrow{font-family:inherit}
2296
2626
  }
2297
2627
  /* 字体大小:组件尺寸是 px,改 root font-size 无效;用 body zoom 整体缩放(WebView 支持)。 */
2298
2628
  function applyFontPref(v) { try { document.body.style.zoom = (v && v !== '1') ? v : ''; } catch (e) {} }
2299
- function applyHapticPref(v) { window.MAGIC_HAPTICS = (v !== 'off'); }
2300
2629
  /* 任务模式:parallel(默认,全部并行)| serial(同一空间同时只跑一个新任务)。 */
2301
2630
  var TASK_MODE = 'parallel';
2302
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) {}
2303
2636
  var PREFS = loadPrefs();
2304
- applyThemePref(PREFS.theme); applyFontPref(PREFS.font); applyHapticPref(PREFS.haptic);
2637
+ applyThemePref(PREFS.theme); applyFontPref(PREFS.font);
2305
2638
  paintRunPref();
2306
2639
  paintTeamPref(); // 团队配置卡片(能力位要到连上桌面后才亮,见 loadSessions)
2307
2640
 
2308
2641
  /* 挂 conversation:传输层由宿主注入,组件不连后端(见 CLIENT-CONTRACT.md 3.2.1)。
2309
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
+
2310
2722
  function mountConversation(sid, s) {
2311
2723
  /* 同一条会话、同一个空间:组件还活着,画面也还在 —— 直接把这一屏端回来。
2312
2724
  以前这里无条件 destroy() + create() + innerHTML='',于是「返回列表 → 再点进来」
2313
2725
  每次都要重拉全量历史、重新建 DOM、重新回放(长会话 = 又慢又费流量)。
2314
- 标题 / 引擎 / 模型交给组件的 setSession 同步(它对本会话是幂等的)。 */
2315
- if (conv && sid && sid === convSid && WS === convWs) {
2316
- 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) {}
2317
2736
  window.magicConv = conv;
2318
2737
  return;
2319
2738
  }
@@ -2340,6 +2759,9 @@ button.setrow{font-family:inherit}
2340
2759
  onQueueTake: queueTaken,
2341
2760
  onQueueDrop: queueDropped,
2342
2761
  onPick: rememberRunPick,
2762
+ /* 会话托管:开关值由宿主给(权威在 localStorage),自动暂停时组件回调这里同步。 */
2763
+ hosting: HOSTING,
2764
+ onHostingChange: setHostingPref,
2343
2765
  /* 团队模式:开关初值按**这条会话**给;配置是设备偏好(每轮现读,改配置立刻生效)。
2344
2766
  features 也给函数:能力清单是异步拉到的(/desk/health),
2345
2767
  查到之后开关才会亮出来,不必重新挂载组件。 */
@@ -2371,7 +2793,10 @@ button.setrow{font-family:inherit}
2371
2793
  }
2372
2794
  }
2373
2795
  });
2374
- if (sid) conv.setSession(sid, s);
2796
+ /* 历史画完再把落库的排队条目接回画布(见 adoptPendingFor):抢在回放之前插气泡,
2797
+ 会被组件的 clearThread 一起清掉;而且那一刻「这一轮还在不在跑」已经由
2798
+ loadHistory / follow 落定,flush 的时机才是对的。 */
2799
+ if (sid) adoptAfterHistory(sid, s);
2375
2800
  window.magicConv = conv;
2376
2801
  } catch (e) {
2377
2802
  var d = document.createElement('div');
@@ -2408,6 +2833,9 @@ button.setrow{font-family:inherit}
2408
2833
  CONN_SEQ++; // 上一台桌面还在飞的请求:回来时直接丢弃(见 loadSessions)
2409
2834
  var c = upsertConn(target);
2410
2835
  ACTIVE_ID = c.id;
2836
+ PREFS_PULLED = false; // 换了桌面:环境配置要重新从新桌面拉一次(见 pullPrefs)
2837
+ /* 记住「停在哪台」:下次打开(或切环境后重载)直接回到这里。 */
2838
+ try { localStorage.setItem(ACTIVE_ENV_KEY, c.url || target.url || ''); } catch (e) {}
2411
2839
  showError('');
2412
2840
  showHistory(c.host || '');
2413
2841
  startPoll();
@@ -2419,8 +2847,10 @@ button.setrow{font-family:inherit}
2419
2847
  CONN_SEQ++;
2420
2848
  if (conv) { try { conv.destroy(); } catch (e) {} conv = null; }
2421
2849
  convEl.innerHTML = '';
2850
+ saveEnvScope(envScopeId()); // 断开前把这台的本机状态留在它自己的槽里
2422
2851
  BASE = ''; TOKEN = '';
2423
2852
  ACTIVE_ID = '';
2853
+ try { localStorage.removeItem(ACTIVE_ENV_KEY); } catch (e) {} // 主动断开 = 下次回启动页
2424
2854
  SERVER_VERSION = ''; // 版本号属于「那一条连接」:断开就清掉,避免下次连到别的机器还显示旧值
2425
2855
  /* 排队任务**不跟着断连消失**(它们已经落库):只清内存里的等待态,下次连上由
2426
2856
  restoreQueue 从磁盘接回来。串行门的 FIFO 也一并重置 —— 里面的 tail 是挂在一条
@@ -2429,7 +2859,8 @@ button.setrow{font-family:inherit}
2429
2859
  ASK_WAITING = [];
2430
2860
  SERIAL = {};
2431
2861
  QUEUE_RESTORED = false;
2432
- showLauncher();
2862
+ /* 断开后回任务页的空态(不再回启动页):添加/切换环境都在标题菜单里。 */
2863
+ showHistory(null);
2433
2864
  }
2434
2865
 
2435
2866
  /* ══════════ 历史会话列表(GET /desk/sessions,字段同 core 的 --sessions)══════════ */
@@ -2499,16 +2930,26 @@ button.setrow{font-family:inherit}
2499
2930
  TITLES = a[1] || {};
2500
2931
  DW = (a[2] && a[2].defaultWorkspace) || '';
2501
2932
  DW_READY = true;
2933
+ /* 环境配置以桌面端为准:连上后拉一次(见 pullPrefs)。只拉一次,
2934
+ 后面本地的改动由 schedulePushPrefs 推回去。 */
2935
+ if (!PREFS_PULLED) pullPrefs();
2502
2936
  SPACES_ROOT = (a[2] && a[2].workspacesRoot) || '';
2503
- /* core 版本(2026-10-22):/desk/health 的 version,顺手缓存,「我的」页打开即用。
2504
- 老 core 没有该字段 → 不覆盖已有值(可能刚由 refreshMineVersion 拿到)。 */
2505
- if (a[2] && a[2].version) SERVER_VERSION = a[2].version;
2937
+ /* core 版本不要从这里取:/desk/health.version 是插件启动时的缓存,可能是旧值
2938
+ (见 refreshMineVersion 的注释)。版本行打开时走 /desk/contract 拿实时值。 */
2506
2939
  if (!mineEl.hidden) paintMineVersion();
2507
2940
  /* core 能力位(feature.*):团队开关要不要显示就看它。连上桌面后才有,
2508
2941
  所以「我的」那张卡片与运行设置里的那一行都要在这里刷一次。 */
2509
2942
  CORE_FEATURES = ((a[2] && a[2].features) || []);
2510
2943
  /* 连接的身份(app):连着哪台设备就记到哪条连接上,环境菜单里当类型标签用。 */
2511
2944
  if (a[2] && a[2].app) noteActiveMeta({ app: a[2].app });
2945
+ /* 桌面绑了公网隧道时上报 publicUrl(cloudflared,不在同一 Wi-Fi 也能连),
2946
+ 没有则退回局域网入口:自动补一条「公司远程」环境,用户不用手抄地址。 */
2947
+ if (a[2]) {
2948
+ var remotes = [];
2949
+ if (a[2].publicUrl) remotes.push(a[2].publicUrl);
2950
+ if (a[2].lanUrls && a[2].lanUrls.length) remotes = remotes.concat(a[2].lanUrls);
2951
+ if (remotes.length) seedRemoteConns(remotes);
2952
+ }
2512
2953
  /* 团队角色清单(2026-10-22):core 上报什么就画什么,客户端不硬编码角色名。
2513
2954
  老 core 没有 teamRoles → 空数组 → paintTeamPref 用内置表兜底。 */
2514
2955
  TEAM_ROLES = ((a[2] && a[2].teamRoles) || []).filter(function (r) {
@@ -2535,7 +2976,9 @@ button.setrow{font-family:inherit}
2535
2976
  });
2536
2977
  }
2537
2978
  /* ── 会话列表:**置顶 / 任务 / 空间** 三段(对齐截图)──
2538
- 置顶:本地 pin(长按会话行切换);任务:最近会话(默认前 8,可「展开更多」);
2979
+ 置顶:本地 pin(长按会话行切换)+ **运行中自动临时置顶**(2026-10-22:跑着的那条
2980
+ 自己进「置顶」段,不写 PINNED,跑完自动归位;见 isAutoPinned);
2981
+ 任务:最近会话(默认前 8,可「展开更多」);
2539
2982
  空间:会话里出现过的 workspace(点行=按空间筛选任务,+=在该空间新建)。 */
2540
2983
  var LAST_SESSIONS = [];
2541
2984
  var SEC_COLLAPSED = {};
@@ -2546,7 +2989,7 @@ button.setrow{font-family:inherit}
2546
2989
  var TASK_QUERY = '';
2547
2990
  var PINNED = [];
2548
2991
  try { PINNED = JSON.parse(localStorage.getItem('magic.pinned') || '[]') || []; } catch (e) { PINNED = []; }
2549
- function savePinned() { try { localStorage.setItem('magic.pinned', JSON.stringify(PINNED)); } catch (e) {} }
2992
+ function savePinned() { try { localStorage.setItem('magic.pinned', JSON.stringify(PINNED)); } catch (e) {} schedulePushPrefs(); }
2550
2993
  var TITLES = {}; // 服务端标题覆盖表(GET /desk/titles),重命名用
2551
2994
  var DW = ''; // 桌面默认工作空间(GET /desk/health 的 defaultWorkspace = core 的 cwd)
2552
2995
  var DW_READY = false; // DW 是否已从 health 落定(空间键归一依赖它,见 restoreQueue)
@@ -2554,7 +2997,7 @@ button.setrow{font-family:inherit}
2554
2997
  var ROOT_DIRS = []; // 根目录下 magic-* 目录(GET /desk/dirs)
2555
2998
  var CUSTOM_SPACES = [];
2556
2999
  try { CUSTOM_SPACES = JSON.parse(localStorage.getItem('magic.spaces') || '[]') || []; } catch (e) { CUSTOM_SPACES = []; }
2557
- function saveSpaces() { try { localStorage.setItem('magic.spaces', JSON.stringify(CUSTOM_SPACES)); } catch (e) {} }
3000
+ function saveSpaces() { try { localStorage.setItem('magic.spaces', JSON.stringify(CUSTOM_SPACES)); } catch (e) {} schedulePushPrefs(); }
2558
3001
  /* 空间候选 = 根目录下的 magic-* + 自定义 + 会话里出现过的 workspace(去重、保序)。
2559
3002
  ⚠️ 会话的 workspace 为空时**不要**拿 DW 顶上(原写法 `s.workspace || DW`):
2560
3003
  那样「默认(不指定)」与「默认目录名」会变成两行同名选项,一个落「任务」段、
@@ -2568,6 +3011,48 @@ button.setrow{font-family:inherit}
2568
3011
  (LAST_SESSIONS || []).forEach(function (s) { add(s && s.workspace); });
2569
3012
  return out;
2570
3013
  }
3014
+ /* 会话里哪些是**顶层会话**(不是别人的子会话)。
3015
+ 团队模式会把子代理的会话挂到父会话下面去,它们的工作目录是
3016
+ /…/run-xxx/a1 这种**临时目录** —— 任务页不把它们单独列成空间
3017
+ (见 renderSessions 的 CHILD_OF 归类),需求页若照单全收就会多出
3018
+ 一堆 a1/t1/tmp 之类的假空间(用户 2026-10-22:「没有空间的不需要」)。
3019
+ 口径必须与任务页一致,所以这里按同一套父子关系判。 */
3020
+ function topLevelSessions(list) {
3021
+ var rows = list || [];
3022
+ var key2sid = {};
3023
+ rows.forEach(function (s) {
3024
+ var sid = sidOf(s);
3025
+ if (!sid) return;
3026
+ key2sid[sid] = sid;
3027
+ if (s.run_id) key2sid[s.run_id] = sid;
3028
+ if (s.session_id) key2sid[s.session_id] = sid;
3029
+ });
3030
+ return rows.filter(function (s) {
3031
+ var pk = key2sid[parentKeyOf(s)] || '';
3032
+ return !(pk && pk !== sidOf(s));
3033
+ });
3034
+ }
3035
+ /* 路径归一:只去尾部斜杠,不做 realpath —— 与 core 登记表里的 workspace 同一口径。
3036
+ 任务页 / 需求页共用(需求页已拆到 reqs.js,那边自带一份同名实现;这里留着
3037
+ 是因为 isRealSpaceDir / isTeamRunDir 还服务于任务页的空间分组)。 */
3038
+ function reqNormDir(p) { return String(p || '').replace(/\/+$/, ''); }
3039
+ /* 一个目录算不算「空间」:得是**用户认得的工作区**。
3040
+ 根目录 / 不是空间(哪怕历史上有会话在那儿跑过)—— 需求页显示它只会让人
3041
+ 以为「/ 是个项目」(用户 2026-10-22:「没有空间的不需要」)。
3042
+ ⚠️ 别把整个 /tmp 一刀切:/tmp/teamapi 是用户自己建的项目,是真空间
3043
+ (任务页也列着它)。团队运行目录另有专门的判法(见 isTeamRunDir)。 */
3044
+ function isRealSpaceDir(dir) {
3045
+ var d = reqNormDir(dir);
3046
+ if (!d) return false; // 根目录 /(归一后是空串)
3047
+ if (isTeamRunDir(d)) return false;
3048
+ return true;
3049
+ }
3050
+ /* 团队模式的运行目录:<空间>/.magic/team/run-xxx[/子代理目录]。
3051
+ 这些是团队**这一轮**的工作区,跑完就没了 —— 不是用户意义上的空间。 */
3052
+ function isTeamRunDir(dir) {
3053
+ var d = reqNormDir(dir);
3054
+ return d.indexOf('/.magic/team/') >= 0;
3055
+ }
2571
3056
  /* 会话标题:**以 core 给的 title 为准**(2026-10-15,用户「应该 magic-agent 统一归纳作为
2572
3057
  会话标题」)。core 的 --sessions 已把「标题表覆盖 → prompt_head」合并成一个 title 字段,
2573
3058
  所以这里不再自己拼优先级 —— 此前本文件是 `TITLES[id] || prompt_head`、PC 端是另一套
@@ -2636,6 +3121,30 @@ button.setrow{font-family:inherit}
2636
3121
 
2637
3122
  function sidOf(s) { return s.session_id || s.run_id; }
2638
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
+ }
2639
3148
  function togglePin(s) {
2640
3149
  var id = sidOf(s), i = PINNED.indexOf(id);
2641
3150
  if (i >= 0) PINNED.splice(i, 1); else PINNED.unshift(id);
@@ -2681,10 +3190,46 @@ button.setrow{font-family:inherit}
2681
3190
  .then(function () { TITLES[id] = name; renderSessions(LAST_SESSIONS); })
2682
3191
  .catch(function (e) { window.alert('重命名失败:' + (e && e.message ? e.message : e)); });
2683
3192
  }
2684
- /* 删除: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}(登记表记录 + 事件日志 + 子任务会话),然后刷新列表。 */
2685
3225
  function deleteSessionRow(s) {
2686
3226
  var id = sidOf(s);
2687
- 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;
2688
3233
  var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
2689
3234
  fetch(BASE + '/desk/session/' + encodeURIComponent(id), { method: 'DELETE', headers: hdr })
2690
3235
  .then(function (r) {
@@ -2692,10 +3237,11 @@ button.setrow{font-family:inherit}
2692
3237
  return r.json().catch(function () { return {}; });
2693
3238
  })
2694
3239
  .then(function () {
2695
- var i = PINNED.indexOf(id); if (i >= 0) { PINNED.splice(i, 1); savePinned(); }
2696
- 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]; });
2697
3243
  /* 本地移除 + 静默重绘:不走 loadSessions(那会先塞「读取中…」再重建 = 整页闪) */
2698
- LAST_SESSIONS = LAST_SESSIONS.filter(function (x) { return sidOf(x) !== id; });
3244
+ LAST_SESSIONS = LAST_SESSIONS.filter(function (x) { return !del[sidOf(x)]; });
2699
3245
  renderSessions(LAST_SESSIONS);
2700
3246
  })
2701
3247
  .catch(function (e) { window.alert('删除失败:' + (e && e.message ? e.message : e)); });
@@ -2806,20 +3352,30 @@ button.setrow{font-family:inherit}
2806
3352
  }
2807
3353
  /* 子会话(团队模式拆出的子任务,2026-10-22):core 在 /desk/sessions 里给出
2808
3354
  parent_session_id / parent_run_id(见 internal/session 的 Record)。子任务是**普通会话**
2809
- (自己的登记 + 自己的日志,点进去看完整过程),列表里只做两件事:
3355
+ (自己的登记 + 自己的日志,点进去看完整过程),列表里只做三件事:
2810
3356
  · 不占顶层一行 —— 缩进挂在主会话下面(CHILD_OF:父 key → 子会话数组);
3357
+ · 默认**折叠**(2026-10-23 用户:「列表中默认不展开显示子会话」):父行只留一枚
3358
+ 计数徽标,点它才展开;展开状态按会话 id 记在 KIDS_OPEN 里,刷新列表不收回;
2811
3359
  · 不进「任务 / 空间」分组(它们的 workspace 是主会话里的任务目录,不该自成一个空间)。
2812
3360
  ⚠️ 父会话不在列表里(被删 / 老 core 没给父字段)→ 回落成顶层一行,
2813
3361
  宁可多一行也不能让子会话凭空消失。
2814
3362
  ⚠️ 主会话可能还没拿到 session_id,parent_session_id 同时给了 parent_run_id 兜底。 */
2815
- var CHILD_OF = {}, CHILD_IDS = {};
3363
+ var CHILD_OF = {}, CHILD_IDS = {}, KIDS_OPEN = {};
2816
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
+ }
2817
3371
  function sessionBlock(s) {
2818
3372
  var wrap = document.createElement('div');
2819
3373
  wrap.className = 'sblock';
2820
3374
  wrap.appendChild(s.pendingWait ? pendingRow(s) : sessionRow(s, !!CHILD_IDS[sidOf(s)]));
2821
- var kids = CHILD_OF[sidOf(s)] || [];
2822
- if (kids.length) {
3375
+ var sid = sidOf(s);
3376
+ var kids = CHILD_OF[sid] || [];
3377
+ /* 默认不展开:只有用户点过父行上的计数徽标(KIDS_OPEN[sid])才挂子行。 */
3378
+ if (kids.length && kidsOpenOf(sid)) {
2823
3379
  var box = document.createElement('div');
2824
3380
  box.className = 'sblock__kids';
2825
3381
  kids.forEach(function (k) { box.appendChild(sessionRow(k, true)); });
@@ -2843,6 +3399,49 @@ button.setrow{font-family:inherit}
2843
3399
  col.appendChild(subEl);
2844
3400
  /* 排队与团队标记都不在这里做:两者都做在图标里(见 sessionIcon),行尾不再挂角标。 */
2845
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
+ }
2846
3445
  var timeEl = elSpan('srow__time', run ? elapsedText(s) : fmtRel(s.updated_at || s.started_at));
2847
3446
  b.appendChild(timeEl);
2848
3447
  if (run) { RUN_ROWS[sid] = { subEl: subEl, timeEl: timeEl, s: s }; refreshProgress(sid); }
@@ -2964,7 +3563,10 @@ button.setrow{font-family:inherit}
2964
3563
 
2965
3564
  /* 搜索态:不分组、不出「分组视图」行,命中项平铺(对齐截图 2)。 */
2966
3565
  if (searching) {
2967
- var matched = query ? rows.filter(taskMatches) : rows;
3566
+ var matched = (query ? rows.filter(taskMatches) : rows).slice();
3567
+ /* 与分组列表同一口径:运行中的自动临时置顶排最前(搜索是临时态,跑完同样归位)。 */
3568
+ /* 与分组列表同一口径:人工置顶在前,运行中的自动临时置顶紧随其后(搜索是临时态,跑完归位)。 */
3569
+ matched.sort(pinnedOrder);
2968
3570
  if (!matched.length && query) {
2969
3571
  var miss = document.createElement('div'); miss.className = 'history__hint';
2970
3572
  miss.textContent = '没有匹配「' + TASK_QUERY + '」的任务。';
@@ -2980,9 +3582,11 @@ button.setrow{font-family:inherit}
2980
3582
  return;
2981
3583
  }
2982
3584
 
2983
- /* 置顶:只在这里显示;置顶项不再出现在「任务 / 空间」里(去重) */
2984
- var pinned = rows.filter(isPinned);
2985
- 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);
2986
3590
 
2987
3591
  /* 任务:**不属于任何空间**的普通任务(且未置顶)+ 默认空间(不指定)的串行等待任务。
2988
3592
  显式选了空间的等待任务归到下面的「空间」组(用户 2026-10-20:「串行等待中的任务
@@ -3196,306 +3800,6 @@ button.setrow{font-family:inherit}
3196
3800
  if (e.key === 'Escape' && readerEl && !readerEl.hidden) { e.preventDefault(); closeReader(); }
3197
3801
  }, true);
3198
3802
 
3199
- /* ══════════ 需求页(会话自动聚合成的功能点文档)══════════
3200
- 数据形状见 CLIENT-CONTRACT §3.4.1:{ok, root, workspaces[{id,dir,name,pending,memory,
3201
- modules:[{name,features:[{module,name,file,desc,points,conclusions,conclusion,
3202
- sources,has_final,final_preview,updated_at}]}],feature_count,last_consolidated_at,last_error}]}
3203
-
3204
- 三个口径(照抄契约,别自己发明):
3205
- · 列表只读:`loadRequirements` 只打 GET /desk/requirements,**不触发归纳**;
3206
- · 「整理」才归纳:POST /desk/requirements/consolidate(可能跑几十秒,按钮态要变);
3207
- · 一篇文档就是一个绝对路径:点行直接进既有阅读器(markdown,不另做渲染)。 */
3208
- var REQ_DATA = null; // 最近一次清单(重画用,避免每次点都重新拉)
3209
- var REQ_BUSY = false; // 「整理」进行中(防重复点击)
3210
- var REQ_BUSY_LABEL = '全部整理'; // 顶栏按钮的进行中文案(重渲染后要能恢复)
3211
- var REQ_OPEN = {}; // 工作区展开状态(key = 工作区 id),与会话列表的 WS_OPEN 同构
3212
-
3213
- function reqFeatureRow(f) {
3214
- /* 一行 = 一个功能点,**照会话行的排布**做(图标 + 标题 / 副标题 / 右侧时间):
3215
- 标题(功能点名称)→ 归纳结论 / 需求描述 → 「N 需求点 · M 结论 · 来源 K」。
3216
- 为什么优先结论:这一页要回答的是「这个功能定了什么」,不是「记录了多少条」。 */
3217
- var b = hEl('button', 'srow srow--req');
3218
- b.type = 'button';
3219
- var ico = hEl('span', 'srow__ico srow__ico--idle');
3220
- ico.appendChild(svgUse('#i-doc', 20));
3221
- b.appendChild(ico);
3222
- var col = hEl('span', 'srow__col');
3223
- col.appendChild(hEl('span', 'srow__t', f.name || '(未命名)'));
3224
- /* 先结论,再一句话描述,最后才是定稿预览。定稿预览是 markdown(可能带 `**目标**:`),
3225
- 直接塞进这一行会把星号原样显示出来;有 desc 时它更适合当副标题。 */
3226
- var sub = f.conclusion || f.desc || f.final_preview || '';
3227
- if (sub) col.appendChild(hEl('span', 'srow__s', sub));
3228
- var meta = [];
3229
- if (f.points) meta.push(f.points + ' 需求点');
3230
- if (f.conclusions) meta.push(f.conclusions + ' 结论');
3231
- if (f.sources) meta.push('来源 ' + f.sources);
3232
- if (!f.has_final) meta.push('未定稿');
3233
- if (meta.length) col.appendChild(hEl('span', 'srow__s', meta.join(' · ')));
3234
- b.appendChild(col);
3235
- if (f.updated_at) {
3236
- var d = new Date(Number(f.updated_at) || 0);
3237
- if (!isNaN(d.getTime())) {
3238
- b.appendChild(hEl('span', 'srow__time',
3239
- (d.getMonth() + 1) + '-' + ('0' + d.getDate()).slice(-2)));
3240
- }
3241
- }
3242
- b.onclick = function () { openReader(f.file); };
3243
- return b;
3244
- }
3245
-
3246
- /* 工作区组:与会话列表的 workspaceGroup 同一套骨架(可折叠的组头 + 组内行)。
3247
- 差别只有两点:组头右侧没有「+ 新建」,行是需求文档不是会话。 */
3248
- function reqWorkspaceBlock(w) {
3249
- var wrap = hEl('div', '');
3250
- var head = hEl('div', 'wsrow__head');
3251
- var key = w.id || w.dir || w.name || '';
3252
- var count = Number(w.feature_count) || 0;
3253
- var open = (REQ_OPEN[key] !== undefined) ? !!REQ_OPEN[key] : true; // 需求页默认全展开
3254
- var tog = document.createElement('button');
3255
- tog.type = 'button'; tog.className = 'wsrow__open';
3256
- tog.appendChild(elSpan('wsrow__l', w.name || w.dir || w.id));
3257
- /* 待整理条数做成徽标(与「等待中」同款):折叠起来也看得见这个空间在长新需求。 */
3258
- if (w.pending > 0) tog.appendChild(elSpan('wsrow__badge', w.pending + ' 待整理'));
3259
- /* 未初始化 = 还没按空间目录生成过基线。老 core 不带 initialized 字段(undefined),
3260
- 此时不提示 —— 不能把「不知道」画成「未初始化」。 */
3261
- if (w.initialized === false) tog.appendChild(elSpan('wsrow__badge wsrow__badge--init', '未初始化'));
3262
- var chev = elSpan('wsrow__chev' + (open ? ' is-open' : ''));
3263
- chev.appendChild(svgUse('#i-chev-r', 16));
3264
- tog.appendChild(chev);
3265
- tog.onclick = function () { REQ_OPEN[key] = !open; renderRequirements(); };
3266
- head.appendChild(tog);
3267
- /* 组头右侧的「整理」:只作用于这个空间(初始化它 / 归纳它的新会话)。 */
3268
- var sync = document.createElement('button');
3269
- sync.type = 'button'; sync.className = 'wsrow__sync'; sync.textContent = '整理';
3270
- sync.setAttribute('data-ws', key);
3271
- sync.onclick = function (ev) {
3272
- if (ev && ev.stopPropagation) ev.stopPropagation();
3273
- consolidateRequirements(w);
3274
- };
3275
- head.appendChild(sync);
3276
- wrap.appendChild(head);
3277
- if (!open) return wrap;
3278
-
3279
- if (w.last_error) {
3280
- var err = hEl('div', 'history__hint', '上次整理失败:' + w.last_error);
3281
- err.style.color = 'var(--warning)';
3282
- wrap.appendChild(err);
3283
- }
3284
- (w.modules || []).forEach(function (m) {
3285
- /* 模块(大功能点)用一条小节标题,形状与「置顶 / 任务 / 空间」的 sechead 一致:
3286
- 名字 + 条数。未归类(name 为空)不标题、直接平铺 ——
3287
- 老数据是平铺目录,硬给它编个「未归类」反而像出了错。 */
3288
- var fs = (m.features || []);
3289
- if (!fs.length) return;
3290
- if (m.name) {
3291
- var mh = hEl('div', 'sechead');
3292
- mh.style.paddingLeft = '16px';
3293
- mh.appendChild(elSpan('sechead__t', m.name + ' (' + fs.length + ')'));
3294
- wrap.appendChild(mh);
3295
- }
3296
- fs.forEach(function (f) { wrap.appendChild(reqFeatureRow(f)); });
3297
- });
3298
- return wrap;
3299
- }
3300
-
3301
- function renderRequirements() {
3302
- if (!reqListEl) return;
3303
- var data = REQ_DATA;
3304
- if (!data) return;
3305
- /* 只画**有文档**的工作区(用户 2026-10-22:「没有的空间不显示」)。
3306
- feature_count 是 core 给的真值;老 core 不给时退化成数一遍模块里的行数。 */
3307
- var all = (data && data.workspaces) || [];
3308
- var list = all.filter(function (w) {
3309
- if (Number(w.feature_count) > 0) return true;
3310
- return (w.modules || []).some(function (m) { return (m.features || []).length > 0; });
3311
- });
3312
- var frag = document.createDocumentFragment();
3313
- if (!list.length) {
3314
- /* 一个功能点都还没有:说清「是没聊过」还是「聊过但没整理」——
3315
- 后者要引导用户点「整理」,否则这一页看起来永远长不出东西。 */
3316
- var pend = 0, uninit = 0;
3317
- all.forEach(function (w) {
3318
- pend += Number(w.pending) || 0;
3319
- if (w.initialized === false) uninit++;
3320
- });
3321
- frag.appendChild(hEl('div', 'history__hint',
3322
- pend > 0
3323
- ? '有 ' + pend + ' 条对话还没整理成需求。点空间右侧「整理」只处理这个空间;右上角「全部整理」一次跑完所有空间。'
3324
- : (uninit > 0
3325
- ? '还没有需求。点空间右侧「整理」会先按空间目录生成一版需求基线;右上角「全部整理」一次处理所有空间。'
3326
- : '还没有任何需求。在任务里聊几句,这里会自动整理出功能点文档。')));
3327
- reqListEl.replaceChildren(frag);
3328
- return;
3329
- }
3330
- /* 工作区顺序照 core 给的(最后归纳时间倒序)—— 最近在动的排最前,
3331
- 用户打开这一页多半是想看「刚聊的那些变成什么了」。 */
3332
- list.forEach(function (w) { frag.appendChild(reqWorkspaceBlock(w)); });
3333
- reqListEl.replaceChildren(frag);
3334
- /* 整理进行中切走再回来:新 DOM 的按钮默认是可点的,这里按当前状态重新置忙。 */
3335
- if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
3336
- }
3337
-
3338
- function loadRequirements(quiet) {
3339
- if (!reqListEl) return;
3340
- if (!quiet && !REQ_DATA) reqListEl.innerHTML = '<div class="history__hint">读取中…</div>';
3341
- var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
3342
- fetch(BASE + '/desk/requirements', { headers: hdr })
3343
- .then(function (r) {
3344
- if (r.status === 501) {
3345
- /* 老 core 没有这个能力位:**说清楚**,不要给一页空白
3346
- (空白看起来像「没有需求」,是最难查的一种)。 */
3347
- return r.json().then(function (e) { throw new Error((e && e.error) || '当前 core 不支持需求聚合'); });
3348
- }
3349
- if (!r.ok) throw new Error('HTTP ' + r.status);
3350
- return r.json();
3351
- })
3352
- .then(function (d) {
3353
- REQ_DATA = d || { workspaces: [] };
3354
- renderRequirements();
3355
- })
3356
- .catch(function (e) {
3357
- if (quiet && REQ_DATA) return; // 后台刷新失败不覆盖已有画面
3358
- reqListEl.innerHTML = '';
3359
- reqListEl.appendChild(hEl('div', 'history__hint', '读取需求失败:' + ((e && e.message) || e)));
3360
- });
3361
- }
3362
-
3363
- /* 「整理」:手动触发一轮(POST /desk/requirements/consolidate)。
3364
- 服务端按 state 判断走哪条路:
3365
- · 没初始化过 → 先按空间目录反推需求基线(首次可能跑几分钟,按钮要能等);
3366
- · 已初始化 → 只消费上次整理之后的新会话。
3367
- 入口有两个:
3368
- · 每个空间组头的「整理」——只整理这个空间(用户点哪个就是哪个,不猜);
3369
- · 右上角「全部整理」——按空间逐个串行跑一遍,汇总结果(一键触发全部)。
3370
- 两个入口都带「我的 → 默认引擎/模型」,服务端不再自动替换引擎。 */
3371
- function reqPostConsolidate(ws) {
3372
- var hdr = { 'Content-Type': 'application/json' };
3373
- if (TOKEN) hdr['X-Magic-Token'] = TOKEN;
3374
- return fetch(BASE + '/desk/requirements/consolidate', {
3375
- method: 'POST', headers: hdr,
3376
- body: JSON.stringify({
3377
- workspace: ws || '',
3378
- engine: RUN_PREF.engine || '',
3379
- model: (RUN_PREF.engine && RUN_PREF.model) || ''
3380
- })
3381
- })
3382
- .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
3383
- .then(function (x) {
3384
- var d = x.d || {};
3385
- if (!x.ok) throw new Error(d.error || ('HTTP ' + (x.d && x.d.status)));
3386
- if (d.error) throw new Error(d.error);
3387
- return d;
3388
- });
3389
- }
3390
-
3391
- /* 统一置忙:顶部主按钮 + 所有空间组头的「整理」一起禁用。
3392
- label 只改顶部;组头统一显示「整理中…」/「整理」,不各自变文案。 */
3393
- function reqSetBusy(on, label) {
3394
- if (on) { if (label !== undefined) REQ_BUSY_LABEL = label; }
3395
- else REQ_BUSY_LABEL = '全部整理';
3396
- var btn = document.getElementById('btnReqSync');
3397
- if (btn) {
3398
- btn.disabled = !!on;
3399
- if (label !== undefined) btn.textContent = label;
3400
- }
3401
- Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
3402
- b.disabled = !!on;
3403
- b.textContent = on ? '整理中…' : '整理';
3404
- });
3405
- }
3406
-
3407
- function consolidateRequirements(target) {
3408
- if (REQ_BUSY || !target) return;
3409
- var ws = target.dir || target.id || '';
3410
- if (!ws) return;
3411
- var firstTime = target.initialized === false;
3412
- REQ_BUSY = true;
3413
- reqSetBusy(true, firstTime ? '初始化中…' : '整理中…');
3414
- var targetKey = target.id || target.dir || '';
3415
- var groupBtn = null;
3416
- Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
3417
- if (!groupBtn && targetKey && b.getAttribute('data-ws') === targetKey) groupBtn = b;
3418
- });
3419
- /* 点的是哪个空间,就只让哪个组头显示「初始化中…」;其它组头保持「整理中…」。 */
3420
- if (groupBtn) groupBtn.textContent = firstTime ? '初始化中…' : '整理中…';
3421
- reqPostConsolidate(ws)
3422
- .then(function (d) {
3423
- if (d.bootstrap) {
3424
- if (d.created || d.updated) toast('已按空间目录生成需求:新建 ' + (d.created || 0) + ' · 更新 ' + (d.updated || 0));
3425
- else toast('已按空间目录初始化:暂未识别到功能点');
3426
- }
3427
- else if (d.noop) toast('没有新的对话需要整理');
3428
- else if (d.updated || d.created) toast('整理完成:更新 ' + (d.updated || 0) + ' · 新建 ' + (d.created || 0));
3429
- else toast('整理完成');
3430
- loadRequirements(true); // 后台刷新(不闪「读取中」)
3431
- })
3432
- .catch(function (e) { toast('整理失败:' + ((e && e.message) || e)); })
3433
- .then(function () {
3434
- REQ_BUSY = false;
3435
- reqSetBusy(false, '全部整理');
3436
- });
3437
- }
3438
-
3439
- /* 全部整理:按空间串行跑,避免多个初始化同时读目录把机器拖垮。
3440
- 顺序 = 有待整理的 → 未初始化的 → 其它(最近动过的仍在前),先做便宜/该做的。
3441
- 每个空间单独一次 HTTP(各自 10 分钟超时);某个失败不影响后面的。 */
3442
- function reqAllWorkspaces() {
3443
- var seen = {};
3444
- var list = ((REQ_DATA && REQ_DATA.workspaces) || []).filter(function (w) {
3445
- var key = w.dir || w.id || '';
3446
- if (!key || seen[key]) return false;
3447
- seen[key] = true;
3448
- return true;
3449
- });
3450
- function rank(w) {
3451
- if (Number(w.pending) > 0) return 0;
3452
- if (w.initialized === false) return 1;
3453
- return 2;
3454
- }
3455
- return list.sort(function (a, b) { return rank(a) - rank(b); });
3456
- }
3457
-
3458
- function consolidateAllRequirements() {
3459
- if (REQ_BUSY) return;
3460
- var list = reqAllWorkspaces();
3461
- if (!list.length) {
3462
- toast('还没有可整理的空间');
3463
- return;
3464
- }
3465
- REQ_BUSY = true;
3466
- reqSetBusy(true, '整理中 0/' + list.length);
3467
- var i = 0, ok = 0, fail = 0, created = 0, updated = 0, firstErr = '';
3468
- function done() {
3469
- REQ_BUSY = false;
3470
- reqSetBusy(false, '全部整理');
3471
- loadRequirements(true);
3472
- if (fail) {
3473
- toast('全部整理完成:成功 ' + ok + ' · 失败 ' + fail + (firstErr ? '(' + firstErr + ')' : ''));
3474
- } else {
3475
- toast('全部整理完成:' + ok + ' 个空间 · 新建 ' + created + ' · 更新 ' + updated);
3476
- }
3477
- }
3478
- function step() {
3479
- if (i >= list.length) { done(); return; }
3480
- var w = list[i++];
3481
- REQ_BUSY_LABEL = '整理中 ' + i + '/' + list.length;
3482
- var btn = document.getElementById('btnReqSync');
3483
- if (btn) btn.textContent = REQ_BUSY_LABEL;
3484
- reqPostConsolidate(w.dir || w.id || '')
3485
- .then(function (d) {
3486
- ok++;
3487
- created += Number(d.created) || 0;
3488
- updated += Number(d.updated) || 0;
3489
- })
3490
- .catch(function (e) {
3491
- fail++;
3492
- if (!firstErr) firstErr = (e && e.message) || String(e);
3493
- })
3494
- .then(step);
3495
- }
3496
- step();
3497
- }
3498
- on('btnReqSync', function () { consolidateAllRequirements(); });
3499
3803
 
3500
3804
  function openHostSheet(title, build) {
3501
3805
  closeHostSheet();
@@ -3620,6 +3924,16 @@ button.setrow{font-family:inherit}
3620
3924
  丢了它这条排队任务就会从「团队」静默降级成单任务。 */
3621
3925
  team: info.team || null,
3622
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',
3623
3937
  hide: !!hide, dev: devKey(), at: Date.now() };
3624
3938
  PENDING.push(p); saveQueue();
3625
3939
  if (!hide) renderSessions(LAST_SESSIONS);
@@ -3640,11 +3954,12 @@ button.setrow{font-family:inherit}
3640
3954
  (2026-10-21 用户:「非团队的串行排队也没落库」)。
3641
3955
  这里把它接进同一个 PENDING:进队即写 magic.serial.queue.v1、轮到就删行、取消就清盘。
3642
3956
  重开 App 后由 restoreQueue 直接下发(不挂会话页面,事件仍进会话日志,点开可回放)。 */
3643
- var QPEND = {}; // 组件条目 id(q…)→ 本端 PENDING id(p…)
3957
+ var QPEND = {}; // 组件条目 id(q… / 重新接管后的 p…)→ 本端 PENDING id(p…)
3644
3958
  function queuePushed(info) {
3645
3959
  if (!info || !info.id || !info.prompt || QPEND[info.id]) return;
3646
3960
  /* hide:用户此刻就在会话里,气泡已经画出来了 —— 列表那条「等待中」延后
3647
3961
  PENDING_SHOW_MS 再出现(与 gateAsk 同一口径:空间本来就空闲时不会闪一条假排队)。 */
3962
+ info.owner = 'conv';
3648
3963
  var p = addPending(wsKey(info.workspace), info, true);
3649
3964
  QPEND[info.id] = p.id;
3650
3965
  setTimeout(function () {
@@ -3689,7 +4004,10 @@ button.setrow{font-family:inherit}
3689
4004
  session: p.session || '',
3690
4005
  prompt: p.prompt || '', engine: p.engine || '', model: p.model || '',
3691
4006
  permission: p.permission || '', team: p.team || null,
3692
- attachments: p.attachments || [],
4007
+ attachments: p.attachments || [], atts: p.atts || [],
4008
+ /* owner 刻意不落盘:重启后所有残留条目都由 restoreQueue 交给宿主
4009
+ (enqueueDispatch),语义与 'host' 一致 —— 存下来反而会让「上次是
4010
+ 组件排队」的那条永远等不到组件接管。 */
3693
4011
  dev: p.dev, at: p.at };
3694
4012
  });
3695
4013
  try { localStorage.setItem(QUEUE_KEY, JSON.stringify(others.concat(mineRows))); } catch (e) {}
@@ -3776,6 +4094,35 @@ button.setrow{font-family:inherit}
3776
4094
  check();
3777
4095
  });
3778
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
+ }
3779
4126
  /* 排队行的出现时机:**进队即落库**(关 App 也不能丢,这是用户报障的正题);
3780
4127
  列表上那条「等待中」行则由**是否真的要等**决定可见性(见 gateAsk 的 markWaiting)。
3781
4128
  这个常量只剩一处用处:组件内排队(queuePushed,同一会话上一轮还没收尾)在
@@ -3898,6 +4245,9 @@ button.setrow{font-family:inherit}
3898
4245
  var list = loadQueue();
3899
4246
  if (!list.length) return;
3900
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'; });
3901
4251
  PENDING = list;
3902
4252
  renderSessions(LAST_SESSIONS);
3903
4253
  list.forEach(function (p) { enqueueDispatch(p); });
@@ -3907,11 +4257,20 @@ button.setrow{font-family:inherit}
3907
4257
  var q = serialQ(ws);
3908
4258
  /* 重新尝试(用户点「未能启动」那条):先把红字连原因一起摘掉,回到正常的排队态。 */
3909
4259
  if (p.stalled) { p.stalled = false; p.stalledReason = ''; renderSessions(LAST_SESSIONS); }
3910
- var mine = q.tail.then(function () { return waitForSlot(ws, q); }).then(function () {
3911
- /* 断线 / 还没配对:**留在队列里**,等下次连上再发 —— 不能当成「已下发」删掉。 */
3912
- if (!connected()) return false;
3913
- return fireQueued(p);
3914
- });
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
+ });
3915
4274
  q.tail = mine.catch(function () { return false; });
3916
4275
  return mine.then(function (sent) {
3917
4276
  if (!sent) return; // 没落盘:留在队列里(stalled),不删
@@ -4225,6 +4584,7 @@ button.setrow{font-family:inherit}
4225
4584
  localStorage.setItem('magic.engine', RUN_PREF.engine || '');
4226
4585
  localStorage.setItem('magic.model', RUN_PREF.model || '');
4227
4586
  } catch (e) {}
4587
+ schedulePushPrefs();
4228
4588
  paintRunPref();
4229
4589
  }
4230
4590
  function paintRunPref() {
@@ -4257,14 +4617,13 @@ button.setrow{font-family:inherit}
4257
4617
  var sum = document.getElementById('teamSummary');
4258
4618
  if (sum) sum.textContent = custom ? (custom + ' 项已单独指定') : '跟随主引擎';
4259
4619
  }
4260
- /* 团队模式子页面 = **两张卡**(用户 2026-10-22:「配引擎和配规则要分开配置 不要在 1 个页面」):
4261
- #teamRouteCard「交给谁」= 计划 + 7 个角色的引擎/模型,点开只有引擎;
4262
- #teamRuleCard「规则」= 计划的拆解口径 + 7 个角色的「适合什么」,点开只有规则。
4620
+ /* 团队模式子页面 = **一张卡、每行两个触发点**(用户 2026-10-22:「两个触发就行了」):
4621
+ 左边(名字 + 规则描述)→ 只改规则;右边(引擎值)→ 只选引擎。
4622
+ 两块配置分属两个抽屉,但不必各占一整张卡。
4263
4623
  签名含两侧文案与引擎:变了才重建,避免 5s 轮询时闪。 */
4264
4624
  function paintTeamRoles(roles) {
4265
- var routeCard = document.getElementById('teamRouteCard');
4266
- var ruleCard = document.getElementById('teamRuleCard');
4267
- if (!routeCard || !ruleCard) return;
4625
+ var card = document.getElementById('teamRouteCard');
4626
+ if (!card) return;
4268
4627
  var show = teamSupported();
4269
4628
  var plan = (TEAM.routes || {}).plan || {};
4270
4629
  var planSub = '拆解:' + ({ coarse: '少', standard: '标准', fine: '细' }[TEAM.split] || '标准')
@@ -4277,50 +4636,36 @@ button.setrow{font-family:inherit}
4277
4636
  }).join(',');
4278
4637
  if (sig === TEAM_ROLE_SIG) return;
4279
4638
  TEAM_ROLE_SIG = sig;
4280
- routeCard.innerHTML = '';
4281
- ruleCard.innerHTML = '';
4639
+ card.innerHTML = '';
4282
4640
  if (!show) return;
4283
- /* 上卡:只讲「交给谁」,右侧是引擎;不展示规则描述。 */
4284
- addTeamRouteRow(routeCard, 'plan', '计划任务');
4285
- roles.forEach(function (r) {
4286
- addTeamRouteRow(routeCard, 'role:' + r.name, TEAM_ROLE_LABELS[r.name] || r.name);
4287
- });
4288
- /* 下卡:只讲「规则」——计划是拆解口径,角色是「这类适合什么」。 */
4289
- addTeamRuleRow(ruleCard, 'plan', '计划任务规则', planSub);
4641
+ addTeamSplitRow(card, 'plan', '计划任务', planSub);
4290
4642
  roles.forEach(function (r) {
4291
- var label = (TEAM_ROLE_LABELS[r.name] || r.name) + '规则';
4292
- addTeamRuleRow(ruleCard, 'role:' + r.name, label, teamRoleDuty(r));
4643
+ addTeamSplitRow(card, 'role:' + r.name, TEAM_ROLE_LABELS[r.name] || r.name, teamRoleDuty(r));
4293
4644
  });
4294
4645
  }
4295
- /* 上卡的一行:名字 + 右侧引擎。点开 = 只选引擎/模型。 */
4296
- function addTeamRouteRow(card, key, label) {
4646
+ /* 一行两个点击区:左边改规则,右边选引擎。
4647
+ ⚠️ 外层必须是 div —— button 不能套 button;`.setrow` 的边框/间距照常用在外层。 */
4648
+ function addTeamSplitRow(card, key, label, sub) {
4297
4649
  var rt = (TEAM.routes || {})[key] || {};
4298
- var b = hEl('button', 'setrow');
4299
- b.type = 'button';
4300
- var main = hEl('span', 'setrow__main');
4650
+ var row = hEl('div', 'setrow setrow--split');
4651
+ var main = hEl('button', 'setrow__mainbtn');
4652
+ main.type = 'button';
4301
4653
  main.appendChild(hEl('span', 'setrow__l', label));
4302
- b.appendChild(main);
4303
- b.appendChild(hEl('span', 'setrow__v',
4654
+ if (sub) main.appendChild(hEl('span', 'setrow__sub', sub));
4655
+ main.onclick = function () { openTeamRuleSheet(key, label + ':规则'); };
4656
+ row.appendChild(main);
4657
+ var v = hEl('button', 'setrow__vbtn');
4658
+ v.type = 'button';
4659
+ v.appendChild(hEl('span', 'setrow__v',
4304
4660
  rt.engine ? (rt.engine + (rt.model ? ' · ' + qaModelText(rt.model) : '')) : '跟随主引擎'));
4305
- b.appendChild(hEl('span', 'setrow__chev', '›'));
4306
- b.onclick = function () {
4661
+ v.appendChild(hEl('span', 'setrow__chev', '›'));
4662
+ v.onclick = function () {
4307
4663
  openTeamEnginePicker(key,
4308
4664
  String(key).indexOf('role:') === 0 ? (label + '交给谁') : '计划任务交给谁',
4309
4665
  teamRoleKindOf(key));
4310
4666
  };
4311
- card.appendChild(b);
4312
- }
4313
- /* 下卡的一行:名字 + 当前规则。点开 = 只改规则(不含引擎)。 */
4314
- function addTeamRuleRow(card, key, label, sub) {
4315
- var b = hEl('button', 'setrow');
4316
- b.type = 'button';
4317
- var main = hEl('span', 'setrow__main');
4318
- main.appendChild(hEl('span', 'setrow__l', label));
4319
- if (sub) main.appendChild(hEl('span', 'setrow__sub', sub));
4320
- b.appendChild(main);
4321
- b.appendChild(hEl('span', 'setrow__chev', '›'));
4322
- b.onclick = function () { openTeamRuleSheet(key, label); };
4323
- card.appendChild(b);
4667
+ row.appendChild(v);
4668
+ card.appendChild(row);
4324
4669
  }
4325
4670
  function teamRoleKindOf(key) {
4326
4671
  if (String(key).indexOf('role:') !== 0) return 'plan';
@@ -4747,6 +5092,9 @@ button.setrow{font-family:inherit}
4747
5092
  document.getElementById('btnConnect').onclick = function () {
4748
5093
  var target = parseTarget(addrEl.value);
4749
5094
  if (!target) { showError('请输入 magic-agent 桌面地址(形如 http://192.168.1.5:8791/?token=…)'); return; }
5095
+ /* 名称栏(2026-10-22):手填的优先于地址里的 ?name=,连上后就是环境名。 */
5096
+ var nm = connNameEl ? String(connNameEl.value || '').trim() : '';
5097
+ if (nm) target.name = nm;
4750
5098
  connect(target);
4751
5099
  };
4752
5100
  addrEl.addEventListener('input', function () { showError(''); });
@@ -4778,6 +5126,39 @@ button.setrow{font-family:inherit}
4778
5126
  document.addEventListener('click', function () {
4779
5127
  if (!runSettingsPageEl.hidden) paintRunSettings();
4780
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
+ }
4781
5162
  Array.prototype.forEach.call(tabbarEl.querySelectorAll('.tab'), function (b) {
4782
5163
  b.onclick = function () {
4783
5164
  var t = b.getAttribute('data-tab');
@@ -4792,10 +5173,6 @@ button.setrow{font-family:inherit}
4792
5173
  归属只在用户显式选空间时确定,不能让上一次的 WS 悄悄粘过来。 */
4793
5174
  on('btnFabNew', function () { newConversation(); });
4794
5175
  on('btnMineBack', function () { showHistory(); });
4795
- /* 我的 → 环境(多连接):列表(点行编辑)+ 新建;返回回「我的」。 */
4796
- on('btnEnvPage', function () { showEnvPage('mine'); });
4797
- on('btnEnvBack', function () { if (ENV_BACK === 'tasks') showHistory(); else showMine(); });
4798
- on('btnEnvAdd', function () { openEnvEditor(null); });
4799
5176
  on('dscBrowser', function () { openBrowser(HOME_URL); });
4800
5177
  on('dscApps', function () { openComingSheet('应用', '暂无可用应用。桌面端发布的应用会出现在这里。'); });
4801
5178
  on('dscPlugins', function () { showPlugins(); });
@@ -4819,14 +5196,25 @@ button.setrow{font-family:inherit}
4819
5196
  on('btnTeamBack', showMine);
4820
5197
  on('btnLogout', disconnect);
4821
5198
  on('btnUpgrade', reloadApp);
5199
+ on('btnMineVersion', checkAppUpdate);
4822
5200
  if (segTheme) takeSeg(segTheme, 'magic.theme', function (v) { PREFS.theme = v; applyThemePref(v); });
4823
5201
  if (segFont) takeSeg(segFont, 'magic.font', function (v) { PREFS.font = v; applyFontPref(v); });
4824
5202
  if (segMode) takeSeg(segMode, 'magic.taskmode', function (v) { TASK_MODE = v; });
4825
- if (tglHaptic) tglHaptic.onclick = function () {
4826
- PREFS.haptic = (PREFS.haptic === 'off') ? 'on' : 'off';
4827
- savePref('magic.haptic', PREFS.haptic); applyHapticPref(PREFS.haptic);
4828
- tglHaptic.classList.toggle('is-on', PREFS.haptic !== 'off');
4829
- };
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
+ }
4830
5218
 
4831
5219
  /* ══════════ 网络代理(VPN 隧道)══════════
4832
5220
  把整机流量经隧道送到桌面端的 SOCKS5 出口:TUN → 内核 → 桌面端 → 互联网。
@@ -4880,12 +5268,13 @@ button.setrow{font-family:inherit}
4880
5268
  scanEl.hidden = true;
4881
5269
  }
4882
5270
  document.getElementById('btnScanClose').onclick = stopScan;
4883
- document.getElementById('btnScan').onclick = function () {
5271
+ /* 通用扫码:扫到就把原文交给回调(启动页用来直连,「添加环境」用来填链接)。 */
5272
+ function startScan(onResult, onError) {
5273
+ var fail = onError || function (m) { toast(m); };
4884
5274
  if (!('BarcodeDetector' in window) || !navigator.mediaDevices) {
4885
- showError('当前环境不支持扫码,请把桌面配对页的「桌面地址」粘贴到输入框。');
5275
+ fail('当前环境不支持扫码,请把桌面给的地址粘贴进来。');
4886
5276
  return;
4887
5277
  }
4888
- showError('');
4889
5278
  scanEl.hidden = false;
4890
5279
  navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }).then(function (stream) {
4891
5280
  scanStream = stream;
@@ -4898,19 +5287,47 @@ button.setrow{font-family:inherit}
4898
5287
  scanDetector.detect(scanVideo).then(function (codes) {
4899
5288
  if (!codes || !codes.length) return;
4900
5289
  stopScan();
4901
- var raw = codes[0].rawValue || '';
4902
- var target = parseTarget(raw);
4903
- if (target) connect(target);
4904
- else showError('这个二维码不是 magic-agent 连接地址。');
5290
+ onResult(codes[0].rawValue || '');
4905
5291
  }).catch(function () {});
4906
5292
  }, 400);
4907
5293
  }).catch(function () {
4908
5294
  stopScan();
4909
- showError('无法启动相机,请改用粘贴地址的方式。');
5295
+ fail('无法启动相机,请改用粘贴地址的方式。');
4910
5296
  });
5297
+ }
5298
+ document.getElementById('btnScan').onclick = function () {
5299
+ showError('');
5300
+ startScan(function (raw) {
5301
+ var target = parseTarget(raw);
5302
+ if (!target) { showError('这个二维码不是 magic-agent 连接地址。'); return; }
5303
+ var nm = connNameEl ? String(connNameEl.value || '').trim() : '';
5304
+ if (nm) target.name = nm;
5305
+ connect(target);
5306
+ }, function (m) { showError(m); });
4911
5307
  };
4912
5308
 
4913
5309
  window.addEventListener('pagehide', stopScan);
5310
+ /* 关掉 App / 刷新之前也把这台的本机状态记进它自己的槽:
5311
+ 用户改完设置不一定马上切环境,不记就会在下次启动时丢。 */
5312
+ window.addEventListener('pagehide', function () {
5313
+ if (!TOKEN || ENV_SWITCHING) return;
5314
+ try { saveEnvScope(envScopeId()); } catch (e) {}
5315
+ });
5316
+
5317
+ /* 壳注入的那条连接换令牌了(App 每次启动都会换):把存档里同地址的记录同步过去。
5318
+ 否则「切回本机」会用一枚已经失效的旧令牌去连,表现成「这台连不上」。 */
5319
+ function syncInjectedConnToken(url, tok) {
5320
+ if (!url || !tok) return;
5321
+ var list = loadConns(), changed = false;
5322
+ for (var i = 0; i < list.length; i++) {
5323
+ var c = list[i];
5324
+ if (!c || c.url !== url || String(c.token || '') === String(tok)) continue;
5325
+ c.token = tok;
5326
+ c.id = connIdOf({ url: url, token: tok });
5327
+ changed = true;
5328
+ }
5329
+ if (changed) saveConns(list);
5330
+ }
4914
5331
 
4915
5332
  /* ══════════ 首屏 ══════════
4916
5333
  · 打包进 App(Capacitor):boot.js 注入 window.MAGIC_BASE / MAGIC_TOKEN → 直连会话;
@@ -4919,16 +5336,56 @@ button.setrow{font-family:inherit}
4919
5336
  var injectedToken = window.MAGIC_TOKEN || new URLSearchParams(location.search).get('token') || '';
4920
5337
  var injectedBase = (window.MAGIC_BASE || '').replace(/\/+$/, '');
4921
5338
  try {
4922
- if (injectedToken || injectedBase) {
5339
+ /* 上次停在哪个环境(见 switchEnv):切过环境后整页重载,必须回到**那台**,
5340
+ 否则壳注入的地址会把人拽回本机 —— 用户会看到「切了又跳回来」。 */
5341
+ var injUrl = injectedBase || (injectedToken ? location.origin : '');
5342
+ syncInjectedConnToken(injUrl, injectedToken);
5343
+ var resumed = false;
5344
+ try {
5345
+ var lastUrl = localStorage.getItem(ACTIVE_ENV_KEY) || '';
5346
+ if (lastUrl) {
5347
+ var saved = loadConns();
5348
+ for (var i = 0; i < saved.length; i++) {
5349
+ var sc = saved[i];
5350
+ if (!sc || sc.url !== lastUrl) continue;
5351
+ /* 同一地址(壳注入的这台)令牌以注入为准:App 每次启动会换令牌,存档里的旧令牌已失效。 */
5352
+ var tok = (lastUrl === injUrl && injectedToken)
5353
+ ? injectedToken : (sc.token || injectedToken);
5354
+ if (!tok) break;
5355
+ BASE = sc.url; TOKEN = tok; ACTIVE_ID = sc.id;
5356
+ showHistory(sc.host || connHost(sc.url));
5357
+ restoreQueue();
5358
+ resumed = true;
5359
+ break;
5360
+ }
5361
+ }
5362
+ } catch (e) { resumed = false; }
5363
+ if (!resumed && (injectedToken || injectedBase)) {
4923
5364
  BASE = injectedBase; TOKEN = injectedToken;
5365
+ /* 把当前这条(壳注入的)连接落成环境,名字默认「公司远程」——
5366
+ 否则它只是一条改不了名、也留不住的「未命名」。 */
5367
+ seedCurrentConn();
4924
5368
  showHistory(injectedBase ? injectedBase.replace(/^https?:\/\//, '') : '本机');
4925
5369
  restoreQueue(); // App 重启:上次没轮到的排队任务接着排(见 restoreQueue)
4926
- } else {
4927
- showLauncher();
5370
+ } else if (!resumed) {
5371
+ /* 没有注入、也没有可恢复的上次环境:有存过的环境就直接连最近用过的那条;
5372
+ 一条都没有才进任务页空态 —— 不再用独立启动页挡在前面
5373
+ (2026-10-22 用户:「这个界面不需要了 直接切换环境就行」)。 */
5374
+ var all = loadConns().filter(function (c) { return c && c.url; });
5375
+ all.sort(function (a, b) { return (b.at || 0) - (a.at || 0); });
5376
+ var pick = all[0];
5377
+ if (pick) {
5378
+ BASE = pick.url; TOKEN = pick.token || ''; ACTIVE_ID = pick.id;
5379
+ try { localStorage.setItem(ACTIVE_ENV_KEY, pick.url); } catch (e) {}
5380
+ showHistory(pick.host || connHost(pick.url));
5381
+ restoreQueue();
5382
+ } else {
5383
+ showHistory(null);
5384
+ }
4928
5385
  }
4929
5386
  } catch (e) {
4930
- /* 任何初始化异常都退回启动页,绝不留白屏。 */
4931
- try { showLauncher(); } catch (e2) {}
5387
+ /* 任何初始化异常都退回任务页空态,绝不留白屏。 */
5388
+ try { showHistory(null); } catch (e2) { try { showLauncher(); } catch (e3) {} }
4932
5389
  }
4933
5390
 
4934
5391
  /* ══════════ 启动闪屏:掌天瓶灌绿液(2026-10-20 用户:「app 启动动画也要有绿液」)══════════