dsh-issue2pr 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -80,6 +80,10 @@ window.__ModuleLoader__.load({
80
80
  --err-bg:rgba(213,70,58,.10);
81
81
  --code-bg:#10141d; --code-line:#1d2330;
82
82
  --mono:'JetBrains Mono','IBM Plex Mono','Cascadia Code',Consolas,'Liberation Mono',monospace;
83
+ --fs-xs:11.5px; --fs-sm:12.5px; --fs-md:13.5px; --fs-lg:14.5px; /* 字号 scale:AI 面板段统一走 token */
84
+ /* 字体必须显式声明:不声明则继承宿主→系统默认字体(Win 雅黑/Mac 苹方/Linux Noto
85
+ 度量差异大),同一字号在不同电脑上观感"一大一小"——跨平台栈保证同类字形 */
86
+ font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei','Noto Sans CJK SC','Source Han Sans SC',sans-serif;
83
87
  color:var(--ink); font-size:14.5px; line-height:1.7;
84
88
  flex:1; min-height:0; display:flex; flex-direction:column;
85
89
  }
@@ -118,7 +122,7 @@ window.__ModuleLoader__.load({
118
122
  hover 显色;双击恢复默认宽 */
119
123
  .i2p-nav-grip{position:absolute;top:0;bottom:0;right:0;width:8px;z-index:5;cursor:col-resize;
120
124
  border-radius:5px;background:transparent;transition:background .15s}
121
- .i2p-nav-grip:hover,.i2p-nav-grip:focus-visible{background:color-mix(in srgb,var(--accent) 26%,transparent)}
125
+ .i2p-nav-grip:hover,.i2p-nav-grip:focus-visible{background:var(--hover);background:color-mix(in srgb,var(--accent) 26%,transparent)}
122
126
  body.i2p-dragging{cursor:col-resize}
123
127
  body.i2p-dragging *{cursor:col-resize!important}
124
128
  body.i2p-dragging{user-select:none}
@@ -445,6 +449,38 @@ body.i2p-dragging{user-select:none}
445
449
  .i2p .conn-row .cn-tok{font-family:var(--mono);font-size:11.5px;color:var(--muted)}
446
450
  .i2p .conn-row .cn-act{margin-left:auto;display:flex;gap:6px}
447
451
 
452
+ /* ===== 委外智能体绑定卡(项目页 P6=claude / 配置页 P6 共用):多方式发现 + 测试门禁 ===== */
453
+ .i2p .agent-bind{border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-top:10px}
454
+ .i2p .agent-cands{display:flex;flex-direction:column;gap:5px;margin:8px 0 2px}
455
+ .i2p .agent-cand{display:flex;align-items:center;gap:10px;width:100%;text-align:left;cursor:pointer;
456
+ background:none;border:1px solid var(--line);border-radius:9px;padding:5px 10px;
457
+ transition:border-color .15s,background .15s;font-family:inherit;font-size:inherit;color:inherit}
458
+ .i2p .agent-cand:hover{background:var(--hover)}
459
+ .i2p .agent-cand.on{border-color:var(--accent);background:var(--nav-on)}
460
+ .i2p .agent-cand .ac-path{font-size:12.5px;color:var(--ink);min-width:0;word-break:break-all}
461
+ .i2p .agent-cand .ac-path.mono{font-family:var(--mono)}
462
+ .i2p .agent-cand .ac-meta{margin-left:auto;flex:none;display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--muted)}
463
+ .i2p .agent-cand .ac-src{border:1px solid var(--line);border-radius:4px;padding:0 5px;font-size:10.5px;white-space:nowrap}
464
+ .i2p .agent-cand .miss{color:var(--warn)}
465
+ .i2p .agent-gate{margin-top:8px;font-size:12.5px;line-height:1.6}
466
+ .i2p .agent-gate .ag-line{margin:0;font-weight:600}
467
+ .i2p .agent-gate.ok .ag-line{color:var(--good)}
468
+ .i2p .agent-gate.bad .ag-line{color:var(--err)}
469
+ .i2p .agent-gate .ag-steps{margin:4px 0 0;display:flex;gap:6px;flex-wrap:wrap}
470
+ .i2p .agent-gate .ag-step{font-family:var(--mono);font-size:11px;border-radius:4px;padding:0 6px;border:1px solid var(--line)}
471
+ .i2p .agent-gate .ag-step.ok{color:var(--good)}
472
+ .i2p .agent-gate .ag-step.bad{color:var(--err);border-color:currentColor}
473
+ .i2p .agent-gate .ag-hint{margin:6px 0 0;color:var(--warn)}
474
+ /* 发现扫描走马灯:滚动展示解析全过程(各来源阶段);标记位当前阶段文字轮播 */
475
+ .i2p .scan-ticker{margin:8px 0 0;overflow:hidden;white-space:nowrap;border:1px dashed var(--line-2);
476
+ border-radius:8px;background:var(--card)}
477
+ .i2p .scan-ticker .tk{display:inline-block;padding-left:100%;line-height:22px;font-size:12px;
478
+ color:var(--muted);animation:i2p-tk 12s linear infinite}
479
+ @keyframes i2p-tk{from{transform:translateX(0)}to{transform:translateX(-100%)}}
480
+ @media (prefers-reduced-motion:reduce){.i2p .scan-ticker .tk{animation:none;padding-left:8px}}
481
+ .i2p .scan-now{color:var(--accent)}
482
+ .i2p .scan-fail{margin:8px 0 0}
483
+
448
484
  /* ---- 侧边栏入口按钮:1:1 复刻宿主设置按钮(settings-general VOzbGW_trigger 配方) ---- */
449
485
  .i2p-entry{box-sizing:border-box;cursor:pointer;width:calc(100% + 4px);height:42px;
450
486
  color:var(--dsw-alias-label-primary);background:none;border:none;border-radius:12px;
@@ -478,21 +514,27 @@ body.i2p-dragging{user-select:none}
478
514
  .i2p-ai button{font-family:inherit;cursor:pointer;color:inherit}
479
515
  /* 入口 = 中性工具钮(非"AI 球"):描边消息图标,hover 才显底;开启态反色 */
480
516
  .i2p-ai-fab{pointer-events:auto;position:absolute;top:64px;right:88px;width:30px;height:30px;border-radius:9px;
517
+ border:1px solid var(--line-2,#d8dae3);
481
518
  border:1px solid color-mix(in srgb,#3aa0f5 24%,var(--line-2,#d8dae3));
519
+ background:var(--panel,#fff);
482
520
  background:color-mix(in srgb,#3aa0f5 9%,var(--panel,#fff));color:var(--ink-2,#414351);
483
521
  display:inline-flex;align-items:center;justify-content:center;
484
522
  box-shadow:0 1px 4px rgba(9,12,20,.08);
485
523
  transition:background .18s,color .18s,border-color .18s,transform .12s}
486
- .i2p-ai-fab:hover{background:color-mix(in srgb,#3aa0f5 16%,var(--panel,#fff));color:var(--ink);transform:translateY(-1px)}
524
+ .i2p-ai-fab:hover{background:var(--hover);background:color-mix(in srgb,#3aa0f5 16%,var(--panel,#fff));color:var(--ink);transform:translateY(-1px)}
487
525
  .i2p-ai-fab:active{transform:scale(.95)}
488
526
  .i2p-ai-fab.on{background:var(--ink,#17181f);color:var(--card,#fff);border-color:var(--ink,#17181f)}
489
527
  @keyframes i2p-ai-in{from{opacity:0;transform:translateX(14px)}to{opacity:1;transform:none}}
490
528
  /* 面板:轻天蓝底(color-mix 混主题底色,浅色=淡天蓝/深色=深蓝调,官方 Markdown 文字两主题可读),
491
- 全部强调(边框/focus/hover/chip 圆点)统一同一天蓝色系,避免杂色 */
529
+ 全部强调(边框/focus/hover/chip 圆点)统一同一天蓝色系,避免杂色。
530
+ 每处 color-mix 前都有一行传统语法兜底:旧内核(Chromium<111)把不认识的值整条丢弃、
531
+ 保留兜底行——面板退化为"无天蓝染色但结构完整",而不是无背景无边框 */
492
532
  .i2p-ai-panel{pointer-events:auto;position:absolute;top:60px;width:400px;height:calc(100% - 76px);
493
533
  min-width:240px;min-height:200px;
494
534
  display:flex;flex-direction:column;
535
+ background:var(--panel,#fff);
495
536
  background:color-mix(in srgb,#3aa0f5 7%,var(--panel,#fff));
537
+ border:1px solid var(--line-2,#d8dae3);
496
538
  border:1px solid color-mix(in srgb,#3aa0f5 20%,var(--line-2,#d8dae3));border-radius:12px;
497
539
  box-shadow:0 6px 28px rgba(9,12,20,.12);overflow:hidden;
498
540
  animation:i2p-ai-in .2s cubic-bezier(.2,.8,.2,1)}
@@ -507,12 +549,13 @@ body.i2p-dragging{user-select:none}
507
549
  .i2p-ai-rz.ne{top:0;right:0;width:24px;height:24px;cursor:nesw-resize}
508
550
  .i2p-ai-rz.sw{bottom:0;left:0;width:24px;height:24px;cursor:nesw-resize}
509
551
  .i2p-ai-rz.se{bottom:0;right:0;width:24px;height:24px;cursor:nwse-resize}
510
- .i2p-ai-rz:hover{background:color-mix(in srgb,#3aa0f5 35%,transparent)}
552
+ .i2p-ai-rz:hover{background:var(--hover);background:color-mix(in srgb,#3aa0f5 35%,transparent)}
511
553
  /* 头部:标题 + 小字副题 + 关闭,细分割线(无图标) */
512
554
  .i2p-ai-head{flex:none;display:flex;align-items:baseline;gap:8px;padding:11px 14px;cursor:move;
555
+ border-bottom:1px solid var(--line,#e5e6ec);
513
556
  border-bottom:1px solid color-mix(in srgb,#3aa0f5 14%,var(--line,#e5e6ec))}
514
- .i2p-ai-title{font-size:13.5px;font-weight:600;color:var(--ink);letter-spacing:.01em}
515
- .i2p-ai-sub{font-size:11.5px;color:var(--muted)}
557
+ .i2p-ai-title{font-size:var(--fs-md,13.5px);font-weight:600;color:var(--ink);letter-spacing:.01em}
558
+ .i2p-ai-sub{font-size:var(--fs-xs,11.5px);color:var(--muted)}
516
559
  .i2p-ai-close{margin-left:auto;align-self:center;width:26px;height:26px;border:none;border-radius:7px;background:none;
517
560
  color:var(--muted);display:inline-flex;align-items:center;justify-content:center;
518
561
  transition:background .15s,color .15s}
@@ -524,28 +567,51 @@ body.i2p-dragging{user-select:none}
524
567
  .i2p-ai-msgs::-webkit-scrollbar-thumb{background:var(--line-2);border-radius:6px}
525
568
  /* 空态:一句能力说明 + 快捷问题(浅底单行钮,前缀天蓝圆点) */
526
569
  .i2p-ai-empty{display:flex;flex-direction:column;gap:10px;padding-top:2px}
527
- .i2p-ai-hint{margin:0;font-size:12.5px;color:var(--muted);line-height:1.6}
570
+ .i2p-ai-hint{margin:0;font-size:var(--fs-sm,12.5px);color:var(--muted);line-height:1.6}
528
571
  .i2p-ai-chip{display:flex;align-items:center;gap:8px;text-align:left;
572
+ background:var(--card,#fff);
529
573
  background:color-mix(in srgb,var(--card,#fff) 58%,transparent);
530
- border:none;border-radius:8px;padding:7px 11px;font-size:13px;color:var(--ink-2);
574
+ border:none;border-radius:8px;padding:7px 11px;font-size:var(--fs-md,13.5px);color:var(--ink-2);
531
575
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
532
576
  transition:background .15s,color .15s}
533
577
  .i2p-ai-chip::before{content:"";flex:none;width:5px;height:5px;border-radius:50%;
578
+ background:#3aa0f5;
534
579
  background:color-mix(in srgb,#3aa0f5 70%,var(--muted,#82848f))}
535
- .i2p-ai-chip:hover:not(:disabled){background:color-mix(in srgb,#3aa0f5 12%,var(--panel,#fff));color:var(--ink)}
580
+ .i2p-ai-chip:hover:not(:disabled){background:var(--hover);background:color-mix(in srgb,#3aa0f5 12%,var(--panel,#fff));color:var(--ink)}
536
581
  .i2p-ai-chip:disabled{opacity:.5;cursor:default}
537
582
  /* 用户问题:半透明卡色块(在蓝底上呈更浅的蓝白),全宽不挤字 */
538
- .i2p-ai-m{max-width:100%;font-size:13.5px;line-height:1.65}
539
- .i2p-ai-m.user .i2p-ai-t{background:color-mix(in srgb,var(--card,#fff) 62%,transparent);
583
+ .i2p-ai-m{max-width:100%;font-size:var(--fs-md,13.5px);line-height:1.65}
584
+ .i2p-ai-m.user .i2p-ai-t{background:var(--card,#fff);
585
+ background:color-mix(in srgb,var(--card,#fff) 62%,transparent);
586
+ border:1px solid var(--line,#e5e6ec);
540
587
  border:1px solid color-mix(in srgb,#3aa0f5 12%,var(--line,#e5e6ec));
541
588
  border-radius:10px;padding:8px 12px;color:var(--ink);white-space:pre-wrap;word-break:break-word}
542
589
  .i2p-ai-m.assistant{overflow-wrap:break-word}
543
- .i2p-ai-m.assistant.err .i2p-ai-t{color:var(--err);font-size:12.5px;
590
+ .i2p-ai-m.assistant.err .i2p-ai-t{color:var(--err);font-size:var(--fs-sm,12.5px);
544
591
  background:var(--err-bg,rgba(213,70,58,.10));border-radius:8px;padding:7px 11px}
545
592
  .i2p-ai-m .i2p-ai-t{white-space:pre-wrap;word-break:break-word}
546
- .i2p-ai-md{font-size:13.5px;color:var(--ink)}
593
+ .i2p-ai-md{font-size:var(--fs-md,13.5px);color:var(--ink)}
547
594
  .i2p-ai-md :first-child{margin-top:0}
548
595
  .i2p-ai-md :last-child{margin-bottom:0}
596
+ /* 助手回答 Markdown:插件自持排版,不随宿主 .md 全局样式版本漂移——
597
+ ".i2p-page .i2p-ai-md xxx" 的 specificity 压过宿主 ".md xxx" 类规则,所有电脑渲染一致 */
598
+ .i2p-page .i2p-ai-md p,.i2p-page .i2p-ai-md ul,.i2p-page .i2p-ai-md ol{margin:0 0 8px}
599
+ .i2p-page .i2p-ai-md li{margin:2px 0}
600
+ .i2p-page .i2p-ai-md h1,.i2p-page .i2p-ai-md h2,.i2p-page .i2p-ai-md h3,.i2p-page .i2p-ai-md h4{
601
+ margin:12px 0 6px;line-height:1.4;color:var(--ink)}
602
+ .i2p-page .i2p-ai-md h1{font-size:16px}
603
+ .i2p-page .i2p-ai-md h2{font-size:15px}
604
+ .i2p-page .i2p-ai-md h3{font-size:var(--fs-lg,14.5px)}
605
+ .i2p-page .i2p-ai-md h4{font-size:var(--fs-md,13.5px)}
606
+ .i2p-page .i2p-ai-md code{font-family:var(--mono);font-size:12.5px;background:var(--hover);
607
+ padding:1px 5px;border-radius:4px}
608
+ .i2p-page .i2p-ai-md pre{background:var(--code-bg);border-radius:8px;padding:10px 12px;overflow-x:auto}
609
+ .i2p-page .i2p-ai-md pre code{background:none;padding:0;font-size:12.5px;line-height:1.6;color:#e6edf3}
610
+ .i2p-page .i2p-ai-md table{border-collapse:collapse;margin:0 0 8px;font-size:var(--fs-sm,12.5px)}
611
+ .i2p-page .i2p-ai-md th,.i2p-page .i2p-ai-md td{border:1px solid var(--line);padding:4px 8px;text-align:left}
612
+ .i2p-page .i2p-ai-md blockquote{margin:0 0 8px;padding:2px 0 2px 10px;border-left:3px solid var(--line-2);color:var(--ink-2)}
613
+ .i2p-page .i2p-ai-md a{color:var(--accent)}
614
+ .i2p-page .i2p-ai-md hr{border:none;border-top:1px solid var(--line);margin:10px 0}
549
615
  /* 流式等待:三点呼吸 */
550
616
  @keyframes i2p-ai-dot{50%{opacity:.25}}
551
617
  .i2p-ai-dots{display:inline-flex;gap:4px;padding:2px 0}
@@ -555,25 +621,29 @@ body.i2p-dragging{user-select:none}
555
621
  .i2p-ai-dots i:nth-child(3){animation-delay:.4s}
556
622
  /* 输入区:半透明卡色输入框 + 图标钮(生成中变停止,可中断) */
557
623
  .i2p-ai-input{flex:none;display:flex;gap:8px;align-items:flex-end;padding:10px 12px 12px;
624
+ border-top:1px solid var(--line,#e5e6ec);
558
625
  border-top:1px solid color-mix(in srgb,#3aa0f5 14%,var(--line,#e5e6ec))}
559
626
  .i2p-ai-input textarea{flex:1;min-height:40px;max-height:120px;resize:none;
627
+ background:var(--card,#fff);
560
628
  background:color-mix(in srgb,var(--card,#fff) 66%,transparent);
629
+ border:1px solid var(--line-2,#d8dae3);
561
630
  border:1px solid color-mix(in srgb,#3aa0f5 16%,var(--line-2,#d8dae3));
562
- border-radius:9px;color:var(--ink);padding:8px 11px;font-size:13.5px;
631
+ border-radius:9px;color:var(--ink);padding:8px 11px;font-size:var(--fs-md,13.5px);
563
632
  line-height:1.55;font-family:inherit;transition:border-color .15s}
564
- .i2p-ai-input textarea:focus{border-color:color-mix(in srgb,#3aa0f5 55%,var(--line-2,#d8dae3))}
633
+ .i2p-ai-input textarea:focus{border-color:var(--accent);border-color:color-mix(in srgb,#3aa0f5 55%,var(--line-2,#d8dae3))}
565
634
  .i2p-ai-input textarea::placeholder{color:var(--muted)}
566
635
  .i2p-ai-send{flex:none;width:38px;height:38px;border-radius:9px;
636
+ border:1px solid var(--line-2,#d8dae3);
567
637
  border:1px solid color-mix(in srgb,#3aa0f5 30%,var(--line-2,#d8dae3));
638
+ background:var(--panel,#fff);
568
639
  background:color-mix(in srgb,#3aa0f5 12%,var(--panel,#fff));color:var(--ink-2);
569
640
  display:inline-flex;align-items:center;justify-content:center;
570
641
  transition:background .15s,border-color .15s,color .15s,transform .1s}
571
- .i2p-ai-send:hover:not(:disabled){border-color:color-mix(in srgb,#3aa0f5 55%,var(--line-2,#d8dae3));color:var(--ink)}
642
+ .i2p-ai-send:hover:not(:disabled){border-color:var(--accent);border-color:color-mix(in srgb,#3aa0f5 55%,var(--line-2,#d8dae3));color:var(--ink)}
572
643
  .i2p-ai-send:active:not(:disabled){transform:scale(.95)}
573
644
  .i2p-ai-send:disabled{opacity:.4;cursor:default}
574
645
  @media (max-width:880px){
575
646
  .i2p-ai-fab{right:88px;top:60px}
576
- .i2p-ai-panel{width:min(360px,calc(100% - 100px))}
577
647
  }
578
648
 
579
649
  /* ===== 动效与质感层(丰富但克制:150-300ms、语义化、可关) ===== */
@@ -800,6 +870,34 @@ body.i2p-dragging{user-select:none}
800
870
  return v;
801
871
  }
802
872
 
873
+ /* ================================================================
874
+ * lastRun 记忆(按项目记最近选中的 Run):localStorage 快路径 + 服务端
875
+ * ui-state 兜底(与 i2p.proj 同双写策略)。宿主标签切换会销毁重建插件
876
+ * webview,选中 Run 的现场以此恢复,而不是每次都掉回最新 Run。
877
+ * ================================================================ */
878
+ const lastRunStore = {
879
+ map: (function () { try { return JSON.parse(localStorage.getItem("i2p.run") || "{}") || {}; } catch (e) { return {}; } })(),
880
+ get(slug) { return this.map[slug] || null; },
881
+ set(slug, runId) {
882
+ if (!slug) return;
883
+ if (runId) this.map[slug] = runId; else delete this.map[slug];
884
+ try { localStorage.setItem("i2p.run", JSON.stringify(this.map)); } catch (e) { /* 忽略 */ }
885
+ apiPost("/ui-state", { lastRunBySlug: runId ? { [slug]: runId } : { [slug]: null } })
886
+ .catch(function () { /* 兜底写失败静默:localStorage 仍有效 */ });
887
+ },
888
+ // 服务端兜底返回后并入(只补缺失键,不覆盖本地较新的记忆)
889
+ adopt(map) {
890
+ if (!map || typeof map !== "object") return;
891
+ for (const k of Object.keys(map)) if (!this.map[k] && map[k]) this.map[k] = map[k];
892
+ },
893
+ };
894
+
895
+ /* ================================================================
896
+ * 运行页现场(选中阶段 / 二级菜单 / 产物文件):模块级 store,
897
+ * RunsPanel 每次渲染写回;切走一级标签卸载后重挂载,runId 一致则恢复
898
+ * ================================================================ */
899
+ const runsViewStore = { runId: null, selStage: null, stTab: "run", selArt: null };
900
+
803
901
  /* ================================================================
804
902
  * Git 托管连接辅助:host 解析(与服务端 lib/connections.js 对齐)。
805
903
  * 支持 https / ssh:// / scp(git@host:path)三种形态。
@@ -945,15 +1043,144 @@ body.i2p-dragging{user-select:none}
945
1043
  : h("button", { type: "button", className: "add-row", onClick: function () { setAdding(true); } }, Ic("plus", 12), " 添加连接"));
946
1044
  }
947
1045
 
1046
+ // 委外智能体发现扫描阶段(与服务端 discoverAgents 探测顺序一致;走马灯滚动展示全过程)
1047
+ const AGENT_SCAN_STEPS = [
1048
+ "读取项目配置 stageConfig.P6.params.claudeBin",
1049
+ "检查环境变量 ISSUE2PR_CLAUDE_BIN",
1050
+ "扫描常见安装位置(.npm-global / .npm_global / AppData\\Roaming\\npm / .local\\bin)",
1051
+ "查询 npm 全局目录(npm config get prefix)",
1052
+ "PATH 查找 claude(where / which)",
1053
+ "候选去重合并 · 存在性校验",
1054
+ ];
1055
+ const AGENT_SCAN_SHORT = ["读项目配置", "查环境变量", "扫常见位置", "查 npm 全局", "PATH 查找", "去重校验"];
1056
+
1057
+ /* 委外智能体绑定卡(项目页 P6=claude 与「配置」页 P6 共用):
1058
+ * 发现——五种来源(项目配置/环境变量/常见安装位置/npm 全局目录/PATH)+ 手动路径;
1059
+ * 门禁——定位 → 版本 → 认证微任务(一次极小真实调用,403 IP 白名单等在绑定时拦截而非 Run 中暴露)。
1060
+ * gate 状态由父组件持有(控制保存按钮):{key:"bin:<路径>"|"auto", status:"pass"|"fail", result}
1061
+ * 扫描过程走马灯可见:请求 20s 超时 / 404(旧版 node 半)显式报错 + 重试,不再无限「解析中…」。 */
1062
+ function AgentBindCard(props) {
1063
+ const p = props; // { bin, onBinChange, gate, onGate, toast, slug }
1064
+ const [found, setFound] = React.useState(null);
1065
+ // 扫描态:status scanning|done|fail;phase 驱动标记位阶段轮播与走马灯
1066
+ const [scan, setScan] = React.useState({ status: "scanning", phase: 0, error: "" });
1067
+ const [testing, setTesting] = React.useState(false);
1068
+
1069
+ const doDiscover = React.useCallback(function () {
1070
+ setFound(null);
1071
+ setScan({ status: "scanning", phase: 0, error: "" });
1072
+ const q = p.slug ? "?slug=" + encodeURIComponent(p.slug) : "";
1073
+ const req = apiGet("/agents/discover" + q).catch(function () { return null; });
1074
+ const guard = new Promise(function (r) { setTimeout(function () { r({ __timeout: true }); }, 20000); });
1075
+ Promise.race([req, guard]).then(function (r) {
1076
+ if (r && r.ok) {
1077
+ setFound({ agents: r.agents || [], resolved: r.resolved || "" });
1078
+ setScan({ status: "done", phase: 0, error: "" });
1079
+ return;
1080
+ }
1081
+ const msg = !r ? "发现请求失败(网络错误)"
1082
+ : r.__timeout ? "发现请求超时(20 秒无响应)"
1083
+ : "发现接口不可用" + (r.message ? ":" + r.message : "");
1084
+ setScan({ status: "fail", phase: 0, error: msg + "——若刚升级插件,node 半需重启 DSH 生效后重试" });
1085
+ });
1086
+ }, [p.slug]);
1087
+ React.useEffect(function () { doDiscover(); }, [doDiscover]);
1088
+ // 扫描期间阶段轮播(标记位「读项目配置…」→「查环境变量…」→ …)
1089
+ React.useEffect(function () {
1090
+ if (scan.status !== "scanning") return;
1091
+ const t = setInterval(function () { setScan(function (s) { return Object.assign({}, s, { phase: s.phase + 1 }); }); }, 1200);
1092
+ return function () { clearInterval(t); };
1093
+ }, [scan.status]);
1094
+
1095
+ const cur = (p.bin || "").trim();
1096
+ const gateLive = p.gate && p.gate.key === (cur ? "bin:" + cur : "auto") ? p.gate : null;
1097
+ const runGate = function () {
1098
+ if (testing) return;
1099
+ setTesting(true);
1100
+ apiPost("/agents/test", cur ? { bin: cur } : {}).then(function (r) {
1101
+ setTesting(false);
1102
+ if (!r || typeof r.ok !== "boolean") { p.toast("门禁请求失败", "bad"); return; }
1103
+ // r.gate 缺失 = 接口 404/异常(常见于 UI 已新版而 node 半未随重启更新)——给出可操作文案
1104
+ const res = r.gate || { message: (r && r.message) ? ("接口异常:" + r.message) : "接口不可用——若刚升级插件,请重启 DSH 后重试" };
1105
+ p.onGate({ key: cur ? "bin:" + cur : "auto", status: r.ok ? "pass" : "fail", result: res });
1106
+ if (r.ok) p.toast("委外智能体门禁通过" + (r.gate && r.gate.version ? ":" + r.gate.version : ""));
1107
+ }).catch(function (e) { setTesting(false); p.toast("请求失败: " + e, "bad"); });
1108
+ };
1109
+
1110
+ return h("div", { className: "agent-bind" },
1111
+ h("span", { className: "f-label" }, "委外智能体 · Claude Code(测试门禁通过后才能保存)"),
1112
+ h("p", { className: "hint-line", style: { margin: "6px 0 0" } },
1113
+ "P6「委托 Claude Code」由本机 claude CLI 无人值守执行。绑定 = 从候选选择或手动指定可执行文件;",
1114
+ "「测试门禁」真实跑一次极小调用(定位 → 版本 → 认证,费用可忽略)——403 IP 白名单 / 未登录这类错误在绑定时拦截,而不是等 Run 失败后才暴露。"),
1115
+ h("div", { className: "agent-cands", role: "radiogroup", "aria-label": "发现的 claude 安装" },
1116
+ h("button", {
1117
+ type: "button", key: "__auto__", className: "agent-cand" + (!cur ? " on" : ""),
1118
+ onClick: function () { p.onBinChange(""); },
1119
+ },
1120
+ h("span", { className: "ac-path" }, "自动探测"),
1121
+ h("span", { className: "ac-meta" },
1122
+ found ? "当前解析 " + (found.resolved || "claude")
1123
+ : scan.status === "fail" ? h("span", { className: "miss" }, "发现失败")
1124
+ : h("span", { className: "scan-now" }, AGENT_SCAN_SHORT[scan.phase % AGENT_SCAN_SHORT.length] + "…"))),
1125
+ (found ? found.agents : []).map(function (a, i) {
1126
+ return h("button", {
1127
+ type: "button", key: "a" + i,
1128
+ className: "agent-cand" + (cur && cur.toLowerCase() === a.path.toLowerCase() ? " on" : ""),
1129
+ onClick: function () { p.onBinChange(a.path); },
1130
+ },
1131
+ h("span", { className: "ac-path mono" }, a.path),
1132
+ h("span", { className: "ac-meta" },
1133
+ h("span", { className: "ac-src" }, a.label),
1134
+ a.exists ? "已就绪" : h("span", { className: "miss" }, "不存在")));
1135
+ })),
1136
+ // —— 解析过程走马灯(标记位下方通栏滚动)——
1137
+ scan.status === "scanning" ? h("div", { className: "scan-ticker", role: "status", "aria-live": "polite" },
1138
+ h("span", { className: "tk" }, "正在解析 claude 安装 ▸ " + AGENT_SCAN_STEPS.join(" ▸ ") + " ▸ ")) : null,
1139
+ scan.status === "done" && found ? h("p", { className: "hint-line", style: { margin: "8px 0 0" } },
1140
+ found.agents.length
1141
+ ? "扫描完成:发现 " + found.agents.length + " 个候选 · 自动解析 → " + (found.resolved || "claude")
1142
+ : "扫描完成:未发现已安装的 claude——请手动指定完整路径(如 C:\\Users\\you\\AppData\\Roaming\\npm\\claude.cmd),或先安装 claude CLI。") : null,
1143
+ scan.status === "fail" ? h("div", { className: "scan-fail" },
1144
+ h("p", { className: "hint-line", style: { margin: 0, color: "var(--err)" } }, "✗ " + scan.error),
1145
+ h("button", { type: "button", className: "btn sm", style: { marginTop: 6 }, onClick: doDiscover }, "重试扫描")) : null,
1146
+ h("div", { className: "dyn-row", style: { marginTop: 6 } },
1147
+ h("input", {
1148
+ className: "f-input mono", value: p.bin || "",
1149
+ placeholder: "手动指定可执行文件(留空 = 自动探测)",
1150
+ "aria-label": "claude 可执行文件路径",
1151
+ onChange: function (e) { p.onBinChange(e.target.value); },
1152
+ }),
1153
+ h("button", {
1154
+ type: "button", className: "btn sm" + (gateLive && gateLive.status === "pass" ? "" : " pri"),
1155
+ disabled: testing, onClick: runGate,
1156
+ }, testing ? "测试中…" : "测试门禁")),
1157
+ testing ? h("p", { className: "hint-line", style: { margin: "6px 0 0" } },
1158
+ "门禁测试中:真实调用一次 claude(通常 5–30 秒),请勿关闭本页…") : null,
1159
+ gateLive ? (function () {
1160
+ const g = gateLive.result || {};
1161
+ return h("div", { className: "agent-gate " + (gateLive.status === "pass" ? "ok" : "bad") },
1162
+ h("p", { className: "ag-line" },
1163
+ gateLive.status === "pass"
1164
+ ? "✓ 门禁通过" + (g.version ? " · " + g.version : "") + (g.ms != null ? " · " + (Math.round(g.ms / 100) / 10) + "s" : "")
1165
+ : "✗ 未通过:" + (g.message || "未知错误")),
1166
+ (g.steps || []).length ? h("p", { className: "ag-steps" }, g.steps.map(function (s, i) {
1167
+ return h("span", { key: i, className: "ag-step " + (s.ok ? "ok" : "bad") }, (s.ok ? "✓" : "✗") + s.name);
1168
+ })) : null,
1169
+ g.hint ? h("p", { className: "ag-hint" }, g.hint) : null);
1170
+ })() : null);
1171
+ }
1172
+
948
1173
  function ProjectsPanel(props) {
949
1174
  const p = props;
950
1175
  const [form, setForm] = React.useState(null);
951
1176
  const [saving, setSaving] = React.useState(false);
952
1177
  // 本地触发源存在性检查结果:uri → true/false(check-local 端点;onBlur 与载入时触发)
953
1178
  const [trigCheck, setTrigCheck] = React.useState({});
1179
+ // 委外智能体测试门禁结果:{key:"bin:<路径>"|"auto", status:"pass"|"fail", result}(p6Mode=claude 时保存前置条件)
1180
+ const [agentGate, setAgentGate] = React.useState(null);
954
1181
 
955
1182
  const blankForm = function () {
956
- return { name: "", slug: "", repos: [{ uri: "" }], triggers: [], reviewMode: "every", p6Mode: "builtin", testCommand: "" };
1183
+ return { name: "", slug: "", repos: [{ uri: "" }], triggers: [], reviewMode: "every", p6Mode: "builtin", testCommand: "", claudeBin: "" };
957
1184
  };
958
1185
 
959
1186
  React.useEffect(function () {
@@ -969,8 +1196,11 @@ body.i2p-dragging{user-select:none}
969
1196
  reviewMode: pr.reviewMode || "every",
970
1197
  p6Mode: pr.p6Mode || "builtin",
971
1198
  testCommand: pr.testCommand || "",
1199
+ // 委外智能体绑定:claudeBin 存于 stageConfig.P6.params(「配置」页同源),空 = 自动探测
1200
+ claudeBin: (pr.stageConfig && pr.stageConfig.P6 && pr.stageConfig.P6.params && pr.stageConfig.P6.params.claudeBin) || "",
972
1201
  });
973
1202
  setTrigCheck({});
1203
+ setAgentGate(null);
974
1204
  (pr.triggers || []).forEach(function (t) { checkLocal(t.uri); }); // 载入即查本地触发源存在性
975
1205
  }, [p.slug, p.projects]);
976
1206
 
@@ -986,8 +1216,18 @@ body.i2p-dragging{user-select:none}
986
1216
 
987
1217
  const collect = function () {
988
1218
  const f = form;
989
- // stageConfig(「配置」页维护)与项目表单互不感知:原样透传,避免保存项目时丢失阶段配置
1219
+ // stageConfig(「配置」页维护)与项目表单互不感知:原样透传,避免保存项目时丢失阶段配置;
1220
+ // 唯一例外 claudeBin——委外智能体绑定卡在本表单维护,合并进 P6.params(清空 = 移除覆盖恢复自动探测)
990
1221
  const pr = p.projects ? p.projects.find(function (x) { return x.slug === p.slug; }) : null;
1222
+ const stageConfig = JSON.parse(JSON.stringify((pr && pr.stageConfig) || {}));
1223
+ const bin = (f.claudeBin || "").trim();
1224
+ if (bin || (stageConfig.P6 && stageConfig.P6.params && stageConfig.P6.params.claudeBin)) {
1225
+ const p6 = stageConfig.P6 || {};
1226
+ const params = Object.assign({}, p6.params);
1227
+ if (bin) params.claudeBin = bin; else delete params.claudeBin;
1228
+ if (Object.keys(params).length) p6.params = params; else delete p6.params;
1229
+ if (Object.keys(p6).length) stageConfig.P6 = p6; else delete stageConfig.P6;
1230
+ }
991
1231
  return {
992
1232
  name: (f.name || "").trim(),
993
1233
  slug: (f.slug || "").trim(),
@@ -998,7 +1238,7 @@ body.i2p-dragging{user-select:none}
998
1238
  reviewMode: f.reviewMode,
999
1239
  p6Mode: f.p6Mode,
1000
1240
  testCommand: (f.testCommand || "").trim(),
1001
- stageConfig: (pr && pr.stageConfig) || {},
1241
+ stageConfig: stageConfig,
1002
1242
  };
1003
1243
  };
1004
1244
  const validate = function (o) {
@@ -1007,11 +1247,21 @@ body.i2p-dragging{user-select:none}
1007
1247
  if (o.repos.length === 0) return "至少填写一个 Git 仓库链接";
1008
1248
  return null;
1009
1249
  };
1250
+ // 委外智能体门禁是否对当前绑定有效:仅 p6Mode=claude 需要;改过路径后 key 不匹配即失效需重测
1251
+ const agentGateOk = function () {
1252
+ if (form.p6Mode !== "claude") return true;
1253
+ const cur = (form.claudeBin || "").trim();
1254
+ return !!(agentGate && agentGate.status === "pass" && agentGate.key === (cur ? "bin:" + cur : "auto"));
1255
+ };
1010
1256
 
1011
1257
  const ensureSaved = function () {
1012
1258
  const o = collect();
1013
1259
  const err = validate(o);
1014
1260
  if (err) { p.toast(err, "bad"); return Promise.resolve(null); }
1261
+ if (!agentGateOk()) {
1262
+ p.toast("P6 委托 Claude Code:请先通过「委外智能体」测试门禁再保存", "bad");
1263
+ return Promise.resolve(null);
1264
+ }
1015
1265
  setSaving(true);
1016
1266
  return apiPost("/projects", o).then(function (r) {
1017
1267
  setSaving(false);
@@ -1169,8 +1419,7 @@ body.i2p-dragging{user-select:none}
1169
1419
  if (pf && pf.claude && !pf.claude.ok && form.p6Mode === "claude") banners.push(h("div", { key: "claude", className: "callout warn", style: { marginBottom: 12 } },
1170
1420
  h("h4", null, "未探测到 claude CLI"),
1171
1421
  h("p", { style: { margin: 0 } },
1172
- "尝试路径 ", h("code", null, pf.claude.path || "claude"), " 不存在——P6「委托 Claude Code」发起后将回退为等待人工。",
1173
- "可在「配置」页 P6 阶段指定 claude 可执行文件。")));
1422
+ "尝试路径 ", h("code", null, pf.claude.path || "claude"), " 不存在。请在下方 P6 执行模式选「委托 Claude Code」后,用「委外智能体」卡片从扫描候选选择或手动指定路径,并通过测试门禁后保存。")));
1174
1423
  // —— LLM 路由行:默认模型从哪来 + 几个阶段已覆盖(key 由 DSH 宿主管理,插件无法预检其有效性) ——
1175
1424
  const llmRoute = pf && pf.llm
1176
1425
  ? (function () {
@@ -1246,6 +1495,12 @@ body.i2p-dragging{user-select:none}
1246
1495
  { value: "session", label: "交给 DSH 会话", hint: "真 workflow" },
1247
1496
  { value: "claude", label: "委托 Claude Code", hint: "claude CLI 自动执行" },
1248
1497
  ], form.p6Mode, function (v) { setField("p6Mode", v); }, "exec"))),
1498
+ // 委外智能体绑定(仅 claude 模式):多方式发现 + 测试门禁,通过后才能保存
1499
+ form.p6Mode === "claude" ? h(AgentBindCard, {
1500
+ key: "agent-bind", bin: form.claudeBin, gate: agentGate,
1501
+ onBinChange: function (v) { setField("claudeBin", v); },
1502
+ onGate: setAgentGate, toast: p.toast, slug: p.slug,
1503
+ }) : null,
1249
1504
  h("div", { className: "field" },
1250
1505
  h("label", { className: "f-label", htmlFor: "f-test" }, "测试命令(可选 · P8 使用)"),
1251
1506
  h("input", {
@@ -1255,7 +1510,11 @@ body.i2p-dragging{user-select:none}
1255
1510
  })),
1256
1511
  h(ConnectionsCard, { connections: p.connections, toast: p.toast, reloadConnections: p.reloadConnections }),
1257
1512
  h("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" } },
1258
- h("button", { type: "submit", className: "btn pri", disabled: saving }, "保存配置"),
1513
+ h("button", {
1514
+ type: "submit", className: "btn pri",
1515
+ disabled: saving || !agentGateOk(),
1516
+ title: form.p6Mode === "claude" && !agentGateOk() ? "P6 委托 Claude Code 需先通过委外智能体测试门禁" : "",
1517
+ }, "保存配置"),
1259
1518
  h("button", { type: "button", className: "btn", onClick: runFirst }, Ic("play"), " 用该项目发起 Run"),
1260
1519
  form.slug && p.slug ? h("button", { type: "button", className: "btn danger", onClick: deleteProject }, Ic("trash"), " 删除项目") : null),
1261
1520
  h("p", { className: "hint-line" },
@@ -1375,14 +1634,27 @@ body.i2p-dragging{user-select:none}
1375
1634
  const artRef = React.useRef(null);
1376
1635
  React.useEffect(function () { artRef.current = selArt; }, [selArt]);
1377
1636
 
1378
- // run 切换:重置选择(二级菜单回「运行」)
1637
+ // run 切换:重置选择(二级菜单回「运行」);同一 Run 重挂载(切走一级标签再回来)
1638
+ // 则从模块级 runsViewStore 恢复现场(选中阶段 / 二级菜单 / 产物文件)
1379
1639
  React.useEffect(function () {
1380
- setSelArt(null); setArtText(null); setComment(""); setRerunStage(null); setStTab("run");
1640
+ const saved = runsViewStore.runId === p.runId ? runsViewStore : null;
1641
+ setSelArt(saved ? saved.selArt : null);
1642
+ artRef.current = saved ? saved.selArt : null; // 供产物自动选中效果保位(不抢占恢复的文件)
1643
+ setArtText(null); setComment(""); setRerunStage(null);
1644
+ setStTab(saved && saved.stTab ? saved.stTab : "run");
1381
1645
  setReviews(null); setLedger(null); setEvents(null); setOpenEv(-1); evRef.current = null;
1382
- if (p.run) setSelStage(p.run.current || "P1");
1646
+ if (saved && saved.selStage) setSelStage(saved.selStage);
1647
+ else if (p.run) setSelStage(p.run.current || "P1");
1383
1648
  else setSelStage(null);
1384
1649
  // eslint-disable-next-line react-hooks/exhaustive-deps
1385
1650
  }, [p.runId]);
1651
+ // 现场写回模块级 store(每次渲染同步;卸载后由重挂载恢复)
1652
+ React.useEffect(function () {
1653
+ runsViewStore.runId = p.runId; runsViewStore.selStage = selStage;
1654
+ runsViewStore.stTab = stTab; runsViewStore.selArt = selArt;
1655
+ });
1656
+ // 重挂载即拉一次最新状态(不等 3s 轮询):切回「运行」页立刻看到真实进度
1657
+ React.useEffect(function () { if (p.onChanged) p.onChanged(); }, []);
1386
1658
  // 流水线停住(待复核/失败/停止/完成)时钉到 run.current
1387
1659
  React.useEffect(function () {
1388
1660
  if (!p.run) return;
@@ -1419,13 +1691,15 @@ body.i2p-dragging{user-select:none}
1419
1691
  .catch(function (e) { p.toast("请求失败: " + e, "bad"); });
1420
1692
  };
1421
1693
 
1422
- // 阶段或产物文件变化 → 自动选中第一个产物并读取
1694
+ // 阶段或产物文件变化 → 选中产物并读取(恢复上次选中的文件;新产物落盘不抢占已选)
1423
1695
  React.useEffect(function () {
1424
1696
  if (!selStage || !p.runId) return;
1425
1697
  const fs = listFiles(selStage);
1426
1698
  if (!fs.length) { setSelArt(null); setArtText(null); return; }
1427
- setSelArt(fs[0].path);
1428
- fetchArt(fs[0].path);
1699
+ const keep = artRef.current && fs.some(function (f) { return f.path === artRef.current; });
1700
+ const pick = keep ? artRef.current : fs[0].path;
1701
+ setSelArt(pick);
1702
+ fetchArt(pick);
1429
1703
  // eslint-disable-next-line react-hooks/exhaustive-deps
1430
1704
  }, [selStage, filesKey, p.runId]);
1431
1705
 
@@ -1554,7 +1828,8 @@ body.i2p-dragging{user-select:none}
1554
1828
  r.id + "(" + c[1] + " · " + kindLabel(r.trigger && r.trigger.kind) + ")");
1555
1829
  });
1556
1830
  if (!options.length) {
1557
- options.push(h("option", { key: "__none__", value: "" }, "(暂无 Run · 请先在「01 项目」发起)"));
1831
+ options.push(h("option", { key: "__none__", value: "" },
1832
+ p.restoring ? "(恢复 Run 列表…)" : "(暂无 Run · 请先在「01 项目」发起)"));
1558
1833
  }
1559
1834
 
1560
1835
  const sDef = STAGES.find(function (x) { return x.id === selStage; });
@@ -1569,7 +1844,8 @@ body.i2p-dragging{user-select:none}
1569
1844
  const stClaudeRun = selStage === "P6" && stStatus === "running" && !!(ee && ee.status === "running");
1570
1845
 
1571
1846
  let viewHtml = '<span class="c">点击左侧任一阶段查看产物。</span>';
1572
- if (!sDef) viewHtml = '<span class="c">点击左侧任一阶段查看产物。</span>';
1847
+ if (p.restoring && !p.run) viewHtml = '<span class="c">正在恢复运行状态…</span>';
1848
+ else if (!sDef) viewHtml = '<span class="c">点击左侧任一阶段查看产物。</span>';
1573
1849
  else if (artText != null) viewHtml = renderView(artText, selArt);
1574
1850
  else if (selArt) viewHtml = '<span class="c">读取中…</span>';
1575
1851
  else if (!stStatus || stStatus === "pending") viewHtml = '<span class="c">该阶段尚未运行,暂无产物。</span>';
@@ -1601,6 +1877,8 @@ body.i2p-dragging{user-select:none}
1601
1877
  return Object.keys(groups).sort().map(function (g) { return [g, groups[g]]; });
1602
1878
  })();
1603
1879
  const curProject = p.projects ? p.projects.find(function (x) { return x.slug === p.slug; }) : null;
1880
+ // 恢复期(重挂载后详情未到位):阶段徽章统一占位「…」,不渲染误导性的「未开始」
1881
+ const restoringAll = p.restoring && !p.run;
1604
1882
 
1605
1883
  return h("div", null,
1606
1884
  h("div", { className: "run-bar" },
@@ -1627,19 +1905,27 @@ body.i2p-dragging{user-select:none}
1627
1905
  " 五个视图(配置可直接调本阶段参数);", h("b", null, "待复核"), " 时在「运行」视图通过或打回;",
1628
1906
  h("b", null, "停止"), " = 当前阶段跑完即停;", h("b", null, "重跑"), " = 从所选阶段重新推进;",
1629
1907
  h("b", null, "删除"), " = 移除整个 Run 目录。P10 为失败旁路(仅失败时执行)。"),
1908
+ // 失败分析(P10)结果横幅:后端写回 run.failureAnalysis 后台面化,
1909
+ // 失败现场直接给出分类与建议动作,不必再点开 09-failure-analysis.json
1910
+ runStatus === "failed" && p.run && p.run.failureAnalysis ? h("div", { className: "card", style: { margin: "0 0 10px", padding: "10px 14px" } },
1911
+ h("span", { className: "f-label" }, "失败分析(P10 已生成)"),
1912
+ h("div", { style: { marginTop: 6, fontSize: 12.5, lineHeight: 1.7 } },
1913
+ h("span", { className: "tg t-warn" }, p.run.failureAnalysis.category || "未分类"),
1914
+ " ",
1915
+ h("span", null, (p.run.failureAnalysis.detail || "") + "(建议:" + (p.run.failureAnalysis.action || "?") + ")"))) : null,
1630
1916
  h("div", { className: "pipe-layout" },
1631
1917
  h("div", { className: "steps", role: "list", "aria-label": "流水线阶段" },
1632
- STAGES.map(function (s) {
1633
- const cur = p.run && p.run.stages ? p.run.stages[s.id] : null;
1634
- const status = cur ? cur.status : "pending";
1635
- // session/claude 模式的 P6 待复核 = 任务包等外部执行(claude 委托成功后显示正常"待复核")
1636
- const eeRow = p.run ? p.run.externalExec : null;
1637
- const ext = s.id === "P6" && status === "awaiting_review"
1638
- && !!(p.run && (p.run.p6Mode === "session" || p.run.p6Mode === "claude"))
1639
- && !(eeRow && eeRow.status === "done");
1640
- // claude 委托执行中:P6 处于 running,徽标准明示由 claude 执行
1641
- const claudeRun = s.id === "P6" && status === "running" && !!(eeRow && eeRow.status === "running");
1642
- const c = claudeRun ? ["t-acc", "claude 执行中"] : ext ? ["t-warn", "等外部执行"] : tag(status);
1918
+ STAGES.map(function (s) {
1919
+ const cur = p.run && p.run.stages ? p.run.stages[s.id] : null;
1920
+ const status = restoringAll ? "…" : (cur ? cur.status : "pending");
1921
+ // session/claude 模式的 P6 待复核 = 任务包等外部执行(claude 委托成功后显示正常"待复核")
1922
+ const eeRow = p.run ? p.run.externalExec : null;
1923
+ const ext = s.id === "P6" && status === "awaiting_review"
1924
+ && !!(p.run && (p.run.p6Mode === "session" || p.run.p6Mode === "claude"))
1925
+ && !(eeRow && eeRow.status === "done");
1926
+ // claude 委托执行中:P6 处于 running,徽标准明示由 claude 执行
1927
+ const claudeRun = s.id === "P6" && status === "running" && !!(eeRow && eeRow.status === "running");
1928
+ const c = restoringAll ? ["t-off", "…"] : claudeRun ? ["t-acc", "claude 执行中"] : ext ? ["t-warn", "等外部执行"] : tag(status);
1643
1929
  const dur = cur ? fmtDur(cur.startedAt, cur.finishedAt) : "";
1644
1930
  const extra = cur && cur.attempts ? " · 重试" + cur.attempts : "";
1645
1931
  const xp = (ext || claudeRun) ? p.run.externalProgress : null;
@@ -1970,6 +2256,8 @@ body.i2p-dragging{user-select:none}
1970
2256
  const p = props;
1971
2257
  const [edit, setEdit] = React.useState(null);
1972
2258
  const [saving, setSaving] = React.useState(false);
2259
+ // P6 委外智能体测试门禁(claude 模式保存前置条件;与项目页绑定卡同一套逻辑)
2260
+ const [p6Gate, setP6Gate] = React.useState(null);
1973
2261
 
1974
2262
  // defaults / 项目变化 → 重建编辑态(生效值 = 用户覆盖 ?? 默认)
1975
2263
  React.useEffect(function () {
@@ -2003,12 +2291,13 @@ body.i2p-dragging{user-select:none}
2003
2291
  agent: (user.delegate && user.delegate.agent) || "",
2004
2292
  brief: (user.delegate && user.delegate.brief) || "",
2005
2293
  },
2006
- testCommand: (pr && pr.testCommand) || "",
2007
- };
2008
- });
2009
- setEdit(next);
2010
- // eslint-disable-next-line react-hooks/exhaustive-deps
2011
- }, [p.defaults, p.projects, p.slug]);
2294
+ testCommand: (pr && pr.testCommand) || "",
2295
+ };
2296
+ });
2297
+ setEdit(next);
2298
+ setP6Gate(null); // 项目/默认值变化 → 绑定路径可能变,门禁结果作废待重测
2299
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2300
+ }, [p.defaults, p.projects, p.slug]);
2012
2301
 
2013
2302
  if (!p.defaults || !p.projects) return h("p", { className: "empty-hint" }, "加载中…");
2014
2303
  if (!p.slug) {
@@ -2107,7 +2396,18 @@ body.i2p-dragging{user-select:none}
2107
2396
  };
2108
2397
  };
2109
2398
 
2399
+ // P6 门禁是否对当前绑定有效(仅 P6 + claude 模式需要;改过路径 → key 失效需重测)
2400
+ const p6GateOk = function () {
2401
+ if (stageId !== "P6" || e.delegate.mode !== "claude") return true;
2402
+ const cur = (e.params.claudeBin || "").trim();
2403
+ return !!(p6Gate && p6Gate.status === "pass" && p6Gate.key === (cur ? "bin:" + cur : "auto"));
2404
+ };
2405
+
2110
2406
  const save = function () {
2407
+ if (stageId === "P6" && e.delegate.mode === "claude" && !p6GateOk()) {
2408
+ p.toast("P6 委托 Claude Code:请先通过「委外智能体」测试门禁再保存", "bad");
2409
+ return;
2410
+ }
2111
2411
  setSaving(true);
2112
2412
  apiPost("/projects", collect()).then(function (r) {
2113
2413
  setSaving(false);
@@ -2139,6 +2439,8 @@ body.i2p-dragging{user-select:none}
2139
2439
 
2140
2440
  const promptKeys = Object.keys((dDef && dDef.prompts) || {});
2141
2441
  const paramDefs = (dDef && dDef.params) || {};
2442
+ // P6 的 claudeBin 由上方「委外智能体」绑定卡维护(claude 模式时显示),通用参数网格不重复出
2443
+ const paramKeys = Object.keys(paramDefs).filter(function (k) { return !(stageId === "P6" && k === "claudeBin"); });
2142
2444
  const roleLabel = { planner: "派单 Planner", coder: "编码 Coder", reviewer: "门控 Reviewer", desc: "PR 说明", gate: "Gate 评测" };
2143
2445
  const effortOpts = [
2144
2446
  { value: "", label: "跟随全局", hint: "默认" },
@@ -2238,10 +2540,10 @@ body.i2p-dragging{user-select:none}
2238
2540
  onChange: function (ev) { setSt({ testCommand: ev.target.value }); },
2239
2541
  })) : null) : null,
2240
2542
  // —— 阶段专属参数(本阶段工具行为:扫描上限/深读数/claude 路径等;开源场景不藏在代码里) ——
2241
- Object.keys(paramDefs).length ? h("div", { className: "field" },
2543
+ paramKeys.length ? h("div", { className: "field" },
2242
2544
  h("span", { className: "f-label" }, "阶段专属参数(本阶段工具行为 · 留空 = 默认值)"),
2243
2545
  h("div", { className: "field-row" },
2244
- Object.keys(paramDefs).map(function (k) {
2546
+ paramKeys.map(function (k) {
2245
2547
  const meta = paramDefs[k];
2246
2548
  return h("div", { key: k, className: "field", style: { marginBottom: 0 } },
2247
2549
  h("label", { className: "f-label", htmlFor: "cfg-p-" + stageId + "-" + k },
@@ -2260,7 +2562,7 @@ body.i2p-dragging{user-select:none}
2260
2562
  // —— 委托外部智能体 ——
2261
2563
  caps.delegate ? (function () {
2262
2564
  if (stageId === "P6") {
2263
- // P6:执行模式与项目页同源(builtin/session/claude
2565
+ // P6:执行模式与项目页同源(builtin/session/claude);claude 模式出绑定卡(发现 + 测试门禁)
2264
2566
  return h("div", { className: "field" },
2265
2567
  h("span", { className: "f-label", id: "cfg-p6-label" }, "执行模式 · 委托外部智能体"),
2266
2568
  radioGroup("cfg-p6-label", [
@@ -2268,6 +2570,11 @@ body.i2p-dragging{user-select:none}
2268
2570
  { value: "session", label: "交给外部会话", hint: "生成任务包,人工交接" },
2269
2571
  { value: "claude", label: "委托 Claude Code", hint: "claude CLI 自动执行" },
2270
2572
  ], e.delegate.mode, function (v) { setDelegate({ mode: v }); }, "p6mode"),
2573
+ e.delegate.mode === "claude" ? h(AgentBindCard, {
2574
+ bin: e.params.claudeBin, gate: p6Gate,
2575
+ onBinChange: function (v) { setParam("claudeBin", v); },
2576
+ onGate: setP6Gate, toast: p.toast, slug: p.slug,
2577
+ }) : null,
2271
2578
  h("p", { className: "hint-line", style: { margin: "8px 0 0" } },
2272
2579
  "与「项目」页 P6 执行模式为同一配置(p6Mode);claude 模式任务包自动执行,产出补丁后回复核门。"));
2273
2580
  }
@@ -2315,7 +2622,12 @@ body.i2p-dragging{user-select:none}
2315
2622
  "每条 patch 可在「运行」页 P7 详情中逐条回滚。"))
2316
2623
  : null,
2317
2624
  h("div", { className: "cfg-foot" },
2318
- h("button", { type: "button", className: "btn pri", disabled: saving, onClick: save },
2625
+ h("button", {
2626
+ type: "button", className: "btn pri",
2627
+ disabled: saving || !p6GateOk(),
2628
+ title: !p6GateOk() ? "P6 委托 Claude Code 需先通过委外智能体测试门禁" : "",
2629
+ onClick: save,
2630
+ },
2319
2631
  Ic("check"), saving ? " 保存中…" : " 保存阶段配置"),
2320
2632
  h("span", { className: "hint-line", style: { margin: 0 } },
2321
2633
  "写入 …\\issue2pr\\projects\\" + p.slug + "\\project.json 的 stageConfig 字段")));
@@ -2415,7 +2727,14 @@ body.i2p-dragging{user-select:none}
2415
2727
  * 主 Section:左侧导航 + 共享数据 + 3s 轮询
2416
2728
  * ================================================================ */
2417
2729
  function Section() {
2418
- const [nav, setNav] = React.useState("projects");
2730
+ // 一级导航记忆:宿主标签切换销毁重建 webview 后回到上次页面(而不是掉回「项目」)
2731
+ const [nav, setNav] = React.useState(function () {
2732
+ const v = localStorage.getItem("i2p.nav");
2733
+ return ["projects", "runs", "artifacts", "config", "guide"].indexOf(v) >= 0 ? v : "projects";
2734
+ });
2735
+ React.useEffect(function () {
2736
+ try { localStorage.setItem("i2p.nav", nav); } catch (e) { /* 忽略 */ }
2737
+ }, [nav]);
2419
2738
  const [toast, setToast] = React.useState(null);
2420
2739
  const [projects, setProjects] = React.useState(null);
2421
2740
  // 选中记忆:进入时恢复上次选中的项目(无记忆则保持未选中)
@@ -2488,6 +2807,7 @@ body.i2p-dragging{user-select:none}
2488
2807
  }
2489
2808
  apiGet("/ui-state").then(function (r) {
2490
2809
  if (!r || !r.ok) return;
2810
+ lastRunStore.adopt(r.state && r.state.lastRunBySlug); // lastRun 记忆兜底并入(仅补缺)
2491
2811
  const lp = r.state && r.state.lastProject;
2492
2812
  if (!lp || touchedRef.current || ctxRef.current.slug != null) return;
2493
2813
  setSelSlug(lp);
@@ -2499,7 +2819,8 @@ body.i2p-dragging{user-select:none}
2499
2819
  }, []);
2500
2820
  React.useEffect(function () {
2501
2821
  if (!toast) return;
2502
- const t = setTimeout(function () { setToast(null); }, 2600);
2822
+ // 失败类提示停留更久(长文案需要时间读完)
2823
+ const t = setTimeout(function () { setToast(null); }, toast.kind === "bad" ? 5200 : 2600);
2503
2824
  return function () { clearTimeout(t); };
2504
2825
  }, [toast]);
2505
2826
 
@@ -2518,7 +2839,8 @@ body.i2p-dragging{user-select:none}
2518
2839
  if (!projects.some(function (x) { return x.slug === selSlug; })) selectSlug(null);
2519
2840
  }, [selSlug, projects, selectSlug]);
2520
2841
 
2521
- // slug 变化 → 重置 run 选择并加载 runs 摘要
2842
+ // slug 变化 → 重置 run 选择并加载 runs 摘要;恢复优先上次选中的 Run
2843
+ // (lastRun 记忆),无记忆/已删除才退回最新 Run
2522
2844
  React.useEffect(function () {
2523
2845
  setSelRunId(null); setRun(null); setTree(null);
2524
2846
  if (!selSlug) { setRuns(null); return; }
@@ -2529,7 +2851,11 @@ body.i2p-dragging{user-select:none}
2529
2851
  if (ctxRef.current.slug !== slug) return;
2530
2852
  setRuns(r.runs || []);
2531
2853
  setSelRunId(function (prev) {
2532
- return prev || (r.runs && r.runs.length ? r.runs[0].id : null);
2854
+ if (prev) return prev;
2855
+ const list = r.runs || [];
2856
+ if (!list.length) return null;
2857
+ const remembered = lastRunStore.get(slug);
2858
+ return list.some(function (x) { return x.id === remembered; }) ? remembered : list[0].id;
2533
2859
  });
2534
2860
  });
2535
2861
  }, [selSlug]);
@@ -2587,6 +2913,18 @@ body.i2p-dragging{user-select:none}
2587
2913
  }
2588
2914
  }, [selSlug, selRunId]);
2589
2915
 
2916
+ // 回到前台立即刷新(不等 3s 轮询):宿主标签切回 / 窗口聚焦即恢复现场。
2917
+ // 嵌入式 IAB 里 visibilitychange 与 focus 都可能不触发,两个都挂做双保险。
2918
+ React.useEffect(function () {
2919
+ const onVis = function () { if (document.visibilityState === "visible") refreshNow(); };
2920
+ document.addEventListener("visibilitychange", onVis);
2921
+ window.addEventListener("focus", refreshNow);
2922
+ return function () {
2923
+ document.removeEventListener("visibilitychange", onVis);
2924
+ window.removeEventListener("focus", refreshNow);
2925
+ };
2926
+ }, [refreshNow]);
2927
+
2590
2928
  const onSaved = React.useCallback(function (slug) {
2591
2929
  selectSlug(slug);
2592
2930
  loadProjects();
@@ -2596,6 +2934,7 @@ body.i2p-dragging{user-select:none}
2596
2934
  const onRunDeleted = React.useCallback(function () {
2597
2935
  setSelRunId(null); setRun(null); setTree(null);
2598
2936
  const slug = selSlug;
2937
+ lastRunStore.set(slug, null); // 删除后清掉该项目 lastRun 记忆(恢复时退回最新 Run)
2599
2938
  if (!slug) return;
2600
2939
  apiGet("/projects/" + slug + "/runs").then(function (r) {
2601
2940
  if (r && r.ok && ctxRef.current.slug === slug) setRuns(r.runs || []);
@@ -2603,19 +2942,52 @@ body.i2p-dragging{user-select:none}
2603
2942
  }, [selSlug]);
2604
2943
 
2605
2944
  const onProjectDeleted = React.useCallback(function () {
2945
+ lastRunStore.set(selSlug, null); // 项目删除后清 lastRun 记忆
2606
2946
  selectSlug(null); setRuns(null); setSelRunId(null); setRun(null); setTree(null);
2607
2947
  loadProjects();
2608
- }, [loadProjects, selectSlug]);
2948
+ }, [loadProjects, selectSlug, selSlug]);
2609
2949
 
2610
2950
  const onRunStarted = React.useCallback(function (slug, runId) {
2611
2951
  selectSlug(slug);
2612
2952
  setSelRunId(runId);
2953
+ lastRunStore.set(slug, runId); // 新发起的 Run 即为现场:重挂载恢复时优先回到它
2613
2954
  setNav("runs");
2614
2955
  apiGet("/projects/" + slug + "/runs").then(function (r) {
2615
2956
  if (r && r.ok && ctxRef.current.slug === slug) setRuns(r.runs || []);
2616
2957
  });
2617
2958
  }, [selectSlug]);
2618
2959
 
2960
+ // 选中 Run 统一入口:写 lastRun 记忆(localStorage + 服务端兜底),重挂载恢复现场
2961
+ const selectRun = React.useCallback(function (id) {
2962
+ setSelRunId(id);
2963
+ if (ctxRef.current.slug) lastRunStore.set(ctxRef.current.slug, id || null);
2964
+ }, []);
2965
+
2966
+ // Run 状态跃迁显式提示:从执行态(running/awaiting_review)离开时 toast,
2967
+ // 切标签/合上工作台期间的后台失败/完成/待复核不再静默(首帧加载不算跃迁;
2968
+ // 按 {id,status} 记忆——切换到另一个 Run 不误报跃迁)
2969
+ const runStatusRef = React.useRef(null);
2970
+ React.useEffect(function () {
2971
+ const cur = run && run.id === selRunId ? { id: run.id, status: run.status } : null;
2972
+ const prev = runStatusRef.current;
2973
+ runStatusRef.current = cur;
2974
+ if (!cur || !prev || prev.id !== cur.id || prev.status === cur.status) return;
2975
+ if (prev.status !== "running" && prev.status !== "awaiting_review") return;
2976
+ const s = cur.status;
2977
+ if (s === "awaiting_review") toastFn("Run " + run.id + " 待复核:" + run.current + " 阶段等您确认", "warn");
2978
+ else if (s === "failed") {
2979
+ const st = run.stages ? run.stages[run.current] : null;
2980
+ toastFn("Run " + run.id + " 已失败(" + run.current + (st && st.error ? ":" + st.error : "") + ")", "bad");
2981
+ }
2982
+ else if (s === "completed") toastFn("Run " + run.id + " 已完成——全部阶段通过");
2983
+ else if (s === "stopped") toastFn("Run " + run.id + " 已停止", "warn");
2984
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2985
+ }, [run]);
2986
+
2987
+ // 恢复期(重挂载后 runs 列表/详情未到位):渲染「恢复中」而非误导性的「未开始」
2988
+ const restoring = !!selSlug && (runs == null
2989
+ || (!!selRunId && run == null && !!(runs || []).some(function (x) { return x.id === selRunId; })));
2990
+
2619
2991
  const awaitingCount = (runs || []).filter(function (r) { return r.status === "awaiting_review"; }).length;
2620
2992
 
2621
2993
  const navDefs = [
@@ -2737,7 +3109,8 @@ body.i2p-dragging{user-select:none}
2737
3109
  projects: projects,
2738
3110
  defaults: stageDefaults,
2739
3111
  toast: toastFn,
2740
- onPickRun: function (id) { setSelRunId(id); },
3112
+ restoring: restoring,
3113
+ onPickRun: selectRun,
2741
3114
  onChanged: refreshNow,
2742
3115
  onDeleted: onRunDeleted,
2743
3116
  onSaved: onSaved,
@@ -2807,29 +3180,22 @@ body.i2p-dragging{user-select:none}
2807
3180
  const r2 = r != null ? r
2808
3181
  : (oldX != null ? Math.max(4, Math.round(p.w - oldX - w2)) : AI_ANCHOR.right);
2809
3182
  const t2 = oldT != null ? oldT : AI_ANCHOR.top;
2810
- return {
2811
- x: Math.round(p.w - r2 - w2),
2812
- y: t2,
2813
- w: w2,
2814
- h: h != null ? h : Math.max(200, Math.round(p.h - 16 - t2)),
2815
- };
2816
- })(),
3183
+ // 恢复的矩形可能来自大屏(localStorage 按机器各存各的),先按当前页面收敛再启用
3184
+ return aiClampRect(
3185
+ Math.round(p.w - r2 - w2), t2, w2,
3186
+ h != null ? h : Math.max(200, Math.round(p.h - 16 - t2)));
3187
+ })(),
2817
3188
  listeners: new Set(),
2818
- set(v) { this.open = v; for (const fn of this.listeners) fn(); },
3189
+ set(v) { this.open = v; if (v) this.normalize(); for (const fn of this.listeners) fn(); },
2819
3190
  // setRect:先 clamp 尺寸再 clamp 位置(面板始终完整落在工作台内);
2820
3191
  // resize 的锚定边由调用方以推导后的 x/y 一并传入(西/北边拖动时对边固定)
2821
3192
  setRect(patch, persist) {
2822
- const p = aiPageRect();
2823
3193
  const cur = this.rect;
2824
- let w = patch.w != null ? Math.round(patch.w) : cur.w;
2825
- let h = patch.h != null ? Math.round(patch.h) : cur.h;
2826
- w = Math.max(240, Math.min(Math.max(240, Math.min(760, p.w - 24)), w));
2827
- h = Math.max(200, Math.min(Math.max(200, p.h - 60), h));
2828
- let x = patch.x != null ? Math.round(patch.x) : cur.x;
2829
- let y = patch.y != null ? Math.round(patch.y) : cur.y;
2830
- x = Math.max(4, Math.min(Math.max(4, p.w - w - 4), x));
2831
- y = Math.max(44, Math.min(Math.max(44, p.h - h - 8), y));
2832
- this.rect = { x: x, y: y, w: w, h: h };
3194
+ this.rect = aiClampRect(
3195
+ patch.x != null ? Math.round(patch.x) : cur.x,
3196
+ patch.y != null ? Math.round(patch.y) : cur.y,
3197
+ patch.w != null ? Math.round(patch.w) : cur.w,
3198
+ patch.h != null ? Math.round(patch.h) : cur.h);
2833
3199
  if (persist) {
2834
3200
  // 右上角相对坐标落盘:aiR=右缘偏移、aiT=顶部偏移;旧绝对键清除防干扰
2835
3201
  try {
@@ -2850,6 +3216,15 @@ body.i2p-dragging{user-select:none}
2850
3216
  for (const fn of this.listeners) fn();
2851
3217
  },
2852
3218
  resetRect() { this.setRect(aiDefaultRect(), true); },
3219
+ // 收敛当前矩形到页面内(不落盘,用户偏好不动):打开面板与页面几何变化时调用,
3220
+ // 修掉"大屏存下的尺寸到小屏溢出屏幕";矩形没变化时不触发监听
3221
+ normalize() {
3222
+ const cur = this.rect;
3223
+ const n = aiClampRect(cur.x, cur.y, cur.w, cur.h);
3224
+ if (n.x === cur.x && n.y === cur.y && n.w === cur.w && n.h === cur.h) return;
3225
+ this.rect = n;
3226
+ for (const fn of this.listeners) fn();
3227
+ },
2853
3228
  subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); },
2854
3229
  };
2855
3230
  function useAiState() {
@@ -2870,6 +3245,16 @@ body.i2p-dragging{user-select:none}
2870
3245
  const r = el && el.getBoundingClientRect ? el.getBoundingClientRect() : null;
2871
3246
  return r ? { w: r.width, h: r.height } : { w: window.innerWidth || 1280, h: window.innerHeight || 720 };
2872
3247
  }
3248
+ // 矩形收敛(与 setRect 同边界):尺寸先 clamp(240..760/页宽-24、200..页高-60),
3249
+ // 位置后 clamp(面板完整落在工作台内)——恢复路径/打开面板/页面几何变化共用
3250
+ function aiClampRect(x, y, w, h) {
3251
+ const p = aiPageRect();
3252
+ w = Math.max(240, Math.min(Math.max(240, Math.min(760, p.w - 24)), w));
3253
+ h = Math.max(200, Math.min(Math.max(200, p.h - 60), h));
3254
+ x = Math.max(4, Math.min(Math.max(4, p.w - w - 4), x));
3255
+ y = Math.max(44, Math.min(Math.max(44, p.h - h - 8), y));
3256
+ return { x: x, y: y, w: w, h: h };
3257
+ }
2873
3258
  // 默认窗口:右上角按 AI_ANCHOR 固定偏移锚定(窗口尺寸变化时相对位置不变)
2874
3259
  function aiDefaultRect() {
2875
3260
  const p = aiPageRect();
@@ -3184,6 +3569,11 @@ body.i2p-dragging{user-select:none}
3184
3569
  const timer = setInterval(tick, 300);
3185
3570
  return () => clearInterval(timer);
3186
3571
  }, [open]);
3572
+ // 页面几何变化(宿主布局/窗口缩放,由上方 300ms 轮询驱动 rect)后把助手面板
3573
+ // 收敛回页面内;effect 在 DOM 提交后运行,aiPageRect 读到的已是新尺寸
3574
+ React.useEffect(function () {
3575
+ if (aiStore.open) aiStore.normalize();
3576
+ }, [rect]);
3187
3577
  if (!open || rect === null) return null;
3188
3578
  return h("div", {
3189
3579
  className: "i2p-page", role: "region", "aria-label": "Issue2PR 工作台",