magic-agent 1.0.111 → 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}
@@ -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}
@@ -462,6 +496,8 @@ button.setrow{font-family:inherit}
462
496
  .setrow+.setrow{border-top:1px solid var(--border)}
463
497
  .setrow__l{flex:1;min-width:0;font-size:15px;color:var(--ink)}
464
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}
465
501
  .setrow--danger{color:var(--error-text)}
466
502
  .setrow--danger .setrow__l,.setrow--danger .setrow__v{color:var(--error-text)}
467
503
  /* 环境编辑(我的 → 环境):名称 / 地址 / 令牌三栏。
@@ -472,6 +508,8 @@ button.setrow{font-family:inherit}
472
508
  .envfield:focus{border-color:var(--primary-action)}
473
509
  .envfield::placeholder{color:var(--faint)}
474
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)}
475
513
  .envhint{margin-top:10px;font-size:12px;line-height:1.6;color:var(--faint)}
476
514
  .envacts{display:flex;gap:8px;margin-top:16px}
477
515
  .envacts .btn{flex:1;margin-top:0}
@@ -491,6 +529,14 @@ button.setrow{font-family:inherit}
491
529
  .teamedit__acts{display:flex;gap:8px;margin-bottom:10px}
492
530
  /* 团队行尾箭头:点开才有的配置必须看得出「这里能点」。 */
493
531
  .setrow__chev{flex:0 0 auto;color:var(--faint);font-size:16px;line-height:1}
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%}
494
540
  .seg{display:inline-flex;background:var(--subtle);border-radius:var(--r-full);padding:2px;gap:2px}
495
541
  .seg__b{height:28px;padding:0 12px;border-radius:var(--r-full);font-size:13px;color:var(--muted)}
496
542
  .seg__b.is-on{background:var(--raised);color:var(--ink);font-weight:600}
@@ -505,18 +551,20 @@ button.setrow{font-family:inherit}
505
551
  从栏背滚过去,靠半透明 + 18px 模糊透出来。这样栏高不进布局,会话页的输入框也不用被它顶上去。
506
552
 
507
553
  代价是**每一页自己留出底部安全距离**,否则最后一行会被压在栏下面:
508
- · `.history__list` 已有 96px(够 74 + 34 safe-area)
554
+ · `.history__list` 已有 96px(够 56 + 34 safe-area,且给凸出的 + 留了一截)
509
555
  · 会话页由 composer 的下内边距负责,见下面的 `#conv .composer`
510
556
  · 启动页不显示底栏(showLauncher 里 hidden),无需留白
511
557
 
512
558
  min-height 而不是 height:底栏还要吞 safe-area(34px),
513
559
  border-box 下固定 height 会让内部 tab 被压到 26px 高。
514
- **56**(2026-10-22 定稿「底栏只保留图标不需要文字了」):
515
- 去掉标签后只剩 25px 图标 + 44×34 选中浅底,56 给到浅底上下各 11px,
516
- 栏体也矮下来 —— 对话区因此多出 18px。 */
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,栏体也矮下来。 */
517
565
  .tabbar{position:absolute;left:0;right:0;bottom:0;z-index:60;
518
566
  display:flex;align-items:stretch;
519
- min-height:56px;
567
+ min-height:calc(56px + env(safe-area-inset-bottom));
520
568
  background:color-mix(in srgb,var(--raised) 86%,transparent);
521
569
  -webkit-backdrop-filter:blur(18px) saturate(1.4);backdrop-filter:blur(18px) saturate(1.4);
522
570
  box-shadow:0 -1px 0 var(--border),0 -14px 30px -26px var(--app-shadow);
@@ -548,15 +596,16 @@ button.setrow{font-family:inherit}
548
596
  于是看起来像按钮自带发光轮廓。
549
597
 
550
598
  实现:
551
- ① **洞**:`::before` 是一块悬在栏顶边界内的半圆(宽 74 / 高 37,下两角半径 37),
552
- 填槽底 `--fab-well`,在底栏上边缘切出一个半圆缺口。
553
- 高度只到 37,**不侵入上方内容**(试过整圆深度,会盖住列表底部)。
554
- ② **嵌**:按钮直径 68,圆心落在栏顶边线上(上露 34 / 下沉 34),
555
- 于是下半实打实坐进洞里。圆心压边线而不是抬高,是「嵌」与「放」的分界。
599
+ ① **洞**:`::before` 是一块 74×74 的圆(半径 37 = 按钮 34 + 3 接触缝),圆心与按钮同心,
600
+ **再用 `clip-path` 切掉落在栏顶边线以上的部分** —— 这样槽的形状永远跟着按钮走,
601
+ 按钮下沉多少,露出的就是那一段圆弧,不用为每种下沉量另算半圆尺寸。
602
+ 填槽底 `--fab-well`,于是在底栏上边缘切出一道弧。
603
+ ② **嵌**:按钮直径 68,圆心落在栏顶边线**下方 10px**(上露 24 / 下沉 44,距栏底还剩 12)。
604
+ 不是齐边切一半(2026-10-22 用户:「不需要突出半圆 出来一部分即可 再下沉一些 注意保留间距」)。
556
605
  ③ **接触影**:`radial-gradient` 把暗环**锚定在 r=34(按钮半径)**上,
557
- 从 33.5px 起最暗、到 37px 淡完。这是唯一能精确控制「暗在哪一圈」的写法 ——
606
+ 从 33.5px 起最暗、到 37px 淡完,圆心同样与按钮同心。这是唯一能精确控制「暗在哪一圈」的写法 ——
558
607
  `box-shadow: inset` 的暗边永远从元素边界向内,管不到元素外的那道槽。
559
- 几何全用相对量(`bottom:calc(100% - 34px)`、`top:0`),安全区变化时洞和按钮一起走。
608
+ 几何全用相对量(`bottom:calc(100% - 44px)`、`top:-27px`),安全区变化时洞和按钮一起走。
560
609
 
561
610
  **两页一致**:会话页不再把「+」收进栏内(2026-10-22 用户:「圆没有突出了 …两个效果要一致」)。
562
611
  凸出的圆在列表页和对话页长得完全一样,只用 composer 的下内边距给它让出空间。 */
@@ -567,22 +616,21 @@ button.setrow{font-family:inherit}
567
616
  接触影必须画在槽底**之上**,且暗环两端用硬锚点,
568
617
  避免模糊半径把亮度峰值推到按钮外面(那正是前几版读成光晕的原因)。
569
618
 
570
- **露出的槽宽只有 3px**(洞半径 37 − 按钮半径 34)。曾经用更大:那圈灰色槽底读作「按钮下面垫了个盘子」,
571
- 正是用户否掉的形态。3px 只够出一条接触缝 —— 看得见槽、看不清槽底,就等于"切出来的"。
572
- 洞的高也取 37 而不是更深:槽只要在栏顶边界内切出缺口,深了只是浪费。 */
619
+ **露出的槽宽只有 3px**(槽半径 37 − 按钮半径 34)。曾经用更大:那圈灰色槽底读作「按钮下面垫了个盘子」,
620
+ 正是用户否掉的形态。3px 只够出一条接触缝 —— 看得见槽、看不清槽底,就等于"切出来的"。 */
573
621
  .tabbar::before{content:"";position:absolute;z-index:1;pointer-events:none;
574
- left:50%;top:0;transform:translateX(-50%);
575
- width:74px;height:37px;
576
- border-radius:0 0 37px 37px;
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:切掉露在外面的那一截,只留栏内的弧 */
577
625
  background:
578
- radial-gradient(circle 37px at 50% 0,
626
+ radial-gradient(circle 37px at 50% 50%,
579
627
  var(--fab-notch) 33.5px,
580
628
  var(--fab-notch) 34.5px,
581
629
  color-mix(in srgb,var(--fab-notch) 35%,transparent) 36px,
582
630
  transparent 37px),
583
631
  var(--fab-well)}
584
632
  .tabfab__b{position:absolute;z-index:2;
585
- left:50%;bottom:calc(100% - 34px); /* 圆心钉在栏顶边线上:上露 34 / 下沉 34 */
633
+ left:50%;bottom:calc(100% - 44px); /* 上露 24 / 下沉 44,距栏底 12 */
586
634
  transform:translateX(-50%);
587
635
  width:68px;height:68px;
588
636
  display:flex;align-items:center;justify-content:center;
@@ -597,14 +645,14 @@ button.setrow{font-family:inherit}
597
645
  .tabfab__b:active{transform:translateX(-50%) translateY(2px);
598
646
  box-shadow:inset 0 -6px 12px -7px rgba(15,23,42,.56), inset 0 2px 3px -2px rgba(255,255,255,.26)}
599
647
  .tabfab__b svg{display:block;stroke-width:2.4;transform:translateY(-1px)}
600
- /* 会话页(2026-10-22 用户:「不要挤压对话框」「两个效果要一致」「圆可以更大」):
601
- 底栏浮起后不占位,「+」在**两页都凸出 34**(68 直径的一半),所以 composer 只需让出
602
- 「上探 34 + 呼吸 12 + 栏高 56」—— 输入框落在凸出圆的头顶 12px,不遮不压。
648
+ /* 会话页(2026-10-22 用户:「不要挤压对话框」「两个效果要一致」「圆可以更大」「再下沉一些 注意保留间距」):
649
+ 底栏浮起后不占位,「+」在**两页都上露 24**(68 直径、圆心在栏顶下方 10),所以 composer 只需让出
650
+ 「上露 24 + 呼吸 12 + 栏高 56」—— 输入框落在凸出圆弧上方 12px,不遮不压。
603
651
  原来 composer 下留 60(= 上探 30 + 呼吸 30)再叠占位的 68 栏,屏幕底到输入框 162;
604
- 现在 56 + 34 + 12 = 102,对话区多出 60px。
605
- 56 必须与 .tabbar 的 min-height 对齐(改栏高要一起改)。
652
+ 现在 56 + 24 + 12 = 92,对话区多出 70px。
653
+ 56 必须与 .tabbar 的 min-height 对齐、24 必须与 .tabfab__b 的下沉量对齐(改一处要一起改)。
606
654
  **只叠一次**:safe-area 由这里一次性算进来,别再给 .composer 的基础 padding 加一遍。 */
607
- #conv .composer{padding-bottom:calc(12px + 34px + 56px + env(safe-area-inset-bottom))}
655
+ #conv .composer{padding-bottom:calc(12px + 24px + 56px + env(safe-area-inset-bottom))}
608
656
 
609
657
  /* 组件挂载点:占满剩余高度 */
610
658
  #conv{flex:1;min-height:0;display:flex}
@@ -852,8 +900,13 @@ button.setrow{font-family:inherit}
852
900
  <svg width="18" height="18"><use href="#i-back"/></svg><span>返回</span>
853
901
  </button>
854
902
  <h1 class="launcher__title">magic-agent · 远程控制</h1>
855
- <p class="launcher__desc">打开桌面配对页里的连接地址,远程继续你的会话。</p>
903
+ <p class="launcher__desc">填一个名字和桌面地址,或扫码配对。连过的桌面会保存下来,可在任务页标题处切换。</p>
856
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>
857
910
  <div class="field" id="field">
858
911
  <label for="addr">桌面地址</label>
859
912
  <textarea id="addr" rows="2" placeholder="http://192.168.1.5:8791/?token=…"
@@ -911,8 +964,6 @@ button.setrow{font-family:inherit}
911
964
  <div class="history__list">
912
965
  <div class="setcard">
913
966
  <div class="setrow"><span class="setrow__l">设备</span><span class="setrow__v" id="mineDevice">—</span></div>
914
- <!-- 环境(多连接):显式配置每台桌面的名字 / IP / 令牌,并在这里切换 -->
915
- <button class="setrow" id="btnEnvPage"><span class="setrow__l">环境</span><span class="setrow__v" id="mineEnv">—</span></button>
916
967
  <div class="setrow"><span class="setrow__l">主题</span>
917
968
  <span class="seg" id="segTheme">
918
969
  <button class="seg__b" data-v="system">系统</button>
@@ -949,12 +1000,15 @@ button.setrow{font-family:inherit}
949
1000
  <button class="setrow" id="btnTeamPage"><span class="setrow__l">团队模式</span><span class="setrow__v" id="teamSummary">跟随主引擎</span></button>
950
1001
  </div>
951
1002
  <div class="setcard">
952
- <!-- 版本号(2026-10-22 用户:「移动端我的tab中显示版本号」)。
953
- 值由 /desk/health 的 version 给(= core 的 `--version`)—— 移动端界面是
954
- 桌面端 /m/ 远程加载的、本身不带版本,用户要看的是「连着的这份是哪一版」。
955
- 老 core 不给 version 时保持「—」,不写死。 -->
956
- <div class="setrow"><span class="setrow__l">版本</span><span class="setrow__v" id="mineVersion">—</span></div>
957
- <button class="setrow" id="btnUpgrade"><span class="setrow__l">检查更新</span><span class="setrow__v" id="upgradeState">重新加载</span></button>
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>
958
1012
  </div>
959
1013
  <div class="setcard">
960
1014
  <button class="setrow setrow--danger" id="btnLogout">断开连接</button>
@@ -966,40 +1020,18 @@ button.setrow{font-family:inherit}
966
1020
  从「我的」那一行点进来。这里配的全都是**设备级偏好**(开关本身按会话,在会话页运行设置里)。
967
1021
  **只有一套划分**:计划 + 7 个角色(writer / researcher / reviewer / coder / designer /
968
1022
  director / builder),不再并列「6 个类别」那一层。
969
- 2026-10-22 用户:「配引擎和配规则要分开配置 不要在 1 个页面」——
970
- 于是**两张卡、两个入口**:上面「交给谁」点开只有引擎/模型;下面「规则」点开只有规则
971
- (计划 = 拆解口径,角色 = 这类任务适合什么)。两者互不掺杂。 -->
1023
+ 2026-10-22 用户:「两个触发就行了」—— 一行里**两个点击区**:
1024
+ 左边(名字 + 规则描述)点开只改规则;右边(引擎值)点开只选引擎。
1025
+ 不拆两张卡,也不把规则和引擎塞进同一个抽屉。 -->
972
1026
  <div class="history" id="teamPage" hidden>
973
1027
  <div class="history__top">
974
1028
  <button class="topbar__icon" id="btnTeamBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
975
1029
  <span class="history__title">团队模式</span>
976
1030
  </div>
977
1031
  <div class="history__list">
978
- <div class="history__group">交给谁(引擎 / 模型)</div>
1032
+ <div class="history__group">计划与 7 类子任务</div>
979
1033
  <div class="setcard" id="teamRouteCard"></div>
980
- <div class="history__group">规则(只影响计划怎么拆)</div>
981
- <div class="setcard" id="teamRuleCard"></div>
982
- <div class="history__hint" style="text-align:left">两张卡分开配:上面只选引擎,下面只改规则。留空 = 跟随主引擎 / 使用内置规则。</div>
983
- </div>
984
- </div>
985
-
986
- <!-- ══════════ 我的 → 环境(多连接:名字 / IP / 令牌)══════════
987
- 2026-10-22 用户:「环境没有地方配置 ip 和名字啊」。
988
- 环境不能只靠「连过才记得」:这里显式新建 / 改名 / 改地址 / 删,点一行就编辑。
989
- 地址填 IP:端口即可(不带 http:// 自动补),令牌可以写在地址的 ?token= 里,
990
- 也可以单独填下面那栏(显式令牌优先)。 -->
991
- <div class="history" id="envPage" hidden>
992
- <div class="history__top">
993
- <button class="topbar__icon" id="btnEnvBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
994
- <span class="history__title">环境</span>
995
- </div>
996
- <div class="history__list">
997
- <div class="history__group">已配置的桌面</div>
998
- <div class="setcard" id="envList"></div>
999
- <div class="history__actions" style="padding:10px 0 0">
1000
- <button class="newtask" id="btnEnvAdd" type="button"><svg width="18" height="18"><use href="#i-plus"/></svg><span>新建环境</span></button>
1001
- </div>
1002
- <div class="history__hint" style="text-align:left">环境 = 一台跑着 magic-client 的桌面。名字随你起(例如「公司 Mac」),地址填 IP:端口,例如 192.168.1.5:8791。保存后可在这里或任务页标题处切换。</div>
1034
+ <div class="history__hint" style="text-align:left">左边点规则,右边点引擎。规则留空 = 内置规则;引擎留空 = 跟随主引擎。</div>
1003
1035
  </div>
1004
1036
  </div>
1005
1037
 
@@ -1023,7 +1055,10 @@ button.setrow{font-family:inherit}
1023
1055
 
1024
1056
  <!-- ══════════ 需求(会话自动聚合成的功能点文档)══════════
1025
1057
  数据全来自 GET /desk/requirements(core 的 --requirements,只读)。
1026
- 按「工作区 → 模块 → 功能点」两级分组;点一行用既有的阅读器打开那篇 markdown。
1058
+ **两级**(2026-10-22 用户定稿):
1059
+ 一级 = 目录(空间 → 模块),**不画功能点** —— 一级只回答「这个空间有哪几块」;
1060
+ 二级 = 点某个模块进去,看**该空间下所有模块**的需求列表(当前模块高亮)。
1061
+ 点一行用既有的阅读器打开那篇 markdown。
1027
1062
  ⚠️ 这里**不做筛选胶囊与搜索**:第一版只要「看得见 + 能点开」,多了反而没人用
1028
1063
  (用户 2026-10-22 定稿)。 -->
1029
1064
  <div class="history" id="reqs" hidden>
@@ -1034,6 +1069,16 @@ button.setrow{font-family:inherit}
1034
1069
  <div class="history__list" id="reqList"></div>
1035
1070
  </div>
1036
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
+
1037
1082
  <!-- ══════════ 发现(浏览器 / 应用 / 插件 / SKILL)══════════ -->
1038
1083
  <div class="history" id="discover" hidden>
1039
1084
  <div class="history__top"><span class="history__title">发现</span></div>
@@ -1204,6 +1249,7 @@ button.setrow{font-family:inherit}
1204
1249
  var subtitleEl = document.getElementById('subtitle');
1205
1250
  var fieldEl = document.getElementById('field');
1206
1251
  var addrEl = document.getElementById('addr');
1252
+ var connNameEl = document.getElementById('connName');
1207
1253
  var errEl = document.getElementById('err');
1208
1254
  var recentEl = document.getElementById('recent');
1209
1255
  var recentListEl = document.getElementById('recentList');
@@ -1216,9 +1262,6 @@ button.setrow{font-family:inherit}
1216
1262
  var taskSearchInput = document.getElementById('taskSearchInput');
1217
1263
  var mineEl = document.getElementById('mine');
1218
1264
  var mineDevice = document.getElementById('mineDevice');
1219
- var mineEnv = document.getElementById('mineEnv');
1220
- var envPageEl = document.getElementById('envPage');
1221
- var envListEl = document.getElementById('envList');
1222
1265
  var mineVersion = document.getElementById('mineVersion');
1223
1266
  var segTheme = document.getElementById('segTheme');
1224
1267
  var segFont = document.getElementById('segFont');
@@ -1227,6 +1270,9 @@ button.setrow{font-family:inherit}
1227
1270
  var tabbarEl = document.getElementById('tabbar');
1228
1271
  var reqsEl = document.getElementById('reqs');
1229
1272
  var reqListEl = document.getElementById('reqList');
1273
+ var reqDetailEl = document.getElementById('reqDetail');
1274
+ var reqDetailTitle = document.getElementById('reqDetailTitle');
1275
+ var reqDetailListEl = document.getElementById('reqDetailList');
1230
1276
  var discoverEl = document.getElementById('discover');
1231
1277
  var teamPageEl = document.getElementById('teamPage');
1232
1278
  var runSettingsPageEl = document.getElementById('runSettingsPage');
@@ -1259,7 +1305,7 @@ button.setrow{font-family:inherit}
1259
1305
  /* 工作空间要跨重启记住(与引擎 / 模型同一口径):只放内存的话,App 重启后
1260
1306
  「任务运行设置」里选的空间就回到默认,用户会以为没保存。 */
1261
1307
  try { WS = localStorage.getItem('magic.ws') || ''; } catch (e) {}
1262
- function saveWs() { try { localStorage.setItem('magic.ws', WS || ''); } catch (e) {} }
1308
+ function saveWs() { try { localStorage.setItem('magic.ws', WS || ''); } catch (e) {} schedulePushPrefs(); }
1263
1309
  /* 默认引擎 / 模型(「我的」可改;函数与选择器见下面「默认引擎 / 模型」段)。
1264
1310
  提前到这里声明:首屏 paintRunPref 会读它,晚于 PREFS 初始化赋值就会踩 undefined。 */
1265
1311
  var RUN_PREF = loadRunPref();
@@ -1366,6 +1412,7 @@ button.setrow{font-family:inherit}
1366
1412
  try { localStorage.setItem('magic.team.split', TEAM.split || 'standard'); } catch (e) {}
1367
1413
  try { localStorage.setItem('magic.team.splitcode', TEAM.splitCode ? '1' : '0'); } catch (e) {}
1368
1414
  try { localStorage.setItem('magic.team.adaptive', TEAM.adaptive === false ? '0' : '1'); } catch (e) {}
1415
+ schedulePushPrefs();
1369
1416
  paintTeamPref();
1370
1417
  }
1371
1418
  /* 这条会话的开关:本地记过就用本地;没记过 → 跟会话自己的运行模式(core 的 `mode:"team"`)。
@@ -1394,6 +1441,21 @@ button.setrow{font-family:inherit}
1394
1441
  var LS_KEY = 'magic.remote.recent.v1'; // 旧版「最近连接」:只在首次升级时搬一次
1395
1442
  var ACTIVE_ID = '';
1396
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
+ })();
1397
1459
 
1398
1460
  function connHost(url) {
1399
1461
  try { return new URL(url).host; }
@@ -1438,28 +1500,44 @@ button.setrow{font-family:inherit}
1438
1500
  var next = {
1439
1501
  id: id, url: t.url || '', token: t.token || '',
1440
1502
  name: (t.name != null && t.name !== '') ? t.name : (old.name || ''),
1441
- 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 // 种出来的默认环境:改名/改地址后仍然认出它
1442
1505
  };
1443
1506
  list.unshift(next);
1444
1507
  saveConns(list);
1445
1508
  return next;
1446
1509
  }
1447
- 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
+ }
1448
1518
  function clearConns() {
1449
1519
  saveConns([]);
1450
- 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) {}
1451
1524
  }
1452
1525
 
1453
1526
  /* 当前连接:先按地址 + 令牌找;App 壳注入的连接还没落盘时给一条临时记录,
1454
- 这样标题与菜单里仍然看得见「现在连的是谁」。 */
1527
+ 这样标题与菜单里仍然看得见「现在连的是谁」。
1528
+ ⚠️ 壳里 BASE 可能是空串(同源):这时用 location.origin 当它的地址 ——
1529
+ 否则这条「未命名」没有 URL,既存不下来也就没法编辑(2026-10-22 用户:
1530
+ 「现在已有的未命名都编辑不了」)。 */
1531
+ function connUrlNow() { return BASE || (TOKEN ? location.origin : ''); }
1455
1532
  function activeConn() {
1533
+ var url = connUrlNow();
1456
1534
  var list = loadConns(), i;
1457
1535
  for (i = 0; i < list.length; i++) {
1458
- 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];
1459
1537
  }
1460
1538
  for (i = 0; i < list.length; i++) if (ACTIVE_ID && list[i].id === ACTIVE_ID) return list[i];
1461
- if (BASE || TOKEN) {
1462
- return { id: connIdOf({ url: BASE, token: TOKEN }), url: BASE, token: TOKEN, name: '', host: connHost(BASE), app: '' };
1539
+ if (url || TOKEN) {
1540
+ return { id: connIdOf({ url: url, token: TOKEN }), url: url, token: TOKEN, name: '', host: connHost(url), app: '' };
1463
1541
  }
1464
1542
  return null;
1465
1543
  }
@@ -1468,7 +1546,219 @@ button.setrow{font-family:inherit}
1468
1546
  if (!c) return '';
1469
1547
  return c.name || c.app || c.host || connHost(c.url);
1470
1548
  }
1471
- 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
+ }
1472
1762
 
1473
1763
  /* 桌面 health 回来的应用名(app)补进当前连接:它比 host 更像「这台设备是谁」。 */
1474
1764
  function noteActiveMeta(meta) {
@@ -1524,23 +1814,24 @@ button.setrow{font-family:inherit}
1524
1814
  }
1525
1815
  /* 换环境 = 换一台桌面:对话、轮询、运行态都属于旧连接,必须一起收掉。
1526
1816
  排队任务按令牌分别落库(见 QUEUE_KEY),旧环境那批留在原地,切回来再接着跑。 */
1817
+ /* 换环境 = 换一台桌面:会话 / 需求 / 我的设置全都属于那台机器。
1818
+ 做法分两步:① 把当前这台的本机状态快照进它的槽,再把目标环境的槽写回来;
1819
+ ② 记住「停在哪个环境」并整页重载 —— 重载后内存态从零初始化,按目标环境的
1820
+ 连接与设置重建界面(会话、需求都是重载后从目标桌面现拉的)。
1821
+ ⚠️ 不在这里手写「清哪些变量」:那种清单永远会漏(漏一个就带着上一台的数据)。 */
1527
1822
  function switchEnv(c) {
1528
1823
  if (!c) return;
1529
- if (c.url === BASE && String(c.token || '') === String(TOKEN || '')) {
1824
+ if (c.url === connUrlNow() && String(c.token || '') === String(TOKEN || '')) {
1530
1825
  toast('已在「' + (connLabel(c) || '当前环境') + '」');
1531
1826
  return;
1532
1827
  }
1533
- if (conv) { try { conv.destroy(); } catch (e) {} conv = null; }
1534
- convEl.innerHTML = '';
1535
- stopPoll();
1536
- PENDING = []; ASK_WAITING = []; SERIAL = {}; QUEUE_RESTORED = false;
1537
- SERVER_VERSION = ''; CORE_FEATURES = []; TEAM_ROLES = []; ENGINES = []; ENGINES_STALE_AT = '';
1538
- TITLES = {}; LAST_SESSIONS = []; DW = ''; DW_READY = false; ROOT_DIRS = [];
1539
- /* 工作空间属于某台机器的目录树:沿用旧路径会落到不存在的目录,一律回到默认。 */
1540
- WS = '';
1541
- saveWs();
1542
- connect({ url: c.url, token: c.token, name: c.name, host: c.host || connHost(c.url) });
1543
- toast('已切换到「' + (connLabel(c) || connHost(c.url)) + '」');
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; }
1544
1835
  }
1545
1836
  function openEnvMenu() {
1546
1837
  if (envMenuEl) { closeEnvMenu(); return; }
@@ -1557,7 +1848,7 @@ button.setrow{font-family:inherit}
1557
1848
  var active = !!(cur && c.id === cur.id);
1558
1849
  var b = document.createElement('button');
1559
1850
  b.type = 'button';
1560
- b.className = 'envmenu__item';
1851
+ b.className = 'envmenu__item' + (active ? ' is-active' : '');
1561
1852
  var ico = document.createElement('span');
1562
1853
  ico.className = 'envmenu__ico';
1563
1854
  ico.appendChild(svgUse('#i-laptop', 20));
@@ -1575,29 +1866,29 @@ button.setrow{font-family:inherit}
1575
1866
  var csub = (cname && cname !== chost) ? chost : '';
1576
1867
  col.appendChild(elSpan('envmenu__s', (csub ? csub + ' · ' : '') + (active ? '当前连接' : '点击切换')));
1577
1868
  b.appendChild(col);
1578
- if (active) {
1579
- var ck = document.createElement('span');
1580
- ck.className = 'envmenu__check';
1581
- ck.appendChild(svgUse('#i-check', 18));
1582
- b.appendChild(ck);
1583
- }
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);
1584
1877
  b.onclick = function () { closeEnvMenu(); switchEnv(c); };
1585
1878
  envMenuEl.appendChild(b);
1586
1879
  });
1587
1880
  envMenuEl.appendChild(elSpan('envmenu__sep', ''));
1588
- /* 两个入口必须分开:新建环境 = 手填名字 + IP;扫码/粘贴 = 用配对页给的链接直接连。
1589
- 以前只有后者,「环境没有地方配置 ip 和名字」(2026-10-22 用户)。 */
1590
- function menuAct(label, fn) {
1881
+ /* 只留一个「添加环境」:扫码在启动页,改名改链接走每行的铅笔图标。 */
1882
+ function menuAct(icon, label, fn) {
1591
1883
  var b = document.createElement('button');
1592
1884
  b.type = 'button';
1593
1885
  b.className = 'envmenu__add';
1594
- b.appendChild(svgUse('#i-plus', 18));
1886
+ b.appendChild(svgUse(icon, 18));
1595
1887
  b.appendChild(elSpan('', label));
1596
1888
  b.onclick = function () { closeEnvMenu(); fn(); };
1597
1889
  envMenuEl.appendChild(b);
1598
1890
  }
1599
- menuAct('环境设置(名字 / IP)', function () { showEnvPage('tasks'); });
1600
- menuAct('扫码或粘贴连接地址', function () { showLauncher(); });
1891
+ menuAct('#i-plus', '添加环境', function () { openEnvEditor(null); });
1601
1892
  document.body.appendChild(envScrimEl);
1602
1893
  document.body.appendChild(envMenuEl);
1603
1894
  var btn = document.getElementById('btnEnvSwitch');
@@ -1608,53 +1899,25 @@ button.setrow{font-family:inherit}
1608
1899
  btn.classList.add('is-open');
1609
1900
  }
1610
1901
 
1611
- /* ── 环境管理(我的 → 环境):显式配置名字 / IP / 令牌 ──
1612
- 2026-10-22 用户:「环境没有地方配置 ip 和名字啊」——
1613
- 扫码只是「连上并记住」,不是配置入口。这里补上增删改。 */
1614
- function showEnvPage(from) {
1615
- hideAll(); envPageEl.hidden = false; tabbarEl.hidden = true;
1616
- ENV_BACK = from === 'tasks' ? 'tasks' : 'mine';
1617
- renderEnvPage();
1618
- }
1619
- var ENV_BACK = 'mine'; // 环境页的返回目标:从任务页标题进来就回任务页,从「我的」进来就回「我的」
1620
- function renderEnvPage() {
1621
- if (!envListEl) return;
1622
- envListEl.innerHTML = '';
1623
- var list = loadConns();
1624
- if (!list.length) {
1625
- envListEl.appendChild(hEl('div', 'history__hint', '还没有配置环境。点下面的「新建环境」,填名字和 IP 即可。'));
1626
- return;
1627
- }
1628
- var cur = activeConn();
1629
- list.forEach(function (c) {
1630
- var b = hEl('button', 'setrow');
1631
- b.type = 'button';
1632
- var main = hEl('span', 'setrow__main');
1633
- main.appendChild(hEl('span', 'setrow__l', connLabel(c) || '未命名'));
1634
- main.appendChild(hEl('span', 'setrow__sub',
1635
- (c.host || connHost(c.url)) + (cur && c.id === cur.id ? ' · 当前连接' : '')));
1636
- b.appendChild(main);
1637
- b.appendChild(hEl('span', 'setrow__v', '编辑'));
1638
- b.onclick = function () { openEnvEditor(c); };
1639
- envListEl.appendChild(b);
1640
- });
1641
- }
1642
- /* conn 为空 = 新建;否则编辑(名字、地址、令牌都能改,也能删)。 */
1902
+ /* ── 环境编辑(任务页标题菜单里的铅笔 / 添加环境):名字 / 链接 / 令牌 ──
1903
+ 2026-10-22 用户:「我的中的环境配置就不需要了」—— 管理入口只留在标题菜单里,
1904
+ 不再单独做一页。 */
1905
+ /* conn 为空 = 新建;否则编辑(名字、链接、令牌都能改,也能删)。 */
1643
1906
  function openEnvEditor(conn) {
1644
1907
  var isNew = !conn;
1645
- openHostSheet(isNew ? '新建环境' : '编辑环境', function (body) {
1908
+ openHostSheet(isNew ? '添加环境' : '编辑环境', function (body) {
1646
1909
  var nameEl = hEl('input', 'envfield');
1647
1910
  nameEl.type = 'text';
1648
1911
  nameEl.placeholder = '例如 公司 Mac';
1649
1912
  nameEl.value = (conn && conn.name) || '';
1650
1913
  var addrEl = hEl('input', 'envfield');
1651
1914
  addrEl.type = 'text';
1652
- addrEl.placeholder = '192.168.1.5:8791';
1915
+ /* 「链接」= 桌面配对页给的那条地址,可整条粘进来(含 ?token=…)。 */
1916
+ addrEl.placeholder = 'http://192.168.1.5:8791/?token=…';
1653
1917
  addrEl.setAttribute('autocapitalize', 'off');
1654
1918
  addrEl.setAttribute('autocomplete', 'off');
1655
1919
  addrEl.setAttribute('spellcheck', 'false');
1656
- /* 地址栏显示不带 http:// 的短形态(用户脑子里就是 IP:端口);https 原样保留。 */
1657
- addrEl.value = conn && conn.url ? String(conn.url).replace(/^http:\/\//i, '') : '';
1920
+ addrEl.value = (conn && conn.url) ? String(conn.url) : '';
1658
1921
  var tokEl = hEl('input', 'envfield');
1659
1922
  tokEl.type = 'text';
1660
1923
  tokEl.placeholder = '可选,配对令牌';
@@ -1664,15 +1927,26 @@ button.setrow{font-family:inherit}
1664
1927
  tokEl.value = (conn && conn.token) || '';
1665
1928
  body.appendChild(hEl('label', 'envlabel', '名称'));
1666
1929
  body.appendChild(nameEl);
1667
- body.appendChild(hEl('label', 'envlabel', '地址(IP:端口)'));
1930
+ body.appendChild(hEl('label', 'envlabel', '链接'));
1668
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);
1669
1943
  body.appendChild(hEl('label', 'envlabel', '令牌'));
1670
1944
  body.appendChild(tokEl);
1671
1945
  var err = hEl('div', 'field__err');
1672
1946
  err.hidden = true;
1673
1947
  body.appendChild(err);
1674
1948
  body.appendChild(hEl('div', 'envhint',
1675
- '地址也可以带 ?token=…;下面这栏单独填时优先。填好保存后可在这里或任务页标题处切换。'));
1949
+ '链接从桌面配对页复制,可以带 ?token=…;令牌栏单独填时优先。'));
1676
1950
  var acts = hEl('div', 'envacts');
1677
1951
  var saveB = hEl('button', 'btn btn--filled', '保存');
1678
1952
  var goB = hEl('button', 'btn btn--outlined', '保存并连接');
@@ -1690,7 +1964,7 @@ button.setrow{font-family:inherit}
1690
1964
  err.hidden = true;
1691
1965
  var raw = String(addrEl.value || '').trim();
1692
1966
  var t = parseTarget(raw);
1693
- if (!t) { err.textContent = '请填地址,例如 192.168.1.5:8791'; err.hidden = false; return null; }
1967
+ if (!t) { err.textContent = '请填链接,例如 http://192.168.1.5:8791/?token=…'; err.hidden = false; return null; }
1694
1968
  var tok = String(tokEl.value || '').trim();
1695
1969
  var hadParam = /[?&]token=/.test(raw);
1696
1970
  if (tok) t.token = tok;
@@ -1702,7 +1976,7 @@ button.setrow{font-family:inherit}
1702
1976
  return t;
1703
1977
  }
1704
1978
  function persist(t) {
1705
- var wasActive = !!(conn && conn.url === BASE && String(conn.token || '') === String(TOKEN || ''));
1979
+ var wasActive = !!(conn && conn.url === connUrlNow() && String(conn.token || '') === String(TOKEN || ''));
1706
1980
  var next = upsertConn(t);
1707
1981
  /* 改了地址/令牌 = 换了一条记录:旧的那条要删掉,别留一条连不上的僵尸。 */
1708
1982
  if (conn && conn.id !== next.id) removeConn(conn.id);
@@ -1719,9 +1993,7 @@ button.setrow{font-family:inherit}
1719
1993
  switchEnv(next); // 改的正是当前这台:按新地址/令牌重连
1720
1994
  return;
1721
1995
  }
1722
- renderEnvPage();
1723
1996
  if (!historyEl.hidden) historyTitle.textContent = envLabel() || '任务';
1724
- if (!mineEl.hidden && mineEnv) mineEnv.textContent = envLabel() || '—';
1725
1997
  toast(isNew ? ('已添加「' + (next.name || next.host) + '」') : '已保存');
1726
1998
  }
1727
1999
  saveB.onclick = function () { var t = collect(); if (t) persist(t); };
@@ -1731,14 +2003,13 @@ button.setrow{font-family:inherit}
1731
2003
  persist(t);
1732
2004
  /* persist 内部遇到「改的是当前连接」已经自己重连了;这里只处理新增/改别的环境。 */
1733
2005
  var c = findConn(connIdOf(t)) || findConn(connIdOf({ url: t.url, token: t.token }));
1734
- if (c && !(c.url === BASE && String(c.token || '') === String(TOKEN || ''))) switchEnv(c);
2006
+ if (c && !(c.url === connUrlNow() && String(c.token || '') === String(TOKEN || ''))) switchEnv(c);
1735
2007
  };
1736
2008
  if (delB) {
1737
2009
  delB.onclick = function () {
1738
2010
  if (!window.confirm('删除环境「' + (connLabel(conn) || conn.host || '') + '」?')) return;
1739
2011
  removeConn(conn.id);
1740
2012
  closeHostSheet();
1741
- renderEnvPage();
1742
2013
  toast('已删除');
1743
2014
  };
1744
2015
  }
@@ -1773,7 +2044,7 @@ button.setrow{font-family:inherit}
1773
2044
  }
1774
2045
 
1775
2046
  /* ══════════ 视图:启动页 → 会话列表 → 会话 ══════════ */
1776
- function hideAll() { launcherEl.hidden = true; historyEl.hidden = true; mineEl.hidden = true; reqsEl.hidden = true; discoverEl.hidden = true; teamPageEl.hidden = true; runSettingsPageEl.hidden = true; pluginsEl.hidden = true; pluginDetailEl.hidden = true; sessionEl.hidden = true; envPageEl.hidden = true; }
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; }
1777
2048
  function setActive(name) {
1778
2049
  Array.prototype.forEach.call(tabbarEl.querySelectorAll('.tab'), function (b) {
1779
2050
  b.classList.toggle('is-on', b.getAttribute('data-tab') === name);
@@ -1789,6 +2060,14 @@ button.setrow{font-family:inherit}
1789
2060
  hideAll(); historyEl.hidden = false; tabbarEl.hidden = false; setActive('tasks');
1790
2061
  if (address != null) histSubtitle = address;
1791
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
+ }
1792
2071
  loadSessions();
1793
2072
  }
1794
2073
  function showSession(sub) { hideAll(); sessionEl.hidden = false; tabbarEl.hidden = false; setActive('tasks'); subtitleEl.textContent = (sub != null ? sub : histSubtitle) || ''; }
@@ -1797,13 +2076,14 @@ button.setrow{font-family:inherit}
1797
2076
  两者代价差三个数量级,绝不能混成一个动作。 */
1798
2077
  function showReqs() {
1799
2078
  hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false; setActive('reqs');
2079
+ /* 从二级退回来时不要还卡在详情态:回到底栏「需求」这一页 = 回到一级目录。 */
2080
+ REQ_DETAIL = null;
1800
2081
  loadRequirements(false);
1801
2082
  }
1802
2083
  function showDiscover() { hideAll(); discoverEl.hidden = false; tabbarEl.hidden = false; setActive('discover'); }
1803
2084
  function showMine() {
1804
2085
  hideAll(); mineEl.hidden = false; tabbarEl.hidden = false; setActive('mine');
1805
2086
  mineDevice.textContent = histSubtitle || '本机';
1806
- if (mineEnv) mineEnv.textContent = envLabel() || '—';
1807
2087
  var ns = document.getElementById('notifyState');
1808
2088
  if (ns) { var p = notifyPermState(); ns.textContent = p === 'plugin' ? '已接入' : (p === 'granted' ? '已开启' : '未开启'); }
1809
2089
  markSeg(segTheme, PREFS.theme); markSeg(segFont, PREFS.font); markSeg(segMode, TASK_MODE);
@@ -1814,39 +2094,114 @@ button.setrow{font-family:inherit}
1814
2094
  再补一次请求 —— 用户可能一连上就直接点「我的」,那时列表那次还没回来。 */
1815
2095
  paintMineVersion();
1816
2096
  refreshMineVersion();
2097
+ /* 顺带问一次「桌面端的包里带的是哪一版 APK」:有更新就把这一格显示成「升级 vX」。 */
2098
+ probeAppUpdate();
1817
2099
  }
1818
2100
 
1819
- /* ══════════ 版本号(「我的」)══════════
1820
- 来源:桌面端 `GET /desk/health` 的 `version`(= core 的 `--version`,ldflags 注入)。
1821
- 为什么不是移动端自己写死一个常量:这份界面是桌面端 `/m/` 远程加载的,页面文件跟着
1822
- 桌面端一起升级;只有服务端报的版本才是「用户此刻连着的这份」。
1823
- health 没有 version(插件是旧构建、但磁盘上的 core 已经是新的)时,再问一次
1824
- `/desk/contract` —— 它原样代理 core 的 envelope,新 core 里带 version。这样
1825
- **不必重启正在跑的服务**就能显示真实版本。仍然没有(老 core)就保持「—」,不编。 */
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)→ 提示检查失败,不把「—」当成最新版本。 */
1826
2108
  var SERVER_VERSION = '';
2109
+ var APK_LATEST = ''; // 桌面端 npm 包里带的 APK 版本(/desk/app);空 = 没包或还没查
2110
+ function shownVersion() { return APP_VERSION || SERVER_VERSION; }
1827
2111
  function paintMineVersion() {
1828
2112
  if (!mineVersion) return;
1829
- 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 () {});
1830
2135
  }
1831
2136
  function refreshMineVersion() {
1832
2137
  if (!TOKEN) { paintMineVersion(); return; }
1833
2138
  var hdr = { 'X-Magic-Token': TOKEN };
1834
- var seq = CONN_SEQ; // 切环境后旧桌面的 health 不许写到新连接上
1835
- 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 })
1836
2145
  .then(function (r) { return r.ok ? r.json() : null; })
1837
- .then(function (h) {
1838
- if (seq !== CONN_SEQ) return;
1839
- if (h && h.app) noteActiveMeta({ app: h.app });
1840
- if (h && typeof h.version === 'string' && h.version) { SERVER_VERSION = h.version; return; }
1841
- return fetch(BASE + '/desk/contract', { headers: hdr })
1842
- .then(function (r) { return r.ok ? r.json() : null; })
1843
- .then(function (c) {
1844
- if (c && typeof c.version === 'string' && c.version) SERVER_VERSION = c.version;
1845
- })
1846
- .catch(function () {});
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 + ',完成后点安装');
1847
2203
  })
1848
- .then(paintMineVersion)
1849
- .catch(function () { paintMineVersion(); }); // 网络抖动:保留旧值,不把「—」又闪回来
2204
+ .catch(function () { paintMineVersion(); toast('检查更新失败,请确认桌面端已升级'); });
1850
2205
  }
1851
2206
  /* 「我的」→ 团队模式:整块配置进子页面(与插件列表同一套子页面范式)。 */
1852
2207
  function showTeamPage() {
@@ -2408,6 +2763,9 @@ button.setrow{font-family:inherit}
2408
2763
  CONN_SEQ++; // 上一台桌面还在飞的请求:回来时直接丢弃(见 loadSessions)
2409
2764
  var c = upsertConn(target);
2410
2765
  ACTIVE_ID = c.id;
2766
+ PREFS_PULLED = false; // 换了桌面:环境配置要重新从新桌面拉一次(见 pullPrefs)
2767
+ /* 记住「停在哪台」:下次打开(或切环境后重载)直接回到这里。 */
2768
+ try { localStorage.setItem(ACTIVE_ENV_KEY, c.url || target.url || ''); } catch (e) {}
2411
2769
  showError('');
2412
2770
  showHistory(c.host || '');
2413
2771
  startPoll();
@@ -2419,8 +2777,10 @@ button.setrow{font-family:inherit}
2419
2777
  CONN_SEQ++;
2420
2778
  if (conv) { try { conv.destroy(); } catch (e) {} conv = null; }
2421
2779
  convEl.innerHTML = '';
2780
+ saveEnvScope(envScopeId()); // 断开前把这台的本机状态留在它自己的槽里
2422
2781
  BASE = ''; TOKEN = '';
2423
2782
  ACTIVE_ID = '';
2783
+ try { localStorage.removeItem(ACTIVE_ENV_KEY); } catch (e) {} // 主动断开 = 下次回启动页
2424
2784
  SERVER_VERSION = ''; // 版本号属于「那一条连接」:断开就清掉,避免下次连到别的机器还显示旧值
2425
2785
  /* 排队任务**不跟着断连消失**(它们已经落库):只清内存里的等待态,下次连上由
2426
2786
  restoreQueue 从磁盘接回来。串行门的 FIFO 也一并重置 —— 里面的 tail 是挂在一条
@@ -2429,7 +2789,8 @@ button.setrow{font-family:inherit}
2429
2789
  ASK_WAITING = [];
2430
2790
  SERIAL = {};
2431
2791
  QUEUE_RESTORED = false;
2432
- showLauncher();
2792
+ /* 断开后回任务页的空态(不再回启动页):添加/切换环境都在标题菜单里。 */
2793
+ showHistory(null);
2433
2794
  }
2434
2795
 
2435
2796
  /* ══════════ 历史会话列表(GET /desk/sessions,字段同 core 的 --sessions)══════════ */
@@ -2499,16 +2860,26 @@ button.setrow{font-family:inherit}
2499
2860
  TITLES = a[1] || {};
2500
2861
  DW = (a[2] && a[2].defaultWorkspace) || '';
2501
2862
  DW_READY = true;
2863
+ /* 环境配置以桌面端为准:连上后拉一次(见 pullPrefs)。只拉一次,
2864
+ 后面本地的改动由 schedulePushPrefs 推回去。 */
2865
+ if (!PREFS_PULLED) pullPrefs();
2502
2866
  SPACES_ROOT = (a[2] && a[2].workspacesRoot) || '';
2503
- /* core 版本(2026-10-22):/desk/health 的 version,顺手缓存,「我的」页打开即用。
2504
- 老 core 没有该字段 → 不覆盖已有值(可能刚由 refreshMineVersion 拿到)。 */
2505
- if (a[2] && a[2].version) SERVER_VERSION = a[2].version;
2867
+ /* core 版本不要从这里取:/desk/health.version 是插件启动时的缓存,可能是旧值
2868
+ (见 refreshMineVersion 的注释)。版本行打开时走 /desk/contract 拿实时值。 */
2506
2869
  if (!mineEl.hidden) paintMineVersion();
2507
2870
  /* core 能力位(feature.*):团队开关要不要显示就看它。连上桌面后才有,
2508
2871
  所以「我的」那张卡片与运行设置里的那一行都要在这里刷一次。 */
2509
2872
  CORE_FEATURES = ((a[2] && a[2].features) || []);
2510
2873
  /* 连接的身份(app):连着哪台设备就记到哪条连接上,环境菜单里当类型标签用。 */
2511
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
+ }
2512
2883
  /* 团队角色清单(2026-10-22):core 上报什么就画什么,客户端不硬编码角色名。
2513
2884
  老 core 没有 teamRoles → 空数组 → paintTeamPref 用内置表兜底。 */
2514
2885
  TEAM_ROLES = ((a[2] && a[2].teamRoles) || []).filter(function (r) {
@@ -2546,7 +2917,7 @@ button.setrow{font-family:inherit}
2546
2917
  var TASK_QUERY = '';
2547
2918
  var PINNED = [];
2548
2919
  try { PINNED = JSON.parse(localStorage.getItem('magic.pinned') || '[]') || []; } catch (e) { PINNED = []; }
2549
- 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(); }
2550
2921
  var TITLES = {}; // 服务端标题覆盖表(GET /desk/titles),重命名用
2551
2922
  var DW = ''; // 桌面默认工作空间(GET /desk/health 的 defaultWorkspace = core 的 cwd)
2552
2923
  var DW_READY = false; // DW 是否已从 health 落定(空间键归一依赖它,见 restoreQueue)
@@ -2554,7 +2925,7 @@ button.setrow{font-family:inherit}
2554
2925
  var ROOT_DIRS = []; // 根目录下 magic-* 目录(GET /desk/dirs)
2555
2926
  var CUSTOM_SPACES = [];
2556
2927
  try { CUSTOM_SPACES = JSON.parse(localStorage.getItem('magic.spaces') || '[]') || []; } catch (e) { CUSTOM_SPACES = []; }
2557
- function saveSpaces() { try { localStorage.setItem('magic.spaces', JSON.stringify(CUSTOM_SPACES)); } catch (e) {} }
2928
+ function saveSpaces() { try { localStorage.setItem('magic.spaces', JSON.stringify(CUSTOM_SPACES)); } catch (e) {} schedulePushPrefs(); }
2558
2929
  /* 空间候选 = 根目录下的 magic-* + 自定义 + 会话里出现过的 workspace(去重、保序)。
2559
2930
  ⚠️ 会话的 workspace 为空时**不要**拿 DW 顶上(原写法 `s.workspace || DW`):
2560
2931
  那样「默认(不指定)」与「默认目录名」会变成两行同名选项,一个落「任务」段、
@@ -2568,6 +2939,44 @@ button.setrow{font-family:inherit}
2568
2939
  (LAST_SESSIONS || []).forEach(function (s) { add(s && s.workspace); });
2569
2940
  return out;
2570
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
+ }
2571
2980
  /* 会话标题:**以 core 给的 title 为准**(2026-10-15,用户「应该 magic-agent 统一归纳作为
2572
2981
  会话标题」)。core 的 --sessions 已把「标题表覆盖 → prompt_head」合并成一个 title 字段,
2573
2982
  所以这里不再自己拼优先级 —— 此前本文件是 `TITLES[id] || prompt_head`、PC 端是另一套
@@ -3209,6 +3618,10 @@ button.setrow{font-family:inherit}
3209
3618
  var REQ_BUSY = false; // 「整理」进行中(防重复点击)
3210
3619
  var REQ_BUSY_LABEL = '全部整理'; // 顶栏按钮的进行中文案(重渲染后要能恢复)
3211
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(/\/+$/, ''); }
3212
3625
 
3213
3626
  function reqFeatureRow(f) {
3214
3627
  /* 一行 = 一个功能点,**照会话行的排布**做(图标 + 标题 / 副标题 / 右侧时间):
@@ -3243,17 +3656,27 @@ button.setrow{font-family:inherit}
3243
3656
  return b;
3244
3657
  }
3245
3658
 
3246
- /* 工作区组:与会话列表的 workspaceGroup 同一套骨架(可折叠的组头 + 组内行)。
3247
- 差别只有两点:组头右侧没有「+ 新建」,行是需求文档不是会话。 */
3248
- function reqWorkspaceBlock(w) {
3659
+ /* 一级:空间目录。空间是行,模块缩进挂在空间下(树形),**功能点一律不画在这里**。
3660
+ 一级只回答「这个空间有哪几块、每块多少条」—— 需求一多时画功能点会把目录撑成长列表,
3661
+ 结构反而看不见了(2026-10-22 用户定稿:两级,一级只显示空间和模块)。
3662
+ idx = 排序后的位次(0 = 需求条数最多的那个)。 */
3663
+ function reqWorkspaceBlock(w, idx) {
3249
3664
  var wrap = hEl('div', '');
3250
3665
  var head = hEl('div', 'wsrow__head');
3251
3666
  var key = w.id || w.dir || w.name || '';
3252
3667
  var count = Number(w.feature_count) || 0;
3253
- 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);
3254
3672
  var tog = document.createElement('button');
3255
- tog.type = 'button'; tog.className = 'wsrow__open';
3673
+ tog.type = 'button'; tog.className = 'wsrow__open wsrow__open--req';
3256
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 + ' 条'));
3257
3680
  /* 待整理条数做成徽标(与「等待中」同款):折叠起来也看得见这个空间在长新需求。 */
3258
3681
  if (w.pending > 0) tog.appendChild(elSpan('wsrow__badge', w.pending + ' 待整理'));
3259
3682
  /* 未初始化 = 还没按空间目录生成过基线。老 core 不带 initialized 字段(undefined),
@@ -3281,55 +3704,141 @@ button.setrow{font-family:inherit}
3281
3704
  err.style.color = 'var(--warning)';
3282
3705
  wrap.appendChild(err);
3283
3706
  }
3707
+ var shown = 0;
3284
3708
  (w.modules || []).forEach(function (m) {
3285
- /* 模块(大功能点)用一条小节标题,形状与「置顶 / 任务 / 空间」的 sechead 一致:
3286
- 名字 + 条数。未归类(name 为空)不标题、直接平铺 ——
3287
- 老数据是平铺目录,硬给它编个「未归类」反而像出了错。 */
3288
3709
  var fs = (m.features || []);
3289
3710
  if (!fs.length) return;
3290
- if (m.name) {
3291
- var mh = hEl('div', 'sechead');
3292
- mh.style.paddingLeft = '16px';
3293
- mh.appendChild(elSpan('sechead__t', m.name + ' (' + fs.length + ')'));
3294
- wrap.appendChild(mh);
3295
- }
3296
- fs.forEach(function (f) { wrap.appendChild(reqFeatureRow(f)); });
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);
3297
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
+ }
3298
3735
  return wrap;
3299
3736
  }
3300
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
+
3301
3785
  function renderRequirements() {
3302
3786
  if (!reqListEl) return;
3303
3787
  var data = REQ_DATA;
3304
3788
  if (!data) return;
3305
- /* 只画**有文档**的工作区(用户 2026-10-22:「没有的空间不显示」)。
3306
- feature_count 是 core 给的真值;老 core 不给时退化成数一遍模块里的行数。 */
3789
+ /* 空间清单与任务页对齐:**用与 collectSpaceKeys 同一套空间键过滤**
3790
+ (用户 2026-10-22:「4个空间需求里显示的不一样」「会话中全部删除后需求中的空间还在
3791
+ 也要联动删除」)。没有文档的空间不再整块藏掉,而是留一个组头 + 「整理」按钮 ——
3792
+ 这样两页的空间数一致,也给了「这个空间还没生成需求」的入口。
3793
+ 拿不到会话/目录清单时(REQ_ALIVE=null)不筛。 */
3307
3794
  var all = (data && data.workspaces) || [];
3308
3795
  var list = all.filter(function (w) {
3309
- if (Number(w.feature_count) > 0) return true;
3310
- return (w.modules || []).some(function (m) { return (m.features || []).length > 0; });
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) || ''));
3311
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
+ }
3312
3829
  var frag = document.createDocumentFragment();
3313
3830
  if (!list.length) {
3314
- /* 一个功能点都还没有:说清「是没聊过」还是「聊过但没整理」——
3315
- 后者要引导用户点「整理」,否则这一页看起来永远长不出东西。 */
3316
- var pend = 0, uninit = 0;
3317
- all.forEach(function (w) {
3318
- pend += Number(w.pending) || 0;
3319
- if (w.initialized === false) uninit++;
3320
- });
3831
+ /* 一个空间都没有(会话全删了 / 还没聊过)。 */
3321
3832
  frag.appendChild(hEl('div', 'history__hint',
3322
- pend > 0
3323
- ? '有 ' + pend + ' 条对话还没整理成需求。点空间右侧「整理」只处理这个空间;右上角「全部整理」一次跑完所有空间。'
3324
- : (uninit > 0
3325
- ? '还没有需求。点空间右侧「整理」会先按空间目录生成一版需求基线;右上角「全部整理」一次处理所有空间。'
3326
- : '还没有任何需求。在任务里聊几句,这里会自动整理出功能点文档。')));
3833
+ REQ_ALIVE
3834
+ ? '还没有任何空间。在任务里聊几句,这里会按空间整理出功能点文档。'
3835
+ : '还没有任何需求。在任务里聊几句,这里会自动整理出功能点文档。'));
3327
3836
  reqListEl.replaceChildren(frag);
3328
3837
  return;
3329
3838
  }
3330
- /* 工作区顺序照 core 给的(最后归纳时间倒序)—— 最近在动的排最前,
3331
- 用户打开这一页多半是想看「刚聊的那些变成什么了」。 */
3332
- list.forEach(function (w) { frag.appendChild(reqWorkspaceBlock(w)); });
3839
+ /* 工作区顺序:上面已按需求条数排好(多的在前)。默认只展开第一个 ——
3840
+ 见 reqWorkspaceBlock 的 open 判定。 */
3841
+ list.forEach(function (w, i) { frag.appendChild(reqWorkspaceBlock(w, i)); });
3333
3842
  reqListEl.replaceChildren(frag);
3334
3843
  /* 整理进行中切走再回来:新 DOM 的按钮默认是可点的,这里按当前状态重新置忙。 */
3335
3844
  if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
@@ -3339,7 +3848,7 @@ button.setrow{font-family:inherit}
3339
3848
  if (!reqListEl) return;
3340
3849
  if (!quiet && !REQ_DATA) reqListEl.innerHTML = '<div class="history__hint">读取中…</div>';
3341
3850
  var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
3342
- fetch(BASE + '/desk/requirements', { headers: hdr })
3851
+ var pReq = fetch(BASE + '/desk/requirements', { headers: hdr })
3343
3852
  .then(function (r) {
3344
3853
  if (r.status === 501) {
3345
3854
  /* 老 core 没有这个能力位:**说清楚**,不要给一页空白
@@ -3348,9 +3857,38 @@ button.setrow{font-family:inherit}
3348
3857
  }
3349
3858
  if (!r.ok) throw new Error('HTTP ' + r.status);
3350
3859
  return r.json();
3351
- })
3352
- .then(function (d) {
3353
- 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: [] };
3354
3892
  renderRequirements();
3355
3893
  })
3356
3894
  .catch(function (e) {
@@ -3389,7 +3927,8 @@ button.setrow{font-family:inherit}
3389
3927
  }
3390
3928
 
3391
3929
  /* 统一置忙:顶部主按钮 + 所有空间组头的「整理」一起禁用。
3392
- label 只改顶部;组头统一显示「整理中…」/「整理」,不各自变文案。 */
3930
+ label 只改顶部;组头统一显示「整理中…」/「整理」,不各自变文案。
3931
+ 二级页那枚「整理」也跟着禁用 —— 归纳期间它和组头一样不能重复点。 */
3393
3932
  function reqSetBusy(on, label) {
3394
3933
  if (on) { if (label !== undefined) REQ_BUSY_LABEL = label; }
3395
3934
  else REQ_BUSY_LABEL = '全部整理';
@@ -3398,6 +3937,11 @@ button.setrow{font-family:inherit}
3398
3937
  btn.disabled = !!on;
3399
3938
  if (label !== undefined) btn.textContent = label;
3400
3939
  }
3940
+ var dbtn = document.getElementById('btnReqDetailSync');
3941
+ if (dbtn) {
3942
+ dbtn.disabled = !!on;
3943
+ dbtn.textContent = on ? '整理中…' : '整理';
3944
+ }
3401
3945
  Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
3402
3946
  b.disabled = !!on;
3403
3947
  b.textContent = on ? '整理中…' : '整理';
@@ -3444,6 +3988,9 @@ button.setrow{font-family:inherit}
3444
3988
  var list = ((REQ_DATA && REQ_DATA.workspaces) || []).filter(function (w) {
3445
3989
  var key = w.dir || w.id || '';
3446
3990
  if (!key || seen[key]) return false;
3991
+ /* 会话已全部删除的空间不参与「全部整理」:否则又会把它重新生成出来。 */
3992
+ var dir = reqNormDir(w.dir);
3993
+ if (REQ_ALIVE && dir && !REQ_ALIVE[dir]) return false;
3447
3994
  seen[key] = true;
3448
3995
  return true;
3449
3996
  });
@@ -3496,6 +4043,13 @@ button.setrow{font-family:inherit}
3496
4043
  step();
3497
4044
  }
3498
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
+ });
3499
4053
 
3500
4054
  function openHostSheet(title, build) {
3501
4055
  closeHostSheet();
@@ -4225,6 +4779,7 @@ button.setrow{font-family:inherit}
4225
4779
  localStorage.setItem('magic.engine', RUN_PREF.engine || '');
4226
4780
  localStorage.setItem('magic.model', RUN_PREF.model || '');
4227
4781
  } catch (e) {}
4782
+ schedulePushPrefs();
4228
4783
  paintRunPref();
4229
4784
  }
4230
4785
  function paintRunPref() {
@@ -4257,14 +4812,13 @@ button.setrow{font-family:inherit}
4257
4812
  var sum = document.getElementById('teamSummary');
4258
4813
  if (sum) sum.textContent = custom ? (custom + ' 项已单独指定') : '跟随主引擎';
4259
4814
  }
4260
- /* 团队模式子页面 = **两张卡**(用户 2026-10-22:「配引擎和配规则要分开配置 不要在 1 个页面」):
4261
- #teamRouteCard「交给谁」= 计划 + 7 个角色的引擎/模型,点开只有引擎;
4262
- #teamRuleCard「规则」= 计划的拆解口径 + 7 个角色的「适合什么」,点开只有规则。
4815
+ /* 团队模式子页面 = **一张卡、每行两个触发点**(用户 2026-10-22:「两个触发就行了」):
4816
+ 左边(名字 + 规则描述)→ 只改规则;右边(引擎值)→ 只选引擎。
4817
+ 两块配置分属两个抽屉,但不必各占一整张卡。
4263
4818
  签名含两侧文案与引擎:变了才重建,避免 5s 轮询时闪。 */
4264
4819
  function paintTeamRoles(roles) {
4265
- var routeCard = document.getElementById('teamRouteCard');
4266
- var ruleCard = document.getElementById('teamRuleCard');
4267
- if (!routeCard || !ruleCard) return;
4820
+ var card = document.getElementById('teamRouteCard');
4821
+ if (!card) return;
4268
4822
  var show = teamSupported();
4269
4823
  var plan = (TEAM.routes || {}).plan || {};
4270
4824
  var planSub = '拆解:' + ({ coarse: '少', standard: '标准', fine: '细' }[TEAM.split] || '标准')
@@ -4277,50 +4831,36 @@ button.setrow{font-family:inherit}
4277
4831
  }).join(',');
4278
4832
  if (sig === TEAM_ROLE_SIG) return;
4279
4833
  TEAM_ROLE_SIG = sig;
4280
- routeCard.innerHTML = '';
4281
- ruleCard.innerHTML = '';
4834
+ card.innerHTML = '';
4282
4835
  if (!show) return;
4283
- /* 上卡:只讲「交给谁」,右侧是引擎;不展示规则描述。 */
4284
- addTeamRouteRow(routeCard, 'plan', '计划任务');
4836
+ addTeamSplitRow(card, 'plan', '计划任务', planSub);
4285
4837
  roles.forEach(function (r) {
4286
- addTeamRouteRow(routeCard, 'role:' + r.name, TEAM_ROLE_LABELS[r.name] || r.name);
4287
- });
4288
- /* 下卡:只讲「规则」——计划是拆解口径,角色是「这类适合什么」。 */
4289
- addTeamRuleRow(ruleCard, 'plan', '计划任务规则', planSub);
4290
- roles.forEach(function (r) {
4291
- var label = (TEAM_ROLE_LABELS[r.name] || r.name) + '规则';
4292
- addTeamRuleRow(ruleCard, 'role:' + r.name, label, teamRoleDuty(r));
4838
+ addTeamSplitRow(card, 'role:' + r.name, TEAM_ROLE_LABELS[r.name] || r.name, teamRoleDuty(r));
4293
4839
  });
4294
4840
  }
4295
- /* 上卡的一行:名字 + 右侧引擎。点开 = 只选引擎/模型。 */
4296
- function addTeamRouteRow(card, key, label) {
4841
+ /* 一行两个点击区:左边改规则,右边选引擎。
4842
+ ⚠️ 外层必须是 div —— button 不能套 button;`.setrow` 的边框/间距照常用在外层。 */
4843
+ function addTeamSplitRow(card, key, label, sub) {
4297
4844
  var rt = (TEAM.routes || {})[key] || {};
4298
- var b = hEl('button', 'setrow');
4299
- b.type = 'button';
4300
- 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';
4301
4848
  main.appendChild(hEl('span', 'setrow__l', label));
4302
- b.appendChild(main);
4303
- b.appendChild(hEl('span', 'setrow__v',
4849
+ if (sub) main.appendChild(hEl('span', 'setrow__sub', sub));
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',
4304
4855
  rt.engine ? (rt.engine + (rt.model ? ' · ' + qaModelText(rt.model) : '')) : '跟随主引擎'));
4305
- b.appendChild(hEl('span', 'setrow__chev', '›'));
4306
- b.onclick = function () {
4856
+ v.appendChild(hEl('span', 'setrow__chev', '›'));
4857
+ v.onclick = function () {
4307
4858
  openTeamEnginePicker(key,
4308
4859
  String(key).indexOf('role:') === 0 ? (label + '交给谁') : '计划任务交给谁',
4309
4860
  teamRoleKindOf(key));
4310
4861
  };
4311
- card.appendChild(b);
4312
- }
4313
- /* 下卡的一行:名字 + 当前规则。点开 = 只改规则(不含引擎)。 */
4314
- function addTeamRuleRow(card, key, label, sub) {
4315
- var b = hEl('button', 'setrow');
4316
- b.type = 'button';
4317
- var main = hEl('span', 'setrow__main');
4318
- main.appendChild(hEl('span', 'setrow__l', label));
4319
- if (sub) main.appendChild(hEl('span', 'setrow__sub', sub));
4320
- b.appendChild(main);
4321
- b.appendChild(hEl('span', 'setrow__chev', '›'));
4322
- b.onclick = function () { openTeamRuleSheet(key, label); };
4323
- card.appendChild(b);
4862
+ row.appendChild(v);
4863
+ card.appendChild(row);
4324
4864
  }
4325
4865
  function teamRoleKindOf(key) {
4326
4866
  if (String(key).indexOf('role:') !== 0) return 'plan';
@@ -4747,6 +5287,9 @@ button.setrow{font-family:inherit}
4747
5287
  document.getElementById('btnConnect').onclick = function () {
4748
5288
  var target = parseTarget(addrEl.value);
4749
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;
4750
5293
  connect(target);
4751
5294
  };
4752
5295
  addrEl.addEventListener('input', function () { showError(''); });
@@ -4792,10 +5335,6 @@ button.setrow{font-family:inherit}
4792
5335
  归属只在用户显式选空间时确定,不能让上一次的 WS 悄悄粘过来。 */
4793
5336
  on('btnFabNew', function () { newConversation(); });
4794
5337
  on('btnMineBack', function () { showHistory(); });
4795
- /* 我的 → 环境(多连接):列表(点行编辑)+ 新建;返回回「我的」。 */
4796
- on('btnEnvPage', function () { showEnvPage('mine'); });
4797
- on('btnEnvBack', function () { if (ENV_BACK === 'tasks') showHistory(); else showMine(); });
4798
- on('btnEnvAdd', function () { openEnvEditor(null); });
4799
5338
  on('dscBrowser', function () { openBrowser(HOME_URL); });
4800
5339
  on('dscApps', function () { openComingSheet('应用', '暂无可用应用。桌面端发布的应用会出现在这里。'); });
4801
5340
  on('dscPlugins', function () { showPlugins(); });
@@ -4819,6 +5358,7 @@ button.setrow{font-family:inherit}
4819
5358
  on('btnTeamBack', showMine);
4820
5359
  on('btnLogout', disconnect);
4821
5360
  on('btnUpgrade', reloadApp);
5361
+ on('btnMineVersion', checkAppUpdate);
4822
5362
  if (segTheme) takeSeg(segTheme, 'magic.theme', function (v) { PREFS.theme = v; applyThemePref(v); });
4823
5363
  if (segFont) takeSeg(segFont, 'magic.font', function (v) { PREFS.font = v; applyFontPref(v); });
4824
5364
  if (segMode) takeSeg(segMode, 'magic.taskmode', function (v) { TASK_MODE = v; });
@@ -4880,12 +5420,13 @@ button.setrow{font-family:inherit}
4880
5420
  scanEl.hidden = true;
4881
5421
  }
4882
5422
  document.getElementById('btnScanClose').onclick = stopScan;
4883
- document.getElementById('btnScan').onclick = function () {
5423
+ /* 通用扫码:扫到就把原文交给回调(启动页用来直连,「添加环境」用来填链接)。 */
5424
+ function startScan(onResult, onError) {
5425
+ var fail = onError || function (m) { toast(m); };
4884
5426
  if (!('BarcodeDetector' in window) || !navigator.mediaDevices) {
4885
- showError('当前环境不支持扫码,请把桌面配对页的「桌面地址」粘贴到输入框。');
5427
+ fail('当前环境不支持扫码,请把桌面给的地址粘贴进来。');
4886
5428
  return;
4887
5429
  }
4888
- showError('');
4889
5430
  scanEl.hidden = false;
4890
5431
  navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }).then(function (stream) {
4891
5432
  scanStream = stream;
@@ -4898,19 +5439,47 @@ button.setrow{font-family:inherit}
4898
5439
  scanDetector.detect(scanVideo).then(function (codes) {
4899
5440
  if (!codes || !codes.length) return;
4900
5441
  stopScan();
4901
- var raw = codes[0].rawValue || '';
4902
- var target = parseTarget(raw);
4903
- if (target) connect(target);
4904
- else showError('这个二维码不是 magic-agent 连接地址。');
5442
+ onResult(codes[0].rawValue || '');
4905
5443
  }).catch(function () {});
4906
5444
  }, 400);
4907
5445
  }).catch(function () {
4908
5446
  stopScan();
4909
- showError('无法启动相机,请改用粘贴地址的方式。');
5447
+ fail('无法启动相机,请改用粘贴地址的方式。');
4910
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); });
4911
5459
  };
4912
5460
 
4913
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
+ }
4914
5483
 
4915
5484
  /* ══════════ 首屏 ══════════
4916
5485
  · 打包进 App(Capacitor):boot.js 注入 window.MAGIC_BASE / MAGIC_TOKEN → 直连会话;
@@ -4919,16 +5488,56 @@ button.setrow{font-family:inherit}
4919
5488
  var injectedToken = window.MAGIC_TOKEN || new URLSearchParams(location.search).get('token') || '';
4920
5489
  var injectedBase = (window.MAGIC_BASE || '').replace(/\/+$/, '');
4921
5490
  try {
4922
- 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)) {
4923
5516
  BASE = injectedBase; TOKEN = injectedToken;
5517
+ /* 把当前这条(壳注入的)连接落成环境,名字默认「公司远程」——
5518
+ 否则它只是一条改不了名、也留不住的「未命名」。 */
5519
+ seedCurrentConn();
4924
5520
  showHistory(injectedBase ? injectedBase.replace(/^https?:\/\//, '') : '本机');
4925
5521
  restoreQueue(); // App 重启:上次没轮到的排队任务接着排(见 restoreQueue)
4926
- } else {
4927
- 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
+ }
4928
5537
  }
4929
5538
  } catch (e) {
4930
- /* 任何初始化异常都退回启动页,绝不留白屏。 */
4931
- try { showLauncher(); } catch (e2) {}
5539
+ /* 任何初始化异常都退回任务页空态,绝不留白屏。 */
5540
+ try { showHistory(null); } catch (e2) { try { showLauncher(); } catch (e3) {} }
4932
5541
  }
4933
5542
 
4934
5543
  /* ══════════ 启动闪屏:掌天瓶灌绿液(2026-10-20 用户:「app 启动动画也要有绿液」)══════════