magic-agent 1.0.110 → 2.1.5

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.
@@ -206,6 +206,10 @@ textarea{font:inherit}
206
206
  .field label{display:block;font-size:12px;font-weight:500;color:var(--muted);margin-bottom:4px}
207
207
  .field textarea{display:block;width:100%;border:0;outline:0;resize:none;background:none;
208
208
  color:var(--ink);font:15px/20px var(--mono);min-height:40px}
209
+ /* 名称栏(2026-10-22 用户:「添加连接也没有输入名字的地方」):单行输入,与地址栏同样式 */
210
+ .field input{display:block;width:100%;height:40px;border:0;outline:0;background:none;
211
+ color:var(--ink);font-family:inherit;font-size:15px;line-height:20px;-webkit-appearance:none}
212
+ .field+.field{margin-top:12px}
209
213
  .field__err{color:var(--error-text);font-size:12px;line-height:16px;margin-top:6px}
210
214
  /* Button(filled)/ OutlinedButton:40dp 高 · 全圆角 */
211
215
  .btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;height:40px;
@@ -361,6 +365,12 @@ textarea{font:inherit}
361
365
  .wsrow__open{flex:1;display:flex;align-items:center;gap:12px;min-width:0;padding:10px 8px;border-radius:14px;text-align:left}
362
366
  .wsrow__open:active{background:var(--subtle)}
363
367
  .wsrow__l{flex:1;min-width:0;font-size:15px;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
368
+ /* 空间规模(几模块 · 几条):紧跟在空间名后面,中性色 —— 不是状态,别用徽标色。
369
+ 需求页专属:空间名改成自适应宽度,meta 才会贴在名字后面;meta 的 margin-right:auto
370
+ 把「待整理」徽标和箭头推到行尾。 */
371
+ .wsrow__open--req .wsrow__l{flex:0 1 auto}
372
+ .wsrow__open--req .wsrow__meta{margin-right:auto}
373
+ .wsrow__meta{flex:0 1 auto;min-width:0;font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
364
374
  /* 串行等待中的任务数:空间组折叠时也要看得见(颜色与「等待中」行一致) */
365
375
  .wsrow__badge{flex:0 0 auto;padding:1px 7px;border-radius:var(--r-full);font-size:11px;line-height:16px;
366
376
  color:var(--queued);background:var(--tint-queued);white-space:nowrap}
@@ -377,6 +387,24 @@ textarea{font:inherit}
377
387
  /* 「未初始化」与「待整理」同形不同色:前者是「还没按目录生成过基线」,
378
388
  后者是「有对话等着归纳」,两种提示不能混成一个数字。 */
379
389
  .wsrow__badge--init{color:var(--warning);background:var(--tint-wait)}
390
+
391
+ /* ══════════ 需求页:两级(空间→模块 → 需求列表)══════════
392
+ 一级是**目录**:空间是行,模块缩进挂在空间下(树形),功能点一律不画在这里 ——
393
+ 需求一多,一级就会被功能点撑成长列表,什么结构都看不出来。
394
+ 二级才是内容:点某个模块进去,看**该空间下所有模块**的需求列表。 */
395
+ .reqtree__mod{position:relative;display:flex;align-items:center;gap:8px;width:100%;
396
+ padding:9px 10px 9px 30px;background:none;border:0;text-align:left;color:var(--ink)}
397
+ .reqtree__mod:active{background:var(--subtle)}
398
+ /* 连接线:从空间名垂下来的细线 + 每个模块前的一小截横线 —— 归属关系靠线说话,不靠缩进猜 */
399
+ .reqtree__mod::before{content:"";position:absolute;left:14px;top:0;bottom:0;width:1px;background:var(--border)}
400
+ .reqtree__mod::after{content:"";position:absolute;left:14px;top:50%;width:10px;height:1px;background:var(--border)}
401
+ .reqtree__modname{flex:1;min-width:0;font-size:14px;font-weight:500;color:var(--ink);
402
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
403
+ .reqtree__modcount{flex:0 0 auto;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
404
+ .reqtree__modgo{flex:0 0 auto;color:var(--faint);display:flex;align-items:center}
405
+ /* 二级视图的分组小节:当前所在模块高亮,其余弱化 */
406
+ .reqdetail__sec--cur .sechead{color:var(--primary-action)}
407
+ .reqdetail__sec--cur .sechead__t{font-weight:800}
380
408
  /* 长按会话行的浮动菜单 */
381
409
  .rowmenu{position:fixed;z-index:1201;min-width:184px;background:var(--raised);border:1px solid var(--border);
382
410
  border-radius:14px;box-shadow:0 14px 34px -12px var(--app-shadow);padding:6px}
@@ -385,7 +413,7 @@ textarea{font:inherit}
385
413
  .rowmenu__item:active{background:var(--subtle)}
386
414
  .rowmenu__item--danger{color:var(--error-text)}
387
415
  .rowmenu__l{flex:1;min-width:0}
388
- /* 环境切换菜单(挂在任务页标题下):已保存的连接 + 「添加连接」。
416
+ /* 环境切换菜单(挂在任务页标题下):已保存的连接 + 「环境设置」/「扫码或粘贴地址」。
389
417
  与会话行长按菜单同一套浮层范式(scrim 挡住下层,点空白即关)。 */
390
418
  .envmenu{position:fixed;z-index:1201;width:min(320px,calc(100vw - 24px));
391
419
  max-height:min(70vh,560px);overflow-y:auto;
@@ -405,7 +433,13 @@ textarea{font:inherit}
405
433
  font-size:11px;font-weight:600;line-height:17px;color:var(--primary-action);background:var(--subtle)}
406
434
  .envmenu__s{font-size:12px;line-height:16px;color:var(--muted);
407
435
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
408
- .envmenu__check{flex:0 0 auto;color:var(--primary-action)}
436
+ /* 当前连接用**底色**区分,不再挂一枚 ✓(2026-10-22 用户:「不需要选中这个标识 用底色区分」)。
437
+ 底色要能压过同一行的 :active 态,所以写在它后面。 */
438
+ .envmenu__item.is-active{background:var(--subtle)}
439
+ /* 每行的修改入口:铅笔比「›」更明确是「编辑」,且不与整行的「切换」动作打架 */
440
+ .envmenu__edit{flex:0 0 auto;width:36px;height:36px;border-radius:50%;
441
+ display:flex;align-items:center;justify-content:center;color:var(--muted)}
442
+ .envmenu__edit:active{background:var(--subtle);color:var(--ink)}
409
443
  .envmenu__sep{display:block;height:1px;margin:6px 4px;background:var(--border)}
410
444
  .envmenu__add{display:flex;align-items:center;gap:10px;width:100%;padding:12px;border-radius:12px;
411
445
  font-size:15px;font-weight:500;color:var(--primary-action);text-align:left}
@@ -417,8 +451,10 @@ textarea{font:inherit}
417
451
  box-shadow:0 12px 30px -12px var(--app-shadow)}
418
452
  button.setrow{font-family:inherit}
419
453
  /* 默认引擎/模型选择器:左列「自动」与正在浏览的引擎不能都画成选中态(两个浅蓝块会让人
420
- 分不清哪个是已保存的默认)。is-on = 已保存;is-cur = 右列此刻在浏览谁。 */
421
- .pick__citem.is-cur{background:var(--subtle)}
454
+ 分不清哪个是已保存的默认)。is-on = 已保存(实底);is-cur = 右列此刻在浏览谁。
455
+ 浏览态**不铺底**,只留一道左侧细条 + 主色文字 —— 有底色就会被读成「第二个选中项」。 */
456
+ .pick__citem.is-cur{box-shadow:inset 2px 0 0 var(--primary-action)}
457
+ .pick__citem.is-cur .pick__cl{color:var(--primary-action)}
422
458
 
423
459
  /* ══════════ 发现:插件目录 ══════════
424
460
  两项 magic-* 工程的 CLI(magic-shorts / magic-video)。数据来自桌面端 `/desk/plugins`:
@@ -460,8 +496,26 @@ button.setrow{font-family:inherit}
460
496
  .setrow+.setrow{border-top:1px solid var(--border)}
461
497
  .setrow__l{flex:1;min-width:0;font-size:15px;color:var(--ink)}
462
498
  .setrow__v{flex:0 0 auto;font-size:14px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:50%}
499
+ /* 有新版本时这一格变成可点的「升级」提示(2026-10-22 用户:「版本现在也没点击升级的地方」) */
500
+ .setrow__v.is-upgradable{color:var(--primary-action);font-weight:600}
463
501
  .setrow--danger{color:var(--error-text)}
464
502
  .setrow--danger .setrow__l,.setrow--danger .setrow__v{color:var(--error-text)}
503
+ /* 环境编辑(我的 → 环境):名称 / 地址 / 令牌三栏。
504
+ 环境不能只靠扫码「连过才记得」—— 用户要显式配置 IP 和名字(2026-10-22)。 */
505
+ .envfield{display:block;width:100%;height:44px;margin-top:6px;padding:0 12px;border-radius:10px;
506
+ background:var(--subtle);border:1px solid transparent;color:var(--ink);font-size:15px;
507
+ -webkit-appearance:none;outline:none}
508
+ .envfield:focus{border-color:var(--primary-action)}
509
+ .envfield::placeholder{color:var(--faint)}
510
+ .envlabel{display:block;margin-top:14px;font-size:12px;font-weight:700;color:var(--muted)}
511
+ /* 链接栏下方的「扫码填入」:文字按钮,不抢「保存」的位置 */
512
+ .envscan{display:block;margin-top:8px;padding:6px 0;font-size:13px;font-weight:600;color:var(--primary-action)}
513
+ .envhint{margin-top:10px;font-size:12px;line-height:1.6;color:var(--faint)}
514
+ .envacts{display:flex;gap:8px;margin-top:16px}
515
+ .envacts .btn{flex:1;margin-top:0}
516
+ .envdel{margin-top:14px;width:100%;height:40px;border-radius:var(--r-full);
517
+ color:var(--error-text);font-size:14px;font-weight:600}
518
+ .envdel:active{background:var(--subtle)}
465
519
  /* 两行式配置行(团队模式):左列是「名字 + 这条适合什么」,右列是引擎。
466
520
  规则描述必须常显 —— 用户要的是「每类适合什么」一眼可见,而不是藏进详情页。 */
467
521
  .setrow__main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
@@ -473,10 +527,16 @@ button.setrow{font-family:inherit}
473
527
  font:13px/1.55 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;-webkit-appearance:none}
474
528
  .teamedit::placeholder{color:var(--faint)}
475
529
  .teamedit__acts{display:flex;gap:8px;margin-bottom:10px}
476
- /* 团队抽屉里的分区标题(规则 / 交给谁)与行尾箭头:点开才有的配置必须看得出「这里能点」。 */
477
- .pick__cap--team{padding:10px 4px 4px;font-size:11px;font-weight:700;letter-spacing:.04em;color:var(--muted)}
530
+ /* 团队行尾箭头:点开才有的配置必须看得出「这里能点」。 */
478
531
  .setrow__chev{flex:0 0 auto;color:var(--faint);font-size:16px;line-height:1}
479
- .pick__cols--team{height:min(36vh,36dvh,300px)}
532
+ /* 团队行 = **一行两个点击区**(用户 2026-10-22:「两个触发就行了」):
533
+ 左边名字 + 规则描述 → 只改规则;右边引擎值 → 只选引擎。外层是 div(button 不能套 button)。 */
534
+ .setrow--split{padding:0;gap:4px}
535
+ .setrow__mainbtn{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;
536
+ padding:14px 2px 14px 0;text-align:left}
537
+ .setrow__vbtn{flex:0 0 auto;display:flex;align-items:center;gap:4px;max-width:52%;
538
+ padding:14px 0 14px 10px;color:var(--muted);text-align:right}
539
+ .setrow__vbtn .setrow__v{max-width:100%}
480
540
  .seg{display:inline-flex;background:var(--subtle);border-radius:var(--r-full);padding:2px;gap:2px}
481
541
  .seg__b{height:28px;padding:0 12px;border-radius:var(--r-full);font-size:13px;color:var(--muted)}
482
542
  .seg__b.is-on{background:var(--raised);color:var(--ink);font-weight:600}
@@ -491,32 +551,34 @@ button.setrow{font-family:inherit}
491
551
  从栏背滚过去,靠半透明 + 18px 模糊透出来。这样栏高不进布局,会话页的输入框也不用被它顶上去。
492
552
 
493
553
  代价是**每一页自己留出底部安全距离**,否则最后一行会被压在栏下面:
494
- · `.history__list` 已有 96px(够 74 + 34 safe-area)
554
+ · `.history__list` 已有 96px(够 56 + 34 safe-area,且给凸出的 + 留了一截)
495
555
  · 会话页由 composer 的下内边距负责,见下面的 `#conv .composer`
496
556
  · 启动页不显示底栏(showLauncher 里 hidden),无需留白
497
557
 
498
558
  min-height 而不是 height:底栏还要吞 safe-area(34px),
499
- border-box 下固定 height 会让内部 tab 被压到 26px 高、图标文字挤成一团。
500
- **74**(2026-10-22 定稿):68 时图标 23 + 选中浅底 28 + 标签 12 ≈ 50,上下只剩 18px;
501
- 74 给到上下各约 21px,选中浅底才有完整的一圈呼吸,不会被栏边切掉。 */
559
+ border-box 下固定 height 会让内部 tab 被压到 26px 高。
560
+ **56 必须写成 `56 + safe-area`**(2026-10-22 实机:「手机上现在 tab 上下没间距」):
561
+ `*{box-sizing:border-box}` 下 min-height 含 padding,56 里先被 safe-area 吃掉 34,
562
+ 内容只剩 22 —— 44×34 的选中浅底直接顶到了栏的上下边。桌面上 env()=0 看不出这个问题。
563
+ **56**(「底栏只保留图标不需要文字了」):去掉标签后只剩 25px 图标 + 44×34 浅底,
564
+ 56 给到浅底上下各 11px,栏体也矮下来。 */
502
565
  .tabbar{position:absolute;left:0;right:0;bottom:0;z-index:60;
503
566
  display:flex;align-items:stretch;
504
- min-height:74px;
567
+ min-height:calc(56px + env(safe-area-inset-bottom));
505
568
  background:color-mix(in srgb,var(--raised) 86%,transparent);
506
569
  -webkit-backdrop-filter:blur(18px) saturate(1.4);backdrop-filter:blur(18px) saturate(1.4);
507
570
  box-shadow:0 -1px 0 var(--border),0 -14px 30px -26px var(--app-shadow);
508
571
  padding-bottom:env(safe-area-inset-bottom)}
509
- .tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;
510
- padding:6px 0;
511
- color:var(--muted);font-size:10.5px;letter-spacing:.02em;transition:color .16s}
572
+ /* 只有图标:文案从底栏拿掉(2026-10-22 用户),语义交给 aria-label,不靠视觉标签。
573
+ 命中区仍是 flex:1 × 56,比"图标 + 文字"那版更高更宽,点起来更稳。 */
574
+ .tab{flex:1;display:flex;align-items:center;justify-content:center;
575
+ color:var(--muted);transition:color .16s}
512
576
  .tab.is-on{color:var(--primary-action)}
513
- /* 选中态不只换颜色,还给一块 38×28 的浅底(`--activity` 是现成的品牌色低透明令牌):
514
- 颜色是给"看得清"的,形状是给"摸得到"的 —— 暗色下主色是浅蓝,光靠颜色区分太弱。 */
515
- .tab .icw{display:grid;place-items:center;width:38px;height:28px;border-radius:9px;
577
+ /* 选中态不只换颜色,还给一块 44×34 的浅底(`--activity` 是现成的品牌色低透明令牌):
578
+ 没有文字之后,颜色是唯一的语义线索,必须再补一个形状 —— 暗色下主色是浅蓝,光靠颜色区分太弱。 */
579
+ .tab .icw{display:grid;place-items:center;width:44px;height:34px;border-radius:11px;
516
580
  transition:background .18s ease}
517
581
  .tab.is-on .icw{background:var(--activity)}
518
- .tab .lb{line-height:1.15;transition:font-weight .16s}
519
- .tab.is-on .lb{font-weight:600}
520
582
  .tab svg{display:block;transition:transform .16s}
521
583
  .tab:active svg{transform:scale(.92)}
522
584
  /* ── 「快速新建」:**半嵌入**底栏的圆形主按钮(2026-10-22 定稿)
@@ -534,42 +596,43 @@ button.setrow{font-family:inherit}
534
596
  于是看起来像按钮自带发光轮廓。
535
597
 
536
598
  实现:
537
- ① **洞**:`::before` 是一块悬在栏顶边界内的半圆(宽 38*2 / 高 38,下两角半径 38),
538
- 填槽底 `--fab-well`,在底栏上边缘切出一个半圆缺口。
539
- 高度只到 38,**不侵入上方内容**(试过 78px 整圆,会盖住列表底部)。
540
- ② **嵌**:按钮直径 60,圆心落在栏顶边线上(上露 30 / 下沉 30),
541
- 于是下半实打实坐进洞里。圆心压边线而不是抬高,是「嵌」与「放」的分界。
542
- ③ **接触影**:`radial-gradient` 把暗环**锚定在 r=30(按钮半径)**上,
543
- 从 29.5px 起最暗、到 33px 淡完。这是唯一能精确控制「暗在哪一圈」的写法 ——
599
+ ① **洞**:`::before` 是一块 74×74 的圆(半径 37 = 按钮 34 + 3 接触缝),圆心与按钮同心,
600
+ **再用 `clip-path` 切掉落在栏顶边线以上的部分** —— 这样槽的形状永远跟着按钮走,
601
+ 按钮下沉多少,露出的就是那一段圆弧,不用为每种下沉量另算半圆尺寸。
602
+ 填槽底 `--fab-well`,于是在底栏上边缘切出一道弧。
603
+ ② **嵌**:按钮直径 68,圆心落在栏顶边线**下方 10px**(上露 24 / 下沉 44,距栏底还剩 12)。
604
+ 不是齐边切一半(2026-10-22 用户:「不需要突出半圆 出来一部分即可 再下沉一些 注意保留间距」)。
605
+ ③ **接触影**:`radial-gradient` 把暗环**锚定在 r=34(按钮半径)**上,
606
+ 从 33.5px 起最暗、到 37px 淡完,圆心同样与按钮同心。这是唯一能精确控制「暗在哪一圈」的写法 ——
544
607
  `box-shadow: inset` 的暗边永远从元素边界向内,管不到元素外的那道槽。
545
- 实测梯度(正下方):浅色 189→237→255,暗色 21→27→31,两端都单调、无回亮。
546
- 几何全用相对量(`bottom:calc(100% - 30px)`、`top:0`),安全区变化时洞和按钮一起走。 */
547
- .tabfab{flex:0 0 76px}
608
+ 几何全用相对量(`bottom:calc(100% - 44px)`、`top:-27px`),安全区变化时洞和按钮一起走。
609
+
610
+ **两页一致**:会话页不再把「+」收进栏内(2026-10-22 用户:「圆没有突出了 …两个效果要一致」)。
611
+ 凸出的圆在列表页和对话页长得完全一样,只用 composer 的下内边距给它让出空间。 */
612
+ .tabfab{flex:0 0 84px}
548
613
  /* 洞 = 半圆 + 接触影。
549
614
  槽底是 `--fab-well`(栏面稍暗一档的**同色系**),不是 `--canvas`:
550
615
  用页面底色在暗色下会成黑箍,在浅色下又几乎看不见 —— 两头都不对。
551
616
  接触影必须画在槽底**之上**,且暗环两端用硬锚点,
552
617
  避免模糊半径把亮度峰值推到按钮外面(那正是前几版读成光晕的原因)。
553
618
 
554
- **露出的槽宽只有 3px**(洞半径 33 − 按钮半径 30)。曾经用 38(露 8px):
555
- 那圈灰色槽底读作「按钮下面垫了个盘子」,正是用户否掉的形态。
556
- 3px 只够出一条接触缝 —— 看得见槽、看不清槽底,就等于"切出来的"。
557
- 洞的高也取 33 而不是更深:槽只要在栏顶边界内切出缺口,深了只是浪费。 */
619
+ **露出的槽宽只有 3px**(槽半径 37 − 按钮半径 34)。曾经用更大:那圈灰色槽底读作「按钮下面垫了个盘子」,
620
+ 正是用户否掉的形态。3px 只够出一条接触缝 —— 看得见槽、看不清槽底,就等于"切出来的"。 */
558
621
  .tabbar::before{content:"";position:absolute;z-index:1;pointer-events:none;
559
- left:50%;top:0;transform:translateX(-50%);
560
- width:66px;height:33px;
561
- border-radius:0 0 33px 33px;
622
+ left:50%;top:-27px;transform:translateX(-50%);
623
+ width:74px;height:74px;border-radius:50%;
624
+ clip-path:inset(27px 0 0 0); /* 圆心在栏顶下方 10px:切掉露在外面的那一截,只留栏内的弧 */
562
625
  background:
563
- radial-gradient(circle 33px at 50% 0,
564
- var(--fab-notch) 29.5px,
565
- var(--fab-notch) 30.5px,
566
- color-mix(in srgb,var(--fab-notch) 35%,transparent) 32px,
567
- transparent 33px),
626
+ radial-gradient(circle 37px at 50% 50%,
627
+ var(--fab-notch) 33.5px,
628
+ var(--fab-notch) 34.5px,
629
+ color-mix(in srgb,var(--fab-notch) 35%,transparent) 36px,
630
+ transparent 37px),
568
631
  var(--fab-well)}
569
632
  .tabfab__b{position:absolute;z-index:2;
570
- left:50%;bottom:calc(100% - 30px); /* 圆心钉在栏顶边线上:上露 30 / 下沉 30 */
633
+ left:50%;bottom:calc(100% - 44px); /* 上露 24 / 下沉 44,距栏底 12 */
571
634
  transform:translateX(-50%);
572
- width:60px;height:60px;
635
+ width:68px;height:68px;
573
636
  display:flex;align-items:center;justify-content:center;
574
637
  color:var(--on-primary-action);
575
638
  background:linear-gradient(178deg,rgba(255,255,255,.14),rgba(255,255,255,0) 60%),var(--primary-action);
@@ -582,25 +645,14 @@ button.setrow{font-family:inherit}
582
645
  .tabfab__b:active{transform:translateX(-50%) translateY(2px);
583
646
  box-shadow:inset 0 -6px 12px -7px rgba(15,23,42,.56), inset 0 2px 3px -2px rgba(255,255,255,.26)}
584
647
  .tabfab__b svg{display:block;stroke-width:2.4;transform:translateY(-1px)}
585
- /* 会话页(2026-10-22 用户:「不要挤压对话框」「tab 跟会话列表一样是浮在上面」):
586
- 底栏浮起后不再占位,composer 只需让出「栏高 + 安全区 + 12px 呼吸」——
587
- 输入框贴着栏上沿 12px。原来 composer 下留 60(= +上探 30 + 呼吸 30),再叠上占位的 68 栏,
588
- 屏幕底到输入框一共 162;现在 74 + 34 + 12 = 120,对话区多出 42px,也再没有那段死区。
589
- 74 必须与 .tabbar 的 min-height 对齐(改栏高要一起改)。
648
+ /* 会话页(2026-10-22 用户:「不要挤压对话框」「两个效果要一致」「圆可以更大」「再下沉一些 注意保留间距」):
649
+ 底栏浮起后不占位,「+」在**两页都上露 24**(68 直径、圆心在栏顶下方 10),所以 composer 只需让出
650
+ 「上露 24 + 呼吸 12 + 栏高 56」—— 输入框落在凸出圆弧上方 12px,不遮不压。
651
+ 原来 composer 下留 60(= 上探 30 + 呼吸 30)再叠占位的 68 栏,屏幕底到输入框 162;
652
+ 现在 56 + 24 + 12 = 92,对话区多出 70px。
653
+ 56 必须与 .tabbar 的 min-height 对齐、24 必须与 .tabfab__b 的下沉量对齐(改一处要一起改)。
590
654
  **只叠一次**:safe-area 由这里一次性算进来,别再给 .composer 的基础 padding 加一遍。 */
591
- #conv .composer{padding-bottom:calc(12px + 74px + env(safe-area-inset-bottom))}
592
-
593
- /* 会话页的「+」收进槽里(不上浮):
594
- 输入框离栏只剩 12px,骑在边线上的 + 会顶到输入框下沿;而且这一屏已经有 composer
595
- 左侧的附件「+」,两个浮起的圆会让"哪个是新建"含混。
596
- 只改**高度关系**,不改颜色 / 水平位置:圆仍钉在正中,从"骑在边线上"变成"坐在栏里"。
597
- 直径 60 → 56 是跟着 74 的栏高走的(上下各留 9px),不是"缩小来躲"。 */
598
- body.is-conv .tabbar::before{display:none}
599
- body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-bottom) + 9px);
600
- box-shadow:
601
- inset 0 -6px 11px -6px rgba(15,23,42,.42),
602
- inset 0 2px 2px -2px rgba(255,255,255,.28),
603
- 0 6px 14px -8px rgba(15,23,42,.35)}
655
+ #conv .composer{padding-bottom:calc(12px + 24px + 56px + env(safe-area-inset-bottom))}
604
656
 
605
657
  /* 组件挂载点:占满剩余高度 */
606
658
  #conv{flex:1;min-height:0;display:flex}
@@ -848,8 +900,13 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
848
900
  <svg width="18" height="18"><use href="#i-back"/></svg><span>返回</span>
849
901
  </button>
850
902
  <h1 class="launcher__title">magic-agent · 远程控制</h1>
851
- <p class="launcher__desc">打开桌面配对页里的连接地址,远程继续你的会话。</p>
903
+ <p class="launcher__desc">填一个名字和桌面地址,或扫码配对。连过的桌面会保存下来,可在任务页标题处切换。</p>
852
904
 
905
+ <div class="field" id="nameField">
906
+ <label for="connName">名称(可选)</label>
907
+ <input id="connName" type="text" placeholder="例如 公司 Mac"
908
+ autocomplete="off" autocapitalize="off" spellcheck="false">
909
+ </div>
853
910
  <div class="field" id="field">
854
911
  <label for="addr">桌面地址</label>
855
912
  <textarea id="addr" rows="2" placeholder="http://192.168.1.5:8791/?token=…"
@@ -943,12 +1000,15 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
943
1000
  <button class="setrow" id="btnTeamPage"><span class="setrow__l">团队模式</span><span class="setrow__v" id="teamSummary">跟随主引擎</span></button>
944
1001
  </div>
945
1002
  <div class="setcard">
946
- <!-- 版本号(2026-10-22 用户:「移动端我的tab中显示版本号」)。
947
- 值由 /desk/health 的 version 给(= core 的 `--version`)—— 移动端界面是
948
- 桌面端 /m/ 远程加载的、本身不带版本,用户要看的是「连着的这份是哪一版」。
949
- 老 core 不给 version 时保持「—」,不写死。 -->
950
- <div class="setrow"><span class="setrow__l">版本</span><span class="setrow__v" id="mineVersion">—</span></div>
951
- <button class="setrow" id="btnUpgrade"><span class="setrow__l">检查更新</span><span class="setrow__v" id="upgradeState">重新加载</span></button>
1003
+ <!-- 版本号 + 升级(2026-10-22 用户:「移动端版本跟着npm走」+「在版本号的地方
1004
+ 可以显示版本也可检测最新版本号并触发升级」)。
1005
+ 显示的是 **APK 自己的版本**(构建时从 magic-agent/package.json 注入,闸门页
1006
+ 通过 ?appv= 带进来);浏览器预览没有 appv 时才回落到桌面端 health.version。
1007
+ 点这一行就去问桌面端内置的 APK 是哪一版,新了就直接触发下载安装。 -->
1008
+ <button class="setrow" id="btnMineVersion" type="button">
1009
+ <span class="setrow__l">版本</span><span class="setrow__v" id="mineVersion">—</span>
1010
+ </button>
1011
+ <button class="setrow" id="btnUpgrade"><span class="setrow__l">重新加载界面</span><span class="setrow__v" id="upgradeState">重新加载</span></button>
952
1012
  </div>
953
1013
  <div class="setcard">
954
1014
  <button class="setrow setrow--danger" id="btnLogout">断开连接</button>
@@ -959,10 +1019,10 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
959
1019
  <!-- ══════════ 我的 → 团队模式(计划 + 7 类子任务)══════════
960
1020
  从「我的」那一行点进来。这里配的全都是**设备级偏好**(开关本身按会话,在会话页运行设置里)。
961
1021
  **只有一套划分**:计划 + 7 个角色(writer / researcher / reviewer / coder / designer /
962
- director / builder)共 8 行,行内直接写清「这类适合什么」—— 不再并列「6 个类别」那一层
963
- (用户 2026-10-22:「只需要计划+7类子任务即可 描述好每一类适合的规则 规则、引擎都可配置」)。
964
- 每行点开 = 引擎/模型 + 规则:计划配拆解粒度与两个开关,角色配「这类任务适合什么」
965
- (写进 core 的拆解提示词,--team-rule role:<name>=…)。 -->
1022
+ director / builder),不再并列「6 个类别」那一层。
1023
+ 2026-10-22 用户:「两个触发就行了」—— 一行里**两个点击区**:
1024
+ 左边(名字 + 规则描述)点开只改规则;右边(引擎值)点开只选引擎。
1025
+ 不拆两张卡,也不把规则和引擎塞进同一个抽屉。 -->
966
1026
  <div class="history" id="teamPage" hidden>
967
1027
  <div class="history__top">
968
1028
  <button class="topbar__icon" id="btnTeamBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
@@ -971,7 +1031,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
971
1031
  <div class="history__list">
972
1032
  <div class="history__group">计划与 7 类子任务</div>
973
1033
  <div class="setcard" id="teamRouteCard"></div>
974
- <div class="history__hint" style="text-align:left">点一行配置引擎与规则。留空 = 跟随主引擎 / 使用内置描述。</div>
1034
+ <div class="history__hint" style="text-align:left">左边点规则,右边点引擎。规则留空 = 内置规则;引擎留空 = 跟随主引擎。</div>
975
1035
  </div>
976
1036
  </div>
977
1037
 
@@ -995,7 +1055,10 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
995
1055
 
996
1056
  <!-- ══════════ 需求(会话自动聚合成的功能点文档)══════════
997
1057
  数据全来自 GET /desk/requirements(core 的 --requirements,只读)。
998
- 按「工作区 → 模块 → 功能点」两级分组;点一行用既有的阅读器打开那篇 markdown。
1058
+ **两级**(2026-10-22 用户定稿):
1059
+ 一级 = 目录(空间 → 模块),**不画功能点** —— 一级只回答「这个空间有哪几块」;
1060
+ 二级 = 点某个模块进去,看**该空间下所有模块**的需求列表(当前模块高亮)。
1061
+ 点一行用既有的阅读器打开那篇 markdown。
999
1062
  ⚠️ 这里**不做筛选胶囊与搜索**:第一版只要「看得见 + 能点开」,多了反而没人用
1000
1063
  (用户 2026-10-22 定稿)。 -->
1001
1064
  <div class="history" id="reqs" hidden>
@@ -1006,6 +1069,16 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
1006
1069
  <div class="history__list" id="reqList"></div>
1007
1070
  </div>
1008
1071
 
1072
+ <!-- 需求的二级视图:某空间下所有模块的需求列表(从一级目录点模块进来) -->
1073
+ <div class="history" id="reqDetail" hidden>
1074
+ <div class="history__top">
1075
+ <button class="topbar__icon" id="btnReqDetailBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
1076
+ <span class="history__title" id="reqDetailTitle">需求</span>
1077
+ <button class="history__disconnect" id="btnReqDetailSync" type="button">整理</button>
1078
+ </div>
1079
+ <div class="history__list" id="reqDetailList"></div>
1080
+ </div>
1081
+
1009
1082
  <!-- ══════════ 发现(浏览器 / 应用 / 插件 / SKILL)══════════ -->
1010
1083
  <div class="history" id="discover" hidden>
1011
1084
  <div class="history__top"><span class="history__title">发现</span></div>
@@ -1124,18 +1197,20 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
1124
1197
  「定时任务」那个 tab 已删(2026-10-22 用户:「定时任务tab先删除」)——
1125
1198
  功能本身没删:会话菜单里的「定时任务」仍然能唤起,只是不再占一个底部入口
1126
1199
  (那一页本来也只有一句「还没有这个接口」的说明,占着导航反而误导)。
1127
- 中间那枚「+」不是 tab:它没有 data-tab、不参与 setActive,点了就是新建会话。 -->
1200
+ 中间那枚「+」不是 tab:它没有 data-tab、不参与 setActive,点了就是新建会话。
1201
+ **只有图标、没有文字**(2026-10-22 用户:「底栏只保留图标不需要文字了」):
1202
+ 语义靠 aria-label 承担;图标本身已用"清单 / 文档"的轮廓分家,缩到 25px 仍可辨。 -->
1128
1203
  <nav class="tabbar" id="tabbar" hidden>
1129
- <button class="tab" type="button" data-tab="tasks"><span class="icw"><svg width="23" height="23"><use href="#i-task"/></svg></span><span class="lb">任务</span></button>
1204
+ <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>
1130
1205
  <!-- 需求 tab 常驻:不做能力位控制(用户 2026-10-22:「移动端永远显示」)。
1131
1206
  老 core 上点进来会由 loadRequirements 的 501 分支给出明确文案,
1132
1207
  不会是一片空白 —— 入口固定比入口忽隐忽现更好理解。 -->
1133
- <button class="tab" type="button" data-tab="reqs"><span class="icw"><svg width="23" height="23"><use href="#i-req"/></svg></span><span class="lb">需求</span></button>
1208
+ <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>
1134
1209
  <span class="tabfab">
1135
- <button class="tabfab__b" type="button" id="btnFabNew" aria-label="新建会话"><svg width="26" height="26"><use href="#i-plus"/></svg></button>
1210
+ <button class="tabfab__b" type="button" id="btnFabNew" aria-label="新建会话"><svg width="30" height="30"><use href="#i-plus"/></svg></button>
1136
1211
  </span>
1137
- <button class="tab" type="button" data-tab="discover"><span class="icw"><svg width="23" height="23"><use href="#i-compass"/></svg></span><span class="lb">发现</span></button>
1138
- <button class="tab" type="button" data-tab="mine"><span class="icw"><svg width="23" height="23"><use href="#i-user"/></svg></span><span class="lb">我的</span></button>
1212
+ <button class="tab" type="button" data-tab="discover" aria-label="发现"><span class="icw"><svg width="25" height="25"><use href="#i-compass"/></svg></span></button>
1213
+ <button class="tab" type="button" data-tab="mine" aria-label="我的"><span class="icw"><svg width="25" height="25"><use href="#i-user"/></svg></span></button>
1139
1214
  </nav>
1140
1215
  <!-- 第三方库归口 shared/vendor(与内核共用一份):markdown 渲染栈要用 marked + DOMPurify;
1141
1216
  highlight.js 由内核按需加载,地址在下面给(内核自己不知道页面的相对根;
@@ -1174,6 +1249,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
1174
1249
  var subtitleEl = document.getElementById('subtitle');
1175
1250
  var fieldEl = document.getElementById('field');
1176
1251
  var addrEl = document.getElementById('addr');
1252
+ var connNameEl = document.getElementById('connName');
1177
1253
  var errEl = document.getElementById('err');
1178
1254
  var recentEl = document.getElementById('recent');
1179
1255
  var recentListEl = document.getElementById('recentList');
@@ -1194,6 +1270,9 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
1194
1270
  var tabbarEl = document.getElementById('tabbar');
1195
1271
  var reqsEl = document.getElementById('reqs');
1196
1272
  var reqListEl = document.getElementById('reqList');
1273
+ var reqDetailEl = document.getElementById('reqDetail');
1274
+ var reqDetailTitle = document.getElementById('reqDetailTitle');
1275
+ var reqDetailListEl = document.getElementById('reqDetailList');
1197
1276
  var discoverEl = document.getElementById('discover');
1198
1277
  var teamPageEl = document.getElementById('teamPage');
1199
1278
  var runSettingsPageEl = document.getElementById('runSettingsPage');
@@ -1226,7 +1305,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
1226
1305
  /* 工作空间要跨重启记住(与引擎 / 模型同一口径):只放内存的话,App 重启后
1227
1306
  「任务运行设置」里选的空间就回到默认,用户会以为没保存。 */
1228
1307
  try { WS = localStorage.getItem('magic.ws') || ''; } catch (e) {}
1229
- function saveWs() { try { localStorage.setItem('magic.ws', WS || ''); } catch (e) {} }
1308
+ function saveWs() { try { localStorage.setItem('magic.ws', WS || ''); } catch (e) {} schedulePushPrefs(); }
1230
1309
  /* 默认引擎 / 模型(「我的」可改;函数与选择器见下面「默认引擎 / 模型」段)。
1231
1310
  提前到这里声明:首屏 paintRunPref 会读它,晚于 PREFS 初始化赋值就会踩 undefined。 */
1232
1311
  var RUN_PREF = loadRunPref();
@@ -1333,6 +1412,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
1333
1412
  try { localStorage.setItem('magic.team.split', TEAM.split || 'standard'); } catch (e) {}
1334
1413
  try { localStorage.setItem('magic.team.splitcode', TEAM.splitCode ? '1' : '0'); } catch (e) {}
1335
1414
  try { localStorage.setItem('magic.team.adaptive', TEAM.adaptive === false ? '0' : '1'); } catch (e) {}
1415
+ schedulePushPrefs();
1336
1416
  paintTeamPref();
1337
1417
  }
1338
1418
  /* 这条会话的开关:本地记过就用本地;没记过 → 跟会话自己的运行模式(core 的 `mode:"team"`)。
@@ -1361,6 +1441,21 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
1361
1441
  var LS_KEY = 'magic.remote.recent.v1'; // 旧版「最近连接」:只在首次升级时搬一次
1362
1442
  var ACTIVE_ID = '';
1363
1443
  var CONN_SEQ = 0; // 每连一次 +1:上一台桌面迟到的响应不许画到这一台上面
1444
+ /* APK 自己的版本(2026-10-22):闸门页从构建时注入的版本号带进来(?appv=)。
1445
+ 为什么接口里还要 SERVER_VERSION:浏览器预览 / 旧版闸门页没有 appv,此时只能显示
1446
+ 连着的桌面端版本;两个都有时以 App 版本为准(用户问的「移动端版本」)。 */
1447
+ var APP_VERSION = (function () {
1448
+ try {
1449
+ var v = new URL(location.href).searchParams.get('appv');
1450
+ if (v && /^\d+\.\d+\.\d+$/.test(v)) return v;
1451
+ } catch (e) {}
1452
+ /* 兜底:本地 ui.html(桌面端没有 /m/ 时)由 boot.js 注入。 */
1453
+ try {
1454
+ var b = String(window.MAGIC_APP_VERSION || '');
1455
+ if (/^\d+\.\d+\.\d+$/.test(b)) return b;
1456
+ } catch (e) {}
1457
+ return '';
1458
+ })();
1364
1459
 
1365
1460
  function connHost(url) {
1366
1461
  try { return new URL(url).host; }
@@ -1405,28 +1500,44 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
1405
1500
  var next = {
1406
1501
  id: id, url: t.url || '', token: t.token || '',
1407
1502
  name: (t.name != null && t.name !== '') ? t.name : (old.name || ''),
1408
- host: connHost(t.url), app: old.app || '', at: Date.now()
1503
+ host: connHost(t.url), app: old.app || '', at: Date.now(),
1504
+ seed: !!old.seed // 种出来的默认环境:改名/改地址后仍然认出它
1409
1505
  };
1410
1506
  list.unshift(next);
1411
1507
  saveConns(list);
1412
1508
  return next;
1413
1509
  }
1414
- function removeConn(id) { saveConns(loadConns().filter(function (c) { return c.id !== id; })); }
1510
+ function removeConn(id) {
1511
+ var list = loadConns();
1512
+ var hit = null;
1513
+ list.forEach(function (c) { if (c && c.id === id) hit = c; });
1514
+ /* 用户主动删掉种出来的默认环境:记一笔,下次启动别再自动加回来。 */
1515
+ if (hit && hit.seed) { try { localStorage.setItem('magic.remote.seedoff.v1', '1'); } catch (e) {} }
1516
+ saveConns(list.filter(function (c) { return c.id !== id; }));
1517
+ }
1415
1518
  function clearConns() {
1416
1519
  saveConns([]);
1417
- try { localStorage.removeItem(LS_KEY); } catch (e) {}
1520
+ try {
1521
+ localStorage.removeItem(LS_KEY);
1522
+ localStorage.setItem('magic.remote.seedoff.v1', '1'); // 清空 = 明确不要默认环境
1523
+ } catch (e) {}
1418
1524
  }
1419
1525
 
1420
1526
  /* 当前连接:先按地址 + 令牌找;App 壳注入的连接还没落盘时给一条临时记录,
1421
- 这样标题与菜单里仍然看得见「现在连的是谁」。 */
1527
+ 这样标题与菜单里仍然看得见「现在连的是谁」。
1528
+ ⚠️ 壳里 BASE 可能是空串(同源):这时用 location.origin 当它的地址 ——
1529
+ 否则这条「未命名」没有 URL,既存不下来也就没法编辑(2026-10-22 用户:
1530
+ 「现在已有的未命名都编辑不了」)。 */
1531
+ function connUrlNow() { return BASE || (TOKEN ? location.origin : ''); }
1422
1532
  function activeConn() {
1533
+ var url = connUrlNow();
1423
1534
  var list = loadConns(), i;
1424
1535
  for (i = 0; i < list.length; i++) {
1425
- if (list[i].url === BASE && String(list[i].token || '') === String(TOKEN || '')) return list[i];
1536
+ if (list[i].url === url && String(list[i].token || '') === String(TOKEN || '')) return list[i];
1426
1537
  }
1427
1538
  for (i = 0; i < list.length; i++) if (ACTIVE_ID && list[i].id === ACTIVE_ID) return list[i];
1428
- if (BASE || TOKEN) {
1429
- return { id: connIdOf({ url: BASE, token: TOKEN }), url: BASE, token: TOKEN, name: '', host: connHost(BASE), app: '' };
1539
+ if (url || TOKEN) {
1540
+ return { id: connIdOf({ url: url, token: TOKEN }), url: url, token: TOKEN, name: '', host: connHost(url), app: '' };
1430
1541
  }
1431
1542
  return null;
1432
1543
  }
@@ -1435,7 +1546,219 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
1435
1546
  if (!c) return '';
1436
1547
  return c.name || c.app || c.host || connHost(c.url);
1437
1548
  }
1438
- function envLabel() { return connLabel(activeConn()) || histSubtitle || ''; }
1549
+ function envLabel() {
1550
+ var c = activeConn();
1551
+ if (!c) return histSubtitle || '';
1552
+ /* 壳注入的同源连接(还没有名字):标题仍显示「本机」,别把 127.0.0.1:端口 顶到标题上。 */
1553
+ if (!c.name && !c.app && String(c.url) === String(location.origin)) return histSubtitle || '本机';
1554
+ return connLabel(c) || histSubtitle || '';
1555
+ }
1556
+
1557
+ /* ── 默认环境:把「当前正在用的这条连接」落成一条可编辑的环境 ──
1558
+ 2026-10-22 用户:「再添加一个现在远程的地址,名字就叫公司远程」。
1559
+ 壳注入的连接(URL 里的 ?token=)如果不落盘,列表里就只有一条「未命名」的临时记录,
1560
+ 既改不了名也留不住。这里在首屏把它存成环境:本机回环叫「公司本地」,
1561
+ 局域网地址叫「公司远程」,都可在铅笔里改。
1562
+ ⚠️ App 每次启动会换令牌:按 seed 标记 + 地址更新那一条,不要每次多出一条。 */
1563
+ var SEED_NAME = '公司远程';
1564
+ var SEED_LOCAL = '公司本地';
1565
+ function isLoopUrl(u) {
1566
+ try {
1567
+ var h = String(u || '');
1568
+ return /^https?:\/\/(127\.|localhost|\[::1\]|0\.0\.0\.0)/i.test(h);
1569
+ } catch (e) { return false; }
1570
+ }
1571
+ function seedMark(rec) {
1572
+ var l2 = loadConns();
1573
+ for (var j = 0; j < l2.length; j++) if (l2[j].id === rec.id) l2[j].seed = true;
1574
+ saveConns(l2);
1575
+ }
1576
+ function seedCurrentConn() {
1577
+ var url = connUrlNow();
1578
+ if (!url || !TOKEN) return;
1579
+ try { if (localStorage.getItem('magic.remote.seedoff.v1') === '1') return; } catch (e) {}
1580
+ var name = isLoopUrl(url) ? SEED_LOCAL : SEED_NAME;
1581
+ var list = loadConns();
1582
+ for (var i = 0; i < list.length; i++) {
1583
+ var c = list[i];
1584
+ if (!c) continue;
1585
+ if (c.url === url && String(c.token || '') === String(TOKEN || '')) {
1586
+ /* 早先版本把回环连接也叫「公司远程」(同地址种子没带名字):按地址纠正,
1587
+ 但不碰用户自己改过的名字。 */
1588
+ if (c.seed && isLoopUrl(url) && c.name === SEED_NAME) { c.name = SEED_LOCAL; saveConns(list); }
1589
+ return; // 已经有这条
1590
+ }
1591
+ if (c.seed && c.url === url) {
1592
+ c.token = TOKEN;
1593
+ c.id = connIdOf({ url: url, token: TOKEN });
1594
+ c.at = Date.now();
1595
+ saveConns(list);
1596
+ return;
1597
+ }
1598
+ }
1599
+ seedMark(upsertConn({ url: url, token: TOKEN, name: name }));
1600
+ }
1601
+ /* 桌面 health 上报的远程入口:优先公网(cloudflared,不在同一 Wi-Fi 也能连),
1602
+ 没有再退回局域网。用户不用手抄地址。同地址只留一条;令牌跟着当前连接走。 */
1603
+ function seedRemoteConns(urls) {
1604
+ if (!urls || !urls.length || !TOKEN) return;
1605
+ try { if (localStorage.getItem('magic.remote.seedoff.v1') === '1') return; } catch (e) {}
1606
+ var url = String(urls[0] || '').replace(/\/+$/, '');
1607
+ if (!url) return;
1608
+ if (url === String(connUrlNow()).replace(/\/+$/, '')) return;
1609
+ var list = loadConns();
1610
+ /* 隧道换域名(cloudflared 每次重启都换):把上一枚**默认命名**的种子远程记录换到新地址。
1611
+ 用户改过名字的不动 —— 那条是他自己配的环境。 */
1612
+ var kept = [];
1613
+ for (var k = 0; k < list.length; k++) {
1614
+ var o = list[k];
1615
+ if (o && o.seed && o.name === SEED_NAME && o.url !== url) continue;
1616
+ kept.push(o);
1617
+ }
1618
+ if (kept.length !== list.length) saveConns(kept);
1619
+ list = loadConns();
1620
+ for (var i = 0; i < list.length; i++) {
1621
+ var c = list[i];
1622
+ if (!c) continue;
1623
+ if (c.url === url && String(c.token || '') === String(TOKEN)) {
1624
+ if (c.seed && c.name === SEED_LOCAL) { c.name = SEED_NAME; saveConns(list); }
1625
+ return;
1626
+ }
1627
+ if (c.seed && c.url === url) {
1628
+ c.token = TOKEN;
1629
+ c.id = connIdOf({ url: url, token: TOKEN });
1630
+ c.at = Date.now();
1631
+ saveConns(list);
1632
+ return;
1633
+ }
1634
+ }
1635
+ seedMark(upsertConn({ url: url, token: TOKEN, name: SEED_NAME }));
1636
+ }
1637
+
1638
+ /* ── 设备现场(按环境隔离)──
1639
+ 2026-10-22 用户:「切换环境后 所有东西都要跟着变 包括会话 需求 我的这些 是选择环境上的东西」。
1640
+ 会话与需求本来就来自桌面端(切完重拉);这里解决的是**留在本机的那部分状态**:
1641
+ 主题 / 字体 / 任务模式 / 默认引擎与模型 / 团队配置 / 当前空间 / 置顶 / 通知 / 浏览器收藏。
1642
+ 做法:切环境时把这一组键快照到「按地址分桶」的槽里,再把目标环境的槽写回来,然后整页重载
1643
+ —— 内存态全部重新初始化,不留上一台机器的影子。
1644
+ ⚠️ 分桶键用 host:port 而不是 url|token:token 会随 App 重启变,按 token 分桶等于每次都丢现场。 */
1645
+ var ENV_SCOPE_KEYS = [
1646
+ 'magic.ws', 'magic.taskmode', 'magic.engine', 'magic.model',
1647
+ 'magic.theme', 'magic.font', 'magic.haptic', 'magic.notify',
1648
+ 'magic.pinned', 'magic.spaces', 'magic.browser.stars.v1',
1649
+ 'magic.team.sessions.v1', 'magic.team.routes.v1', 'magic.team.rules.v1',
1650
+ 'magic.team.parallel', 'magic.team.split', 'magic.team.splitcode', 'magic.team.adaptive'
1651
+ ];
1652
+ var ENV_SCOPE_PREFIX = 'magic.envscope.v1.';
1653
+ var ACTIVE_ENV_KEY = 'magic.remote.active.v1'; // 上次停在哪个环境(url);重载后据此恢复
1654
+ /* 正在切环境:此时全局键已经被 applyEnvScope 换成**目标环境**的值,
1655
+ pagehide(reload 会触发)绝对不能再把这份值写回「旧环境」的槽 —— 那会把旧现场冲成空。 */
1656
+ var ENV_SWITCHING = false;
1657
+ function envScopeHash(s) {
1658
+ var h = 5381, str = String(s || '');
1659
+ for (var i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) >>> 0;
1660
+ return h.toString(36);
1661
+ }
1662
+ function envScopeIdOf(conn) {
1663
+ var host = connHost((conn && conn.url) || connUrlNow());
1664
+ return host ? envScopeHash(host) : '';
1665
+ }
1666
+ function envScopeId() { return envScopeIdOf(activeConn()); }
1667
+ /* 把当前这组键存进 id 的槽(切走之前调用)。 */
1668
+ function saveEnvScope(id) {
1669
+ if (!id) return;
1670
+ var o = {};
1671
+ for (var i = 0; i < ENV_SCOPE_KEYS.length; i++) {
1672
+ var v = null;
1673
+ try { v = localStorage.getItem(ENV_SCOPE_KEYS[i]); } catch (e) {}
1674
+ if (v !== null) o[ENV_SCOPE_KEYS[i]] = v;
1675
+ }
1676
+ try { localStorage.setItem(ENV_SCOPE_PREFIX + id, JSON.stringify(o)); } catch (e) {}
1677
+ }
1678
+ /* 把 id 的槽写回全局键;槽里没有的键删掉 —— 那台机器没改过的项回到默认,不继承上一台。 */
1679
+ function applyEnvScope(id) {
1680
+ var o = {};
1681
+ if (id) {
1682
+ try { o = JSON.parse(localStorage.getItem(ENV_SCOPE_PREFIX + id) || '{}') || {}; } catch (e) { o = {}; }
1683
+ }
1684
+ for (var i = 0; i < ENV_SCOPE_KEYS.length; i++) {
1685
+ var k = ENV_SCOPE_KEYS[i];
1686
+ try {
1687
+ if (o[k] === undefined || o[k] === null) localStorage.removeItem(k);
1688
+ else localStorage.setItem(k, o[k]);
1689
+ } catch (e) {}
1690
+ }
1691
+ }
1692
+
1693
+ /* ── 环境配置的远端存储(2026-10-22 用户:「环境的配置应该在远程上 不是浏览器缓存
1694
+ 否则同一个环境多个客户端配置还不一致」)──
1695
+ 这台桌面怎么干活(默认引擎/模型、团队配置、空间选择、置顶)属于**环境**,
1696
+ 不属于某台手机:两个客户端连同一台桌面必须看到同一套配置。
1697
+ 所以这组键以 `/desk/prefs` 为准,localStorage 只当离线缓存:
1698
+ · 连上后拉一次,远端是权威 → 有差异就覆盖本地并重载一次;
1699
+ · 本地任何一处改了,防抖推回远端。
1700
+ 纯设备偏好(主题/字体/触感/通知)**不同步** —— 那是手机自己的事。 */
1701
+ var PREF_SYNC_KEYS = [
1702
+ 'magic.engine', 'magic.model', 'magic.ws', 'magic.spaces', 'magic.pinned',
1703
+ 'magic.team.sessions.v1', 'magic.team.routes.v1', 'magic.team.rules.v1',
1704
+ 'magic.team.parallel', 'magic.team.split', 'magic.team.splitcode', 'magic.team.adaptive'
1705
+ ];
1706
+ var PREFS_PUSH_TIMER = 0, PREFS_PULLED = false, PREFS_PULLING = false;
1707
+ function collectPrefs() {
1708
+ var o = {};
1709
+ for (var i = 0; i < PREF_SYNC_KEYS.length; i++) {
1710
+ var v = null;
1711
+ try { v = localStorage.getItem(PREF_SYNC_KEYS[i]); } catch (e) {}
1712
+ if (v !== null) o[PREF_SYNC_KEYS[i]] = v;
1713
+ }
1714
+ return o;
1715
+ }
1716
+ function pushPrefs() {
1717
+ if (!TOKEN) return;
1718
+ try {
1719
+ fetch(BASE + '/desk/prefs', {
1720
+ method: 'PUT',
1721
+ headers: { 'X-Magic-Token': TOKEN, 'Content-Type': 'application/json' },
1722
+ body: JSON.stringify(collectPrefs())
1723
+ }).catch(function () {});
1724
+ } catch (e) {}
1725
+ }
1726
+ function schedulePushPrefs() {
1727
+ if (!TOKEN) return;
1728
+ if (PREFS_PUSH_TIMER) clearTimeout(PREFS_PUSH_TIMER);
1729
+ PREFS_PUSH_TIMER = setTimeout(function () { PREFS_PUSH_TIMER = 0; pushPrefs(); }, 700);
1730
+ }
1731
+ /* 拉一次远端配置。远端是空对象 = 这台桌面还没存过 → 把本地这份推上去(迁移),
1732
+ 不反过来把本地清空 —— 老用户第一次升级时不能丢配置。 */
1733
+ function pullPrefs() {
1734
+ if (!TOKEN || PREFS_PULLING) return;
1735
+ PREFS_PULLING = true;
1736
+ var seq = CONN_SEQ;
1737
+ fetch(BASE + '/desk/prefs', { headers: { 'X-Magic-Token': TOKEN } })
1738
+ .then(function (r) { return r.ok ? r.json() : null; })
1739
+ .then(function (v) {
1740
+ PREFS_PULLING = false;
1741
+ if (seq !== CONN_SEQ) return;
1742
+ if (!v || typeof v !== 'object' || Array.isArray(v)) { PREFS_PULLED = true; return; }
1743
+ if (!Object.keys(v).length) { PREFS_PULLED = true; pushPrefs(); return; }
1744
+ var changed = false;
1745
+ for (var i = 0; i < PREF_SYNC_KEYS.length; i++) {
1746
+ var k = PREF_SYNC_KEYS[i];
1747
+ var cur = null;
1748
+ try { cur = localStorage.getItem(k); } catch (e) {}
1749
+ var want = Object.prototype.hasOwnProperty.call(v, k) ? String(v[k]) : null;
1750
+ if (want === cur) continue;
1751
+ try {
1752
+ if (want === null) localStorage.removeItem(k);
1753
+ else localStorage.setItem(k, want);
1754
+ } catch (e) {}
1755
+ changed = true;
1756
+ }
1757
+ PREFS_PULLED = true;
1758
+ if (changed) { try { location.reload(); } catch (e) {} }
1759
+ })
1760
+ .catch(function () { PREFS_PULLING = false; PREFS_PULLED = true; });
1761
+ }
1439
1762
 
1440
1763
  /* 桌面 health 回来的应用名(app)补进当前连接:它比 host 更像「这台设备是谁」。 */
1441
1764
  function noteActiveMeta(meta) {
@@ -1491,23 +1814,24 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
1491
1814
  }
1492
1815
  /* 换环境 = 换一台桌面:对话、轮询、运行态都属于旧连接,必须一起收掉。
1493
1816
  排队任务按令牌分别落库(见 QUEUE_KEY),旧环境那批留在原地,切回来再接着跑。 */
1817
+ /* 换环境 = 换一台桌面:会话 / 需求 / 我的设置全都属于那台机器。
1818
+ 做法分两步:① 把当前这台的本机状态快照进它的槽,再把目标环境的槽写回来;
1819
+ ② 记住「停在哪个环境」并整页重载 —— 重载后内存态从零初始化,按目标环境的
1820
+ 连接与设置重建界面(会话、需求都是重载后从目标桌面现拉的)。
1821
+ ⚠️ 不在这里手写「清哪些变量」:那种清单永远会漏(漏一个就带着上一台的数据)。 */
1494
1822
  function switchEnv(c) {
1495
1823
  if (!c) return;
1496
- if (c.url === BASE && String(c.token || '') === String(TOKEN || '')) {
1824
+ if (c.url === connUrlNow() && String(c.token || '') === String(TOKEN || '')) {
1497
1825
  toast('已在「' + (connLabel(c) || '当前环境') + '」');
1498
1826
  return;
1499
1827
  }
1500
- if (conv) { try { conv.destroy(); } catch (e) {} conv = null; }
1501
- convEl.innerHTML = '';
1502
- stopPoll();
1503
- PENDING = []; ASK_WAITING = []; SERIAL = {}; QUEUE_RESTORED = false;
1504
- SERVER_VERSION = ''; CORE_FEATURES = []; TEAM_ROLES = []; ENGINES = []; ENGINES_STALE_AT = '';
1505
- TITLES = {}; LAST_SESSIONS = []; DW = ''; DW_READY = false; ROOT_DIRS = [];
1506
- /* 工作空间属于某台机器的目录树:沿用旧路径会落到不存在的目录,一律回到默认。 */
1507
- WS = '';
1508
- saveWs();
1509
- connect({ url: c.url, token: c.token, name: c.name, host: c.host || connHost(c.url) });
1510
- toast('已切换到「' + (connLabel(c) || connHost(c.url)) + '」');
1828
+ ENV_SWITCHING = true;
1829
+ saveEnvScope(envScopeId()); // 旧环境的现场
1830
+ applyEnvScope(envScopeIdOf(c)); // 新环境的现场
1831
+ var rec = upsertConn(c); // 保证这条在册(临时记录也能落盘)
1832
+ try { localStorage.setItem(ACTIVE_ENV_KEY, rec.url || c.url || ''); } catch (e) {}
1833
+ toast('正在切换到「' + (connLabel(c) || connHost(c.url)) + '」');
1834
+ try { location.reload(); } catch (e) { location.href = location.href; }
1511
1835
  }
1512
1836
  function openEnvMenu() {
1513
1837
  if (envMenuEl) { closeEnvMenu(); return; }
@@ -1524,7 +1848,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
1524
1848
  var active = !!(cur && c.id === cur.id);
1525
1849
  var b = document.createElement('button');
1526
1850
  b.type = 'button';
1527
- b.className = 'envmenu__item';
1851
+ b.className = 'envmenu__item' + (active ? ' is-active' : '');
1528
1852
  var ico = document.createElement('span');
1529
1853
  ico.className = 'envmenu__ico';
1530
1854
  ico.appendChild(svgUse('#i-laptop', 20));
@@ -1542,23 +1866,29 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
1542
1866
  var csub = (cname && cname !== chost) ? chost : '';
1543
1867
  col.appendChild(elSpan('envmenu__s', (csub ? csub + ' · ' : '') + (active ? '当前连接' : '点击切换')));
1544
1868
  b.appendChild(col);
1545
- if (active) {
1546
- var ck = document.createElement('span');
1547
- ck.className = 'envmenu__check';
1548
- ck.appendChild(svgUse('#i-check', 18));
1549
- b.appendChild(ck);
1550
- }
1869
+ /* 每行一枚「修改」:改名 / 改链接不用绕去「我的」。行本身仍是切换。 */
1870
+ var ed = document.createElement('span');
1871
+ ed.className = 'envmenu__edit';
1872
+ ed.setAttribute('role', 'button');
1873
+ ed.setAttribute('aria-label', '编辑环境');
1874
+ ed.appendChild(svgUse('#i-edit', 18));
1875
+ ed.onclick = function (e) { e.stopPropagation(); closeEnvMenu(); openEnvEditor(c); };
1876
+ b.appendChild(ed);
1551
1877
  b.onclick = function () { closeEnvMenu(); switchEnv(c); };
1552
1878
  envMenuEl.appendChild(b);
1553
1879
  });
1554
1880
  envMenuEl.appendChild(elSpan('envmenu__sep', ''));
1555
- var add = document.createElement('button');
1556
- add.type = 'button';
1557
- add.className = 'envmenu__add';
1558
- add.appendChild(svgUse('#i-plus', 18));
1559
- add.appendChild(elSpan('', '添加连接'));
1560
- add.onclick = function () { closeEnvMenu(); showLauncher(); };
1561
- envMenuEl.appendChild(add);
1881
+ /* 只留一个「添加环境」:扫码在启动页,改名改链接走每行的铅笔图标。 */
1882
+ function menuAct(icon, label, fn) {
1883
+ var b = document.createElement('button');
1884
+ b.type = 'button';
1885
+ b.className = 'envmenu__add';
1886
+ b.appendChild(svgUse(icon, 18));
1887
+ b.appendChild(elSpan('', label));
1888
+ b.onclick = function () { closeEnvMenu(); fn(); };
1889
+ envMenuEl.appendChild(b);
1890
+ }
1891
+ menuAct('#i-plus', '添加环境', function () { openEnvEditor(null); });
1562
1892
  document.body.appendChild(envScrimEl);
1563
1893
  document.body.appendChild(envMenuEl);
1564
1894
  var btn = document.getElementById('btnEnvSwitch');
@@ -1569,6 +1899,124 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
1569
1899
  btn.classList.add('is-open');
1570
1900
  }
1571
1901
 
1902
+ /* ── 环境编辑(任务页标题菜单里的铅笔 / 添加环境):名字 / 链接 / 令牌 ──
1903
+ 2026-10-22 用户:「我的中的环境配置就不需要了」—— 管理入口只留在标题菜单里,
1904
+ 不再单独做一页。 */
1905
+ /* conn 为空 = 新建;否则编辑(名字、链接、令牌都能改,也能删)。 */
1906
+ function openEnvEditor(conn) {
1907
+ var isNew = !conn;
1908
+ openHostSheet(isNew ? '添加环境' : '编辑环境', function (body) {
1909
+ var nameEl = hEl('input', 'envfield');
1910
+ nameEl.type = 'text';
1911
+ nameEl.placeholder = '例如 公司 Mac';
1912
+ nameEl.value = (conn && conn.name) || '';
1913
+ var addrEl = hEl('input', 'envfield');
1914
+ addrEl.type = 'text';
1915
+ /* 「链接」= 桌面配对页给的那条地址,可整条粘进来(含 ?token=…)。 */
1916
+ addrEl.placeholder = 'http://192.168.1.5:8791/?token=…';
1917
+ addrEl.setAttribute('autocapitalize', 'off');
1918
+ addrEl.setAttribute('autocomplete', 'off');
1919
+ addrEl.setAttribute('spellcheck', 'false');
1920
+ addrEl.value = (conn && conn.url) ? String(conn.url) : '';
1921
+ var tokEl = hEl('input', 'envfield');
1922
+ tokEl.type = 'text';
1923
+ tokEl.placeholder = '可选,配对令牌';
1924
+ tokEl.setAttribute('autocapitalize', 'off');
1925
+ tokEl.setAttribute('autocomplete', 'off');
1926
+ tokEl.setAttribute('spellcheck', 'false');
1927
+ tokEl.value = (conn && conn.token) || '';
1928
+ body.appendChild(hEl('label', 'envlabel', '名称'));
1929
+ body.appendChild(nameEl);
1930
+ body.appendChild(hEl('label', 'envlabel', '链接'));
1931
+ body.appendChild(addrEl);
1932
+ /* 扫码填入:启动页不再出现在主流程里之后,这里是唯一的扫码入口。 */
1933
+ var scanB = hEl('button', 'envscan', '扫码填入');
1934
+ scanB.type = 'button';
1935
+ scanB.onclick = function () {
1936
+ startScan(function (raw) {
1937
+ addrEl.value = raw;
1938
+ var t = parseTarget(raw);
1939
+ if (t && t.token) tokEl.value = t.token;
1940
+ }, function (m) { err.textContent = m; err.hidden = false; });
1941
+ };
1942
+ body.appendChild(scanB);
1943
+ body.appendChild(hEl('label', 'envlabel', '令牌'));
1944
+ body.appendChild(tokEl);
1945
+ var err = hEl('div', 'field__err');
1946
+ err.hidden = true;
1947
+ body.appendChild(err);
1948
+ body.appendChild(hEl('div', 'envhint',
1949
+ '链接从桌面配对页复制,可以带 ?token=…;令牌栏单独填时优先。'));
1950
+ var acts = hEl('div', 'envacts');
1951
+ var saveB = hEl('button', 'btn btn--filled', '保存');
1952
+ var goB = hEl('button', 'btn btn--outlined', '保存并连接');
1953
+ saveB.type = 'button'; goB.type = 'button';
1954
+ acts.appendChild(saveB); acts.appendChild(goB);
1955
+ body.appendChild(acts);
1956
+ var delB = null;
1957
+ if (!isNew) {
1958
+ delB = hEl('button', 'envdel', '删除这个环境');
1959
+ delB.type = 'button';
1960
+ body.appendChild(delB);
1961
+ }
1962
+
1963
+ function collect() {
1964
+ err.hidden = true;
1965
+ var raw = String(addrEl.value || '').trim();
1966
+ var t = parseTarget(raw);
1967
+ if (!t) { err.textContent = '请填链接,例如 http://192.168.1.5:8791/?token=…'; err.hidden = false; return null; }
1968
+ var tok = String(tokEl.value || '').trim();
1969
+ var hadParam = /[?&]token=/.test(raw);
1970
+ if (tok) t.token = tok;
1971
+ else if (!hadParam) {
1972
+ /* 令牌栏没动过就沿用旧的;用户主动清空 = 就是要去掉令牌,不能又塞回去。 */
1973
+ t.token = (conn && tokEl.value === (conn.token || '')) ? (conn.token || '') : '';
1974
+ }
1975
+ t.name = String(nameEl.value || '').trim();
1976
+ return t;
1977
+ }
1978
+ function persist(t) {
1979
+ var wasActive = !!(conn && conn.url === connUrlNow() && String(conn.token || '') === String(TOKEN || ''));
1980
+ var next = upsertConn(t);
1981
+ /* 改了地址/令牌 = 换了一条记录:旧的那条要删掉,别留一条连不上的僵尸。 */
1982
+ if (conn && conn.id !== next.id) removeConn(conn.id);
1983
+ /* 名称允许清空,也允许改:upsertConn 会沿用旧名,这里按输入框的最终值覆盖。 */
1984
+ var list = loadConns();
1985
+ for (var i = 0; i < list.length; i++) {
1986
+ if (list[i].id !== next.id) continue;
1987
+ list[i].name = t.name;
1988
+ if (!list[i].app && conn && conn.app) list[i].app = conn.app;
1989
+ }
1990
+ saveConns(list);
1991
+ closeHostSheet();
1992
+ if (wasActive && (next.url !== BASE || String(next.token || '') !== String(TOKEN || ''))) {
1993
+ switchEnv(next); // 改的正是当前这台:按新地址/令牌重连
1994
+ return;
1995
+ }
1996
+ if (!historyEl.hidden) historyTitle.textContent = envLabel() || '任务';
1997
+ toast(isNew ? ('已添加「' + (next.name || next.host) + '」') : '已保存');
1998
+ }
1999
+ saveB.onclick = function () { var t = collect(); if (t) persist(t); };
2000
+ goB.onclick = function () {
2001
+ var t = collect();
2002
+ if (!t) return;
2003
+ persist(t);
2004
+ /* persist 内部遇到「改的是当前连接」已经自己重连了;这里只处理新增/改别的环境。 */
2005
+ var c = findConn(connIdOf(t)) || findConn(connIdOf({ url: t.url, token: t.token }));
2006
+ if (c && !(c.url === connUrlNow() && String(c.token || '') === String(TOKEN || ''))) switchEnv(c);
2007
+ };
2008
+ if (delB) {
2009
+ delB.onclick = function () {
2010
+ if (!window.confirm('删除环境「' + (connLabel(conn) || conn.host || '') + '」?')) return;
2011
+ removeConn(conn.id);
2012
+ closeHostSheet();
2013
+ toast('已删除');
2014
+ };
2015
+ }
2016
+ setTimeout(function () { try { nameEl.focus(); } catch (e) {} }, 60);
2017
+ });
2018
+ }
2019
+
1572
2020
  /* ══════════ 解析 + 校验连接地址 ══════════
1573
2021
  与 ZCode RemoteControlUrlPolicy 同职责:只放行 http(s),解析出 base 与令牌。
1574
2022
  ZCode 生产包写死 zcode.z.ai;magic-agent 是用户自托管(LAN IP 各不相同),
@@ -1596,8 +2044,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
1596
2044
  }
1597
2045
 
1598
2046
  /* ══════════ 视图:启动页 → 会话列表 → 会话 ══════════ */
1599
- /* 离开会话页要摘掉 is-conv,否则底栏的「+」会一直保持"收进槽里"的形态 */
1600
- function hideAll() { document.body.classList.remove('is-conv'); 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; }
2047
+ 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; }
1601
2048
  function setActive(name) {
1602
2049
  Array.prototype.forEach.call(tabbarEl.querySelectorAll('.tab'), function (b) {
1603
2050
  b.classList.toggle('is-on', b.getAttribute('data-tab') === name);
@@ -1613,14 +2060,24 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
1613
2060
  hideAll(); historyEl.hidden = false; tabbarEl.hidden = false; setActive('tasks');
1614
2061
  if (address != null) histSubtitle = address;
1615
2062
  historyTitle.textContent = envLabel() || '任务';
2063
+ /* 一条环境都没有(首次安装 / 刚断开):直接进任务页的空态。
2064
+ 2026-10-22 用户:「这个界面不需要了 直接切换环境就行」—— 不再用独立的启动页挡在前面。 */
2065
+ if (!TOKEN && !BASE) {
2066
+ historyTitle.textContent = '未连接';
2067
+ historyList.innerHTML = '<div class="history__hint" style="text-align:left">还没有连接桌面端。'
2068
+ + '点上面的标题 →「添加环境」,把桌面给的地址粘进来(也可以在里面扫码)。</div>';
2069
+ return;
2070
+ }
1616
2071
  loadSessions();
1617
2072
  }
1618
- function showSession(sub) { hideAll(); document.body.classList.add('is-conv'); sessionEl.hidden = false; tabbarEl.hidden = false; setActive('tasks'); subtitleEl.textContent = (sub != null ? sub : histSubtitle) || ''; }
2073
+ function showSession(sub) { hideAll(); sessionEl.hidden = false; tabbarEl.hidden = false; setActive('tasks'); subtitleEl.textContent = (sub != null ? sub : histSubtitle) || ''; }
1619
2074
  /* 需求页:进页面即拉一次清单(GET /desk/requirements,只读、不调模型)。
1620
2075
  「整理」按钮才触发归纳(POST /desk/requirements/consolidate)——
1621
2076
  两者代价差三个数量级,绝不能混成一个动作。 */
1622
2077
  function showReqs() {
1623
2078
  hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false; setActive('reqs');
2079
+ /* 从二级退回来时不要还卡在详情态:回到底栏「需求」这一页 = 回到一级目录。 */
2080
+ REQ_DETAIL = null;
1624
2081
  loadRequirements(false);
1625
2082
  }
1626
2083
  function showDiscover() { hideAll(); discoverEl.hidden = false; tabbarEl.hidden = false; setActive('discover'); }
@@ -1637,39 +2094,114 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
1637
2094
  再补一次请求 —— 用户可能一连上就直接点「我的」,那时列表那次还没回来。 */
1638
2095
  paintMineVersion();
1639
2096
  refreshMineVersion();
2097
+ /* 顺带问一次「桌面端的包里带的是哪一版 APK」:有更新就把这一格显示成「升级 vX」。 */
2098
+ probeAppUpdate();
1640
2099
  }
1641
2100
 
1642
- /* ══════════ 版本号(「我的」)══════════
1643
- 来源:桌面端 `GET /desk/health` 的 `version`(= core 的 `--version`,ldflags 注入)。
1644
- 为什么不是移动端自己写死一个常量:这份界面是桌面端 `/m/` 远程加载的,页面文件跟着
1645
- 桌面端一起升级;只有服务端报的版本才是「用户此刻连着的这份」。
1646
- health 没有 version(插件是旧构建、但磁盘上的 core 已经是新的)时,再问一次
1647
- `/desk/contract` —— 它原样代理 core 的 envelope,新 core 里带 version。这样
1648
- **不必重启正在跑的服务**就能显示真实版本。仍然没有(老 core)就保持「—」,不编。 */
2101
+ /* ══════════ 版本号 + 升级(「我的」)══════════
2102
+ 显示:优先 APK 自己的版本(构建时从 magic-agent/package.json 注入,闸门页用 ?appv=
2103
+ 带进来);浏览器预览没有 appv 时才回落到桌面端 `GET /desk/health` 的 `version`。
2104
+ 升级:点这一行 → `GET /desk/app` 问桌面端「你包里带的 APK 是哪一版」。桌面端把
2105
+ 随 npm 包发布的 release APK 放在 npm/apk/ 下,由 magic-client 直接递过来;新版本
2106
+ 就触发下载(APK 壳里走原生 MagicUpdate.open 交给系统浏览器/安装器)。
2107
+ 老插件没有 /desk/app(404)→ 提示检查失败,不把「—」当成最新版本。 */
1649
2108
  var SERVER_VERSION = '';
2109
+ var APK_LATEST = ''; // 桌面端 npm 包里带的 APK 版本(/desk/app);空 = 没包或还没查
2110
+ function shownVersion() { return APP_VERSION || SERVER_VERSION; }
1650
2111
  function paintMineVersion() {
1651
2112
  if (!mineVersion) return;
1652
- mineVersion.textContent = SERVER_VERSION ? ('v' + SERVER_VERSION) : '—';
2113
+ var v = shownVersion();
2114
+ var txt = v ? ('v' + v) : '—';
2115
+ /* 有新版本就直接在这一格写「升级」,别让用户去猜这一行能不能点。 */
2116
+ if (v && APK_LATEST && cmpVersion(APK_LATEST, v) > 0) {
2117
+ mineVersion.textContent = txt + ' · 升级 v' + APK_LATEST;
2118
+ mineVersion.classList.add('is-upgradable');
2119
+ } else {
2120
+ mineVersion.textContent = txt;
2121
+ mineVersion.classList.remove('is-upgradable');
2122
+ }
2123
+ /* 两个版本不一致时把桌面端版本放进 title:排查「App 是新的、桌面还是旧的」用得上。 */
2124
+ mineVersion.title = (APP_VERSION && SERVER_VERSION && APP_VERSION !== SERVER_VERSION)
2125
+ ? ('App v' + APP_VERSION + ' · 桌面 v' + SERVER_VERSION) : '';
2126
+ }
2127
+ /* 进入「我的」时静默问一次桌面端带的是哪一版 APK:有更新就把版本格变成「升级」。
2128
+ 失败(老插件没有 /desk/app)就不动,不打扰。 */
2129
+ function probeAppUpdate() {
2130
+ if (!TOKEN) return;
2131
+ fetch(BASE + '/desk/app', { headers: { 'X-Magic-Token': TOKEN } })
2132
+ .then(function (r) { return r.ok ? r.json() : null; })
2133
+ .then(function (a) { APK_LATEST = (a && a.version) || ''; paintMineVersion(); })
2134
+ .catch(function () {});
1653
2135
  }
1654
2136
  function refreshMineVersion() {
1655
2137
  if (!TOKEN) { paintMineVersion(); return; }
1656
2138
  var hdr = { 'X-Magic-Token': TOKEN };
1657
- var seq = CONN_SEQ; // 切环境后旧桌面的 health 不许写到新连接上
1658
- fetch(BASE + '/desk/health', { headers: hdr })
2139
+ var seq = CONN_SEQ; // 切环境后旧桌面的响应不许写到新连接上
2140
+ /* ⚠️ /desk/health.version 是 magic-client **启动时**缓存的 core 版本:客户端比 core 旧
2141
+ (升级了 CLI 但没重启服务)时会一直报旧值(实测 v1.0.111 / 实际 2.1.3)。
2142
+ /desk/contract 每次都重新问 core,才是「此刻真正的 magic-agent 版本」。
2143
+ 两个并行拿:contract 权威,拿不到(老插件)才退回 health;health 还带 app 身份。 */
2144
+ var pContract = fetch(BASE + '/desk/contract', { headers: hdr })
1659
2145
  .then(function (r) { return r.ok ? r.json() : null; })
1660
- .then(function (h) {
1661
- if (seq !== CONN_SEQ) return;
1662
- if (h && h.app) noteActiveMeta({ app: h.app });
1663
- if (h && typeof h.version === 'string' && h.version) { SERVER_VERSION = h.version; return; }
1664
- return fetch(BASE + '/desk/contract', { headers: hdr })
1665
- .then(function (r) { return r.ok ? r.json() : null; })
1666
- .then(function (c) {
1667
- if (c && typeof c.version === 'string' && c.version) SERVER_VERSION = c.version;
1668
- })
1669
- .catch(function () {});
2146
+ .catch(function () { return null; });
2147
+ var pHealth = fetch(BASE + '/desk/health', { headers: hdr })
2148
+ .then(function (r) { return r.ok ? r.json() : null; })
2149
+ .catch(function () { return null; });
2150
+ Promise.all([pContract, pHealth]).then(function (a) {
2151
+ if (seq !== CONN_SEQ) return;
2152
+ var c = a[0], h = a[1];
2153
+ if (h && h.app) noteActiveMeta({ app: h.app });
2154
+ if (c && typeof c.version === 'string' && c.version) SERVER_VERSION = c.version;
2155
+ else if (h && typeof h.version === 'string' && h.version) SERVER_VERSION = h.version;
2156
+ paintMineVersion();
2157
+ });
2158
+ }
2159
+ function cmpVersion(a, b) {
2160
+ var pa = String(a || '0').split('.'), pb = String(b || '0').split('.');
2161
+ for (var i = 0; i < 3; i++) {
2162
+ var x = parseInt(pa[i], 10) || 0, y = parseInt(pb[i], 10) || 0;
2163
+ if (x !== y) return x - y;
2164
+ }
2165
+ return 0;
2166
+ }
2167
+ /* 下载走系统能力:APK 壳里 MainActivity 挂了 MagicUpdate.open(Intent.ACTION_VIEW),
2168
+ 浏览器预览退化为直接导航到下载地址(浏览器会下载,不会把页面留在 APK 上)。 */
2169
+ function openApkDownload(info) {
2170
+ var rel = (info && info.url) || '/desk/app.apk';
2171
+ var url;
2172
+ if (/^https?:/i.test(rel)) {
2173
+ url = rel;
2174
+ } else if (BASE) {
2175
+ url = BASE + rel;
2176
+ } else {
2177
+ /* 远程界面与 /desk/ 同源,BASE 是空串;交给原生 Intent 的必须是绝对地址。 */
2178
+ try { url = new URL(rel, location.href).toString(); } catch (e) { url = rel; }
2179
+ }
2180
+ url += (url.indexOf('?') >= 0 ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN);
2181
+ try {
2182
+ if (window.MagicUpdate && typeof window.MagicUpdate.open === 'function') {
2183
+ window.MagicUpdate.open(url);
2184
+ return;
2185
+ }
2186
+ } catch (e) {}
2187
+ location.href = url;
2188
+ }
2189
+ function checkAppUpdate() {
2190
+ var cur = shownVersion();
2191
+ if (!TOKEN) { toast('请先连接桌面端'); return; }
2192
+ if (!cur) { toast('版本还没拿到,等列表加载完再试'); return; }
2193
+ mineVersion.textContent = '检测中…';
2194
+ fetch(BASE + '/desk/app', { headers: { 'X-Magic-Token': TOKEN } })
2195
+ .then(function (r) { return r.ok ? r.json() : null; })
2196
+ .then(function (a) {
2197
+ paintMineVersion();
2198
+ if (!a || !a.version) { toast('桌面端没有内置安装包'); return; }
2199
+ if (cmpVersion(a.version, cur) <= 0) { toast('已是最新版本 v' + cur); return; }
2200
+ if (!confirm('发现新版本 v' + a.version + '(当前 v' + cur + '),现在下载升级?')) return;
2201
+ openApkDownload(a);
2202
+ toast('正在下载 v' + a.version + ',完成后点安装');
1670
2203
  })
1671
- .then(paintMineVersion)
1672
- .catch(function () { paintMineVersion(); }); // 网络抖动:保留旧值,不把「—」又闪回来
2204
+ .catch(function () { paintMineVersion(); toast('检查更新失败,请确认桌面端已升级'); });
1673
2205
  }
1674
2206
  /* 「我的」→ 团队模式:整块配置进子页面(与插件列表同一套子页面范式)。 */
1675
2207
  function showTeamPage() {
@@ -2231,6 +2763,9 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
2231
2763
  CONN_SEQ++; // 上一台桌面还在飞的请求:回来时直接丢弃(见 loadSessions)
2232
2764
  var c = upsertConn(target);
2233
2765
  ACTIVE_ID = c.id;
2766
+ PREFS_PULLED = false; // 换了桌面:环境配置要重新从新桌面拉一次(见 pullPrefs)
2767
+ /* 记住「停在哪台」:下次打开(或切环境后重载)直接回到这里。 */
2768
+ try { localStorage.setItem(ACTIVE_ENV_KEY, c.url || target.url || ''); } catch (e) {}
2234
2769
  showError('');
2235
2770
  showHistory(c.host || '');
2236
2771
  startPoll();
@@ -2242,8 +2777,10 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
2242
2777
  CONN_SEQ++;
2243
2778
  if (conv) { try { conv.destroy(); } catch (e) {} conv = null; }
2244
2779
  convEl.innerHTML = '';
2780
+ saveEnvScope(envScopeId()); // 断开前把这台的本机状态留在它自己的槽里
2245
2781
  BASE = ''; TOKEN = '';
2246
2782
  ACTIVE_ID = '';
2783
+ try { localStorage.removeItem(ACTIVE_ENV_KEY); } catch (e) {} // 主动断开 = 下次回启动页
2247
2784
  SERVER_VERSION = ''; // 版本号属于「那一条连接」:断开就清掉,避免下次连到别的机器还显示旧值
2248
2785
  /* 排队任务**不跟着断连消失**(它们已经落库):只清内存里的等待态,下次连上由
2249
2786
  restoreQueue 从磁盘接回来。串行门的 FIFO 也一并重置 —— 里面的 tail 是挂在一条
@@ -2252,7 +2789,8 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
2252
2789
  ASK_WAITING = [];
2253
2790
  SERIAL = {};
2254
2791
  QUEUE_RESTORED = false;
2255
- showLauncher();
2792
+ /* 断开后回任务页的空态(不再回启动页):添加/切换环境都在标题菜单里。 */
2793
+ showHistory(null);
2256
2794
  }
2257
2795
 
2258
2796
  /* ══════════ 历史会话列表(GET /desk/sessions,字段同 core 的 --sessions)══════════ */
@@ -2322,16 +2860,26 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
2322
2860
  TITLES = a[1] || {};
2323
2861
  DW = (a[2] && a[2].defaultWorkspace) || '';
2324
2862
  DW_READY = true;
2863
+ /* 环境配置以桌面端为准:连上后拉一次(见 pullPrefs)。只拉一次,
2864
+ 后面本地的改动由 schedulePushPrefs 推回去。 */
2865
+ if (!PREFS_PULLED) pullPrefs();
2325
2866
  SPACES_ROOT = (a[2] && a[2].workspacesRoot) || '';
2326
- /* core 版本(2026-10-22):/desk/health 的 version,顺手缓存,「我的」页打开即用。
2327
- 老 core 没有该字段 → 不覆盖已有值(可能刚由 refreshMineVersion 拿到)。 */
2328
- if (a[2] && a[2].version) SERVER_VERSION = a[2].version;
2867
+ /* core 版本不要从这里取:/desk/health.version 是插件启动时的缓存,可能是旧值
2868
+ (见 refreshMineVersion 的注释)。版本行打开时走 /desk/contract 拿实时值。 */
2329
2869
  if (!mineEl.hidden) paintMineVersion();
2330
2870
  /* core 能力位(feature.*):团队开关要不要显示就看它。连上桌面后才有,
2331
2871
  所以「我的」那张卡片与运行设置里的那一行都要在这里刷一次。 */
2332
2872
  CORE_FEATURES = ((a[2] && a[2].features) || []);
2333
2873
  /* 连接的身份(app):连着哪台设备就记到哪条连接上,环境菜单里当类型标签用。 */
2334
2874
  if (a[2] && a[2].app) noteActiveMeta({ app: a[2].app });
2875
+ /* 桌面绑了公网隧道时上报 publicUrl(cloudflared,不在同一 Wi-Fi 也能连),
2876
+ 没有则退回局域网入口:自动补一条「公司远程」环境,用户不用手抄地址。 */
2877
+ if (a[2]) {
2878
+ var remotes = [];
2879
+ if (a[2].publicUrl) remotes.push(a[2].publicUrl);
2880
+ if (a[2].lanUrls && a[2].lanUrls.length) remotes = remotes.concat(a[2].lanUrls);
2881
+ if (remotes.length) seedRemoteConns(remotes);
2882
+ }
2335
2883
  /* 团队角色清单(2026-10-22):core 上报什么就画什么,客户端不硬编码角色名。
2336
2884
  老 core 没有 teamRoles → 空数组 → paintTeamPref 用内置表兜底。 */
2337
2885
  TEAM_ROLES = ((a[2] && a[2].teamRoles) || []).filter(function (r) {
@@ -2369,7 +2917,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
2369
2917
  var TASK_QUERY = '';
2370
2918
  var PINNED = [];
2371
2919
  try { PINNED = JSON.parse(localStorage.getItem('magic.pinned') || '[]') || []; } catch (e) { PINNED = []; }
2372
- function savePinned() { try { localStorage.setItem('magic.pinned', JSON.stringify(PINNED)); } catch (e) {} }
2920
+ function savePinned() { try { localStorage.setItem('magic.pinned', JSON.stringify(PINNED)); } catch (e) {} schedulePushPrefs(); }
2373
2921
  var TITLES = {}; // 服务端标题覆盖表(GET /desk/titles),重命名用
2374
2922
  var DW = ''; // 桌面默认工作空间(GET /desk/health 的 defaultWorkspace = core 的 cwd)
2375
2923
  var DW_READY = false; // DW 是否已从 health 落定(空间键归一依赖它,见 restoreQueue)
@@ -2377,7 +2925,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
2377
2925
  var ROOT_DIRS = []; // 根目录下 magic-* 目录(GET /desk/dirs)
2378
2926
  var CUSTOM_SPACES = [];
2379
2927
  try { CUSTOM_SPACES = JSON.parse(localStorage.getItem('magic.spaces') || '[]') || []; } catch (e) { CUSTOM_SPACES = []; }
2380
- function saveSpaces() { try { localStorage.setItem('magic.spaces', JSON.stringify(CUSTOM_SPACES)); } catch (e) {} }
2928
+ function saveSpaces() { try { localStorage.setItem('magic.spaces', JSON.stringify(CUSTOM_SPACES)); } catch (e) {} schedulePushPrefs(); }
2381
2929
  /* 空间候选 = 根目录下的 magic-* + 自定义 + 会话里出现过的 workspace(去重、保序)。
2382
2930
  ⚠️ 会话的 workspace 为空时**不要**拿 DW 顶上(原写法 `s.workspace || DW`):
2383
2931
  那样「默认(不指定)」与「默认目录名」会变成两行同名选项,一个落「任务」段、
@@ -2391,6 +2939,44 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
2391
2939
  (LAST_SESSIONS || []).forEach(function (s) { add(s && s.workspace); });
2392
2940
  return out;
2393
2941
  }
2942
+ /* 会话里哪些是**顶层会话**(不是别人的子会话)。
2943
+ 团队模式会把子代理的会话挂到父会话下面去,它们的工作目录是
2944
+ /…/run-xxx/a1 这种**临时目录** —— 任务页不把它们单独列成空间
2945
+ (见 renderSessions 的 CHILD_OF 归类),需求页若照单全收就会多出
2946
+ 一堆 a1/t1/tmp 之类的假空间(用户 2026-10-22:「没有空间的不需要」)。
2947
+ 口径必须与任务页一致,所以这里按同一套父子关系判。 */
2948
+ function topLevelSessions(list) {
2949
+ var rows = list || [];
2950
+ var key2sid = {};
2951
+ rows.forEach(function (s) {
2952
+ var sid = sidOf(s);
2953
+ if (!sid) return;
2954
+ key2sid[sid] = sid;
2955
+ if (s.run_id) key2sid[s.run_id] = sid;
2956
+ if (s.session_id) key2sid[s.session_id] = sid;
2957
+ });
2958
+ return rows.filter(function (s) {
2959
+ var pk = key2sid[parentKeyOf(s)] || '';
2960
+ return !(pk && pk !== sidOf(s));
2961
+ });
2962
+ }
2963
+ /* 一个目录算不算「空间」:得是**用户认得的工作区**。
2964
+ 根目录 / 不是空间(哪怕历史上有会话在那儿跑过)—— 需求页显示它只会让人
2965
+ 以为「/ 是个项目」(用户 2026-10-22:「没有空间的不需要」)。
2966
+ ⚠️ 别把整个 /tmp 一刀切:/tmp/teamapi 是用户自己建的项目,是真空间
2967
+ (任务页也列着它)。团队运行目录另有专门的判法(见 isTeamRunDir)。 */
2968
+ function isRealSpaceDir(dir) {
2969
+ var d = reqNormDir(dir);
2970
+ if (!d) return false; // 根目录 /(归一后是空串)
2971
+ if (isTeamRunDir(d)) return false;
2972
+ return true;
2973
+ }
2974
+ /* 团队模式的运行目录:<空间>/.magic/team/run-xxx[/子代理目录]。
2975
+ 这些是团队**这一轮**的工作区,跑完就没了 —— 不是用户意义上的空间。 */
2976
+ function isTeamRunDir(dir) {
2977
+ var d = reqNormDir(dir);
2978
+ return d.indexOf('/.magic/team/') >= 0;
2979
+ }
2394
2980
  /* 会话标题:**以 core 给的 title 为准**(2026-10-15,用户「应该 magic-agent 统一归纳作为
2395
2981
  会话标题」)。core 的 --sessions 已把「标题表覆盖 → prompt_head」合并成一个 title 字段,
2396
2982
  所以这里不再自己拼优先级 —— 此前本文件是 `TITLES[id] || prompt_head`、PC 端是另一套
@@ -3032,6 +3618,10 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
3032
3618
  var REQ_BUSY = false; // 「整理」进行中(防重复点击)
3033
3619
  var REQ_BUSY_LABEL = '全部整理'; // 顶栏按钮的进行中文案(重渲染后要能恢复)
3034
3620
  var REQ_OPEN = {}; // 工作区展开状态(key = 工作区 id),与会话列表的 WS_OPEN 同构
3621
+ var REQ_ALIVE = null; // 仍有会话的空间路径集合;null = 拿不到会话清单(不筛)
3622
+ var REQ_DETAIL = null; // 二级视图:{key: 工作区 key, mod: 模块名, ws: 工作区对象}
3623
+ /* 路径归一:只去尾部斜杠,不做 realpath —— 与 core 登记表里的 workspace 同一口径。 */
3624
+ function reqNormDir(p) { return String(p || '').replace(/\/+$/, ''); }
3035
3625
 
3036
3626
  function reqFeatureRow(f) {
3037
3627
  /* 一行 = 一个功能点,**照会话行的排布**做(图标 + 标题 / 副标题 / 右侧时间):
@@ -3066,17 +3656,27 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
3066
3656
  return b;
3067
3657
  }
3068
3658
 
3069
- /* 工作区组:与会话列表的 workspaceGroup 同一套骨架(可折叠的组头 + 组内行)。
3070
- 差别只有两点:组头右侧没有「+ 新建」,行是需求文档不是会话。 */
3071
- function reqWorkspaceBlock(w) {
3659
+ /* 一级:空间目录。空间是行,模块缩进挂在空间下(树形),**功能点一律不画在这里**。
3660
+ 一级只回答「这个空间有哪几块、每块多少条」—— 需求一多时画功能点会把目录撑成长列表,
3661
+ 结构反而看不见了(2026-10-22 用户定稿:两级,一级只显示空间和模块)。
3662
+ idx = 排序后的位次(0 = 需求条数最多的那个)。 */
3663
+ function reqWorkspaceBlock(w, idx) {
3072
3664
  var wrap = hEl('div', '');
3073
3665
  var head = hEl('div', 'wsrow__head');
3074
3666
  var key = w.id || w.dir || w.name || '';
3075
3667
  var count = Number(w.feature_count) || 0;
3076
- var open = (REQ_OPEN[key] !== undefined) ? !!REQ_OPEN[key] : true; // 需求页默认全展开
3668
+ /* 默认只展开**第一个**(需求条数最多的那个),其余折叠(用户 2026-10-22):
3669
+ 空间一多,全展开就是一屏拉到底的长列表,什么结构都看不出来。
3670
+ 用户手动点过谁就按用户的来(REQ_OPEN 里有记录就不动它)。 */
3671
+ var open = (REQ_OPEN[key] !== undefined) ? !!REQ_OPEN[key] : (idx === 0);
3077
3672
  var tog = document.createElement('button');
3078
- tog.type = 'button'; tog.className = 'wsrow__open';
3673
+ tog.type = 'button'; tog.className = 'wsrow__open wsrow__open--req';
3079
3674
  tog.appendChild(elSpan('wsrow__l', w.name || w.dir || w.id));
3675
+ /* 模块数 + 功能点数:一行说清这个空间有多大,比只写空间名有用。
3676
+ 用**中性色**(不是 wsrow__badge 那枚 queued 紫)—— 那是「等待中」的语义色,
3677
+ 空间规模不是状态,套上会让人误以为这个空间在排队。 */
3678
+ var mods = (w.modules || []).filter(function (m) { return (m.features || []).length; });
3679
+ tog.appendChild(elSpan('wsrow__meta', mods.length + ' 模块 · ' + count + ' 条'));
3080
3680
  /* 待整理条数做成徽标(与「等待中」同款):折叠起来也看得见这个空间在长新需求。 */
3081
3681
  if (w.pending > 0) tog.appendChild(elSpan('wsrow__badge', w.pending + ' 待整理'));
3082
3682
  /* 未初始化 = 还没按空间目录生成过基线。老 core 不带 initialized 字段(undefined),
@@ -3104,55 +3704,141 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
3104
3704
  err.style.color = 'var(--warning)';
3105
3705
  wrap.appendChild(err);
3106
3706
  }
3707
+ var shown = 0;
3107
3708
  (w.modules || []).forEach(function (m) {
3108
- /* 模块(大功能点)用一条小节标题,形状与「置顶 / 任务 / 空间」的 sechead 一致:
3109
- 名字 + 条数。未归类(name 为空)不标题、直接平铺 ——
3110
- 老数据是平铺目录,硬给它编个「未归类」反而像出了错。 */
3111
3709
  var fs = (m.features || []);
3112
3710
  if (!fs.length) return;
3113
- if (m.name) {
3114
- var mh = hEl('div', 'sechead');
3115
- mh.style.paddingLeft = '16px';
3116
- mh.appendChild(elSpan('sechead__t', m.name + ' (' + fs.length + ')'));
3117
- wrap.appendChild(mh);
3118
- }
3119
- fs.forEach(function (f) { wrap.appendChild(reqFeatureRow(f)); });
3711
+ shown += fs.length;
3712
+ /* 模块 = 目录里的一行(缩进 + 连接线归属到空间),点它进二级看需求列表。
3713
+ 未归类(name 为空)的老数据没有模块名可写 —— 这种情况直接不画这一行,
3714
+ 功能点仍能在二级里看到(core 把空 module 排在最后)。 */
3715
+ if (!m.name) return;
3716
+ var row = document.createElement('button');
3717
+ row.type = 'button'; row.className = 'reqtree__mod';
3718
+ row.appendChild(elSpan('reqtree__modname', m.name));
3719
+ row.appendChild(elSpan('reqtree__modcount', String(fs.length)));
3720
+ var go = elSpan('reqtree__modgo');
3721
+ go.appendChild(svgUse('#i-chev-r', 16));
3722
+ row.appendChild(go);
3723
+ row.onclick = function () { openReqDetail(w, m.name); };
3724
+ wrap.appendChild(row);
3120
3725
  });
3726
+ /* 空间还在但还没有文档:别画成一片空白,给出这一组的下一步(整理)。 */
3727
+ if (!shown) {
3728
+ var tip = Number(w.pending) > 0
3729
+ ? '有 ' + w.pending + ' 条对话待整理'
3730
+ : (w.initialized === false ? '还没有需求文档,点右侧「整理」按空间生成' : '还没有需求文档');
3731
+ var hint = hEl('div', 'history__hint', tip);
3732
+ hint.style.padding = '6px 12px 12px';
3733
+ wrap.appendChild(hint);
3734
+ }
3121
3735
  return wrap;
3122
3736
  }
3123
3737
 
3738
+ /* 二级:某空间下**所有模块**的需求列表(用户 2026-10-22 定稿)。
3739
+ 进来的那个模块高亮,滚到它那儿;其余模块照常列出来 —— 需求之间有交叉,
3740
+ 只看当前模块会漏掉相关需求。 */
3741
+ function reqDetailBlock(d) {
3742
+ var w = d.ws;
3743
+ var frag = document.createDocumentFragment();
3744
+ (w.modules || []).forEach(function (m) {
3745
+ var fs = (m.features || []);
3746
+ if (!fs.length) return;
3747
+ var sec = hEl('div', 'reqdetail__sec' + (m.name === d.mod ? ' reqdetail__sec--cur' : ''));
3748
+ var mh = hEl('div', 'sechead');
3749
+ mh.style.paddingLeft = '16px';
3750
+ mh.appendChild(elSpan('sechead__t', (m.name || '未归类') + ' (' + fs.length + ')'));
3751
+ sec.appendChild(mh);
3752
+ fs.forEach(function (f) { sec.appendChild(reqFeatureRow(f)); });
3753
+ frag.appendChild(sec);
3754
+ });
3755
+ if (!frag.childNodes.length) {
3756
+ frag.appendChild(hEl('div', 'history__hint', '这个空间还没有需求文档'));
3757
+ }
3758
+ return frag;
3759
+ }
3760
+
3761
+ /* 进二级:记下是哪个空间的哪个模块,渲染后滚到那个模块。 */
3762
+ function openReqDetail(w, mod) {
3763
+ var key = w.id || w.dir || w.name || '';
3764
+ REQ_DETAIL = { key: key, mod: mod, ws: w };
3765
+ renderRequirements();
3766
+ /* 空间在两次渲染之间被删掉了(会话全删 → 空间联动消失):
3767
+ renderRequirements 会把 REQ_DETAIL 清空,这时别停在空白的二级页,退回目录。 */
3768
+ if (!REQ_DETAIL) { hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false; return; }
3769
+ hideAll(); reqDetailEl.hidden = false; tabbarEl.hidden = true;
3770
+ if (reqDetailTitle) reqDetailTitle.textContent = (w.name || w.dir || w.id) + ' · ' + mod;
3771
+ /* 滚到当前模块:它高亮,但用户进来第一眼要看到的是自己点的那一块。 */
3772
+ if (reqDetailListEl) {
3773
+ var cur = reqDetailListEl.querySelector('.reqdetail__sec--cur');
3774
+ if (cur && cur.scrollIntoView) cur.scrollIntoView({ block: 'start' });
3775
+ }
3776
+ }
3777
+
3778
+ /* 退二级回一级目录。 */
3779
+ function closeReqDetail() {
3780
+ REQ_DETAIL = null;
3781
+ hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false;
3782
+ renderRequirements();
3783
+ }
3784
+
3124
3785
  function renderRequirements() {
3125
3786
  if (!reqListEl) return;
3126
3787
  var data = REQ_DATA;
3127
3788
  if (!data) return;
3128
- /* 只画**有文档**的工作区(用户 2026-10-22:「没有的空间不显示」)。
3129
- feature_count 是 core 给的真值;老 core 不给时退化成数一遍模块里的行数。 */
3789
+ /* 空间清单与任务页对齐:**用与 collectSpaceKeys 同一套空间键过滤**
3790
+ (用户 2026-10-22:「4个空间需求里显示的不一样」「会话中全部删除后需求中的空间还在
3791
+ 也要联动删除」)。没有文档的空间不再整块藏掉,而是留一个组头 + 「整理」按钮 ——
3792
+ 这样两页的空间数一致,也给了「这个空间还没生成需求」的入口。
3793
+ 拿不到会话/目录清单时(REQ_ALIVE=null)不筛。 */
3130
3794
  var all = (data && data.workspaces) || [];
3131
3795
  var list = all.filter(function (w) {
3132
- if (Number(w.feature_count) > 0) return true;
3133
- return (w.modules || []).some(function (m) { return (m.features || []).length > 0; });
3796
+ var dir = reqNormDir(w && w.dir);
3797
+ /* 根目录 / 归一后是空串 —— 「!dir 就放行」的老写法会把它漏进来,
3798
+ 需求页于是出现一个叫「/」的假空间(用户 2026-10-22:「没有空间的不需要」)。
3799
+ 这里先卡一道:不是真空间目录的直接不画。 */
3800
+ if (!isRealSpaceDir(dir)) return false;
3801
+ return !REQ_ALIVE || !dir || !!REQ_ALIVE[dir];
3802
+ });
3803
+ /* 排序:**需求条数多的排前面**(用户 2026-10-22)。
3804
+ core 给的顺序是「最后归纳时间倒序」,但那是给「最近在动的」用的;
3805
+ 需求页要回答的是「哪块需求最多」,条数多的才是用户要先看的那个。
3806
+ 条数一样时按名字排 —— 同一条数下顺序要稳定,不能每次刷新都在跳。 */
3807
+ list.sort(function (a, b) {
3808
+ var ca = Number(a && a.feature_count) || 0;
3809
+ var cb = Number(b && b.feature_count) || 0;
3810
+ if (ca !== cb) return cb - ca;
3811
+ return String((a && a.name) || '').localeCompare(String((b && b.name) || ''));
3134
3812
  });
3813
+ /* 二级视图:只画这个空间的需求列表。空间可能已被会话删除联动掉
3814
+ (list 里没有它)—— 那就退回一级目录,不要留一页空白。 */
3815
+ if (REQ_DETAIL) {
3816
+ var hit = null;
3817
+ for (var i = 0; i < list.length; i++) {
3818
+ if ((list[i].id || list[i].dir || list[i].name || '') === REQ_DETAIL.key) { hit = list[i]; break; }
3819
+ }
3820
+ if (!hit) { REQ_DETAIL = null; }
3821
+ else {
3822
+ REQ_DETAIL.ws = hit;
3823
+ if (reqDetailTitle) reqDetailTitle.textContent = (hit.name || hit.dir || hit.id) + ' · ' + REQ_DETAIL.mod;
3824
+ if (reqDetailListEl) reqDetailListEl.replaceChildren(reqDetailBlock(REQ_DETAIL));
3825
+ if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
3826
+ return;
3827
+ }
3828
+ }
3135
3829
  var frag = document.createDocumentFragment();
3136
3830
  if (!list.length) {
3137
- /* 一个功能点都还没有:说清「是没聊过」还是「聊过但没整理」——
3138
- 后者要引导用户点「整理」,否则这一页看起来永远长不出东西。 */
3139
- var pend = 0, uninit = 0;
3140
- all.forEach(function (w) {
3141
- pend += Number(w.pending) || 0;
3142
- if (w.initialized === false) uninit++;
3143
- });
3831
+ /* 一个空间都没有(会话全删了 / 还没聊过)。 */
3144
3832
  frag.appendChild(hEl('div', 'history__hint',
3145
- pend > 0
3146
- ? '有 ' + pend + ' 条对话还没整理成需求。点空间右侧「整理」只处理这个空间;右上角「全部整理」一次跑完所有空间。'
3147
- : (uninit > 0
3148
- ? '还没有需求。点空间右侧「整理」会先按空间目录生成一版需求基线;右上角「全部整理」一次处理所有空间。'
3149
- : '还没有任何需求。在任务里聊几句,这里会自动整理出功能点文档。')));
3833
+ REQ_ALIVE
3834
+ ? '还没有任何空间。在任务里聊几句,这里会按空间整理出功能点文档。'
3835
+ : '还没有任何需求。在任务里聊几句,这里会自动整理出功能点文档。'));
3150
3836
  reqListEl.replaceChildren(frag);
3151
3837
  return;
3152
3838
  }
3153
- /* 工作区顺序照 core 给的(最后归纳时间倒序)—— 最近在动的排最前,
3154
- 用户打开这一页多半是想看「刚聊的那些变成什么了」。 */
3155
- list.forEach(function (w) { frag.appendChild(reqWorkspaceBlock(w)); });
3839
+ /* 工作区顺序:上面已按需求条数排好(多的在前)。默认只展开第一个 ——
3840
+ 见 reqWorkspaceBlock 的 open 判定。 */
3841
+ list.forEach(function (w, i) { frag.appendChild(reqWorkspaceBlock(w, i)); });
3156
3842
  reqListEl.replaceChildren(frag);
3157
3843
  /* 整理进行中切走再回来:新 DOM 的按钮默认是可点的,这里按当前状态重新置忙。 */
3158
3844
  if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
@@ -3162,7 +3848,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
3162
3848
  if (!reqListEl) return;
3163
3849
  if (!quiet && !REQ_DATA) reqListEl.innerHTML = '<div class="history__hint">读取中…</div>';
3164
3850
  var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
3165
- fetch(BASE + '/desk/requirements', { headers: hdr })
3851
+ var pReq = fetch(BASE + '/desk/requirements', { headers: hdr })
3166
3852
  .then(function (r) {
3167
3853
  if (r.status === 501) {
3168
3854
  /* 老 core 没有这个能力位:**说清楚**,不要给一页空白
@@ -3171,9 +3857,38 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
3171
3857
  }
3172
3858
  if (!r.ok) throw new Error('HTTP ' + r.status);
3173
3859
  return r.json();
3174
- })
3175
- .then(function (d) {
3176
- REQ_DATA = d || { workspaces: [] };
3860
+ });
3861
+ /* 会话清单与空间目录:用来判断「这个空间在任务页还在不在」。
3862
+ 两者都允许失败 —— 拿不到就不筛空间(见 renderRequirements)。 */
3863
+ var pSess = fetch(BASE + '/desk/sessions', { headers: hdr })
3864
+ .then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
3865
+ var pDirs = fetch(BASE + '/desk/dirs', { headers: hdr })
3866
+ .then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
3867
+ Promise.all([pReq, pSess, pDirs])
3868
+ .then(function (a) {
3869
+ var sess = a[1], dirs = (a[2] && a[2].dirs) || null;
3870
+ if (Array.isArray(sess) || Array.isArray(dirs)) {
3871
+ /* 与任务页**同一套空间键**(用户 2026-10-22:「4个空间需求里显示的不一样」
3872
+ 「会话中全部删除后需求中的空间还在 也要联动删除」「没有空间的不需要」):
3873
+ ① 根目录下的 magic-* ② 自定义空间 ③ **顶层会话**的工作目录。
3874
+ ⚠️ ③ 只能取**顶层**会话的 workspace:团队子代理的会话挂在父会话下面,
3875
+ 它们的工作目录是 /…/run-xxx/a1 这种临时目录 —— 任务页不把它们列成空间,
3876
+ 需求页照收就会多出一堆 a1/t1/tmp 假空间。
3877
+ ⚠️ 根目录 / 与 /tmp 这类系统目录不是空间,哪怕历史上有会话在那儿跑过。 */
3878
+ var alive = {};
3879
+ (ROOT_DIRS || []).forEach(function (d) { if (d && d.path) alive[reqNormDir(d.path)] = 1; });
3880
+ (dirs || []).forEach(function (d) {
3881
+ if (d && d.path && String(d.name || '').indexOf('magic-') === 0) alive[reqNormDir(d.path)] = 1;
3882
+ });
3883
+ (CUSTOM_SPACES || []).forEach(function (p) { if (p) alive[reqNormDir(p)] = 1; });
3884
+ topLevelSessions(sess).forEach(function (s) {
3885
+ if (s && s.workspace && isRealSpaceDir(s.workspace)) alive[reqNormDir(s.workspace)] = 1;
3886
+ });
3887
+ REQ_ALIVE = alive;
3888
+ } else {
3889
+ REQ_ALIVE = null; // 清单没拿到:不筛,宁可多显示
3890
+ }
3891
+ REQ_DATA = a[0] || { workspaces: [] };
3177
3892
  renderRequirements();
3178
3893
  })
3179
3894
  .catch(function (e) {
@@ -3212,7 +3927,8 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
3212
3927
  }
3213
3928
 
3214
3929
  /* 统一置忙:顶部主按钮 + 所有空间组头的「整理」一起禁用。
3215
- label 只改顶部;组头统一显示「整理中…」/「整理」,不各自变文案。 */
3930
+ label 只改顶部;组头统一显示「整理中…」/「整理」,不各自变文案。
3931
+ 二级页那枚「整理」也跟着禁用 —— 归纳期间它和组头一样不能重复点。 */
3216
3932
  function reqSetBusy(on, label) {
3217
3933
  if (on) { if (label !== undefined) REQ_BUSY_LABEL = label; }
3218
3934
  else REQ_BUSY_LABEL = '全部整理';
@@ -3221,6 +3937,11 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
3221
3937
  btn.disabled = !!on;
3222
3938
  if (label !== undefined) btn.textContent = label;
3223
3939
  }
3940
+ var dbtn = document.getElementById('btnReqDetailSync');
3941
+ if (dbtn) {
3942
+ dbtn.disabled = !!on;
3943
+ dbtn.textContent = on ? '整理中…' : '整理';
3944
+ }
3224
3945
  Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
3225
3946
  b.disabled = !!on;
3226
3947
  b.textContent = on ? '整理中…' : '整理';
@@ -3267,6 +3988,9 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
3267
3988
  var list = ((REQ_DATA && REQ_DATA.workspaces) || []).filter(function (w) {
3268
3989
  var key = w.dir || w.id || '';
3269
3990
  if (!key || seen[key]) return false;
3991
+ /* 会话已全部删除的空间不参与「全部整理」:否则又会把它重新生成出来。 */
3992
+ var dir = reqNormDir(w.dir);
3993
+ if (REQ_ALIVE && dir && !REQ_ALIVE[dir]) return false;
3270
3994
  seen[key] = true;
3271
3995
  return true;
3272
3996
  });
@@ -3319,6 +4043,13 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
3319
4043
  step();
3320
4044
  }
3321
4045
  on('btnReqSync', function () { consolidateAllRequirements(); });
4046
+ /* 二级视图的两个动作:返回一级目录 / 整理**当前这个空间**(不是全部空间 ——
4047
+ 二级页的作用域就是这个空间,标成「全部整理」会让人以为还是全局动作)。 */
4048
+ on('btnReqDetailBack', function () { closeReqDetail(); });
4049
+ on('btnReqDetailSync', function () {
4050
+ if (!REQ_DETAIL || !REQ_DETAIL.ws) return;
4051
+ consolidateRequirements(REQ_DETAIL.ws);
4052
+ });
3322
4053
 
3323
4054
  function openHostSheet(title, build) {
3324
4055
  closeHostSheet();
@@ -4048,6 +4779,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
4048
4779
  localStorage.setItem('magic.engine', RUN_PREF.engine || '');
4049
4780
  localStorage.setItem('magic.model', RUN_PREF.model || '');
4050
4781
  } catch (e) {}
4782
+ schedulePushPrefs();
4051
4783
  paintRunPref();
4052
4784
  }
4053
4785
  function paintRunPref() {
@@ -4080,10 +4812,10 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
4080
4812
  var sum = document.getElementById('teamSummary');
4081
4813
  if (sum) sum.textContent = custom ? (custom + ' 项已单独指定') : '跟随主引擎';
4082
4814
  }
4083
- /* 团队模式子页面 = 计划行 + 7 个角色行,全部进同一个 #teamRouteCard(只有一套路由配置)。
4084
- 每行常显「这类适合什么」(角色 = 规则 / 计划 = 拆解口径),右侧是引擎 ——
4085
- 用户要的是「描述好每一类适合的规则」,所以描述不藏进详情页。
4086
- 签名含行文案与引擎:变了才重建,避免 5s 轮询时闪。 */
4815
+ /* 团队模式子页面 = **一张卡、每行两个触发点**(用户 2026-10-22:「两个触发就行了」):
4816
+ 左边(名字 + 规则描述)→ 只改规则;右边(引擎值)→ 只选引擎。
4817
+ 两块配置分属两个抽屉,但不必各占一整张卡。
4818
+ 签名含两侧文案与引擎:变了才重建,避免 5s 轮询时闪。 */
4087
4819
  function paintTeamRoles(roles) {
4088
4820
  var card = document.getElementById('teamRouteCard');
4089
4821
  if (!card) return;
@@ -4101,29 +4833,34 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
4101
4833
  TEAM_ROLE_SIG = sig;
4102
4834
  card.innerHTML = '';
4103
4835
  if (!show) return;
4104
- addTeamRow(card, 'plan', '计划任务', planSub);
4836
+ addTeamSplitRow(card, 'plan', '计划任务', planSub);
4105
4837
  roles.forEach(function (r) {
4106
- addTeamRow(card, 'role:' + r.name, TEAM_ROLE_LABELS[r.name] || r.name, teamRoleDuty(r));
4838
+ addTeamSplitRow(card, 'role:' + r.name, TEAM_ROLE_LABELS[r.name] || r.name, teamRoleDuty(r));
4107
4839
  });
4108
4840
  }
4109
- /* 一行:名字 + 规则描述 + 右侧引擎。点开 = 引擎与规则(见 openTeamRoutePicker)。 */
4110
- function addTeamRow(card, key, label, sub) {
4841
+ /* 一行两个点击区:左边改规则,右边选引擎。
4842
+ ⚠️ 外层必须是 div —— button 不能套 button;`.setrow` 的边框/间距照常用在外层。 */
4843
+ function addTeamSplitRow(card, key, label, sub) {
4111
4844
  var rt = (TEAM.routes || {})[key] || {};
4112
- var b = hEl('button', 'setrow');
4113
- b.type = 'button';
4114
- var main = hEl('span', 'setrow__main');
4845
+ var row = hEl('div', 'setrow setrow--split');
4846
+ var main = hEl('button', 'setrow__mainbtn');
4847
+ main.type = 'button';
4115
4848
  main.appendChild(hEl('span', 'setrow__l', label));
4116
4849
  if (sub) main.appendChild(hEl('span', 'setrow__sub', sub));
4117
- b.appendChild(main);
4118
- b.appendChild(hEl('span', 'setrow__v',
4850
+ main.onclick = function () { openTeamRuleSheet(key, label + ':规则'); };
4851
+ row.appendChild(main);
4852
+ var v = hEl('button', 'setrow__vbtn');
4853
+ v.type = 'button';
4854
+ v.appendChild(hEl('span', 'setrow__v',
4119
4855
  rt.engine ? (rt.engine + (rt.model ? ' · ' + qaModelText(rt.model) : '')) : '跟随主引擎'));
4120
- b.appendChild(hEl('span', 'setrow__chev', '›'));
4121
- b.onclick = function () {
4122
- openTeamRoutePicker(key,
4123
- String(key).indexOf('role:') === 0 ? (label + ':适合做什么、交给谁') : '计划任务:怎么拆、交给谁',
4856
+ v.appendChild(hEl('span', 'setrow__chev', '›'));
4857
+ v.onclick = function () {
4858
+ openTeamEnginePicker(key,
4859
+ String(key).indexOf('role:') === 0 ? (label + '交给谁') : '计划任务交给谁',
4124
4860
  teamRoleKindOf(key));
4125
4861
  };
4126
- card.appendChild(b);
4862
+ row.appendChild(v);
4863
+ card.appendChild(row);
4127
4864
  }
4128
4865
  function teamRoleKindOf(key) {
4129
4866
  if (String(key).indexOf('role:') !== 0) return 'plan';
@@ -4136,26 +4873,19 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
4136
4873
  var ov = (TEAM.rules || {})['role:' + r.name];
4137
4874
  return (ov && String(ov).trim()) || r.duty || '';
4138
4875
  }
4139
- /* 点一行 → 抽屉:先「规则」,再「引擎 · 模型」,与「默认引擎」那套同一个交互。
4140
- 计划行:拆解粒度 + 简单代码 / 自动补任务(--team-split / --team-split-code /
4141
- --team-adaptive);角色行:「这类任务适合什么」(--team-rule role:<name>=…)。
4142
- 首项 = 不单独指定 / 跟随主引擎。
4876
+ /* 上卡点一行 → 抽屉:**只选引擎 · 模型**(与「默认引擎」那套同一个交互)。
4877
+ 规则不在这里 —— 见下卡与 openTeamRuleSheet(用户 2026-10-22:「配引擎和配规则要分开配置」)。
4878
+ 首项 = 跟随主引擎。
4143
4879
  2026-10-21 口径(用户:「图像视频不是说需要生成 是擅长做 比如有一个带了视频 skill 的引擎」):
4144
4880
  这里问的是「这类活谁擅长」,所以不写「交给 core 自动路由」—— 说「自动」会让人以为
4145
4881
  不配也能跑,而实际语义是「跟主引擎一致」。 */
4146
- function openTeamRoutePicker(key, title, kind) {
4882
+ function openTeamEnginePicker(key, title, kind) {
4147
4883
  var isRole = String(key).indexOf('role:') === 0;
4148
4884
  if (!kind) kind = key; // 计划键时 kind 就是键本身
4149
4885
  openHostSheet(title, function (body) {
4150
4886
  var hint = hEl('div', 'history__hint', '正在探测引擎与模型…');
4151
4887
  hint.style.textAlign = 'left';
4152
4888
  body.appendChild(hint);
4153
- /* 规则控件放在引擎清单**之前**:规则是这一行的主内容,引擎是「交给谁」。
4154
- 它们与探测无关,引擎清单拉失败也照样能改。分区标题必须显式给出 ——
4155
- 用户 2026-10-22:「入口在哪里 不能配置规则」:控件在,但没标题就读不出这里是配置区。 */
4156
- body.appendChild(hEl('div', 'pick__cap pick__cap--team', '规则'));
4157
- if (isRole) body.appendChild(buildTeamRuleEditor(key));
4158
- else if (kind === 'plan') body.appendChild(buildTeamSplitControls());
4159
4889
  fetchEngines().then(function (list) {
4160
4890
  if (hint.parentNode) hint.remove();
4161
4891
  var avail = list.filter(function (x) { return x.ok; });
@@ -4173,19 +4903,13 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
4173
4903
  不是给 core 的配置(core 只认 engine / model)。
4174
4904
  ⚠️ 必须建在 paint() **外面**:paint 每次切引擎都会重画两列,放里面会一行行堆起来
4175
4905
  (2026-10-21 实测:点两次引擎,抽屉底部就多出三段同样的说明)。 */
4176
- var tipText;
4177
- if (isRole) {
4178
- var info = teamRoleList().filter(function (r) { return 'role:' + r.name === key; })[0];
4179
- tipText = '这个角色内置的职责:' + ((info && info.duty) || '—')
4180
- + '。上面写的规则会替掉它,进计划提示词;这里留空 = 跟随主引擎。';
4181
- } else {
4182
- tipText = '计划阶段负责把任务拆成子任务,交给更擅长规划、上下文更长的引擎更稳。';
4183
- }
4184
- body.appendChild(hEl('div', 'pick__cap pick__cap--team', '交给谁'));
4906
+ var tipText = isRole
4907
+ ? '只选这一类活交给谁;「这类任务适合什么」在团队模式页的「规则」卡里改。'
4908
+ : '计划阶段负责把任务拆成子任务,交给更擅长规划、上下文更长的引擎更稳;拆解规则在「规则」卡里改。';
4185
4909
  var tip = hEl('div', 'history__hint', tipText);
4186
4910
  tip.style.textAlign = 'left';
4187
4911
  body.appendChild(tip);
4188
- var wrap = hEl('div', 'pick__cols pick__cols--team');
4912
+ var wrap = hEl('div', 'pick__cols');
4189
4913
  var left = hEl('div', 'pick__col');
4190
4914
  var right = hEl('div', 'pick__col pick__col--r');
4191
4915
  wrap.appendChild(left); wrap.appendChild(right); body.appendChild(wrap);
@@ -4230,6 +4954,15 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
4230
4954
  });
4231
4955
  });
4232
4956
  }
4957
+ /* 下卡点一行 → 抽屉:**只改规则**(不含引擎)。计划 = 拆解粒度与两个开关;
4958
+ 角色 = 「这类任务适合什么」。与上卡的引擎选择互不掺杂(用户 2026-10-22)。 */
4959
+ function openTeamRuleSheet(key, label) {
4960
+ var isRole = String(key).indexOf('role:') === 0;
4961
+ openHostSheet(label, function (body) {
4962
+ if (isRole) body.appendChild(buildTeamRuleEditor(key));
4963
+ else body.appendChild(buildTeamSplitControls());
4964
+ });
4965
+ }
4233
4966
  /* 角色行的「规则」:一句话写清这类任务适合什么。**默认值直接填内置职责**(用户
4234
4967
  2026-10-22:「也没看到默认规则啊」—— placeholder 在手机上几乎看不见),保存后覆盖它
4235
4968
  (core 的 --team-rule role:<name>=…);改回内置值等于没改,不落覆盖。 */
@@ -4461,7 +5194,7 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
4461
5194
 
4462
5195
  avail.forEach(function (en) {
4463
5196
  var n = (en.models || []).length;
4464
- /* is-on = 已保存的默认(浅蓝实底);is-cur = 右列此刻在浏览谁(淡底)——
5197
+ /* is-on = 已保存的默认(浅蓝实底);is-cur = 右列此刻在浏览谁(左侧细条,无底色)——
4465
5198
  两者分开,默认是「自动」时不会出现两个一样的选中块。 */
4466
5199
  var b = hEl('button', 'pick__citem'
4467
5200
  + (RUN_PREF.engine === en.engine ? ' is-on' : '')
@@ -4554,6 +5287,9 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
4554
5287
  document.getElementById('btnConnect').onclick = function () {
4555
5288
  var target = parseTarget(addrEl.value);
4556
5289
  if (!target) { showError('请输入 magic-agent 桌面地址(形如 http://192.168.1.5:8791/?token=…)'); return; }
5290
+ /* 名称栏(2026-10-22):手填的优先于地址里的 ?name=,连上后就是环境名。 */
5291
+ var nm = connNameEl ? String(connNameEl.value || '').trim() : '';
5292
+ if (nm) target.name = nm;
4557
5293
  connect(target);
4558
5294
  };
4559
5295
  addrEl.addEventListener('input', function () { showError(''); });
@@ -4617,11 +5353,12 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
4617
5353
  on('btnDefaultEngine', openDefaultEnginePicker);
4618
5354
  on('btnDefaultModel', openDefaultEnginePicker);
4619
5355
  /* 团队模式配置(「我的」→ 子页面):计划 + 7 个角色,行由 paintTeamRoles 动态渲染,
4620
- 行的点击在渲染时绑定(见 addTeamRow / openTeamRoutePicker),这里只留入口与返回。 */
5356
+ 行的点击在渲染时绑定(见 addTeamRouteRow / addTeamRuleRow),这里只留入口与返回。 */
4621
5357
  on('btnTeamPage', showTeamPage);
4622
5358
  on('btnTeamBack', showMine);
4623
5359
  on('btnLogout', disconnect);
4624
5360
  on('btnUpgrade', reloadApp);
5361
+ on('btnMineVersion', checkAppUpdate);
4625
5362
  if (segTheme) takeSeg(segTheme, 'magic.theme', function (v) { PREFS.theme = v; applyThemePref(v); });
4626
5363
  if (segFont) takeSeg(segFont, 'magic.font', function (v) { PREFS.font = v; applyFontPref(v); });
4627
5364
  if (segMode) takeSeg(segMode, 'magic.taskmode', function (v) { TASK_MODE = v; });
@@ -4683,12 +5420,13 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
4683
5420
  scanEl.hidden = true;
4684
5421
  }
4685
5422
  document.getElementById('btnScanClose').onclick = stopScan;
4686
- document.getElementById('btnScan').onclick = function () {
5423
+ /* 通用扫码:扫到就把原文交给回调(启动页用来直连,「添加环境」用来填链接)。 */
5424
+ function startScan(onResult, onError) {
5425
+ var fail = onError || function (m) { toast(m); };
4687
5426
  if (!('BarcodeDetector' in window) || !navigator.mediaDevices) {
4688
- showError('当前环境不支持扫码,请把桌面配对页的「桌面地址」粘贴到输入框。');
5427
+ fail('当前环境不支持扫码,请把桌面给的地址粘贴进来。');
4689
5428
  return;
4690
5429
  }
4691
- showError('');
4692
5430
  scanEl.hidden = false;
4693
5431
  navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }).then(function (stream) {
4694
5432
  scanStream = stream;
@@ -4701,19 +5439,47 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
4701
5439
  scanDetector.detect(scanVideo).then(function (codes) {
4702
5440
  if (!codes || !codes.length) return;
4703
5441
  stopScan();
4704
- var raw = codes[0].rawValue || '';
4705
- var target = parseTarget(raw);
4706
- if (target) connect(target);
4707
- else showError('这个二维码不是 magic-agent 连接地址。');
5442
+ onResult(codes[0].rawValue || '');
4708
5443
  }).catch(function () {});
4709
5444
  }, 400);
4710
5445
  }).catch(function () {
4711
5446
  stopScan();
4712
- showError('无法启动相机,请改用粘贴地址的方式。');
5447
+ fail('无法启动相机,请改用粘贴地址的方式。');
4713
5448
  });
5449
+ }
5450
+ document.getElementById('btnScan').onclick = function () {
5451
+ showError('');
5452
+ startScan(function (raw) {
5453
+ var target = parseTarget(raw);
5454
+ if (!target) { showError('这个二维码不是 magic-agent 连接地址。'); return; }
5455
+ var nm = connNameEl ? String(connNameEl.value || '').trim() : '';
5456
+ if (nm) target.name = nm;
5457
+ connect(target);
5458
+ }, function (m) { showError(m); });
4714
5459
  };
4715
5460
 
4716
5461
  window.addEventListener('pagehide', stopScan);
5462
+ /* 关掉 App / 刷新之前也把这台的本机状态记进它自己的槽:
5463
+ 用户改完设置不一定马上切环境,不记就会在下次启动时丢。 */
5464
+ window.addEventListener('pagehide', function () {
5465
+ if (!TOKEN || ENV_SWITCHING) return;
5466
+ try { saveEnvScope(envScopeId()); } catch (e) {}
5467
+ });
5468
+
5469
+ /* 壳注入的那条连接换令牌了(App 每次启动都会换):把存档里同地址的记录同步过去。
5470
+ 否则「切回本机」会用一枚已经失效的旧令牌去连,表现成「这台连不上」。 */
5471
+ function syncInjectedConnToken(url, tok) {
5472
+ if (!url || !tok) return;
5473
+ var list = loadConns(), changed = false;
5474
+ for (var i = 0; i < list.length; i++) {
5475
+ var c = list[i];
5476
+ if (!c || c.url !== url || String(c.token || '') === String(tok)) continue;
5477
+ c.token = tok;
5478
+ c.id = connIdOf({ url: url, token: tok });
5479
+ changed = true;
5480
+ }
5481
+ if (changed) saveConns(list);
5482
+ }
4717
5483
 
4718
5484
  /* ══════════ 首屏 ══════════
4719
5485
  · 打包进 App(Capacitor):boot.js 注入 window.MAGIC_BASE / MAGIC_TOKEN → 直连会话;
@@ -4722,16 +5488,56 @@ body.is-conv .tabfab__b{width:56px;height:56px;bottom:calc(env(safe-area-inset-b
4722
5488
  var injectedToken = window.MAGIC_TOKEN || new URLSearchParams(location.search).get('token') || '';
4723
5489
  var injectedBase = (window.MAGIC_BASE || '').replace(/\/+$/, '');
4724
5490
  try {
4725
- if (injectedToken || injectedBase) {
5491
+ /* 上次停在哪个环境(见 switchEnv):切过环境后整页重载,必须回到**那台**,
5492
+ 否则壳注入的地址会把人拽回本机 —— 用户会看到「切了又跳回来」。 */
5493
+ var injUrl = injectedBase || (injectedToken ? location.origin : '');
5494
+ syncInjectedConnToken(injUrl, injectedToken);
5495
+ var resumed = false;
5496
+ try {
5497
+ var lastUrl = localStorage.getItem(ACTIVE_ENV_KEY) || '';
5498
+ if (lastUrl) {
5499
+ var saved = loadConns();
5500
+ for (var i = 0; i < saved.length; i++) {
5501
+ var sc = saved[i];
5502
+ if (!sc || sc.url !== lastUrl) continue;
5503
+ /* 同一地址(壳注入的这台)令牌以注入为准:App 每次启动会换令牌,存档里的旧令牌已失效。 */
5504
+ var tok = (lastUrl === injUrl && injectedToken)
5505
+ ? injectedToken : (sc.token || injectedToken);
5506
+ if (!tok) break;
5507
+ BASE = sc.url; TOKEN = tok; ACTIVE_ID = sc.id;
5508
+ showHistory(sc.host || connHost(sc.url));
5509
+ restoreQueue();
5510
+ resumed = true;
5511
+ break;
5512
+ }
5513
+ }
5514
+ } catch (e) { resumed = false; }
5515
+ if (!resumed && (injectedToken || injectedBase)) {
4726
5516
  BASE = injectedBase; TOKEN = injectedToken;
5517
+ /* 把当前这条(壳注入的)连接落成环境,名字默认「公司远程」——
5518
+ 否则它只是一条改不了名、也留不住的「未命名」。 */
5519
+ seedCurrentConn();
4727
5520
  showHistory(injectedBase ? injectedBase.replace(/^https?:\/\//, '') : '本机');
4728
5521
  restoreQueue(); // App 重启:上次没轮到的排队任务接着排(见 restoreQueue)
4729
- } else {
4730
- showLauncher();
5522
+ } else if (!resumed) {
5523
+ /* 没有注入、也没有可恢复的上次环境:有存过的环境就直接连最近用过的那条;
5524
+ 一条都没有才进任务页空态 —— 不再用独立启动页挡在前面
5525
+ (2026-10-22 用户:「这个界面不需要了 直接切换环境就行」)。 */
5526
+ var all = loadConns().filter(function (c) { return c && c.url; });
5527
+ all.sort(function (a, b) { return (b.at || 0) - (a.at || 0); });
5528
+ var pick = all[0];
5529
+ if (pick) {
5530
+ BASE = pick.url; TOKEN = pick.token || ''; ACTIVE_ID = pick.id;
5531
+ try { localStorage.setItem(ACTIVE_ENV_KEY, pick.url); } catch (e) {}
5532
+ showHistory(pick.host || connHost(pick.url));
5533
+ restoreQueue();
5534
+ } else {
5535
+ showHistory(null);
5536
+ }
4731
5537
  }
4732
5538
  } catch (e) {
4733
- /* 任何初始化异常都退回启动页,绝不留白屏。 */
4734
- try { showLauncher(); } catch (e2) {}
5539
+ /* 任何初始化异常都退回任务页空态,绝不留白屏。 */
5540
+ try { showHistory(null); } catch (e2) { try { showLauncher(); } catch (e3) {} }
4735
5541
  }
4736
5542
 
4737
5543
  /* ══════════ 启动闪屏:掌天瓶灌绿液(2026-10-20 用户:「app 启动动画也要有绿液」)══════════