dsh-conversation-overview 1.5.0 → 1.7.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
@@ -24,6 +24,40 @@ window.__ModuleLoader__.load({
24
24
  as clickable chips and external links open in a new tab; borrow the provider too. */
25
25
  const ChatMarkdownProvider = primitives !== null && typeof primitives.MarkdownDelegateProvider === "function" ? primitives.MarkdownDelegateProvider : null;
26
26
 
27
+ /* Floating layers must not live inside the overview's sticky toolbar: that wrapper
28
+ is transform-pinned (and carries a backdrop-filter once stuck), and both make a
29
+ containing block for `position: fixed` children, so viewport-anchored popovers
30
+ land in the wrong place after the conversation scrolls. The host chat renders its
31
+ own popovers through react-dom portals for the same reason. */
32
+ const ReactDOM = (() => {
33
+ try {
34
+ const module = require("react-dom");
35
+ return module && typeof module.createPortal === "function" ? module : null;
36
+ } catch (error) {
37
+ return null;
38
+ }
39
+ })();
40
+ const createPortal = ReactDOM !== null ? ReactDOM.createPortal : null;
41
+ let overlayHost = null;
42
+
43
+ const overlayNode = () => {
44
+ if (createPortal === null) return null;
45
+ if (overlayHost !== null && overlayHost.isConnected !== false) return overlayHost;
46
+ if (typeof document === "undefined" || !document.body || typeof document.body.appendChild !== "function") return null;
47
+ const host = document.createElement("div");
48
+ host.className = "dsov-overlayHost";
49
+ host.setAttribute("data-dsov-overlay", "true");
50
+ document.body.appendChild(host);
51
+ overlayHost = host;
52
+ return host;
53
+ };
54
+
55
+ /** Portal a fixed-position popover to the body, or render it in place without a DOM. */
56
+ const renderOverlay = (node) => {
57
+ const host = overlayNode();
58
+ return host === null ? node : createPortal(node, host);
59
+ };
60
+
27
61
  function cx(...parts) {
28
62
  return parts.filter((part) => typeof part === "string" && part.length > 0).join(" ");
29
63
  }
@@ -43,7 +77,7 @@ window.__ModuleLoader__.load({
43
77
  const RELEASE_TAGS_API_URL = "https://api.github.com/repos/LingF/dsh-conversation-overview/tags?per_page=100";
44
78
  const TAGS_URL = "https://github.com/LingF/dsh-conversation-overview/tags";
45
79
  /* keep in sync with package.json */
46
- const PLUGIN_VERSION = "1.1.0";
80
+ const PLUGIN_VERSION = "1.7.0";
47
81
  const LATEST_CACHE_KEY = "dsh.conversationOverview.latestRelease";
48
82
  const LATEST_CACHE_TTL = 6 * 60 * 60 * 1000;
49
83
  const latestStore = { tag: null, via: "release", loaded: false, inflight: null };
@@ -204,7 +238,7 @@ window.__ModuleLoader__.load({
204
238
  const zh = {
205
239
  "view.tab": "概览",
206
240
  "panel.title": "概览",
207
- "guide.title": "会话概览",
241
+ "guide.title": "对话概览",
208
242
  "guide.description": "进展、产物与引用",
209
243
  "section.progress": "进展",
210
244
  "section.artifacts": "产物",
@@ -234,12 +268,19 @@ window.__ModuleLoader__.load({
234
268
  "progress.todoCopied": "未找到对话框,提示词已复制到剪贴板",
235
269
  "progress.todoFailed": "填入失败,请重试",
236
270
  "progress.waiting": "等待确认",
271
+ "progress.live": "进行中",
272
+ "progress.liveThinking": "正在生成…",
273
+ "progress.liveElapsed": "已 {v}",
274
+ "progress.liveElapsedTitle": "当前步骤已用时;没有执行中的步骤时为本轮用时",
237
275
  "progress.counts": "{completed}/{total} 已完成",
276
+ "progress.meterTitle": "{completed}/{total} 已完成 · {running} 进行中 · {pending} 待处理",
277
+ "progress.leftover": "{running} 进行中 · {pending} 待处理",
278
+ "progress.staleHint": "待办状态取自执行方的最后一次更新;本轮结束时仍有未完成项",
238
279
  "artifacts.empty": "本次对话产出的文件将在此展示",
239
280
  "artifacts.viewAll": "查看所有改动",
240
281
  "settings.summary": "控制概览的板块与显示位置",
241
282
  "settings.nav": "概览",
242
- "settings.pageTitle": "会话概览(dsh-conversation-overview)",
283
+ "settings.pageTitle": "对话概览(dsh-conversation-overview)",
243
284
  "settings.sections": "板块显示",
244
285
  "settings.showProgress": "进展(任务与待办)",
245
286
  "settings.showArtifacts": "产物(本次对话产出)",
@@ -367,6 +408,49 @@ window.__ModuleLoader__.load({
367
408
  "stats.errors.list": "失败详情({n})",
368
409
  "stats.errors.truncated": "仅列出步骤窗口内的失败",
369
410
  "stats.errors.close": "关闭",
411
+ "stats.more": "更多",
412
+ "stats.more.title": "本轮统计:步数、模型、用时与 Token 用量",
413
+ "stats.more.head": "本轮统计",
414
+ "stats.more.headAll": "会话统计",
415
+ "stats.more.turn": "第 {n} 轮",
416
+ "stats.more.allTurns": "全部轮次",
417
+ "stats.more.turns": "轮数",
418
+ "stats.more.turnsValue": "{n} 轮",
419
+ "stats.more.turnsHint": "轮数:至少有一个已结束步(step/end)的轮次数。",
420
+ "stats.more.steps": "步数",
421
+ "stats.more.stepsValue": "{n} 步",
422
+ "stats.more.stepsHint": "步数:该轮 step/end 事件数,与宿主统计中的「步」一致。",
423
+ "stats.more.model": "模型",
424
+ "stats.more.modelHint": "模型:该轮 assistant/message 上报的提供方/模型,按首次出现顺序去重。",
425
+ "stats.more.unknown": "—",
426
+ "stats.more.firstToken": "本轮首字延迟",
427
+ "stats.more.firstTokenHint": "本轮首字延迟:本轮第一个 step/start → 本轮第一个输出 token(文本、推理或工具调用)的时间,包含请求组装与排队。",
428
+ "stats.more.llmTime": "模型用时",
429
+ "stats.more.llmTimeHint": "模型用时:各步 step/start → assistant/message 的累计时长;未落定消息的步不计。",
430
+ "stats.more.toolTime": "工具用时",
431
+ "stats.more.toolTimeHint": "工具用时:tool/call → tool/result 按 callId 配对后的累计时长。",
432
+ "stats.more.ttft": "首 token 平均",
433
+ "stats.more.ttftHint": "首 token 平均:各步 step/start → 该步第一个输出 token(文本、推理或工具调用)的平均等待,只计有首 token 的步,与宿主同口径。",
434
+ "stats.more.speed": "输出速度",
435
+ "stats.more.speedValue": "{v} tok/s",
436
+ "stats.more.speedHint": "输出速度:首 token → 消息落定期间,输出 token ÷ 解码时长。",
437
+ "stats.more.tokens": "Token 用量",
438
+ "stats.more.tokenTotal": "总量",
439
+ "stats.more.tokenValue": "{count} tok",
440
+ "stats.more.tokensHint": "Token 用量:提供方上报的 usage 累计;总量 = 未缓存输入 + 缓存读取 + 缓存写入 + 输出。",
441
+ "stats.more.cacheHit": "缓存命中",
442
+ "stats.more.cacheHitHint": "缓存命中:缓存读取 ÷(未缓存输入 + 缓存读取 + 缓存写入)。",
443
+ "stats.more.input": "未缓存输入",
444
+ "stats.more.inputHint": "未缓存输入:每次请求中未被缓存命中的 prompt token。",
445
+ "stats.more.cacheRead": "缓存读取",
446
+ "stats.more.cacheReadHint": "缓存读取:命中前缀缓存、按缓存价计费的 prompt token。",
447
+ "stats.more.cacheWrite": "缓存写入",
448
+ "stats.more.cacheWriteHint": "缓存写入:写入缓存的 prompt token,部分提供方不上报。",
449
+ "stats.more.output": "输出",
450
+ "stats.more.outputHint": "输出:模型生成的 completion token,含推理。",
451
+ "stats.more.reasoning": "其中推理",
452
+ "stats.more.reasoningHint": "推理:输出 token 中属于推理过程的部分,部分提供方不上报。",
453
+ "stats.more.close": "关闭统计",
370
454
  "reference.types": "类型",
371
455
  "reference.types.title": "按类型筛选",
372
456
  "reference.types.all": "全部类型",
@@ -446,6 +530,53 @@ window.__ModuleLoader__.load({
446
530
  "role.parentPrefix": "·",
447
531
  "state.noSession": "当前没有打开的会话",
448
532
  "state.error": "概览渲染失败",
533
+ "planWorkbench.action": "批注并修改",
534
+ "planWorkbench.actionHint": "逐段批注或直接改写计划,提交后请求模型修订;与「要求修改」同级的细粒度方式",
535
+ "planWorkbench.openFailed": "侧边栏不可用,无法打开计划工作台",
536
+ "planWorkbench.tabTitle": "计划工作台",
537
+ "planWorkbench.reviewPending": "待审阅",
538
+ "planWorkbench.reviewClosed": "审阅已结束",
539
+ "planWorkbench.modes": "工作台模式",
540
+ "planWorkbench.modeAnnotate": "批注",
541
+ "planWorkbench.modeEdit": "改写",
542
+ "planWorkbench.openOriginal": "打开原计划",
543
+ "planWorkbench.open": "计划操作",
544
+ "planWorkbench.whole": "整体意见",
545
+ "planWorkbench.preface": "开头",
546
+ "planWorkbench.addHere": "在此处批注",
547
+ "planWorkbench.addAria": "在「{heading}」处批注",
548
+ "planWorkbench.commentPlaceholder": "这一节要怎么改…",
549
+ "planWorkbench.commentAria": "批注「{heading}」",
550
+ "planWorkbench.save": "保存批注",
551
+ "planWorkbench.cancel": "取消",
552
+ "planWorkbench.remove": "删除批注",
553
+ "planWorkbench.editHint": "直接改写全文;提交时以这份修订稿为准。",
554
+ "planWorkbench.editAria": "编辑计划全文",
555
+ "planWorkbench.editSave": "保存草稿",
556
+ "planWorkbench.editSaved": "草稿已保存",
557
+ "planWorkbench.revert": "恢复原文",
558
+ "planWorkbench.count": "{n} 条批注",
559
+ "planWorkbench.empty": "还没有批注或改写",
560
+ "planWorkbench.submitReview": "提交批注并请求修订",
561
+ "planWorkbench.submitDraft": "带入输入框",
562
+ "planWorkbench.submitComposer": "插入输入框",
563
+ "planWorkbench.submitting": "提交中…",
564
+ "planWorkbench.copyFeedback": "复制反馈",
565
+ "planWorkbench.submitted": "已提交,等待模型修订计划",
566
+ "planWorkbench.drafted": "已带入输入框,检查后可发送",
567
+ "planWorkbench.copied": "已复制",
568
+ "planWorkbench.copyFailed": "复制失败,请手动选择文本",
569
+ "planWorkbench.failed": "提交失败",
570
+ "planWorkbench.unavailable": "计划内容不可用(宿主计划页或会话记录缺失),可打开原计划查看。",
571
+ "planWorkbench.untitled": "未命名计划",
572
+ "planWorkbench.menuAnnotate": "批注修改",
573
+ "planWorkbench.menuCopy": "复制计划",
574
+ "planWorkbench.feedbackIntro": "我在计划《{title}》上做了批注,请据此修订计划;若仍处于计划模式,修订后请重新调用 exit_plan_mode 提交,先不要执行。",
575
+ "planWorkbench.feedbackHead": "{n}. 「{heading}」",
576
+ "planWorkbench.feedbackQuote": "原文:{quote}",
577
+ "planWorkbench.feedbackComment": "批注:{comment}",
578
+ "planWorkbench.feedbackEdit": "另外,我直接改写了计划,请以这份修订稿为准(并回应上面的批注):",
579
+ "planWorkbench.feedbackOutro": "未涉及的部分保持不变。",
449
580
  };
450
581
 
451
582
  const en = {
@@ -476,12 +607,19 @@ window.__ModuleLoader__.load({
476
607
  "progress.todoCopied": "Composer not found — prompt copied to the clipboard",
477
608
  "progress.todoFailed": "Could not fill the composer, try again",
478
609
  "progress.waiting": "Waiting for confirmation",
610
+ "progress.live": "Live",
611
+ "progress.liveThinking": "Generating…",
612
+ "progress.liveElapsed": "{v} in",
613
+ "progress.liveElapsedTitle": "Elapsed for the current step; for this turn while no step is running",
479
614
  "team.phase.provisioning": "Provisioning",
480
615
  "team.phase.active": "Active",
481
616
  "team.phase.inactive": "Inactive",
482
617
  "team.phase.failed": "Failed",
483
618
  "team.blocked": "Blocked {n}",
484
619
  "progress.counts": "{completed} of {total} completed",
620
+ "progress.meterTitle": "{completed} of {total} completed · {running} in progress · {pending} pending",
621
+ "progress.leftover": "{running} in progress · {pending} pending",
622
+ "progress.staleHint": "Todo state follows the agent's last update; items were still open when the turn ended",
485
623
  "artifacts.empty": "Files produced in this conversation appear here",
486
624
  "artifacts.viewAll": "Review all changes",
487
625
  "settings.summary": "Control the overview sections and where they appear",
@@ -614,6 +752,49 @@ window.__ModuleLoader__.load({
614
752
  "stats.errors.list": "Failure details ({n})",
615
753
  "stats.errors.truncated": "Only failures within the step window are listed",
616
754
  "stats.errors.close": "Close",
755
+ "stats.more": "More",
756
+ "stats.more.title": "Turn statistics: steps, model, timings and token usage",
757
+ "stats.more.head": "Turn statistics",
758
+ "stats.more.headAll": "Session statistics",
759
+ "stats.more.turn": "Turn {n}",
760
+ "stats.more.allTurns": "All turns",
761
+ "stats.more.turns": "Turns",
762
+ "stats.more.turnsValue": "{n} turns",
763
+ "stats.more.turnsHint": "Turns: turns with at least one closed step (step/end).",
764
+ "stats.more.steps": "Steps",
765
+ "stats.more.stepsValue": "{n} steps",
766
+ "stats.more.stepsHint": "Steps: step/end events in this scope, the harness's step count.",
767
+ "stats.more.model": "Model",
768
+ "stats.more.modelHint": "Models: provider/model reported by assistant/message, deduplicated in first-seen order.",
769
+ "stats.more.unknown": "—",
770
+ "stats.more.firstToken": "First token",
771
+ "stats.more.firstTokenHint": "Time from this turn's first step/start to its first output token (text, reasoning or tool call), including request assembly and queueing.",
772
+ "stats.more.llmTime": "Model time",
773
+ "stats.more.llmTimeHint": "Summed step/start to assistant/message wall time; steps whose message never settled are not counted.",
774
+ "stats.more.toolTime": "Tool time",
775
+ "stats.more.toolTimeHint": "Summed tool/call to tool/result wall time, paired by callId.",
776
+ "stats.more.ttft": "Avg first token",
777
+ "stats.more.ttftHint": "Average step/start to first output token per step (text, reasoning or tool call), over steps that reported a first token; matches the harness rule.",
778
+ "stats.more.speed": "Output speed",
779
+ "stats.more.speedValue": "{v} tok/s",
780
+ "stats.more.speedHint": "Output tokens divided by decode time from the first token to the assembled message.",
781
+ "stats.more.tokens": "Token usage",
782
+ "stats.more.tokenTotal": "Total",
783
+ "stats.more.tokenValue": "{count} tok",
784
+ "stats.more.tokensHint": "Provider-reported usage; total = uncached input + cache read + cache write + output.",
785
+ "stats.more.cacheHit": "Cache hit",
786
+ "stats.more.cacheHitHint": "Cache read divided by (uncached input + cache read + cache write).",
787
+ "stats.more.input": "Uncached input",
788
+ "stats.more.inputHint": "Prompt tokens that were not served from cache.",
789
+ "stats.more.cacheRead": "Cache read",
790
+ "stats.more.cacheReadHint": "Prompt tokens served from the prefix cache, billed at the cache rate.",
791
+ "stats.more.cacheWrite": "Cache write",
792
+ "stats.more.cacheWriteHint": "Prompt tokens written to cache; not every provider reports them.",
793
+ "stats.more.output": "Output",
794
+ "stats.more.outputHint": "Generated completion tokens, including reasoning.",
795
+ "stats.more.reasoning": "Reasoning",
796
+ "stats.more.reasoningHint": "Output tokens spent on reasoning; not every provider reports them.",
797
+ "stats.more.close": "Close statistics",
617
798
  "reference.types": "Types",
618
799
  "reference.types.title": "Filter by type",
619
800
  "reference.types.all": "All types",
@@ -693,6 +874,53 @@ window.__ModuleLoader__.load({
693
874
  "role.parentPrefix": "·",
694
875
  "state.noSession": "No session is open",
695
876
  "state.error": "Overview failed to render",
877
+ "planWorkbench.action": "Annotate & revise",
878
+ "planWorkbench.actionHint": "Annotate each section or rewrite the plan, then request the revision; a finer-grained alternative to Request changes",
879
+ "planWorkbench.openFailed": "No sidebar available to open the plan workbench",
880
+ "planWorkbench.tabTitle": "Plan workbench",
881
+ "planWorkbench.reviewPending": "Awaiting review",
882
+ "planWorkbench.reviewClosed": "Review closed",
883
+ "planWorkbench.modes": "Workbench mode",
884
+ "planWorkbench.modeAnnotate": "Annotate",
885
+ "planWorkbench.modeEdit": "Rewrite",
886
+ "planWorkbench.openOriginal": "Open plan",
887
+ "planWorkbench.open": "Plan actions",
888
+ "planWorkbench.whole": "Whole plan",
889
+ "planWorkbench.preface": "Opening",
890
+ "planWorkbench.addHere": "Annotate here",
891
+ "planWorkbench.addAria": "Annotate “{heading}”",
892
+ "planWorkbench.commentPlaceholder": "How should this section change…",
893
+ "planWorkbench.commentAria": "Annotation for “{heading}”",
894
+ "planWorkbench.save": "Save annotation",
895
+ "planWorkbench.cancel": "Cancel",
896
+ "planWorkbench.remove": "Delete annotation",
897
+ "planWorkbench.editHint": "Rewrite the plan directly; submitting sends this revision as the plan.",
898
+ "planWorkbench.editAria": "Edit the plan markdown",
899
+ "planWorkbench.editSave": "Save draft",
900
+ "planWorkbench.editSaved": "Draft saved",
901
+ "planWorkbench.revert": "Revert to original",
902
+ "planWorkbench.count": "{n} annotation(s)",
903
+ "planWorkbench.empty": "No annotations or rewrite yet",
904
+ "planWorkbench.submitReview": "Submit for revision",
905
+ "planWorkbench.submitDraft": "Put in composer",
906
+ "planWorkbench.submitComposer": "Insert into composer",
907
+ "planWorkbench.submitting": "Submitting…",
908
+ "planWorkbench.copyFeedback": "Copy feedback",
909
+ "planWorkbench.submitted": "Submitted; waiting for the revised plan",
910
+ "planWorkbench.drafted": "Put in the composer; review before sending",
911
+ "planWorkbench.copied": "Copied",
912
+ "planWorkbench.copyFailed": "Copy failed; select the text manually",
913
+ "planWorkbench.failed": "Submit failed",
914
+ "planWorkbench.unavailable": "Plan text unavailable (host plan page or session log missing). Open the original plan instead.",
915
+ "planWorkbench.untitled": "Untitled plan",
916
+ "planWorkbench.menuAnnotate": "Annotate",
917
+ "planWorkbench.menuCopy": "Copy plan",
918
+ "planWorkbench.feedbackIntro": "I annotated the plan “{title}”. Please revise the plan accordingly; if still in plan mode, call exit_plan_mode again with the revision and do not start executing yet.",
919
+ "planWorkbench.feedbackHead": "{n}. “{heading}”",
920
+ "planWorkbench.feedbackQuote": "Quote: {quote}",
921
+ "planWorkbench.feedbackComment": "Annotation: {comment}",
922
+ "planWorkbench.feedbackEdit": "I also rewrote the plan directly; treat this revision as the source of truth (and address the annotations above):",
923
+ "planWorkbench.feedbackOutro": "Keep everything else unchanged.",
696
924
  };
697
925
 
698
926
  /* ------------------------------------------------------------------ */
@@ -1622,22 +1850,12 @@ window.__ModuleLoader__.load({
1622
1850
  opens. The plan plugin re-resolves the markdown from the session log by
1623
1851
  tool-call id, so this address stays valid across reloads. */
1624
1852
  function openNativePlan(sessionId, callId) {
1625
- if (typeof sessionId !== "string" || sessionId === "" || typeof callId !== "string" || callId === "") return false;
1853
+ const address = planResourceAddress(sessionId, callId);
1854
+ if (address === undefined) return false;
1626
1855
  const sidebar = runtime && runtime.ctx ? runtime.ctx.sidebarRight : undefined;
1627
1856
  if (sidebar === undefined || typeof sidebar.openResource !== "function") return false;
1628
- let segments;
1629
- try {
1630
- const sessions = runtime.ctx.sessions;
1631
- const child = sessions !== undefined && typeof sessions.subagentAddress === "function" ? sessions.subagentAddress(sessionId) : undefined;
1632
- segments =
1633
- child === undefined
1634
- ? [sessionId, callId]
1635
- : ["subagent", child.parentSessionId, child.childSessionId, child.mode, callId];
1636
- } catch {
1637
- segments = [sessionId, callId]; /* uninjected ctx access throws; plain sessions are the common case */
1638
- }
1639
1857
  try {
1640
- sidebar.openResource(`dsh-resource://plan/${segments.map(encodeURIComponent).join("/")}`);
1858
+ sidebar.openResource(address);
1641
1859
  return true;
1642
1860
  } catch {
1643
1861
  return false;
@@ -1883,7 +2101,7 @@ window.__ModuleLoader__.load({
1883
2101
  }
1884
2102
 
1885
2103
  function createTurnBucket() {
1886
- return { artifacts: new Map(), refs: new Map(), refOrder: [], changes: [], steps: [], anchors: [], members: new Map(), teamTasks: new Map(), subagents: new Map(), stats: createStats(), conclusion: null, lastReply: null, ended: false };
2104
+ return { artifacts: new Map(), refs: new Map(), refOrder: [], changes: [], steps: [], anchors: [], members: new Map(), teamTasks: new Map(), subagents: new Map(), stats: createStats(), metrics: createTurnMetrics(), conclusion: null, lastReply: null, ended: false };
1887
2105
  }
1888
2106
 
1889
2107
  const EMPTY_BUCKET = Object.freeze({ artifacts: new Map(), refs: new Map(), refOrder: [], changes: [], steps: [], anchors: [], members: new Map(), teamTasks: new Map(), subagents: new Map(), stats: Object.freeze(createStats()) });
@@ -1897,6 +2115,315 @@ window.__ModuleLoader__.load({
1897
2115
  return bucket;
1898
2116
  }
1899
2117
 
2118
+ /* ------------------------------------------------------------------ */
2119
+ /* Per-turn session statistics (the 「更多」 panel) */
2120
+ /* ------------------------------------------------------------------ */
2121
+
2122
+ /** The readings behind the chat's session-statistics pill, folded per turn:
2123
+ closed steps, model/tool wall time, first-token latency, decode throughput
2124
+ and provider-reported token usage. The rules mirror the host's
2125
+ `sessionStats` / `tokenUsage` folds so both surfaces agree. */
2126
+ function createTurnMetrics() {
2127
+ return {
2128
+ steps: 0,
2129
+ llmMs: 0,
2130
+ toolMs: 0,
2131
+ ttftMs: 0,
2132
+ ttftSteps: 0,
2133
+ decodeMs: 0,
2134
+ decodeTokens: 0,
2135
+ /* the turn's first step/start and the first token after it: 「本轮首字延迟」 */
2136
+ firstStepStartTime: null,
2137
+ firstTokenMs: null,
2138
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, samples: 0 },
2139
+ routes: [],
2140
+ openStep: null,
2141
+ pendingCalls: new Map(),
2142
+ };
2143
+ }
2144
+
2145
+ /** Record the turn's very first output token once; later steps never overwrite it.
2146
+ The clock starts at the turn's first `step/start`, so the wait includes that
2147
+ step's request assembly, not the pause between turns. */
2148
+ function recordTurnFirstToken(metrics, tokenTime) {
2149
+ if (tokenTime === null || metrics.firstTokenMs !== null || metrics.firstStepStartTime === null) return;
2150
+ metrics.firstTokenMs = Math.max(0, tokenTime - metrics.firstStepStartTime);
2151
+ }
2152
+
2153
+ /** Whether one stream chunk carries the model's first output token (the host's
2154
+ `isTokenDelta`): non-empty text/reasoning delta, or a tool-call delta that
2155
+ already names its tool. */
2156
+ function isTokenDelta(chunk) {
2157
+ switch (chunk.type) {
2158
+ case "text-delta":
2159
+ case "reasoning-delta":
2160
+ return chunk.text !== "";
2161
+ case "tool-call-delta":
2162
+ return chunk.argumentsDelta !== "" || chunk.name !== undefined;
2163
+ default:
2164
+ return false;
2165
+ }
2166
+ }
2167
+
2168
+ /** Time of the first token in one compact durable stream, expanding packed
2169
+ delta runs through their `dt` gaps exactly like the host helper. */
2170
+ function streamFirstTokenTime(stream) {
2171
+ if (!Array.isArray(stream)) return null;
2172
+ for (const record of stream) {
2173
+ if (!isRecord(record)) continue;
2174
+ if (record.type === "chunk") {
2175
+ if (isRecord(record.chunk) && isTokenDelta(record.chunk) && typeof record.time === "number") return record.time;
2176
+ continue;
2177
+ }
2178
+ if (record.type === "text-chunks" || record.type === "reasoning-chunks") {
2179
+ const texts = Array.isArray(record.texts) ? record.texts : [];
2180
+ const dt = Array.isArray(record.dt) ? record.dt : [];
2181
+ let time = typeof record.time0 === "number" ? record.time0 : null;
2182
+ for (let index = 0; index < texts.length && time !== null; index += 1) {
2183
+ if (index > 0) time += typeof dt[index - 1] === "number" ? dt[index - 1] : 0;
2184
+ if (texts[index] !== "") return time;
2185
+ }
2186
+ continue;
2187
+ }
2188
+ if (record.type === "tool-call-chunks") {
2189
+ if (typeof record.time0 !== "number") continue;
2190
+ if (record.name !== undefined) return record.time0;
2191
+ const args = Array.isArray(record.args) ? record.args : [];
2192
+ const dt = Array.isArray(record.dt) ? record.dt : [];
2193
+ let time = record.time0;
2194
+ for (let index = 0; index < args.length; index += 1) {
2195
+ if (index > 0) time += typeof dt[index - 1] === "number" ? dt[index - 1] : 0;
2196
+ if (args[index] !== "") return time;
2197
+ }
2198
+ }
2199
+ }
2200
+ return null;
2201
+ }
2202
+
2203
+ /** The last raw chunk of one never-packed type in a compact stream. */
2204
+ function lastStreamChunk(stream, type) {
2205
+ if (!Array.isArray(stream)) return null;
2206
+ for (let index = stream.length - 1; index >= 0; index -= 1) {
2207
+ const record = stream[index];
2208
+ if (isRecord(record) && record.type === "chunk" && isRecord(record.chunk) && record.chunk.type === type) return record.chunk;
2209
+ }
2210
+ return null;
2211
+ }
2212
+
2213
+ /** A provider count, or null when the adapter did not report a usable value. */
2214
+ function usableCount(value) {
2215
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
2216
+ }
2217
+
2218
+ /** Fold one provider usage sample into a turn's token buckets. */
2219
+ function addTurnTokens(metrics, usage) {
2220
+ if (!isRecord(usage)) return;
2221
+ const input = usableCount(usage.inputTokens);
2222
+ const output = usableCount(usage.outputTokens);
2223
+ if (input === null || output === null) return;
2224
+ metrics.tokens.input += input;
2225
+ metrics.tokens.output += output;
2226
+ const cacheRead = usableCount(usage.cacheReadTokens);
2227
+ const cacheWrite = usableCount(usage.cacheWriteTokens);
2228
+ const reasoning = usableCount(usage.reasoningTokens);
2229
+ if (cacheRead !== null) metrics.tokens.cacheRead += cacheRead;
2230
+ if (cacheWrite !== null) metrics.tokens.cacheWrite += cacheWrite;
2231
+ if (reasoning !== null) metrics.tokens.reasoning += reasoning;
2232
+ metrics.tokens.samples += 1;
2233
+ }
2234
+
2235
+ /** Read one stream's trailing usage chunk, when the adapter embedded one. */
2236
+ function streamUsage(stream) {
2237
+ const chunk = lastStreamChunk(stream, "usage");
2238
+ return chunk !== null && isRecord(chunk.usage) ? chunk.usage : null;
2239
+ }
2240
+
2241
+ /** Fold the boundary events that carry per-turn session statistics. Runs beside
2242
+ the display fold: it only reads event data, never changes the message view. */
2243
+ function foldTurnMetrics(folder, event) {
2244
+ if (!isRecord(event) || !isRecord(event.data)) return;
2245
+ const data = event.data;
2246
+ const time = typeof event.time === "number" && Number.isFinite(event.time) ? event.time : null;
2247
+ const turnOf = () => {
2248
+ if (typeof data.turn === "number" && Number.isSafeInteger(data.turn) && data.turn >= 1) return data.turn;
2249
+ return folder.currentTurn >= 1 ? folder.currentTurn : null;
2250
+ };
2251
+ switch (event.type) {
2252
+ case "step/start": {
2253
+ const turn = turnOf();
2254
+ if (turn === null || time === null) return;
2255
+ const metrics = turnBucket(folder, turn).metrics;
2256
+ metrics.openStep = { turn, step: typeof data.step === "number" ? data.step : 0, startTime: time, firstTokenTime: null };
2257
+ if (metrics.firstStepStartTime === null) metrics.firstStepStartTime = time;
2258
+ touch(folder);
2259
+ return;
2260
+ }
2261
+ case "assistant/attempt": {
2262
+ const turn = turnOf();
2263
+ if (turn === null) return;
2264
+ const metrics = turnBucket(folder, turn).metrics;
2265
+ const open = metrics.openStep;
2266
+ if (open === null || open.turn !== turn || open.step !== data.step) return;
2267
+ if (open.firstTokenTime === null) open.firstTokenTime = streamFirstTokenTime(data.stream);
2268
+ recordTurnFirstToken(metrics, open.firstTokenTime);
2269
+ return;
2270
+ }
2271
+ case "assistant/message": {
2272
+ const turn = turnOf();
2273
+ if (turn === null) return;
2274
+ const metrics = turnBucket(folder, turn).metrics;
2275
+ const sample = isRecord(data.usage) ? data.usage : streamUsage(data.stream);
2276
+ addTurnTokens(metrics, sample);
2277
+ const body = isRecord(data.message) ? data.message : null;
2278
+ const source = body !== null && isRecord(body.source) ? body.source : null;
2279
+ if (source !== null && typeof source.provider === "string" && source.provider !== "" && typeof source.model === "string" && source.model !== "") {
2280
+ const route = `${source.provider}/${source.model}`;
2281
+ if (!metrics.routes.includes(route)) metrics.routes.push(route);
2282
+ }
2283
+ const open = metrics.openStep;
2284
+ if (open !== null && open.turn === turn && open.step === data.step && time !== null) {
2285
+ metrics.llmMs += Math.max(0, time - open.startTime);
2286
+ const firstToken = open.firstTokenTime !== null ? open.firstTokenTime : streamFirstTokenTime(data.stream);
2287
+ recordTurnFirstToken(metrics, firstToken);
2288
+ if (firstToken !== null) {
2289
+ metrics.ttftMs += Math.max(0, firstToken - open.startTime);
2290
+ metrics.ttftSteps += 1;
2291
+ const outputTokens = sample === null ? null : usableCount(sample.outputTokens);
2292
+ if (outputTokens !== null) {
2293
+ metrics.decodeMs += Math.max(0, time - firstToken);
2294
+ metrics.decodeTokens += outputTokens;
2295
+ }
2296
+ }
2297
+ metrics.openStep = null;
2298
+ }
2299
+ touch(folder);
2300
+ return;
2301
+ }
2302
+ case "tool/call": {
2303
+ const turn = turnOf();
2304
+ const callId = typeof data.callId === "string" && data.callId !== "" ? data.callId : null;
2305
+ if (turn === null || callId === null || time === null) return;
2306
+ turnBucket(folder, turn).metrics.pendingCalls.set(callId, time);
2307
+ return;
2308
+ }
2309
+ case "tool/result": {
2310
+ const turn = turnOf();
2311
+ const message = isRecord(data.message) ? data.message : null;
2312
+ const source = message !== null && isRecord(message.source) ? message.source : null;
2313
+ const callId =
2314
+ source !== null && typeof source.callId === "string" && source.callId !== ""
2315
+ ? source.callId
2316
+ : typeof data.callId === "string" && data.callId !== ""
2317
+ ? data.callId
2318
+ : null;
2319
+ if (turn === null || callId === null || time === null) return;
2320
+ const metrics = turnBucket(folder, turn).metrics;
2321
+ const dispatched = metrics.pendingCalls.get(callId);
2322
+ if (dispatched === undefined) return;
2323
+ metrics.pendingCalls.delete(callId);
2324
+ metrics.toolMs += Math.max(0, time - dispatched);
2325
+ return;
2326
+ }
2327
+ case "step/end": {
2328
+ const turn = turnOf();
2329
+ if (turn === null) return;
2330
+ const metrics = turnBucket(folder, turn).metrics;
2331
+ metrics.steps += 1;
2332
+ metrics.openStep = null;
2333
+ touch(folder);
2334
+ return;
2335
+ }
2336
+ case "turn/end": {
2337
+ const turn = turnOf();
2338
+ if (turn === null) return;
2339
+ const metrics = turnBucket(folder, turn).metrics;
2340
+ /* A call whose result never landed belongs to a cancelled turn. */
2341
+ metrics.pendingCalls.clear();
2342
+ metrics.openStep = null;
2343
+ return;
2344
+ }
2345
+ default:
2346
+ return;
2347
+ }
2348
+ }
2349
+
2350
+ /** Snapshot one turn's metrics for the 「更多」 panel. */
2351
+ function turnDetailOf(turn, metrics) {
2352
+ if (!isRecord(metrics)) return null;
2353
+ const tokens =
2354
+ metrics.tokens.samples > 0
2355
+ ? {
2356
+ input: metrics.tokens.input,
2357
+ output: metrics.tokens.output,
2358
+ cacheRead: metrics.tokens.cacheRead,
2359
+ cacheWrite: metrics.tokens.cacheWrite,
2360
+ reasoning: metrics.tokens.reasoning,
2361
+ total: metrics.tokens.input + metrics.tokens.output + metrics.tokens.cacheRead + metrics.tokens.cacheWrite,
2362
+ }
2363
+ : null;
2364
+ return {
2365
+ scope: "turn",
2366
+ turn,
2367
+ turns: 1,
2368
+ steps: metrics.steps,
2369
+ llmMs: metrics.llmMs,
2370
+ toolMs: metrics.toolMs,
2371
+ ttftMs: metrics.ttftMs,
2372
+ ttftSteps: metrics.ttftSteps,
2373
+ firstTokenMs: metrics.firstTokenMs,
2374
+ decodeMs: metrics.decodeMs,
2375
+ decodeTokens: metrics.decodeTokens,
2376
+ models: metrics.routes.slice(),
2377
+ tokens,
2378
+ };
2379
+ }
2380
+
2381
+ /** Sum every observed turn into one session-wide detail (the 「全部轮次」 scope).
2382
+ A turn counts once it has a closed step, matching the host's counter. */
2383
+ function aggregateTurnDetail(entries) {
2384
+ const totals = { scope: "all", turn: null, turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, firstTokenMs: null, decodeMs: 0, decodeTokens: 0, models: [], tokens: null };
2385
+ let input = 0;
2386
+ let output = 0;
2387
+ let cacheRead = 0;
2388
+ let cacheWrite = 0;
2389
+ let reasoning = 0;
2390
+ let samples = 0;
2391
+ const models = [];
2392
+ for (const metrics of entries) {
2393
+ if (!isRecord(metrics)) continue;
2394
+ totals.steps += metrics.steps;
2395
+ if (metrics.steps > 0) totals.turns += 1;
2396
+ totals.llmMs += metrics.llmMs;
2397
+ totals.toolMs += metrics.toolMs;
2398
+ totals.ttftMs += metrics.ttftMs;
2399
+ totals.ttftSteps += metrics.ttftSteps;
2400
+ totals.decodeMs += metrics.decodeMs;
2401
+ totals.decodeTokens += metrics.decodeTokens;
2402
+ input += metrics.tokens.input;
2403
+ output += metrics.tokens.output;
2404
+ cacheRead += metrics.tokens.cacheRead;
2405
+ cacheWrite += metrics.tokens.cacheWrite;
2406
+ reasoning += metrics.tokens.reasoning;
2407
+ samples += metrics.tokens.samples;
2408
+ for (const route of metrics.routes) if (!models.includes(route)) models.push(route);
2409
+ }
2410
+ totals.models = models;
2411
+ totals.tokens = samples > 0 ? { input, output, cacheRead, cacheWrite, reasoning, total: input + output + cacheRead + cacheWrite } : null;
2412
+ return totals;
2413
+ }
2414
+
2415
+ /** The 「更多」 detail for the current selection: one turn, or every loaded turn. */
2416
+ function turnDetailFor(folder, selection, latestTurn) {
2417
+ if (selection === "all") {
2418
+ const entries = [...folder.byTurn.entries()].filter(([turn, bucket]) => turn >= 1 && isRecord(bucket) && isRecord(bucket.metrics)).map(([, bucket]) => bucket.metrics);
2419
+ return entries.length > 0 ? aggregateTurnDetail(entries) : null;
2420
+ }
2421
+ const turn = selection.startsWith("turn:") ? Number.parseInt(selection.slice(5), 10) : latestTurn;
2422
+ if (typeof turn !== "number" || !Number.isSafeInteger(turn) || turn < 1) return null;
2423
+ const bucket = folder.byTurn.get(turn);
2424
+ return bucket === undefined || !isRecord(bucket.metrics) ? null : turnDetailOf(turn, bucket.metrics);
2425
+ }
2426
+
1900
2427
  /** Snapshot text for context-injection previews, keyed by session plus reference key. */
1901
2428
  const contextPreviews = new Map();
1902
2429
  const CONTEXT_PREVIEW_LIMIT = 400;
@@ -2416,6 +2943,86 @@ window.__ModuleLoader__.load({
2416
2943
  });
2417
2944
  }
2418
2945
 
2946
+ /** Stable identity of one step across renders (callless rows fall back to name|seq). */
2947
+ function stepIdentityOf(step) {
2948
+ if (!isRecord(step)) return "";
2949
+ if (typeof step.callId === "string" && step.callId !== "") return step.callId;
2950
+ return `${typeof step.name === "string" ? step.name : ""}|${typeof step.seq === "number" ? step.seq : ""}`;
2951
+ }
2952
+
2953
+ /** What the sliding step window gained and lost between two renders. */
2954
+ function stepWindowDelta(previous, current) {
2955
+ const before = Array.isArray(previous) ? previous.filter((step) => isRecord(step)) : [];
2956
+ const after = Array.isArray(current) ? current.filter((step) => isRecord(step)) : [];
2957
+ const beforeIds = new Set(before.map(stepIdentityOf));
2958
+ const afterIds = new Set(after.map(stepIdentityOf));
2959
+ return {
2960
+ added: after.filter((step) => !beforeIds.has(stepIdentityOf(step))),
2961
+ dropped: before.filter((step) => !afterIds.has(stepIdentityOf(step))),
2962
+ };
2963
+ }
2964
+
2965
+ /** Running keys that just finished successfully (their rows earn a completion pop). */
2966
+ function completedStepKeys(previousKeys, currentKeys, stepByKey) {
2967
+ const before = Array.isArray(previousKeys) ? previousKeys : [];
2968
+ const now = new Set(Array.isArray(currentKeys) ? currentKeys : []);
2969
+ const hits = [];
2970
+ for (const key of before) {
2971
+ if (now.has(key)) continue;
2972
+ const step = stepByKey !== null && typeof stepByKey.get === "function" ? stepByKey.get(key) : undefined;
2973
+ if (!isRecord(step) || step.done !== true || step.error === true) continue;
2974
+ hits.push(key);
2975
+ }
2976
+ return hits;
2977
+ }
2978
+
2979
+ /** The last step still running (approval children are rendered under their call). */
2980
+ function liveStepOf(steps) {
2981
+ const list = Array.isArray(steps) ? steps : [];
2982
+ for (let index = list.length - 1; index >= 0; index -= 1) {
2983
+ const step = list[index];
2984
+ if (!isRecord(step) || step.name === "approval") continue;
2985
+ if (step.done !== true && step.error !== true) return step;
2986
+ }
2987
+ return null;
2988
+ }
2989
+
2990
+ /** Whether a live cue makes sense for this scope: whole session / latest / current turn. */
2991
+ function liveScopeMatches(scope, latestTurn) {
2992
+ if (scope === "all" || scope === "latest") return true;
2993
+ if (typeof scope !== "string" || !scope.startsWith("turn:")) return false;
2994
+ const turn = Number.parseInt(scope.slice(5), 10);
2995
+ return Number.isFinite(turn) && Number.isFinite(latestTurn) && turn === latestTurn;
2996
+ }
2997
+
2998
+ /** Todo counters for the progress status row: the meter, its label and leftover hint
2999
+ must always describe the same snapshot, so they read from one place. */
3000
+ function summarizeTodos(todos) {
3001
+ const list = Array.isArray(todos) ? todos.filter((item) => isRecord(item)) : [];
3002
+ let completed = 0;
3003
+ let running = 0;
3004
+ for (const item of list) {
3005
+ if (item.status === "completed") completed += 1;
3006
+ else if (item.status === "in_progress") running += 1;
3007
+ }
3008
+ const total = list.length;
3009
+ const pending = total - completed - running;
3010
+ return { total, completed, running, pending, leftover: running + pending };
3011
+ }
3012
+
3013
+ /** Ticking clock for the live strip; zero when inactive or without a base time. */
3014
+ function useLiveElapsed(active, start) {
3015
+ const [now, setNow] = React.useState(() => (typeof Date === "function" ? Date.now() : 0));
3016
+ React.useEffect(() => {
3017
+ if (active !== true) return undefined;
3018
+ setNow(Date.now());
3019
+ const timer = setInterval(() => setNow(Date.now()), 1000);
3020
+ return () => clearInterval(timer);
3021
+ }, [active, start]);
3022
+ if (active !== true || typeof start !== "number" || !Number.isFinite(start) || start <= 0) return 0;
3023
+ return Math.max(0, now - start);
3024
+ }
3025
+
2419
3026
  /** One-line human target for a tool call: file, command, query, url, name… */
2420
3027
  /** Team-record key a tool call creates: teammate name / task subject / subagent label. */
2421
3028
  /** Word/bigram tokens of one todo or delegate text: latin words plus CJK bigrams, so
@@ -2630,6 +3237,9 @@ window.__ModuleLoader__.load({
2630
3237
  function foldEvent(folder, event, cwd) {
2631
3238
  if (!isRecord(event) || typeof event.type !== "string") return;
2632
3239
  const data = event.data;
3240
+ /* Session statistics fold in parallel: boundary events (step, stream, tool
3241
+ and usage records) never reach the message fold below. */
3242
+ foldTurnMetrics(folder, event);
2633
3243
  /* Tool work after a reply proves that reply was not the turn's closing word:
2634
3244
  the candidate stays provisional until a fresh reply replaces it. */
2635
3245
  if ((event.type === "tool/call" || event.type === "tool/result") && folder.currentTurn >= 1) {
@@ -3212,11 +3822,13 @@ window.__ModuleLoader__.load({
3212
3822
  scope,
3213
3823
  /* how many turns this scope spans: "all" covers the session, a turn view spans 1 */
3214
3824
  scopeTurns: source === folder ? turns.length : 1,
3825
+ /* the 「更多」 panel's per-turn (or per-session) session statistics */
3826
+ turnDetail: turnDetailFor(folder, scope, latestTurn),
3215
3827
  conclusion,
3216
3828
  };
3217
3829
  }
3218
3830
 
3219
- const EMPTY_STATE = { todos: null, todosTime: 0, goal: null, artifacts: [], references: [], steps: [], stepsAll: [], team: { members: [], tasks: [], subagents: [] }, stats: { durationMs: 0, toolCalls: 0, commands: 0, errors: 0, files: 0 }, turnStart: null, turnEnd: null, milestones: [], todoHistory: [], changeTurns: [], hasMore: false, localeRev: 0, turns: [], latestTurn: null, scope: "latest", scopeTurns: 1, conclusion: null };
3831
+ const EMPTY_STATE = { todos: null, todosTime: 0, goal: null, artifacts: [], references: [], steps: [], stepsAll: [], team: { members: [], tasks: [], subagents: [] }, stats: { durationMs: 0, toolCalls: 0, commands: 0, errors: 0, files: 0 }, turnStart: null, turnEnd: null, milestones: [], todoHistory: [], changeTurns: [], hasMore: false, localeRev: 0, turns: [], latestTurn: null, scope: "latest", scopeTurns: 1, turnDetail: null, conclusion: null };
3220
3832
 
3221
3833
  function createSummaryStore(binding) {
3222
3834
  const source = binding.eventSource;
@@ -3224,7 +3836,12 @@ window.__ModuleLoader__.load({
3224
3836
  const views = new Map();
3225
3837
  let folder = createFolder();
3226
3838
  folder.sessionId = typeof binding.sessionId === "string" ? binding.sessionId : null;
3227
- let lastWindow = { hasMore: false };
3839
+ /* The feed snapshot's `change` is a delta against the snapshot it was derived
3840
+ from, not against this folder: a store mounted mid-turn (or a missed publish)
3841
+ must not fold a delta into an empty/older window. `lastRevision` lets a single
3842
+ skipped publish fall back to a full rebuild instead of appending out of order. */
3843
+ let lastWindow = null;
3844
+ let lastRevision = null;
3228
3845
  let localeRev = 0;
3229
3846
 
3230
3847
  const store = {
@@ -3261,6 +3878,7 @@ window.__ModuleLoader__.load({
3261
3878
  for (const entry of windowSnapshot.entries) foldEntry(folder, entry, cwd);
3262
3879
  folder.changed = false;
3263
3880
  lastWindow = windowSnapshot;
3881
+ lastRevision = typeof windowSnapshot.revision === "number" ? windowSnapshot.revision : null;
3264
3882
  views.clear();
3265
3883
  notify();
3266
3884
  };
@@ -3269,16 +3887,37 @@ window.__ModuleLoader__.load({
3269
3887
  const windowSnapshot = source.getSnapshot();
3270
3888
  if (windowSnapshot === lastWindow) return;
3271
3889
  const previous = lastWindow;
3272
- const change = windowSnapshot.change;
3273
- const appended = previous !== null && isRecord(change) && change.kind === "append" && Array.isArray(change.entries) ? change.entries : null;
3274
- if (appended !== null) {
3890
+ const change = isRecord(windowSnapshot.change) ? windowSnapshot.change : null;
3891
+ const revision = typeof windowSnapshot.revision === "number" ? windowSnapshot.revision : null;
3892
+ /* A delta is only usable when this store consumed the snapshot it was derived
3893
+ from: first read, replace, or a revision gap means rebuild from entries. */
3894
+ const contiguous =
3895
+ previous !== null &&
3896
+ previous.entries !== undefined &&
3897
+ change !== null &&
3898
+ change.kind !== "replace" &&
3899
+ (revision === null || lastRevision === null || revision === lastRevision + 1);
3900
+ if (contiguous === true && change.kind === "append") {
3901
+ const appended = Array.isArray(change.entries) ? change.entries : [];
3275
3902
  // Live streaming rows carry nothing durable; skip them without a rebuild.
3276
- if (appended.every((entry) => isRecord(entry) && entry.type === "transient")) return;
3277
- if (appended.every((entry) => isRecord(entry) && entry.type !== "transient" && isRecord(entry.event))) {
3903
+ if (appended.length > 0 && appended.every((entry) => isRecord(entry) && entry.type === "transient")) {
3278
3904
  lastWindow = windowSnapshot;
3905
+ if (revision !== null) lastRevision = revision;
3906
+ return;
3907
+ }
3908
+ if (appended.length > 0 && appended.every((entry) => isRecord(entry) && entry.type !== "transient" && isRecord(entry.event))) {
3279
3909
  const cwd = sessionCwd(binding.sessionId);
3280
- for (const entry of appended) foldEntry(folder, entry, cwd);
3281
- if (folder.changed || windowSnapshot.hasMore !== (previous.hasMore === true)) {
3910
+ try {
3911
+ for (const entry of appended) foldEntry(folder, entry, cwd);
3912
+ } catch {
3913
+ /* A half-folded folder cannot be trusted; rebuild from the snapshot. */
3914
+ fullRebuild(windowSnapshot);
3915
+ return;
3916
+ }
3917
+ const hasMoreChanged = (previous.hasMore === true) !== (windowSnapshot.hasMore === true);
3918
+ lastWindow = windowSnapshot;
3919
+ if (revision !== null) lastRevision = revision;
3920
+ if (folder.changed || hasMoreChanged) {
3282
3921
  folder.changed = false;
3283
3922
  views.clear();
3284
3923
  notify();
@@ -3286,10 +3925,6 @@ window.__ModuleLoader__.load({
3286
3925
  return;
3287
3926
  }
3288
3927
  }
3289
- if (windowSnapshot.entries.length === 0 && previous !== null && previous.entries !== undefined && previous.entries.length === 0) {
3290
- lastWindow = windowSnapshot;
3291
- return;
3292
- }
3293
3928
  fullRebuild(windowSnapshot);
3294
3929
  };
3295
3930
 
@@ -4541,7 +5176,7 @@ function FileTypeIcon(props) {
4541
5176
  return { introAttr: introSeq % 2 === 0 ? "even" : "odd", introDelay: delayMs };
4542
5177
  }
4543
5178
 
4544
- function Section({ id, icon, title, badge, empty, action, children, introAttr, introDelay }) {
5179
+ function Section({ id, icon, title, badge, empty, action, children, introAttr, introDelay, live, bodyMode }) {
4545
5180
  const [open, setOpen] = React.useState(() => collapsedSections.get(id) !== true);
4546
5181
  const cfg = useConfig();
4547
5182
  const kids = Array.isArray(children) ? children.filter((child) => child !== null && child !== undefined && child !== false) : children ? [children] : [];
@@ -4555,6 +5190,8 @@ function FileTypeIcon(props) {
4555
5190
  {
4556
5191
  className: "dsov-section",
4557
5192
  "data-full": cfg.sectionFullHeight === true ? "true" : "false",
5193
+ "data-live": live === true ? "true" : undefined,
5194
+ "data-body": bodyMode,
4558
5195
  "data-intro": introAttr,
4559
5196
  style: typeof introDelay === "number" ? { animationDelay: `${introDelay}ms` } : undefined,
4560
5197
  },
@@ -4603,7 +5240,7 @@ function FileTypeIcon(props) {
4603
5240
  complete: "success",
4604
5241
  };
4605
5242
 
4606
- function ProgressSection({ summary, goal, t, waiting, cfg, asOf, introSeq }) {
5243
+ function ProgressSection({ summary, goal, t, waiting, running, cfg, asOf, introSeq }) {
4607
5244
  const fmtTime = (ts) => formatTime(ts, cfg.timeMode, summary.turnStart);
4608
5245
  const timeTitle = (ts) => (cfg.timeMode === "elapsed" ? formatTime(ts) : undefined);
4609
5246
  /* timeline scrubbing: only what already happened at `asOf` is shown */
@@ -4666,6 +5303,24 @@ function FileTypeIcon(props) {
4666
5303
  /* steps: newest-first window; keep the evicted bottom row as a fading ghost */
4667
5304
  const stepsRaw = Array.isArray(summary.steps) ? summary.steps.filter((step) => isRecord(step)) : [];
4668
5305
  const stepsAllRaw = Array.isArray(summary.stepsAll) ? summary.stepsAll.filter((step) => isRecord(step)) : [];
5306
+ /* Live cues only describe work happening now: never while scrubbing the
5307
+ timeline, viewing a historical session, or looking at an older turn. */
5308
+ const liveRunning = running === true && asOfTime === null && liveScopeMatches(summary.scope, summary.latestTurn);
5309
+ const liveWaiting = waiting === true && liveScopeMatches(summary.scope, summary.latestTurn);
5310
+ const liveVisible = liveRunning || liveWaiting;
5311
+ const liveState = liveWaiting ? "waiting" : "running";
5312
+ /* clock base: the running step's own start, else the latest turn's start; the
5313
+ session-wide scope has no single turn, so it only clocks a running step */
5314
+ const liveClockStep = liveRunning ? liveStepOf(stepsRaw) : null;
5315
+ const liveClockBase =
5316
+ liveClockStep !== null && typeof liveClockStep.time === "number" && liveClockStep.time > 0
5317
+ ? liveClockStep.time
5318
+ : summary.scope === "all"
5319
+ ? null
5320
+ : typeof summary.turnStart === "number" && summary.turnStart > 0
5321
+ ? summary.turnStart
5322
+ : null;
5323
+ const liveElapsed = useLiveElapsed(liveRunning && liveClockBase !== null, liveClockBase);
4669
5324
  const stepAt = (step) => asOfTime === null || (typeof step.time === "number" ? step.time <= asOfTime : true);
4670
5325
  /* a step whose result landed after the scrub point reads as still running */
4671
5326
  const stepClamped = (step) => {
@@ -4690,7 +5345,10 @@ function FileTypeIcon(props) {
4690
5345
  })();
4691
5346
  const [ghost, setGhost] = React.useState(null);
4692
5347
  const [stepsExpanded, setStepsExpanded] = React.useState(false);
5348
+ const [listTick, setListTick] = React.useState(0);
5349
+ const [doneHits, setDoneHits] = React.useState(null);
4693
5350
  const ghostTimer = React.useRef(undefined);
5351
+ const hitTimer = React.useRef(undefined);
4694
5352
  const prevSteps = React.useRef([]);
4695
5353
  const prevScope = React.useRef(summary.scope);
4696
5354
  /* enter animation only for rows that arrive while mounted; first paint per scope is silent */
@@ -4756,9 +5414,12 @@ function FileTypeIcon(props) {
4756
5414
  if (step.done !== true && step.error !== true) runningKeys.push(key);
4757
5415
  });
4758
5416
  const runningSig = runningKeys.join("|");
5417
+ const liveStep = liveVisible ? liveStepOf(renderSteps) : null;
4759
5418
  const prevRunningKeys = React.useRef(new Set());
4760
5419
  React.useEffect(() => {
4761
5420
  const now = new Set(runningKeys);
5421
+ const wasRunning = [...prevRunningKeys.current];
5422
+ const finished = completedStepKeys(wasRunning, [...now], stepByKey);
4762
5423
  for (const key of prevRunningKeys.current) {
4763
5424
  if (now.has(key)) continue;
4764
5425
  const step = stepByKey.get(key);
@@ -4766,6 +5427,17 @@ function FileTypeIcon(props) {
4766
5427
  if (overrideOf(`step:${key}`, false)) toggleKey(`step:${key}`, false);
4767
5428
  }
4768
5429
  prevRunningKeys.current = now;
5430
+ if (finished.length === 0) return;
5431
+ setDoneHits((current) => {
5432
+ const next = { ...(current !== null && isRecord(current) ? current : {}) };
5433
+ for (const key of finished) next[key] = true;
5434
+ return next;
5435
+ });
5436
+ if (hitTimer.current !== undefined) clearTimeout(hitTimer.current);
5437
+ hitTimer.current = setTimeout(() => {
5438
+ hitTimer.current = undefined;
5439
+ setDoneHits(null);
5440
+ }, 800);
4769
5441
  }, [runningSig]);
4770
5442
  if (seenSteps.current === null || seenSteps.current.scope !== summary.scope) {
4771
5443
  seenSteps.current = { scope: summary.scope, keys: new Set(renderSteps.map(stepKeyOf)) };
@@ -4773,6 +5445,7 @@ function FileTypeIcon(props) {
4773
5445
  React.useEffect(
4774
5446
  () => () => {
4775
5447
  if (ghostTimer.current !== undefined) clearTimeout(ghostTimer.current);
5448
+ if (hitTimer.current !== undefined) clearTimeout(hitTimer.current);
4776
5449
  },
4777
5450
  []
4778
5451
  );
@@ -4784,15 +5457,17 @@ function FileTypeIcon(props) {
4784
5457
  return;
4785
5458
  }
4786
5459
  if (asOfTime !== null || previous.length === 0) return;
4787
- const currentIds = new Set(steps.map((step) => step.callId));
4788
- const dropped = previous.filter((step) => typeof step.callId === "string" && !currentIds.has(step.callId));
4789
- if (dropped.length === 0) return;
4790
- setGhost(dropped[dropped.length - 1]);
4791
- if (ghostTimer.current !== undefined) clearTimeout(ghostTimer.current);
4792
- ghostTimer.current = setTimeout(() => {
4793
- ghostTimer.current = undefined;
4794
- setGhost(null);
4795
- }, 400);
5460
+ const delta = stepWindowDelta(previous, steps);
5461
+ if (delta.dropped.length > 0) {
5462
+ setGhost(delta.dropped[delta.dropped.length - 1]);
5463
+ if (ghostTimer.current !== undefined) clearTimeout(ghostTimer.current);
5464
+ ghostTimer.current = setTimeout(() => {
5465
+ ghostTimer.current = undefined;
5466
+ setGhost(null);
5467
+ }, 520);
5468
+ }
5469
+ /* alternate two identical keyframes so the conveyor nudge replays on every slide */
5470
+ if (delta.added.length > 0 || delta.dropped.length > 0) setListTick((value) => value + 1);
4796
5471
  }, [steps]);
4797
5472
  /* team data + todos drive both the merged list and the step nesting */
4798
5473
  const team = isRecord(summary.team) ? summary.team : null;
@@ -5091,6 +5766,16 @@ function FileTypeIcon(props) {
5091
5766
  const attached = stepAttachments.get(key);
5092
5767
  const approvals = approvalChildren.get(key);
5093
5768
  const dying = extraProps !== undefined && extraProps["data-exit"] === "true";
5769
+ const activeRow =
5770
+ dying !== true &&
5771
+ liveRunning === true &&
5772
+ liveState !== "waiting" &&
5773
+ stepRunning === true &&
5774
+ failed !== true &&
5775
+ waitingRow !== true &&
5776
+ liveStep !== null &&
5777
+ stepIdentityOf(step) === stepIdentityOf(liveStep);
5778
+ const hitRow = dying !== true && doneHits !== null && isRecord(doneHits) && doneHits[key] === true;
5094
5779
  const body = dying ? null : stepBodyOf(step);
5095
5780
  const gated = !dying && Array.isArray(approvals) && approvals.length > 0;
5096
5781
  const pendingApproval = gated && approvals.some((child) => approvalToneOf(child) === "warn");
@@ -5169,6 +5854,8 @@ function FileTypeIcon(props) {
5169
5854
  key,
5170
5855
  "data-error": failed ? "true" : "false",
5171
5856
  "data-waiting": waitingRow ? "true" : "false",
5857
+ "data-active": activeRow ? "true" : undefined,
5858
+ "data-hit": hitRow ? "true" : undefined,
5172
5859
  "data-flash": flash === "ok" ? "ok" : flash === "err" ? "err" : undefined,
5173
5860
  title: full === "" ? undefined : waitingRow ? `${t("progress.waiting")} · ${full}` : full,
5174
5861
  ...(extraProps === undefined ? {} : extraProps),
@@ -5273,7 +5960,119 @@ function FileTypeIcon(props) {
5273
5960
  if (Array.isArray(rendered)) target.push(...rendered);
5274
5961
  else target.push(rendered);
5275
5962
  };
5276
- const kids = [];
5963
+ /* Live HUD: one persistent status row above the goal/todos/steps so a long turn
5964
+ stays visibly alive between durable events (a step's own row can be off-window). */
5965
+ const liveName =
5966
+ liveStep === null
5967
+ ? ""
5968
+ : liveStep.name === "compact-checkpoint"
5969
+ ? t("step.compact")
5970
+ : liveStep.name === "approval"
5971
+ ? t("step.approval")
5972
+ : typeof liveStep.name === "string"
5973
+ ? liveStep.name
5974
+ : "";
5975
+ const liveDetail =
5976
+ liveStep !== null && typeof liveStep.detail === "string" && liveStep.detail !== "" ? liveStep.detail : "";
5977
+ const liveFull =
5978
+ liveStep !== null && typeof liveStep.full === "string" && liveStep.full !== "" ? liveStep.full : liveDetail;
5979
+ const liveNowText =
5980
+ liveState === "waiting"
5981
+ ? [liveName, liveDetail].filter((part) => part !== "").join(" · ")
5982
+ : liveStep !== null
5983
+ ? [liveName, liveDetail].filter((part) => part !== "").join(" · ")
5984
+ : t("progress.liveThinking");
5985
+ /* The status row is the section's only chrome: live while the turn runs or
5986
+ waits, and a neutral summary once it ends — the todo counter lives inside it
5987
+ (meter) instead of a second, differently styled progress bar. */
5988
+ const todoSummary = hasTodos ? summarizeTodos(todos) : null;
5989
+ const statusLive = liveVisible === true;
5990
+ const statusState = statusLive ? liveState : "summary";
5991
+ const meterText =
5992
+ todoSummary === null
5993
+ ? ""
5994
+ : t("progress.meterTitle", {
5995
+ completed: todoSummary.completed,
5996
+ total: todoSummary.total,
5997
+ running: todoSummary.running,
5998
+ pending: todoSummary.pending,
5999
+ });
6000
+ const summaryLeftover =
6001
+ todoSummary !== null && todoSummary.leftover > 0
6002
+ ? t("progress.leftover", { running: todoSummary.running, pending: todoSummary.pending })
6003
+ : "";
6004
+ const statusTitle =
6005
+ todoSummary !== null
6006
+ ? summaryLeftover !== "" && !statusLive
6007
+ ? `${meterText} · ${t("progress.staleHint")}`
6008
+ : meterText
6009
+ : liveFull === ""
6010
+ ? undefined
6011
+ : liveFull;
6012
+ const statusLabel = statusLive
6013
+ ? liveState === "waiting"
6014
+ ? t("progress.waiting")
6015
+ : t("progress.live")
6016
+ : todoSummary !== null
6017
+ ? t("progress.counts", { completed: todoSummary.completed, total: todoSummary.total })
6018
+ : "";
6019
+ const statusNow = statusLive ? liveNowText : summaryLeftover;
6020
+ const meterPercent =
6021
+ todoSummary === null || todoSummary.total === 0 ? 0 : Math.round((todoSummary.completed / todoSummary.total) * 100);
6022
+ const liveMeter =
6023
+ todoSummary !== null
6024
+ ? h(
6025
+ "span",
6026
+ {
6027
+ className: "dsov-liveMeter",
6028
+ role: "progressbar",
6029
+ "aria-valuemin": 0,
6030
+ "aria-valuemax": todoSummary.total,
6031
+ "aria-valuenow": todoSummary.completed,
6032
+ "aria-valuetext": meterText,
6033
+ },
6034
+ h("span", { className: "dsov-liveMeterTrack" }, h("span", { className: "dsov-liveMeterFill", style: { width: `${meterPercent}%` } })),
6035
+ statusLive
6036
+ ? h("span", { className: "dsov-liveMeterCount", "aria-hidden": "true" }, `${todoSummary.completed}/${todoSummary.total}`)
6037
+ : null
6038
+ )
6039
+ : null;
6040
+ const liveStrip =
6041
+ statusLive || todoSummary !== null
6042
+ ? h(
6043
+ "div",
6044
+ {
6045
+ className: "dsov-live",
6046
+ key: "live",
6047
+ "data-state": statusState,
6048
+ "data-complete": todoSummary !== null && todoSummary.leftover === 0 ? "true" : undefined,
6049
+ role: statusLive ? "status" : undefined,
6050
+ title: statusTitle,
6051
+ },
6052
+ h("span", { className: "dsov-livePulse", "aria-hidden": "true" }, h("span", { className: "dsov-liveDot" })),
6053
+ h(
6054
+ "span",
6055
+ { className: "dsov-liveText", "aria-live": statusLive ? "polite" : undefined },
6056
+ h("span", { className: "dsov-liveLabel" }, statusLabel),
6057
+ statusNow !== "" ? h("span", { className: "dsov-liveNow" }, statusNow) : null
6058
+ ),
6059
+ liveMeter !== null
6060
+ ? liveMeter
6061
+ : h(
6062
+ "span",
6063
+ { className: "dsov-liveBars", "aria-hidden": "true" },
6064
+ [0, 1, 2, 3, 4].map((index) => h("i", { key: `bar-${index}` }))
6065
+ ),
6066
+ statusLive && liveElapsed > 0
6067
+ ? h(
6068
+ "span",
6069
+ { className: "dsov-liveClock", title: t("progress.liveElapsedTitle"), "aria-hidden": "true" },
6070
+ t("progress.liveElapsed", { v: formatElapsed(liveElapsed) })
6071
+ )
6072
+ : null
6073
+ )
6074
+ : null;
6075
+ const kids = liveStrip !== null ? [liveStrip] : [];
5277
6076
  if (goal) {
5278
6077
  /* A session goal is a mode, not a todo: rounds are re-driven automatically.
5279
6078
  The banner says so, carries the phase and the round budget, and names the
@@ -5310,16 +6109,6 @@ function FileTypeIcon(props) {
5310
6109
  );
5311
6110
  }
5312
6111
  if (hasTodos) {
5313
- const total = todos.length;
5314
- const done = todos.filter((item) => item.status === "completed").length;
5315
- kids.push(
5316
- h(
5317
- "div",
5318
- { className: "dsov-progress", key: "bar" },
5319
- h("div", { className: "dsov-progressTrack" }, h("div", { className: "dsov-progressFill", style: { width: `${Math.round((done / total) * 100)}%` } })),
5320
- h("div", { className: "dsov-progressMeta" }, t("progress.counts", { completed: done, total }))
5321
- )
5322
- );
5323
6112
  const todoRows = [];
5324
6113
  todos.forEach((todo, index) => {
5325
6114
  const status = TODO_KEYS[todo.status] ? todo.status : "pending";
@@ -5449,31 +6238,51 @@ function FileTypeIcon(props) {
5449
6238
  pushStepRow(rows, ghost, `ghost-${ghost.callId}`, { "data-exit": "true" }, false);
5450
6239
  }
5451
6240
  if (hasTeam) pushFlatTeam(rows);
6241
+ const stepsHeadText = t(stepsOpen ? "progress.allSteps" : "progress.recentSteps");
6242
+ const stepsToggle = (suffix) =>
6243
+ canExpand
6244
+ ? h(
6245
+ "button",
6246
+ {
6247
+ type: "button",
6248
+ key: `stepsToggle-${suffix}`,
6249
+ className: "dsov-moreSteps",
6250
+ title: stepsOpen ? t("progress.hideEarlierSteps") : t("progress.moreStepsCount", { n: expandedSteps.length - steps.length }),
6251
+ "aria-expanded": stepsOpen ? "true" : "false",
6252
+ onClick: () => setStepsExpanded(!stepsOpen),
6253
+ },
6254
+ t(stepsOpen ? "progress.hideEarlierSteps" : "progress.showEarlierSteps"),
6255
+ h(IconHistory, { size: 12, className: "dsov-moreIcon" })
6256
+ )
6257
+ : null;
5452
6258
  kids.push(
5453
6259
  h(
5454
6260
  "div",
5455
6261
  { className: "dsov-stepsHead", key: "stepsHead" },
5456
- h("span", null, t(stepsOpen ? "progress.allSteps" : "progress.recentSteps")),
5457
- canExpand
5458
- ? h(
5459
- "button",
5460
- {
5461
- type: "button",
5462
- className: "dsov-moreSteps",
5463
- title: stepsOpen ? t("progress.hideEarlierSteps") : t("progress.moreStepsCount", { n: expandedSteps.length - steps.length }),
5464
- onClick: () => setStepsExpanded(!stepsOpen),
5465
- },
5466
- t(stepsOpen ? "progress.hideEarlierSteps" : "progress.showEarlierSteps"),
5467
- h(IconHistory, { size: 12, className: "dsov-moreIcon" })
5468
- )
5469
- : null
6262
+ h("span", { className: "dsov-stepsHeadLabel", title: stepsHeadText }, stepsHeadText),
6263
+ stepsToggle("head")
5470
6264
  )
5471
6265
  );
5472
- kids.push(h("div", { className: "dsov-steps", key: "steps" }, rows));
6266
+ kids.push(h("div", { className: "dsov-steps", key: "steps", "data-nudge": listTick === 0 ? undefined : listTick % 2 === 0 ? "even" : "odd" }, rows));
6267
+ /* An expanded list is long enough to scroll away from its own header control,
6268
+ so the collapse action is repeated where the reading ends. */
6269
+ if (stepsOpen && canExpand) kids.push(h("div", { className: "dsov-stepsFoot", key: "stepsFoot" }, stepsToggle("foot")));
5473
6270
  }
5474
6271
  return h(
5475
6272
  Section,
5476
- { id: "progress", ...sectionIntroProps(introSeq, 0), icon: h(IconProgress, { size: 15 }), title: t("section.progress"), empty: t(summary.scope === "all" ? "progress.empty" : "progress.emptyTurn") },
6273
+ {
6274
+ id: "progress",
6275
+ ...sectionIntroProps(introSeq, 0),
6276
+ icon: h(IconProgress, { size: 15 }),
6277
+ title: t("section.progress"),
6278
+ empty: t(summary.scope === "all" ? "progress.empty" : "progress.emptyTurn"),
6279
+ live: liveVisible,
6280
+ /* the collapsed list never overflows (six-step window / full todo list), so the
6281
+ body keeps its natural height and can never flash a scrollbar; the expanded
6282
+ list is pinned to one height so arriving steps scroll inside it instead of
6283
+ resizing the section */
6284
+ bodyMode: stepsOpen ? "fixed" : "auto",
6285
+ },
5477
6286
  kids
5478
6287
  );
5479
6288
  }
@@ -5488,9 +6297,13 @@ function FileTypeIcon(props) {
5488
6297
  const cwd = sessionCwd(sessionId);
5489
6298
  const canFileMenu = hostSessionRemote() !== null;
5490
6299
  const [fileMenu, setFileMenu] = React.useState(null);
6300
+ const [planMenu, setPlanMenu] = React.useState(null);
5491
6301
  React.useEffect(() => {
5492
- if (fileMenu === null) return undefined;
5493
- const close = () => setFileMenu(null);
6302
+ if (fileMenu === null && planMenu === null) return undefined;
6303
+ const close = () => {
6304
+ setFileMenu(null);
6305
+ setPlanMenu(null);
6306
+ };
5494
6307
  const onKey = (event) => {
5495
6308
  if (event.key === "Escape") close();
5496
6309
  };
@@ -5504,7 +6317,7 @@ function FileTypeIcon(props) {
5504
6317
  window.removeEventListener("scroll", close, true);
5505
6318
  window.removeEventListener("resize", close);
5506
6319
  };
5507
- }, [fileMenu]);
6320
+ }, [fileMenu, planMenu]);
5508
6321
  const turns = summary.changeTurns;
5509
6322
  const latest = turns.length > 0 ? turns[turns.length - 1] : null;
5510
6323
  const action =
@@ -5523,22 +6336,20 @@ function FileTypeIcon(props) {
5523
6336
  const isConclusion = artifact.kind === "conclusion";
5524
6337
  const isPlan = artifact.plan === true;
5525
6338
  const planName = isPlan ? (typeof artifact.planTitle === "string" && artifact.planTitle !== "" ? artifact.planTitle : t("artifact.plan")) : "";
5526
- return h(
5527
- "button",
5528
- {
5529
- type: "button",
5530
- className: "dsov-row",
5531
- key: `${artifact.path}#${index}`,
5532
- title: isConclusion ? `${artifact.title} · ${t("artifact.conclusionHint")}` : isPlan ? planName : artifact.description ? `${artifact.path} — ${artifact.description}` : artifact.path,
5533
- onClick: () => openArtifact(binding, artifact, stats),
5534
- onContextMenu:
5535
- canFileMenu && !isMemory && !isPlan && !isConclusion
5536
- ? (event) => {
5537
- event.preventDefault();
5538
- setFileMenu({ x: event.clientX, y: event.clientY, path: absPath(cwd, artifact.path) });
5539
- }
5540
- : undefined,
5541
- },
6339
+ const buttonProps = {
6340
+ type: "button",
6341
+ className: "dsov-row",
6342
+ title: isConclusion ? `${artifact.title} · ${t("artifact.conclusionHint")}` : isPlan ? planName : artifact.description ? `${artifact.path} — ${artifact.description}` : artifact.path,
6343
+ onClick: () => openArtifact(binding, artifact, stats),
6344
+ onContextMenu:
6345
+ canFileMenu && !isMemory && !isPlan && !isConclusion
6346
+ ? (event) => {
6347
+ event.preventDefault();
6348
+ setFileMenu({ x: event.clientX, y: event.clientY, path: absPath(cwd, artifact.path) });
6349
+ }
6350
+ : undefined,
6351
+ };
6352
+ const children = [
5542
6353
  h(
5543
6354
  "span",
5544
6355
  { className: "dsov-fileIcon" },
@@ -5564,10 +6375,39 @@ function FileTypeIcon(props) {
5564
6375
  isPlan ? h("span", { className: "dsov-badge", "data-tone": "plan" }, t("artifact.plan")) : null,
5565
6376
  isConclusion ? h("span", { className: "dsov-badge", "data-tone": "brand" }, t("artifact.conclusion")) : null,
5566
6377
  isConclusion && typeof artifact.chars === "number" ? h("span", { className: "dsov-badge", "data-tone": "quiet" }, t("artifact.conclusionChars", { n: artifact.chars })) : null,
5567
- h("span", { className: "dsov-rowTail" }, h("span", { className: "dsov-time", title: timeTitle(artifact.time) }, fmtTime(artifact.time)))
6378
+ h("span", { className: "dsov-rowTail" }, h("span", { className: "dsov-time", title: timeTitle(artifact.time) }, fmtTime(artifact.time))),
6379
+ ];
6380
+ const rowKey = `${artifact.path}#${index}`;
6381
+ if (!isPlan) return h("button", { ...buttonProps, key: rowKey }, ...children);
6382
+ /* Plan rows keep their click (the host plan page) and gain a hover entry:
6383
+ the workbench needs no new surface to reach already-approved plans. */
6384
+ const hasPlanIdentity =
6385
+ (typeof artifact.planCallId === "string" && artifact.planCallId !== "") || (typeof artifact.previewKey === "string" && artifact.previewKey !== "");
6386
+ return h(
6387
+ "div",
6388
+ { className: "dsov-planRow", key: rowKey },
6389
+ h("button", buttonProps, ...children),
6390
+ hasPlanIdentity
6391
+ ? h(
6392
+ "button",
6393
+ {
6394
+ type: "button",
6395
+ className: "dsov-planRowAction",
6396
+ title: t("planWorkbench.open"),
6397
+ "aria-label": t("planWorkbench.open"),
6398
+ onClick: (event) => {
6399
+ event.preventDefault();
6400
+ event.stopPropagation();
6401
+ setPlanMenu({ x: event.clientX, y: event.clientY, artifact, sessionId });
6402
+ },
6403
+ },
6404
+ h(IconPlan, { size: 14 })
6405
+ )
6406
+ : null
5568
6407
  );
5569
6408
  });
5570
6409
  if (fileMenu !== null) rows.push(h(ArtifactFileMenu, { key: "dsov-file-menu", menu: fileMenu, t, onClose: () => setFileMenu(null) }));
6410
+ if (planMenu !== null) rows.push(h(PlanRowMenu, { key: "dsov-plan-menu", menu: planMenu, t, onClose: () => setPlanMenu(null) }));
5571
6411
  return h(
5572
6412
  Section,
5573
6413
  {
@@ -6001,33 +6841,139 @@ function FileTypeIcon(props) {
6001
6841
  return `${seconds}s`;
6002
6842
  }
6003
6843
 
6004
- /** Left-hand stat pills on the scope row: duration, tool calls, commands, files, failures. */
6005
- function StatsBar({ stats, t, canTimeline, timelineOpen, scrubbing, onToggleTimeline, prevJump, onPrevTurn, failedSteps }) {
6006
- /* failure details popover: hooks must run before any early return */
6007
- const failed = Array.isArray(failedSteps) ? failedSteps.filter((step) => isRecord(step)) : [];
6008
- /* fixed-position popover: the toolbar wrappers clip absolutely positioned
6009
- children (overflow hidden/auto), so anchor to the viewport instead */
6010
- const [errPop, setErrPop] = React.useState(null);
6011
- const errOpen = isRecord(errPop);
6012
- const errRef = React.useRef(null);
6013
- const errBtnRef = React.useRef(null);
6014
- /* Place the fixed popover under its pill, clamped into the viewport. */
6015
- const placePopover = React.useCallback(() => {
6016
- const button = errBtnRef.current;
6844
+ /** Sub-minute durations keep one decimal so first-token latency stays readable. */
6845
+ function formatDurationPrecise(ms) {
6846
+ const seconds = Math.max(0, (Number.isFinite(ms) ? ms : 0) / 1000);
6847
+ if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`;
6848
+ const whole = Math.round(seconds);
6849
+ return `${Math.floor(whole / 60)}m ${String(whole % 60).padStart(2, "0")}s`;
6850
+ }
6851
+
6852
+ /** Compact token count, matching the host pills: 517 / 12.2K / 1.2M. */
6853
+ function formatTokensCompact(value) {
6854
+ const count = Math.max(0, Math.round(Number.isFinite(value) ? value : 0));
6855
+ const scaled = (candidate) => (candidate >= 100 ? String(Math.round(candidate)) : String(Math.round(candidate * 10) / 10));
6856
+ if (count < 1e3) return String(count);
6857
+ if (count < 1e6) return `${scaled(count / 1e3)}K`;
6858
+ return `${scaled(count / 1e6)}M`;
6859
+ }
6860
+
6861
+ /** Exact token count with thousands separators for the breakdown rows. */
6862
+ function formatTokenGroups(value) {
6863
+ const digits = String(Math.max(0, Math.round(Number.isFinite(value) ? value : 0)));
6864
+ const groups = [];
6865
+ for (let end = digits.length; end > 0; end -= 3) groups.unshift(digits.slice(Math.max(0, end - 3), end));
6866
+ return groups.join(",");
6867
+ }
6868
+
6869
+ /** Decode throughput: whole tokens from ten up, one decimal below. */
6870
+ function formatTokensPerSecond(tps) {
6871
+ const clamped = Math.max(0, Number.isFinite(tps) ? tps : 0);
6872
+ return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10);
6873
+ }
6874
+
6875
+ /** Cache-read share of prompt-side input; partial hits never round up to 100%. */
6876
+ function cacheHitPercentOf(tokens) {
6877
+ const prompt = tokens.input + tokens.cacheRead + tokens.cacheWrite;
6878
+ if (prompt <= 0 || tokens.cacheRead <= 0) return null;
6879
+ if (tokens.cacheRead >= prompt) return 100;
6880
+ const rounded = Math.round((tokens.cacheRead / prompt) * 1000) / 10;
6881
+ return rounded >= 100 ? 99.9 : rounded;
6882
+ }
6883
+
6884
+ /** Rows of the 「更多」 panel: one turn's session statistics in reading order.
6885
+ Section rows carry `section: true` and render as a group heading. */
6886
+ function turnDetailRows(detail, t) {
6887
+ if (!isRecord(detail)) return [];
6888
+ const rows = [];
6889
+ if (detail.scope === "all") {
6890
+ rows.push({ key: "turns", label: t("stats.more.turns"), value: t("stats.more.turnsValue", { n: detail.turns }), hint: t("stats.more.turnsHint") });
6891
+ }
6892
+ rows.push({ key: "steps", label: t("stats.more.steps"), value: t("stats.more.stepsValue", { n: detail.steps }), hint: t("stats.more.stepsHint") });
6893
+ rows.push({
6894
+ key: "model",
6895
+ label: t("stats.more.model"),
6896
+ value: detail.models.length > 0 ? detail.models.join(", ") : t("stats.more.unknown"),
6897
+ hint: t("stats.more.modelHint"),
6898
+ });
6899
+ /* turn-only: no single start point exists once the scope aggregates turns */
6900
+ if (detail.scope === "turn" && detail.firstTokenMs !== null) {
6901
+ rows.push({ key: "firstToken", label: t("stats.more.firstToken"), value: formatDurationPrecise(detail.firstTokenMs), hint: t("stats.more.firstTokenHint") });
6902
+ }
6903
+ if (detail.llmMs > 0) rows.push({ key: "llm", label: t("stats.more.llmTime"), value: formatDurationPrecise(detail.llmMs), hint: t("stats.more.llmTimeHint") });
6904
+ if (detail.toolMs > 0) rows.push({ key: "tool", label: t("stats.more.toolTime"), value: formatDurationPrecise(detail.toolMs), hint: t("stats.more.toolTimeHint") });
6905
+ if (detail.ttftSteps > 0) {
6906
+ rows.push({ key: "ttft", label: t("stats.more.ttft"), value: formatDurationPrecise(detail.ttftMs / detail.ttftSteps), hint: t("stats.more.ttftHint") });
6907
+ }
6908
+ if (detail.decodeMs > 0 && detail.decodeTokens > 0) {
6909
+ rows.push({
6910
+ key: "speed",
6911
+ label: t("stats.more.speed"),
6912
+ value: t("stats.more.speedValue", { v: formatTokensPerSecond(detail.decodeTokens / (detail.decodeMs / 1000)) }),
6913
+ hint: t("stats.more.speedHint"),
6914
+ });
6915
+ }
6916
+ if (isRecord(detail.tokens)) {
6917
+ const tokens = detail.tokens;
6918
+ rows.push({ key: "tokensHead", section: true, label: t("stats.more.tokens"), hint: t("stats.more.tokensHint") });
6919
+ rows.push({
6920
+ key: "tokenTotal",
6921
+ label: t("stats.more.tokenTotal"),
6922
+ value: t("stats.more.tokenValue", { count: formatTokensCompact(tokens.total) }),
6923
+ hint: t("stats.more.tokensHint"),
6924
+ });
6925
+ const cacheHit = cacheHitPercentOf(tokens);
6926
+ if (cacheHit !== null) rows.push({ key: "cacheHit", label: t("stats.more.cacheHit"), value: `${cacheHit}%`, hint: t("stats.more.cacheHitHint") });
6927
+ rows.push({ key: "input", label: t("stats.more.input"), value: t("stats.more.tokenValue", { count: formatTokenGroups(tokens.input) }), hint: t("stats.more.inputHint") });
6928
+ if (tokens.cacheRead > 0) {
6929
+ rows.push({ key: "cacheRead", label: t("stats.more.cacheRead"), value: t("stats.more.tokenValue", { count: formatTokenGroups(tokens.cacheRead) }), hint: t("stats.more.cacheReadHint") });
6930
+ }
6931
+ if (tokens.cacheWrite > 0) {
6932
+ rows.push({ key: "cacheWrite", label: t("stats.more.cacheWrite"), value: t("stats.more.tokenValue", { count: formatTokenGroups(tokens.cacheWrite) }), hint: t("stats.more.cacheWriteHint") });
6933
+ }
6934
+ rows.push({ key: "output", label: t("stats.more.output"), value: t("stats.more.tokenValue", { count: formatTokenGroups(tokens.output) }), hint: t("stats.more.outputHint") });
6935
+ if (tokens.reasoning > 0) {
6936
+ rows.push({ key: "reasoning", label: t("stats.more.reasoning"), value: t("stats.more.tokenValue", { count: formatTokenGroups(tokens.reasoning) }), hint: t("stats.more.reasoningHint") });
6937
+ }
6938
+ }
6939
+ return rows;
6940
+ }
6941
+
6942
+ /** One fixed-position popover anchored under a pill. The toolbar wrappers clip
6943
+ absolutely positioned children, so this anchors to the viewport, follows its
6944
+ pill while the chat auto-scrolls, and closes on outside pointerdown/Escape. */
6945
+ function useAnchoredPopover(heightGuess) {
6946
+ const [open, setOpen] = React.useState(false);
6947
+ const [pos, setPos] = React.useState(null);
6948
+ const anchorRef = React.useRef(null);
6949
+ const popRef = React.useRef(null);
6950
+ const place = React.useCallback(() => {
6951
+ const button = anchorRef.current;
6017
6952
  if (button === null || button.isConnected !== true) return false;
6018
6953
  const rect = button.getBoundingClientRect();
6019
6954
  if (rect.bottom < 0 || rect.top > window.innerHeight) return false;
6020
- setErrPop({
6021
- top: Math.min(rect.bottom + 5, Math.max(8, window.innerHeight - 292)),
6955
+ /* After the first paint the mounted popover knows its own height, which is
6956
+ what keeps a long list (every row of the 全部轮次 scope) inside the view. */
6957
+ const height = popRef.current !== null && popRef.current.offsetHeight > 0 ? popRef.current.offsetHeight : heightGuess;
6958
+ setPos({
6959
+ top: Math.min(rect.bottom + 5, Math.max(8, window.innerHeight - height)),
6022
6960
  right: Math.max(8, window.innerWidth - rect.right),
6023
6961
  });
6024
6962
  return true;
6963
+ }, [heightGuess]);
6964
+ const close = React.useCallback(() => {
6965
+ setOpen(false);
6966
+ setPos(null);
6025
6967
  }, []);
6026
6968
  React.useEffect(() => {
6027
- if (!errOpen) return undefined;
6028
- const close = () => setErrPop(null);
6969
+ if (!open) return undefined;
6970
+ /* the popover is mounted by now: re-place with its measured height */
6971
+ if (!place()) {
6972
+ close();
6973
+ return undefined;
6974
+ }
6029
6975
  const onDown = (event) => {
6030
- if (errRef.current && !errRef.current.contains(event.target)) close();
6976
+ if (popRef.current && !popRef.current.contains(event.target)) close();
6031
6977
  };
6032
6978
  const onKey = (event) => {
6033
6979
  if (event.key === "Escape") close();
@@ -6039,9 +6985,13 @@ function FileTypeIcon(props) {
6039
6985
  let frame = 0;
6040
6986
  const follow = () => {
6041
6987
  if (frame !== 0) return;
6988
+ if (typeof requestAnimationFrame !== "function") {
6989
+ if (!place()) close();
6990
+ return;
6991
+ }
6042
6992
  frame = requestAnimationFrame(() => {
6043
6993
  frame = 0;
6044
- if (!placePopover()) setErrPop(null);
6994
+ if (!place()) close();
6045
6995
  });
6046
6996
  };
6047
6997
  document.addEventListener("mousedown", onDown);
@@ -6049,13 +6999,22 @@ function FileTypeIcon(props) {
6049
6999
  window.addEventListener("scroll", follow, true);
6050
7000
  window.addEventListener("resize", follow);
6051
7001
  return () => {
6052
- if (frame !== 0) cancelAnimationFrame(frame);
7002
+ if (frame !== 0 && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame);
6053
7003
  document.removeEventListener("mousedown", onDown);
6054
7004
  document.removeEventListener("keydown", onKey);
6055
7005
  window.removeEventListener("scroll", follow, true);
6056
7006
  window.removeEventListener("resize", follow);
6057
7007
  };
6058
- }, [errOpen, placePopover]);
7008
+ }, [open, place, close]);
7009
+ return { open, setOpen, pos, anchorRef, popRef, place, close };
7010
+ }
7011
+
7012
+ /** Left-hand stat pills on the scope row: duration, tool calls, commands, files, failures. */
7013
+ function StatsBar({ stats, t, canTimeline, timelineOpen, scrubbing, onToggleTimeline, prevJump, onPrevTurn, failedSteps, detail }) {
7014
+ /* both popovers keep their hooks above every early return */
7015
+ const failed = Array.isArray(failedSteps) ? failedSteps.filter((step) => isRecord(step)) : [];
7016
+ const err = useAnchoredPopover(292);
7017
+ const more = useAnchoredPopover(360);
6059
7018
  if (!isRecord(stats)) return null;
6060
7019
  const duration = typeof stats.durationMs === "number" ? stats.durationMs : 0;
6061
7020
  const toolCalls = typeof stats.toolCalls === "number" ? stats.toolCalls : 0;
@@ -6075,6 +7034,8 @@ function FileTypeIcon(props) {
6075
7034
  if (duration === 0 && toolCalls === 0) return jumpBtn === null ? null : h("div", { className: "dsov-stats" }, jumpBtn);
6076
7035
  const pill = (key, text, title, tone) => h("span", { className: "dsov-stat", key, title, ...(tone === undefined ? {} : { "data-tone": tone }) }, text);
6077
7036
  const scrubable = canTimeline === true && typeof onToggleTimeline === "function";
7037
+ /* the aggregate scope is a session reading, not a single turn's */
7038
+ const moreHead = detail !== null && isRecord(detail) && detail.scope === "all" ? t("stats.more.headAll") : t("stats.more.head");
6078
7039
  return h(
6079
7040
  "div",
6080
7041
  { className: "dsov-stats" },
@@ -6102,39 +7063,41 @@ function FileTypeIcon(props) {
6102
7063
  errors > 0
6103
7064
  ? h(
6104
7065
  "span",
6105
- { className: "dsov-statWrap", key: "errors", ref: errRef },
7066
+ { className: "dsov-statWrap", key: "errors", ref: err.popRef },
6106
7067
  h(
6107
7068
  "button",
6108
7069
  {
6109
7070
  type: "button",
6110
7071
  className: "dsov-stat dsov-statBtn",
6111
7072
  "data-tone": "err",
6112
- "data-active": errOpen ? "true" : undefined,
6113
- ref: errBtnRef,
7073
+ "data-active": err.open ? "true" : undefined,
7074
+ ref: err.anchorRef,
6114
7075
  disabled: failed.length === 0,
6115
7076
  title: failed.length > 0 ? t("stats.errors.popTitle") : t("stats.errors.title"),
6116
7077
  onClick:
6117
7078
  failed.length > 0
6118
7079
  ? () => {
6119
- if (errOpen) {
6120
- setErrPop(null);
7080
+ if (err.open) {
7081
+ err.close();
6121
7082
  return;
6122
7083
  }
6123
- if (!placePopover()) setErrPop(null);
7084
+ err.setOpen(true);
7085
+ if (!err.place()) err.close();
6124
7086
  }
6125
7087
  : undefined,
6126
7088
  },
6127
7089
  t("stats.errors", { n: errors })
6128
7090
  ),
6129
- errOpen && failed.length > 0
6130
- ? h(
7091
+ err.open && err.pos !== null && failed.length > 0
7092
+ ? renderOverlay(
7093
+ h(
6131
7094
  "div",
6132
- { className: "dsov-errPop", style: { top: `${errPop.top}px`, right: `${errPop.right}px` } },
7095
+ { className: "dsov-errPop", style: { top: `${err.pos.top}px`, right: `${err.pos.right}px` } },
6133
7096
  h(
6134
7097
  "div",
6135
7098
  { className: "dsov-errPopHead" },
6136
7099
  h("span", null, t("stats.errors.list", { n: failed.length }), errors > failed.length ? h("span", { className: "dsov-errPopHint" }, t("stats.errors.truncated")) : null),
6137
- h("button", { type: "button", className: "dsov-errPopClose", title: t("stats.errors.close"), onClick: () => setErrPop(null) }, "\u00d7")
7100
+ h("button", { type: "button", className: "dsov-errPopClose", title: t("stats.errors.close"), onClick: err.close }, "\u00d7")
6138
7101
  ),
6139
7102
  ...failed
6140
7103
  .slice()
@@ -6158,6 +7121,61 @@ function FileTypeIcon(props) {
6158
7121
  )
6159
7122
  )
6160
7123
  )
7124
+ )
7125
+ : null
7126
+ )
7127
+ : null,
7128
+ detail !== null && isRecord(detail)
7129
+ ? h(
7130
+ "span",
7131
+ { className: "dsov-statWrap", key: "more", ref: more.popRef },
7132
+ h(
7133
+ "button",
7134
+ {
7135
+ type: "button",
7136
+ className: "dsov-stat dsov-statBtn dsov-moreBtn",
7137
+ "aria-haspopup": "dialog",
7138
+ "aria-expanded": more.open ? "true" : "false",
7139
+ "data-active": more.open ? "true" : undefined,
7140
+ ref: more.anchorRef,
7141
+ title: t("stats.more.title"),
7142
+ onClick: () => {
7143
+ if (more.open) {
7144
+ more.close();
7145
+ return;
7146
+ }
7147
+ more.setOpen(true);
7148
+ if (!more.place()) more.close();
7149
+ },
7150
+ },
7151
+ t("stats.more"),
7152
+ h(IconChevron, { size: 10, className: "dsov-moreChevron" })
7153
+ ),
7154
+ more.open && more.pos !== null
7155
+ ? renderOverlay(
7156
+ h(
7157
+ "div",
7158
+ { className: "dsov-morePop", style: { top: `${more.pos.top}px`, right: `${more.pos.right}px` }, role: "dialog", "aria-label": moreHead },
7159
+ h(
7160
+ "div",
7161
+ { className: "dsov-moreHead" },
7162
+ h("span", { className: "dsov-moreTitle" }, moreHead),
7163
+ h("span", { className: "dsov-moreSub" }, detail.scope === "all" ? t("stats.more.allTurns") : t("stats.more.turn", { n: detail.turn })),
7164
+ h("button", { type: "button", className: "dsov-errPopClose", title: t("stats.more.close"), onClick: more.close }, "\u00d7")
7165
+ ),
7166
+ h("div", { className: "dsov-moreRule" }),
7167
+ ...turnDetailRows(detail, t).map((row) =>
7168
+ row.section === true
7169
+ ? h("div", { className: "dsov-moreSection", key: row.key, title: row.hint }, row.label)
7170
+ : h(
7171
+ "div",
7172
+ { className: "dsov-moreRow", key: row.key, title: row.hint },
7173
+ h("span", { className: "dsov-moreLabel" }, row.label),
7174
+ h("span", { className: "dsov-moreValue" }, row.value)
7175
+ )
7176
+ )
7177
+ )
7178
+ )
6161
7179
  : null
6162
7180
  )
6163
7181
  : null
@@ -6582,6 +7600,8 @@ function FileTypeIcon(props) {
6582
7600
  timelineOpen,
6583
7601
  scrubbing: asOf !== null,
6584
7602
  failedSteps,
7603
+ /* the 「更多」 panel: per-turn session statistics for this scope */
7604
+ detail: summary.turnDetail,
6585
7605
  prevJump: asOf !== null && asOf <= summary.turnStart ? prevTurn : null,
6586
7606
  onPrevTurn: () => {
6587
7607
  setSelection(`turn:${prevTurn}`);
@@ -6762,7 +7782,18 @@ function FileTypeIcon(props) {
6762
7782
  )
6763
7783
  : null,
6764
7784
  introBanner !== null ? h("div", { className: "dsov-turnStart", key: `turnStart-${introSeq}` }, h("span", { className: "dsov-turnStartDot" }), t("intro.newTurn", { n: introBanner })) : null,
6765
- cfg.showProgress ? h(ProgressSection, { summary, goal, t, waiting: sessionWaiting && asOf === null, cfg, asOf, introSeq }) : null,
7785
+ cfg.showProgress
7786
+ ? h(ProgressSection, {
7787
+ summary,
7788
+ goal,
7789
+ t,
7790
+ waiting: sessionWaiting && asOf === null,
7791
+ running: sessionRunning && asOf === null,
7792
+ cfg,
7793
+ asOf,
7794
+ introSeq,
7795
+ })
7796
+ : null,
6766
7797
  cfg.showArtifacts ? h(ArtifactsSection, { summary, t, binding, stats, asOf, introSeq }) : null,
6767
7798
  cfg.showReferences ? h(ReferencesSection, { summary, t, sessionId, asOf, introSeq }) : null
6768
7799
  );
@@ -7585,18 +8616,18 @@ function FileTypeIcon(props) {
7585
8616
 
7586
8617
  const CSS = `
7587
8618
  .dsov-view{box-sizing:border-box;width:100%;max-width:880px;margin:0 auto;padding:20px 28px 64px;display:flex;flex-direction:column;gap:14px}
7588
- .dsov-panel{box-sizing:border-box;height:100%;min-height:0;overflow-y:auto;padding:14px 16px 28px;display:flex;flex-direction:column;gap:12px}
7589
- .dsov-panel,.dsov-viking,.dsov-secBody,.dsov-typeMenu,.dsov-md-pre,.dsov-turnbarScroll,.dsov-toolbarScroll{scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--dsw-alias-label-secondary) 35%,transparent) transparent}
7590
- .dsov-panel::-webkit-scrollbar,.dsov-viking::-webkit-scrollbar,.dsov-secBody::-webkit-scrollbar,.dsov-typeMenu::-webkit-scrollbar,.dsov-md-pre::-webkit-scrollbar,.dsov-turnbarScroll::-webkit-scrollbar,.dsov-toolbarScroll::-webkit-scrollbar{width:6px;height:5px}
7591
- .dsov-panel::-webkit-scrollbar-thumb,.dsov-viking::-webkit-scrollbar-thumb,.dsov-secBody::-webkit-scrollbar-thumb,.dsov-typeMenu::-webkit-scrollbar-thumb,.dsov-md-pre::-webkit-scrollbar-thumb,.dsov-turnbarScroll::-webkit-scrollbar-thumb,.dsov-toolbarScroll::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--dsw-alias-label-secondary) 35%,transparent);border-radius:3px}
7592
- .dsov-panel::-webkit-scrollbar-track,.dsov-viking::-webkit-scrollbar-track,.dsov-secBody::-webkit-scrollbar-track,.dsov-typeMenu::-webkit-scrollbar-track,.dsov-md-pre::-webkit-scrollbar-track,.dsov-turnbarScroll::-webkit-scrollbar-track,.dsov-toolbarScroll::-webkit-scrollbar-track{background:transparent}
8619
+ .dsov-panel{box-sizing:border-box;height:100%;min-height:0;overflow-y:auto;scrollbar-gutter:stable;padding:14px 16px 28px;display:flex;flex-direction:column;gap:12px}
8620
+ .dsov-panel,.dsov-viking,.dsov-secBody,.dsov-planWbBody,.dsov-typeMenu,.dsov-md-pre,.dsov-turnbarScroll,.dsov-toolbarScroll{scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--dsw-alias-label-secondary) 35%,transparent) transparent}
8621
+ .dsov-panel::-webkit-scrollbar,.dsov-viking::-webkit-scrollbar,.dsov-secBody::-webkit-scrollbar,.dsov-planWbBody::-webkit-scrollbar,.dsov-typeMenu::-webkit-scrollbar,.dsov-md-pre::-webkit-scrollbar,.dsov-turnbarScroll::-webkit-scrollbar,.dsov-toolbarScroll::-webkit-scrollbar{width:6px;height:5px}
8622
+ .dsov-panel::-webkit-scrollbar-thumb,.dsov-viking::-webkit-scrollbar-thumb,.dsov-secBody::-webkit-scrollbar-thumb,.dsov-planWbBody::-webkit-scrollbar-thumb,.dsov-typeMenu::-webkit-scrollbar-thumb,.dsov-md-pre::-webkit-scrollbar-thumb,.dsov-turnbarScroll::-webkit-scrollbar-thumb,.dsov-toolbarScroll::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--dsw-alias-label-secondary) 35%,transparent);border-radius:3px}
8623
+ .dsov-panel::-webkit-scrollbar-track,.dsov-viking::-webkit-scrollbar-track,.dsov-secBody::-webkit-scrollbar-track,.dsov-planWbBody::-webkit-scrollbar-track,.dsov-typeMenu::-webkit-scrollbar-track,.dsov-md-pre::-webkit-scrollbar-track,.dsov-turnbarScroll::-webkit-scrollbar-track,.dsov-toolbarScroll::-webkit-scrollbar-track{background:transparent}
7593
8624
  .dsov-toolbar{display:flex;align-items:center;gap:8px}
7594
8625
  .dsov-toolbarScroll{flex:1 1 auto;min-width:0;display:flex;align-items:center;overflow-x:auto;overflow-y:hidden;padding-bottom:5px}
7595
8626
  .dsov-toolbarFixed{flex:none;display:flex;align-items:center;gap:8px;margin-left:auto;padding-bottom:5px}
7596
8627
  .dsov-iconBtn{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border-radius:8px;border:.5px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer;white-space:nowrap;flex:none}
7597
8628
  .dsov-turnSelect{flex:none}
7598
8629
  .dsov-iconBtn:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 7%,transparent);color:var(--dsw-alias-label-primary)}
7599
- .dsov-section{background:var(--dsw-alias-bg-layer-1);border:.5px solid var(--dsw-alias-border-l1);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:8px}
8630
+ .dsov-section{position:relative;background:var(--dsw-alias-bg-layer-1);border:.5px solid var(--dsw-alias-border-l1);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:8px}
7600
8631
  .dsov-sectionHead{display:flex;align-items:center;gap:6px}
7601
8632
  .dsov-sectionToggle{flex:1;min-width:0;display:flex;align-items:center;gap:8px;background:transparent;border:none;margin:0;padding:2px 4px;border-radius:8px;font:inherit;color:inherit;text-align:left;cursor:pointer}
7602
8633
  .dsov-sectionToggle:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 6%,transparent)}
@@ -7604,6 +8635,7 @@ function FileTypeIcon(props) {
7604
8635
  .dsov-chevron[data-open=true]{transform:rotate(90deg)}
7605
8636
  .dsov-moreSteps{appearance:none;background:transparent;border:none;cursor:pointer;display:inline-flex;align-items:center;gap:4px;flex:none;margin-left:auto;padding:1px 6px;border-radius:8px;font-size:10.5px;line-height:16px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
7606
8637
  .dsov-moreSteps:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
8638
+ .dsov-moreSteps:focus-visible{outline:1.5px solid var(--dsw-alias-brand-primary);outline-offset:1px}
7607
8639
  .dsov-moreIcon{flex:none;display:inline-flex;opacity:.75}
7608
8640
  .dsov-sectionIcon{flex:none;display:inline-flex;color:var(--dsw-alias-label-secondary)}
7609
8641
  .dsov-sectionTitle{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);white-space:nowrap}
@@ -7611,10 +8643,14 @@ function FileTypeIcon(props) {
7611
8643
  .dsov-headAction{flex:none;display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 9px;border-radius:8px;border:.5px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer;white-space:nowrap}
7612
8644
  .dsov-headAction:hover{color:var(--dsw-alias-brand-primary);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 45%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,transparent)}
7613
8645
  .dsov-empty{font-size:12.5px;line-height:1.6;color:var(--dsw-alias-label-secondary);padding:4px 2px}
7614
- .dsov-secBody{max-height:320px;overflow-y:auto;overflow-x:hidden;margin:0 -12px 0 -8px;padding:0 12px 0 8px}
8646
+ .dsov-secBody{max-height:320px;overflow-y:auto;overflow-x:hidden;scrollbar-gutter:stable;margin:0 -12px 0 -8px;padding:0 12px 0 8px}
7615
8647
  .dsov-section[data-full=true] .dsov-secBody{max-height:none;overflow:visible;margin:0;padding:0}
8648
+ .dsov-section[data-body=auto] .dsov-secBody{max-height:none;overflow:visible}
8649
+ .dsov-section[data-body=auto] .dsov-live{position:relative;top:0}
8650
+ .dsov-section[data-body=fixed] .dsov-secBody{height:320px;max-height:320px;overflow-y:auto}
8651
+ .dsov-section[data-full=true][data-body=fixed] .dsov-secBody{height:auto;max-height:none;overflow:visible}
7616
8652
  .dsov-loading{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-secondary)}
7617
- .dsov-goal{display:flex;flex-direction:column;gap:3px;box-sizing:border-box;border:.5px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 32%,transparent);border-radius:10px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 6%,transparent);padding:7px 9px 8px;color:var(--dsw-alias-label-primary)}
8653
+ .dsov-goal{display:flex;flex-direction:column;gap:3px;margin:0 0 6px;box-sizing:border-box;border:.5px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 32%,transparent);border-radius:10px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 6%,transparent);padding:7px 9px 8px;color:var(--dsw-alias-label-primary)}
7618
8654
  .dsov-goal[data-phase=paused]{border-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#ff9f0a) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#ff9f0a) 7%,transparent)}
7619
8655
  .dsov-goal[data-phase=blocked]{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary,#ff453a) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#ff453a) 7%,transparent)}
7620
8656
  .dsov-goal[data-phase=complete]{border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary,#2ea36a) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#2ea36a) 7%,transparent)}
@@ -7643,10 +8679,6 @@ function FileTypeIcon(props) {
7643
8679
  .dsov-badge[data-tone=warn]{color:var(--dsw-alias-state-warn-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 45%,transparent)}
7644
8680
  .dsov-badge[data-tone=error]{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,transparent)}
7645
8681
  .dsov-badge[data-tone=quiet]{border-color:transparent;background:var(--dsw-alias-bg-layer-2)}
7646
- .dsov-progress{display:flex;flex-direction:column;gap:6px;margin-top:2px}
7647
- .dsov-progressTrack{height:4px;border-radius:2px;background:var(--dsw-alias-bg-layer-2);overflow:hidden}
7648
- .dsov-progressFill{height:100%;border-radius:2px;background:var(--dsw-alias-state-success-primary);transition:width .25s ease}
7649
- .dsov-progressMeta{font-size:11.5px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
7650
8682
  .dsov-todo{display:flex;align-items:flex-start;gap:8px;font-size:13px;line-height:1.55;color:var(--dsw-alias-label-primary);padding:4px 6px;border-radius:8px;cursor:pointer;transition:background-color .18s ease,color .18s ease}
7651
8683
  .dsov-todo+.dsov-todo{margin-top:3px}
7652
8684
  .dsov-todo:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 6%,transparent)}
@@ -7660,7 +8692,9 @@ function FileTypeIcon(props) {
7660
8692
  .dsov-todoText{flex:1;min-width:0;overflow-wrap:anywhere}
7661
8693
  .dsov-todo[data-status=completed] .dsov-todoText{color:var(--dsw-alias-label-secondary)}
7662
8694
  .dsov-todoStatus{flex:none;font-size:11px;line-height:20px;color:var(--dsw-alias-label-secondary)}
7663
- .dsov-stepsHead{display:flex;align-items:center;gap:8px;font-size:10.5px;line-height:16px;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));letter-spacing:.02em;padding:1px 2px 4px}
8695
+ .dsov-stepsHead{display:flex;align-items:center;gap:8px;font-size:10.5px;line-height:16px;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));letter-spacing:.02em;padding:2px 2px 6px}
8696
+ .dsov-stepsHeadLabel{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
8697
+ .dsov-stepsFoot{display:flex;justify-content:flex-end;padding:4px 2px 0}
7664
8698
  .dsov-steps{position:relative;display:flex;flex-direction:column;gap:3px}
7665
8699
  .dsov-step{display:flex;align-items:center;gap:6px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-primary);padding:3px 6px;min-width:0;border-radius:8px;cursor:pointer;transition:background-color .18s ease}
7666
8700
  .dsov-step:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 5%,transparent)}
@@ -7857,7 +8891,8 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
7857
8891
  .dsov-fileMenuErr{padding:5px 8px;font-size:11px;color:var(--dsw-alias-state-error-primary,#ff453a)}
7858
8892
  .dsov-statWrap{position:relative;display:inline-flex}
7859
8893
  .dsov-statBtn[disabled]{cursor:default}
7860
- .dsov-errPop{position:fixed;z-index:80;width:min(360px,78vw);max-height:280px;overflow-y:auto;padding:4px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l1);box-shadow:0 8px 22px rgba(0,0,0,.20)}
8894
+ .dsov-overlayHost{position:fixed;top:0;left:0;width:0;height:0;z-index:80;pointer-events:none}
8895
+ .dsov-errPop{position:fixed;z-index:80;pointer-events:auto;width:min(360px,78vw);max-height:280px;overflow-y:auto;padding:4px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l1);box-shadow:0 8px 22px rgba(0,0,0,.20)}
7861
8896
  .dsov-errPopHead{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:2px 4px 4px;font-size:11px;color:var(--dsw-alias-label-secondary)}
7862
8897
  .dsov-errPopHint{margin-left:6px;font-size:10px;color:var(--dsw-alias-label-tertiary)}
7863
8898
  .dsov-errPopClose{border:0;background:none;cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1;padding:2px 5px;border-radius:4px}
@@ -7869,6 +8904,20 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
7869
8904
  .dsov-errPopTime{color:var(--dsw-alias-label-tertiary);font-size:10px;white-space:nowrap}
7870
8905
  .dsov-errPopDetail{color:var(--dsw-alias-label-secondary);word-break:break-all;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
7871
8906
  .dsov-errPopMsg{margin-top:2px;color:var(--dsw-alias-state-error-primary,#ff453a);white-space:pre-wrap;word-break:break-all;font-size:10.5px;max-height:76px;overflow-y:auto}
8907
+ .dsov-moreBtn{display:inline-flex;align-items:center;gap:3px}
8908
+ .dsov-moreChevron{flex:none;opacity:.7;transition:transform .15s ease}
8909
+ .dsov-moreBtn[data-active=true] .dsov-moreChevron{transform:rotate(180deg)}
8910
+ .dsov-morePop{position:fixed;z-index:80;pointer-events:auto;width:min(300px,82vw);max-height:min(72vh,430px);overflow-y:auto;padding:7px 9px 8px;border-radius:10px;background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l1);box-shadow:0 8px 22px rgba(0,0,0,.20);color:var(--dsw-alias-label-primary);font-size:11.5px;line-height:1.5}
8911
+ .dsov-moreHead{display:flex;align-items:center;gap:8px;padding:1px 1px 3px}
8912
+ .dsov-moreTitle{flex:none;font-weight:600;font-size:12px;color:var(--dsw-alias-label-primary)}
8913
+ .dsov-moreSub{flex:1 1 auto;min-width:0;color:var(--dsw-alias-label-tertiary);font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
8914
+ .dsov-moreRule{height:.5px;background:var(--dsw-alias-border-l1);margin:1px 0 5px}
8915
+ .dsov-moreSection{margin:7px 0 3px;font-size:10px;line-height:16px;letter-spacing:.04em;color:var(--dsw-alias-label-tertiary);font-weight:600}
8916
+ .dsov-moreSection:first-of-type{margin-top:2px}
8917
+ .dsov-moreRow{display:flex;align-items:baseline;gap:10px;padding:2px 2px;border-radius:5px}
8918
+ .dsov-moreRow:hover{background:var(--dsw-alias-bg-layer-3)}
8919
+ .dsov-moreLabel{flex:none;color:var(--dsw-alias-label-secondary);white-space:nowrap}
8920
+ .dsov-moreValue{flex:1 1 auto;min-width:0;text-align:right;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums;overflow-wrap:anywhere;word-break:break-word}
7872
8921
  .dsov-role{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:0 2px;font-size:10.5px;line-height:16px;color:var(--dsw-alias-label-secondary)}
7873
8922
  .dsov-roleBadge{flex:none;display:inline-flex;align-items:center;padding:1px 7px;border-radius:999px;font-weight:500;color:var(--dsw-alias-brand-primary,#247bbf);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#247bbf) 12%,transparent)}
7874
8923
  .dsov-role[data-kind=sub] .dsov-roleBadge{color:var(--dsw-alias-state-warn-primary,#ff9f0a);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#ff9f0a) 15%,transparent)}
@@ -7919,15 +8968,68 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
7919
8968
  .dsov-turnSelect:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3,var(--dsw-alias-border-l2))}
7920
8969
  @keyframes dsov-spin{to{transform:rotate(360deg)}}
7921
8970
  @keyframes dsov-pulse{0%,100%{opacity:1}50%{opacity:.3}}
7922
- @keyframes dsov-stepIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
7923
- @keyframes dsov-stepOut{from{opacity:1;transform:none}to{opacity:0;transform:translateY(-6px)}}
8971
+ @keyframes dsov-stepIn{0%{opacity:0;transform:translateY(14px) scale(.97);filter:blur(1.5px)}60%{opacity:1;filter:blur(0)}100%{opacity:1;transform:none;filter:blur(0)}}
8972
+ @keyframes dsov-stepOut{0%{opacity:1;transform:none;filter:blur(0)}100%{opacity:0;transform:translateY(-16px) scale(.96);filter:blur(2px)}}
7924
8973
  @keyframes dsov-refIn{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}
8974
+ @keyframes dsov-liveSweep{0%{transform:translateX(-120%)}55%,100%{transform:translateX(120%)}}
8975
+ @keyframes dsov-liveHalo{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--dsw-alias-brand-primary,#247bbf) 45%,transparent)}70%{box-shadow:0 0 0 7px transparent}100%{box-shadow:0 0 0 0 transparent}}
8976
+ @keyframes dsov-liveBar{0%,100%{transform:scaleY(.35)}50%{transform:scaleY(1)}}
8977
+ @keyframes dsov-liveRim{0%,100%{opacity:0}35%{opacity:.55}65%{opacity:.22}}
8978
+ @keyframes dsov-stepScan{0%{background-position:180% 0}100%{background-position:-80% 0}}
8979
+ @keyframes dsov-stepRailPulse{0%,100%{opacity:.4}50%{opacity:1}}
8980
+ @keyframes dsov-stepsNudgeA{0%{transform:translateY(5px)}55%{transform:translateY(-2px)}100%{transform:none}}
8981
+ @keyframes dsov-stepsNudgeB{0%{transform:translateY(5px)}55%{transform:translateY(-2px)}100%{transform:none}}
8982
+ @keyframes dsov-stepHit{0%{background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#2ea36a) 22%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dsw-alias-state-success-primary,#2ea36a) 75%,transparent),0 0 14px color-mix(in srgb,var(--dsw-alias-state-success-primary,#2ea36a) 40%,transparent)}100%{background:transparent;box-shadow:0 0 0 0 transparent}}
8983
+ @keyframes dsov-hitPop{0%{transform:scale(1)}40%{transform:scale(1.35)}100%{transform:scale(1)}}
7925
8984
  .dsov-row[data-enter=true]{animation:dsov-refIn .32s ease both}
7926
- .dsov-step[data-enter=true]{animation:dsov-stepIn .34s ease both}
7927
- .dsov-step[data-exit=true]{position:absolute;top:0;left:0;right:0;animation:dsov-stepOut .4s ease both;pointer-events:none}
8985
+ .dsov-step[data-enter=true]{animation:dsov-stepIn .42s cubic-bezier(.22,.61,.36,1) both}
8986
+ .dsov-step[data-exit=true]{position:absolute;top:0;left:0;right:0;animation:dsov-stepOut .5s cubic-bezier(.22,.61,.36,1) both;pointer-events:none;z-index:1}
8987
+ .dsov-steps[data-nudge=odd]{animation:dsov-stepsNudgeA .34s cubic-bezier(.22,.61,.36,1)}
8988
+ .dsov-steps[data-nudge=even]{animation:dsov-stepsNudgeB .34s cubic-bezier(.22,.61,.36,1)}
8989
+ .dsov-step[data-active=true]{position:relative;isolation:isolate;background:color-mix(in srgb,var(--dsw-alias-brand-primary,#247bbf) 9%,transparent)}
8990
+ .dsov-step[data-active=true]::before{content:"";position:absolute;top:3px;bottom:3px;left:1px;width:2px;border-radius:1px;background:var(--dsw-alias-brand-primary,#247bbf);animation:dsov-stepRailPulse 1.8s ease-in-out infinite;pointer-events:none}
8991
+ .dsov-step[data-active=true]:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#247bbf) 13%,transparent)}
8992
+ .dsov-step[data-active=true]::after{content:"";position:absolute;inset:0;z-index:-1;border-radius:inherit;background:linear-gradient(95deg,color-mix(in srgb,var(--dsw-alias-brand-primary,#247bbf) 20%,transparent),transparent 55%);background-size:220% 100%;animation:dsov-stepScan 2.2s linear infinite;pointer-events:none}
8993
+ .dsov-step[data-active=true] .dsov-stepName{font-weight:600;color:var(--dsw-alias-brand-primary,#247bbf)}
8994
+ .dsov-step[data-hit=true]{animation:dsov-stepHit .8s ease-out}
8995
+ .dsov-step[data-hit=true] .dsov-todoIcon{animation:dsov-hitPop .45s cubic-bezier(.2,1.6,.4,1)}
8996
+ .dsov-live{position:sticky;top:-1px;z-index:3;isolation:isolate;display:flex;align-items:center;gap:8px;min-width:0;box-sizing:border-box;margin:0 0 6px;padding:6px 9px;border-radius:10px;border:.5px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#247bbf) 42%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#247bbf) 7%,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary);font-size:11.5px;line-height:1.5;overflow:hidden}
8997
+ .dsov-live::before{content:"";position:absolute;inset:0;z-index:-1;background:linear-gradient(100deg,transparent 0%,color-mix(in srgb,var(--dsw-alias-brand-primary,#247bbf) 22%,transparent) 45%,transparent 70%);transform:translateX(-120%);animation:dsov-liveSweep 2.4s ease-in-out infinite;pointer-events:none}
8998
+ .dsov-section[data-full=true] .dsov-live{position:relative;top:0}
8999
+ .dsov-live[data-state=waiting]{border-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#ff9f0a) 48%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#ff9f0a) 9%,var(--dsw-alias-bg-layer-1))}
9000
+ .dsov-live[data-state=waiting]::before{background:linear-gradient(100deg,transparent 0%,color-mix(in srgb,var(--dsw-alias-state-warn-primary,#ff9f0a) 22%,transparent) 45%,transparent 70%);animation:none;transform:translateX(-120%)}
9001
+ .dsov-livePulse{position:relative;flex:none;display:inline-flex;align-items:center;justify-content:center;width:8px;height:8px}
9002
+ .dsov-liveDot{width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-brand-primary,#247bbf);animation:dsov-liveHalo 1.6s ease-out infinite}
9003
+ .dsov-live[data-state=waiting] .dsov-liveDot{background:var(--dsw-alias-state-warn-primary,#ff9f0a);animation-duration:2.6s}
9004
+ .dsov-liveText{display:flex;align-items:baseline;gap:7px;min-width:0;flex:1}
9005
+ .dsov-liveLabel{flex:none;font-weight:600;color:var(--dsw-alias-brand-primary,#247bbf);letter-spacing:.02em}
9006
+ .dsov-live[data-state=waiting] .dsov-liveLabel{color:var(--dsw-alias-state-warn-primary,#ff9f0a)}
9007
+ .dsov-liveNow{min-width:0;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-variant-numeric:tabular-nums}
9008
+ .dsov-liveBars{flex:none;display:inline-flex;align-items:flex-end;gap:1.5px;height:12px}
9009
+ .dsov-liveBars i{width:2.5px;height:100%;border-radius:1px;background:var(--dsw-alias-brand-primary,#247bbf);transform-origin:bottom;animation:dsov-liveBar 1s ease-in-out infinite}
9010
+ .dsov-liveBars i:nth-child(2){animation-delay:.12s}
9011
+ .dsov-liveBars i:nth-child(3){animation-delay:.24s}
9012
+ .dsov-liveBars i:nth-child(4){animation-delay:.36s}
9013
+ .dsov-liveBars i:nth-child(5){animation-delay:.48s}
9014
+ .dsov-live[data-state=waiting] .dsov-liveBars i{background:var(--dsw-alias-state-warn-primary,#ff9f0a);animation:none;transform:scaleY(.42);opacity:.55}
9015
+ .dsov-liveClock{flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));font-variant-numeric:tabular-nums;padding-left:7px;border-left:.5px solid color-mix(in srgb,var(--dsw-alias-label-primary) 12%,transparent)}
9016
+ .dsov-liveMeter{flex:none;display:inline-flex;align-items:center;gap:5px;min-width:0}
9017
+ .dsov-liveMeterTrack{flex:none;width:44px;height:5px;border-radius:3px;background:color-mix(in srgb,var(--dsw-alias-label-primary) 12%,transparent);overflow:hidden}
9018
+ .dsov-liveMeterFill{display:block;height:100%;border-radius:3px;background:var(--dsw-alias-brand-primary,#247bbf);transition:width .3s ease}
9019
+ .dsov-live[data-state=waiting] .dsov-liveMeterFill{background:var(--dsw-alias-state-warn-primary,#ff9f0a)}
9020
+ .dsov-live[data-state=summary] .dsov-liveMeterFill{background:var(--dsw-alias-state-success-primary,#2ea36a)}
9021
+ .dsov-liveMeterCount{flex:none;font-size:10.5px;line-height:1;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));font-variant-numeric:tabular-nums}
9022
+ .dsov-live[data-state=summary]{border-color:var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}
9023
+ .dsov-live[data-state=summary]::before{display:none}
9024
+ .dsov-live[data-state=summary] .dsov-liveDot{background:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));animation:none}
9025
+ .dsov-live[data-state=summary][data-complete=true] .dsov-liveDot{background:var(--dsw-alias-state-success-primary,#2ea36a)}
9026
+ .dsov-live[data-state=summary] .dsov-liveLabel{color:var(--dsw-alias-label-secondary);font-weight:600}
9027
+ .dsov-live[data-state=summary] .dsov-liveNow{color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}
9028
+ .dsov-section[data-live=true]::after{content:"";position:absolute;inset:-1px;border-radius:13px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#247bbf) 55%,transparent);opacity:0;pointer-events:none;animation:dsov-liveRim 2.6s ease-in-out infinite}
9029
+ .dsov-section[data-live=true] .dsov-sectionIcon{animation:dsov-pulse 2s ease-in-out infinite}
7928
9030
  .dsov-todoSpin{transform-origin:7.5px 7.5px;animation:dsov-spin 1.1s linear infinite}
7929
9031
  .dsov-todoPulse{animation:dsov-pulse 1.4s ease-in-out infinite}
7930
- @media (prefers-reduced-motion: reduce){.dsov-todoSpin,.dsov-todoPulse,.dsov-step[data-enter=true],.dsov-row[data-enter=true],.dsov-section[data-intro],.dsov-turnStart{animation:none}.dsov-step[data-exit=true]{animation:none;opacity:0}}
9032
+ @media (prefers-reduced-motion: reduce){.dsov-todoSpin,.dsov-todoPulse,.dsov-step[data-enter=true],.dsov-row[data-enter=true],.dsov-section[data-intro],.dsov-turnStart{animation:none}.dsov-step[data-exit=true]{animation:none;opacity:0}.dsov-live::before,.dsov-liveDot,.dsov-liveBars i,.dsov-step[data-active=true],.dsov-step[data-active=true]::before,.dsov-step[data-active=true]::after,.dsov-steps[data-nudge],.dsov-step[data-hit=true],.dsov-step[data-hit=true] .dsov-todoIcon,.dsov-section[data-live=true]::after,.dsov-section[data-live=true] .dsov-sectionIcon{animation:none}.dsov-liveMeterFill{transition:none}}
7931
9033
  .dsov-vikingMode{align-self:flex-start;margin-left:auto}
7932
9034
  .dsov-seg{flex:none;display:inline-flex;gap:2px;padding:2px;border-radius:8px;background:var(--dsw-alias-bg-layer-2)}
7933
9035
  .dsov-segBtn{border:none;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;line-height:18px;padding:0 8px;border-radius:6px;cursor:pointer;white-space:nowrap}
@@ -7979,19 +9081,946 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
7979
9081
  .dsov-ft-ppt{--dsh-file-type-default-color:var(--dsw-static-amber-500)}
7980
9082
  .dsov-ft-video{--dsh-file-type-default-color:var(--dsh-file-type-violet)}
7981
9083
  .dsov-ft-word{--dsh-file-type-default-color:var(--dsw-static-deepseek-450)}
9084
+ .dsov-planActionWrap{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap}
9085
+ .dsov-planActionHost{display:flex;align-items:center;gap:6px;min-width:0}
9086
+ .dsov-planAction{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 12px;border-radius:8px;border:.5px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;cursor:pointer}
9087
+ .dsov-planAction:hover{background:var(--dsw-alias-bg-layer-2)}
9088
+ .dsov-planActionError{font-size:11.5px;color:var(--dsw-alias-state-error-primary)}
9089
+ .dsov-planRow{position:relative;display:flex;align-items:center;margin:0 -8px}
9090
+ .dsov-planRow .dsov-row{flex:1;min-width:0;margin:0}
9091
+ .dsov-planRowAction{position:absolute;right:0;top:50%;transform:translateY(-50%);width:26px;height:26px;border-radius:7px;border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-label-secondary);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;opacity:0;transition:opacity .12s ease;z-index:1}
9092
+ .dsov-planRow:hover .dsov-planRowAction,.dsov-planRow:focus-within .dsov-planRowAction{opacity:1}
9093
+ .dsov-planRow:hover .dsov-rowTail{visibility:hidden}
9094
+ .dsov-planWb{box-sizing:border-box;height:100%;min-height:0;overflow:hidden;padding:0 16px;display:flex;flex-direction:column;font-size:13px}
9095
+ .dsov-planWbHead{flex:none;display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:14px 0 10px;border-bottom:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1)}
9096
+ .dsov-planWbTitle{font-size:13.5px;font-weight:600;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
9097
+ .dsov-planWbState{font-size:11px;line-height:18px;padding:0 7px;border-radius:999px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);white-space:nowrap;flex:none}
9098
+ .dsov-planWbState[data-tone=live]{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent)}
9099
+ .dsov-planWbModes{display:inline-flex;flex:none;padding:2px;gap:2px;border-radius:8px;background:var(--dsw-alias-bg-layer-2)}
9100
+ .dsov-planWbMode{border:none;background:transparent;cursor:pointer;font:inherit;font-size:12px;line-height:20px;padding:0 9px;border-radius:6px;color:var(--dsw-alias-label-secondary)}
9101
+ .dsov-planWbMode[data-active=true]{background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-label-primary);font-weight:600}
9102
+ .dsov-planWbBody{flex:1;min-height:0;overflow-y:auto;scrollbar-gutter:stable;padding:2px 0 12px}
9103
+ .dsov-planWbSections{display:flex;flex-direction:column;gap:2px;padding:6px 0 10px}
9104
+ .dsov-planWbSection{position:relative;padding:4px 30px 4px 0;border-radius:8px}
9105
+ .dsov-planWbSection:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 4%,transparent)}
9106
+ .dsov-planWbSectionBody{min-width:0;overflow-wrap:anywhere}
9107
+ .dsov-planWbWhole{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary);border-bottom:.5px dashed var(--dsw-alias-border-l2);padding-bottom:2px;margin:2px 0 6px}
9108
+ .dsov-planWbAdd{position:absolute;top:6px;right:2px;width:22px;height:22px;border-radius:6px;border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1,transparent);color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:14px;line-height:1;display:inline-flex;align-items:center;justify-content:center;opacity:0;transition:opacity .12s ease}
9109
+ .dsov-planWbSection:hover .dsov-planWbAdd,.dsov-planWbSection:focus-within .dsov-planWbAdd{opacity:1}
9110
+ .dsov-planWbNote{margin:6px 0 4px 12px;padding:6px 10px;border-left:2px solid var(--dsw-alias-brand-primary);border-radius:0 8px 8px 0;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 6%,transparent)}
9111
+ .dsov-planWbNoteHead{display:flex;align-items:flex-start;gap:6px}
9112
+ .dsov-planWbNoteQuote{flex:1;min-width:0;font-size:11.5px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
9113
+ .dsov-planWbNoteDel{flex:none;border:none;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:13px;line-height:16px;padding:0 2px;border-radius:4px}
9114
+ .dsov-planWbNoteDel:hover{color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-bg-layer-2)}
9115
+ .dsov-planWbNoteText{font-size:12.5px;line-height:1.55;color:var(--dsw-alias-label-primary);white-space:pre-wrap;overflow-wrap:anywhere;margin-top:2px}
9116
+ .dsov-planWbEditor{margin:6px 0 6px 12px;display:flex;flex-direction:column;gap:6px}
9117
+ .dsov-planWbTextarea,.dsov-planWbEditText{width:100%;box-sizing:border-box;resize:vertical;border:.5px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1,transparent);color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;line-height:1.55;padding:7px 9px;outline:none}
9118
+ .dsov-planWbTextarea:focus,.dsov-planWbEditText:focus{border-color:var(--dsw-alias-brand-primary)}
9119
+ .dsov-planWbEdit{display:flex;flex-direction:column;gap:8px;padding:8px 0}
9120
+ .dsov-planWbEditText{min-height:52vh;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}
9121
+ .dsov-planWbEditorFoot{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
9122
+ .dsov-planWbHint{flex:1;min-width:120px;font-size:11.5px;color:var(--dsw-alias-label-secondary)}
9123
+ .dsov-planWbPrimary{flex:none;border:none;border-radius:8px;padding:0 12px;height:28px;font:inherit;font-size:12.5px;cursor:pointer;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-onbrand,#fff)}
9124
+ .dsov-planWbPrimary:disabled{opacity:.5;cursor:default}
9125
+ .dsov-planWbGhost{flex:none;border:.5px solid var(--dsw-alias-border-l1);border-radius:8px;padding:0 10px;height:28px;font:inherit;font-size:12.5px;cursor:pointer;background:transparent;color:var(--dsw-alias-label-secondary)}
9126
+ .dsov-planWbGhost:disabled{opacity:.5;cursor:default}
9127
+ .dsov-planWbFoot{flex:none;display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 0 14px;border-top:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1)}
9128
+ .dsov-planWbCount{font-size:12px;color:var(--dsw-alias-label-secondary);flex:none}
9129
+ .dsov-planWbErr{flex:1;min-width:120px;font-size:12px;color:var(--dsw-alias-state-error-primary);overflow-wrap:anywhere}
9130
+ .dsov-planWbNotice{flex:1;min-width:120px;font-size:12px;color:var(--dsw-alias-state-success-primary)}
9131
+ .dsov-planWbUnavailable{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;align-items:flex-start;gap:10px;font-size:13px;color:var(--dsw-alias-label-secondary);padding:14px 2px}
7982
9132
  `;
7983
9133
 
7984
9134
  /* ------------------------------------------------------------------ */
7985
- /* Plugin body */
9135
+ /* Plan workbench (extractable) */
9136
+ /* */
9137
+ /* Review-card annotation + revision for plan-mode plans. This whole */
9138
+ /* region reaches the rest of the module only through planTitleOf, */
9139
+ /* rememberContextPreview / readContextPreview, MarkdownBody, */
9140
+ /* ChatMarkdown, insertIntoComposer, openNativePlan and translate, so */
9141
+ /* it can be lifted into its own plugin without touching anything */
9142
+ /* else. DocumentWorkbench (render/annotate/rewrite) stays free of */
9143
+ /* plan-mode knowledge; the delivery helpers below are the plan half. */
7986
9144
  /* ------------------------------------------------------------------ */
7987
9145
 
7988
- const inject = ["slots", "locale", "sessions", "sidebarRight", "sidebarRightTabs", "uiSession"];
7989
-
7990
- function apply(ctx) {
7991
- runtime = { ctx, t: ctx.locale.bind(NS) };
7992
- mountDesktopRemote(ctx);
9146
+ const PLAN_TAB_ID = "dsh-conversation-overview/plan";
9147
+ const PLAN_TAB_KIND = "overview-plan";
9148
+ const PLAN_REVIEW_ACTION_ID = "dsh-conversation-overview/plan-review";
9149
+ const PLAN_ADDRESS_PREFIX = "dsh-resource://overview-plan/";
9150
+ const PLAN_WB_REF_PREFIX = "planwb|";
9151
+ /* a resource address no protocol claims: the body then keeps to its snapshot */
9152
+ const PLAN_NO_RESOURCE = "sidebar://no-plan-resource";
9153
+ const PLAN_ANNOTATION_STORE_KEY = "dsh.conversationOverview.planAnnotations.v1";
9154
+ const PLAN_ANNOTATION_STORE_VERSION = 1;
9155
+ const PLAN_ANNOTATION_PLANS_LIMIT = 40;
9156
+ const PLAN_ANNOTATION_PER_PLAN_LIMIT = 50;
9157
+ const PLAN_COMMENT_LIMIT = 4000;
9158
+ const PLAN_EDIT_TEXT_LIMIT = 40000;
9159
+ const PLAN_SECTION_QUOTE_LIMIT = 200;
9160
+ /* sectionIndex used by an opinion on the whole plan */
9161
+ const PLAN_WHOLE = -1;
9162
+
9163
+ /** Resource address of one logged plan; mirrors the plan plugin's own scheme. */
9164
+ function planResourceAddress(sessionId, callId) {
9165
+ if (typeof sessionId !== "string" || sessionId === "" || typeof callId !== "string" || callId === "") return undefined;
9166
+ let segments;
9167
+ try {
9168
+ const sessions = runtime && runtime.ctx ? runtime.ctx.sessions : undefined;
9169
+ const child = sessions !== undefined && sessions !== null && typeof sessions.subagentAddress === "function" ? sessions.subagentAddress(sessionId) : undefined;
9170
+ segments = child === undefined || child === null ? [sessionId, callId] : ["subagent", child.parentSessionId, child.childSessionId, child.mode, callId];
9171
+ } catch {
9172
+ segments = [sessionId, callId]; /* uninjected ctx access throws; plain sessions are the common case */
9173
+ }
9174
+ return `dsh-resource://plan/${segments.map(encodeURIComponent).join("/")}`;
9175
+ }
7993
9176
 
7994
- ctx.effect(() => ctx.locale.register(NS, { zh, en }), "conversation-overview: dictionaries");
9177
+ /* ---------------------------------------------------------------- */
9178
+ /* Identity + address */
9179
+ /* ---------------------------------------------------------------- */
9180
+
9181
+ /** Stable per-plan identity: the logged call id wins, then the review request, then the snapshot. */
9182
+ function planWorkbenchIdentity(callId, requestKey, previewKey) {
9183
+ if (typeof callId === "string" && callId !== "") return `call:${callId}`;
9184
+ if (typeof requestKey === "string" && requestKey !== "") return `review:${requestKey}`;
9185
+ if (typeof previewKey === "string" && previewKey !== "") return `artifact:${previewKey}`;
9186
+ return undefined;
9187
+ }
9188
+
9189
+ function planWorkbenchKey(sessionId, identity) {
9190
+ return `${sessionId}|${PLAN_WB_REF_PREFIX}${identity}`;
9191
+ }
9192
+
9193
+ function parsePlanWorkbenchKey(key) {
9194
+ if (typeof key !== "string") return undefined;
9195
+ const separator = key.indexOf("|");
9196
+ if (separator <= 0) return undefined;
9197
+ const sessionId = key.slice(0, separator);
9198
+ const rest = key.slice(separator + 1);
9199
+ if (!rest.startsWith(PLAN_WB_REF_PREFIX)) return undefined;
9200
+ const identity = rest.slice(PLAN_WB_REF_PREFIX.length);
9201
+ const colon = identity.indexOf(":");
9202
+ if (colon <= 0) return undefined;
9203
+ const kind = identity.slice(0, colon);
9204
+ const id = identity.slice(colon + 1);
9205
+ if (id === "" || (kind !== "call" && kind !== "review" && kind !== "artifact")) return undefined;
9206
+ return { sessionId, kind, id };
9207
+ }
9208
+
9209
+ function planWorkbenchAddress(key) {
9210
+ return `${PLAN_ADDRESS_PREFIX}${encodeURIComponent(key)}`;
9211
+ }
9212
+
9213
+ function parsePlanWorkbenchAddress(address) {
9214
+ if (typeof address !== "string" || !address.startsWith(PLAN_ADDRESS_PREFIX)) return undefined;
9215
+ try {
9216
+ const key = decodeURIComponent(address.slice(PLAN_ADDRESS_PREFIX.length));
9217
+ return key === "" ? undefined : key;
9218
+ } catch {
9219
+ return undefined;
9220
+ }
9221
+ }
9222
+
9223
+ /* ---------------------------------------------------------------- */
9224
+ /* Sectioning */
9225
+ /* ---------------------------------------------------------------- */
9226
+
9227
+ /** Split a plan into heading-anchored sections; fences never open a section. */
9228
+ function planSections(text) {
9229
+ const source = typeof text === "string" ? text : "";
9230
+ const lines = source.split("\n");
9231
+ const sections = [];
9232
+ let start = 0;
9233
+ let level = 0;
9234
+ let heading = "";
9235
+ let fence = null;
9236
+ const push = (endLine) => {
9237
+ const body = lines.slice(start, endLine + 1).join("\n").replace(/^\s*\n+/, "").replace(/\n+\s*$/, "");
9238
+ if (body.trim() === "") return;
9239
+ const section = { index: sections.length, level, heading, startLine: start, endLine };
9240
+ section.text = body;
9241
+ section.quote = planSectionQuote(section);
9242
+ sections.push(section);
9243
+ };
9244
+ for (let index = 0; index < lines.length; index += 1) {
9245
+ const trimmed = lines[index].trim();
9246
+ if (fence !== null) {
9247
+ if (trimmed.startsWith(fence)) fence = null;
9248
+ continue;
9249
+ }
9250
+ if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
9251
+ fence = trimmed.slice(0, 3);
9252
+ continue;
9253
+ }
9254
+ const match = /^(#{1,6})[ \t]+(.*)$/.exec(trimmed);
9255
+ if (match === null) continue;
9256
+ push(index - 1);
9257
+ start = index;
9258
+ level = match[1].length;
9259
+ heading = match[2].replace(/[#*`]/g, "").trim().slice(0, 120);
9260
+ }
9261
+ push(lines.length - 1);
9262
+ return sections;
9263
+ }
9264
+
9265
+ /** First body line of a section, collapsed for the feedback message and note cards. */
9266
+ function planSectionQuote(section) {
9267
+ if (!isRecord(section)) return "";
9268
+ const heading = typeof section.heading === "string" ? section.heading : "";
9269
+ const lines = (typeof section.text === "string" ? section.text : "").split("\n");
9270
+ for (const line of lines) {
9271
+ const trimmed = line.trim();
9272
+ if (trimmed === "" || /^#{1,6}[ \t]+/.test(trimmed)) continue;
9273
+ return trimmed.replace(/\s+/g, " ").slice(0, PLAN_SECTION_QUOTE_LIMIT);
9274
+ }
9275
+ return heading.slice(0, PLAN_SECTION_QUOTE_LIMIT);
9276
+ }
9277
+
9278
+ /* ---------------------------------------------------------------- */
9279
+ /* Annotation storage (browser-local) */
9280
+ /* ---------------------------------------------------------------- */
9281
+
9282
+ function emptyPlanBook() {
9283
+ return { annotations: [], edited: "" };
9284
+ }
9285
+
9286
+ function sanitizePlanBook(value) {
9287
+ const source = isRecord(value) ? value : {};
9288
+ const raw = Array.isArray(source.annotations) ? source.annotations : [];
9289
+ const annotations = [];
9290
+ for (const item of raw) {
9291
+ if (annotations.length >= PLAN_ANNOTATION_PER_PLAN_LIMIT) break;
9292
+ if (!isRecord(item)) continue;
9293
+ const comment = typeof item.comment === "string" ? item.comment.trim().slice(0, PLAN_COMMENT_LIMIT) : "";
9294
+ if (comment === "") continue;
9295
+ annotations.push({
9296
+ id: typeof item.id === "string" && item.id !== "" ? item.id : `a${annotations.length}`,
9297
+ sectionIndex: Number.isSafeInteger(item.sectionIndex) ? item.sectionIndex : PLAN_WHOLE,
9298
+ heading: typeof item.heading === "string" ? item.heading.slice(0, 120) : "",
9299
+ quote: typeof item.quote === "string" ? item.quote.slice(0, PLAN_SECTION_QUOTE_LIMIT) : "",
9300
+ comment,
9301
+ createdAt: typeof item.createdAt === "number" ? item.createdAt : 0,
9302
+ });
9303
+ }
9304
+ const edited = typeof source.edited === "string" ? source.edited.slice(0, PLAN_EDIT_TEXT_LIMIT) : "";
9305
+ return { annotations, edited };
9306
+ }
9307
+
9308
+ function readPlanAnnotationStore() {
9309
+ try {
9310
+ const raw = window.localStorage.getItem(PLAN_ANNOTATION_STORE_KEY);
9311
+ const parsed = typeof raw === "string" ? JSON.parse(raw) : null;
9312
+ if (!isRecord(parsed) || !isRecord(parsed.byPlan)) return { version: PLAN_ANNOTATION_STORE_VERSION, byPlan: {} };
9313
+ return { version: PLAN_ANNOTATION_STORE_VERSION, byPlan: parsed.byPlan };
9314
+ } catch {
9315
+ return { version: PLAN_ANNOTATION_STORE_VERSION, byPlan: {} };
9316
+ }
9317
+ }
9318
+
9319
+ function readPlanBook(key) {
9320
+ if (typeof key !== "string" || key === "") return emptyPlanBook();
9321
+ return sanitizePlanBook(readPlanAnnotationStore().byPlan[key]);
9322
+ }
9323
+
9324
+ function writePlanBook(key, book) {
9325
+ if (typeof key !== "string" || key === "") return;
9326
+ try {
9327
+ const store = readPlanAnnotationStore();
9328
+ const next = sanitizePlanBook(book);
9329
+ if (next.annotations.length === 0 && next.edited === "") delete store.byPlan[key];
9330
+ else store.byPlan[key] = { ...next, updatedAt: Date.now() };
9331
+ const keys = Object.keys(store.byPlan);
9332
+ if (keys.length > PLAN_ANNOTATION_PLANS_LIMIT) {
9333
+ keys.sort((a, b) => (Number(store.byPlan[a].updatedAt) || 0) - (Number(store.byPlan[b].updatedAt) || 0));
9334
+ for (const stale of keys.slice(0, keys.length - PLAN_ANNOTATION_PLANS_LIMIT)) delete store.byPlan[stale];
9335
+ }
9336
+ window.localStorage.setItem(PLAN_ANNOTATION_STORE_KEY, JSON.stringify({ version: PLAN_ANNOTATION_STORE_VERSION, byPlan: store.byPlan }));
9337
+ } catch {
9338
+ /* storage full or unavailable: the in-memory book still serves this tab */
9339
+ }
9340
+ }
9341
+
9342
+ /* ---------------------------------------------------------------- */
9343
+ /* Feedback + delivery */
9344
+ /* ---------------------------------------------------------------- */
9345
+
9346
+ /** Structured revision request, or null when there is nothing to send. */
9347
+ function buildPlanFeedback(data, translator) {
9348
+ const t = typeof translator === "function" ? translator : translate;
9349
+ const record = isRecord(data) ? data : {};
9350
+ const annotations = Array.isArray(record.annotations) ? record.annotations : [];
9351
+ const edited = typeof record.edited === "string" ? record.edited.trim() : "";
9352
+ if (annotations.length === 0 && edited === "") return null;
9353
+ const title = typeof record.title === "string" && record.title !== "" ? record.title : t("planWorkbench.untitled");
9354
+ const ordered = annotations.slice().sort((a, b) => {
9355
+ const left = a.sectionIndex === PLAN_WHOLE ? -1 : a.sectionIndex;
9356
+ const right = b.sectionIndex === PLAN_WHOLE ? -1 : b.sectionIndex;
9357
+ return left - right;
9358
+ });
9359
+ const lines = [t("planWorkbench.feedbackIntro", { title })];
9360
+ ordered.forEach((annotation, index) => {
9361
+ const heading = typeof annotation.heading === "string" && annotation.heading !== "" ? annotation.heading : t("planWorkbench.whole");
9362
+ lines.push("");
9363
+ lines.push(t("planWorkbench.feedbackHead", { n: index + 1, heading }));
9364
+ if (typeof annotation.quote === "string" && annotation.quote !== "") lines.push(t("planWorkbench.feedbackQuote", { quote: annotation.quote }));
9365
+ lines.push(t("planWorkbench.feedbackComment", { comment: annotation.comment }));
9366
+ });
9367
+ if (edited !== "") {
9368
+ lines.push("");
9369
+ lines.push(t("planWorkbench.feedbackEdit"));
9370
+ lines.push("");
9371
+ lines.push(edited);
9372
+ }
9373
+ lines.push("");
9374
+ lines.push(t("planWorkbench.feedbackOutro"));
9375
+ return lines.join("\n");
9376
+ }
9377
+
9378
+ /** The live plan-review interaction this workbench may answer, or null. */
9379
+ function findLivePlanReview(pending, key, text) {
9380
+ if (!isRecord(pending) || pending.kind !== "plan-review" || typeof pending.answer !== "function") return null;
9381
+ try {
9382
+ const snapshot = typeof pending.snapshot === "function" ? pending.snapshot() : null;
9383
+ if (isRecord(snapshot) && snapshot.channel === "none") return null;
9384
+ } catch {
9385
+ /* snapshot is best-effort; answer() reports the real channel */
9386
+ }
9387
+ const questions = Array.isArray(pending.questions) ? pending.questions : [];
9388
+ const question = questions.find((item) => isRecord(item) && typeof item.id === "string" && item.id !== "");
9389
+ if (question === undefined) return null;
9390
+ const intent = isRecord(question.intent) ? question.intent : null;
9391
+ const callId = intent !== null && intent.kind === "plan-review" && typeof intent.callId === "string" && intent.callId !== "" ? intent.callId : undefined;
9392
+ const identity = parsePlanWorkbenchKey(key);
9393
+ if (callId !== undefined && identity !== undefined && identity.kind === "call") {
9394
+ return callId === identity.id ? { interaction: pending, reviewId: question.id } : null;
9395
+ }
9396
+ /* no call id on either side (or a snapshot identity): fall back to the plan text */
9397
+ const detail = typeof question.detail === "string" ? question.detail : "";
9398
+ if (detail === "" || text === "") return null;
9399
+ const same = detail === text || (text.length >= 200 && detail.startsWith(text.slice(0, Math.min(text.length, 2000))));
9400
+ return same ? { interaction: pending, reviewId: question.id } : null;
9401
+ }
9402
+
9403
+ /** Where the feedback can go: the live review, the composer draft, or the clipboard. */
9404
+ function planDelivery(options) {
9405
+ const record = isRecord(options) ? options : {};
9406
+ if (record.live !== null && record.live !== undefined) return "review";
9407
+ if (record.hasInputActions === true) return "draft";
9408
+ return "copy";
9409
+ }
9410
+
9411
+ function planErrorMessage(cause) {
9412
+ if (cause instanceof Error && cause.message !== "") return cause.message;
9413
+ return typeof cause === "string" && cause !== "" ? cause : translate("planWorkbench.failed");
9414
+ }
9415
+
9416
+ function useSessionPendingInteraction(sessionId) {
9417
+ const source = readSessionStatusSource();
9418
+ const subscribe = React.useCallback(
9419
+ (onChange) => (source !== undefined && typeof source.subscribe === "function" ? source.subscribe(onChange) : () => {}),
9420
+ [source]
9421
+ );
9422
+ const read = React.useCallback(() => {
9423
+ if (source === undefined || typeof source.getSnapshot !== "function" || typeof sessionId !== "string") return undefined;
9424
+ try {
9425
+ const snapshot = source.getSnapshot();
9426
+ const status = snapshot !== null && snapshot !== undefined && typeof snapshot.get === "function" ? snapshot.get(sessionId) : undefined;
9427
+ return status !== null && status !== undefined ? status.pendingInteraction : undefined;
9428
+ } catch {
9429
+ return undefined;
9430
+ }
9431
+ }, [source, sessionId]);
9432
+ return React.useSyncExternalStore(subscribe, read);
9433
+ }
9434
+
9435
+ /* ---------------------------------------------------------------- */
9436
+ /* Open entry */
9437
+ /* ---------------------------------------------------------------- */
9438
+
9439
+ /** Capture the plan snapshot and open its workbench tab; false when no sidebar can hold it. */
9440
+ function openPlanWorkbench(payload) {
9441
+ const record = isRecord(payload) ? payload : {};
9442
+ const sessionId = typeof record.sessionId === "string" && record.sessionId !== "" ? record.sessionId : undefined;
9443
+ if (sessionId === undefined) return false;
9444
+ const identity = planWorkbenchIdentity(record.callId, record.requestKey, record.previewKey);
9445
+ if (identity === undefined) return false;
9446
+ const markdown = typeof record.markdown === "string" ? record.markdown : "";
9447
+ const title = typeof record.title === "string" && record.title !== "" ? record.title : planTitleOf(markdown);
9448
+ const stored = rememberContextPreview(sessionId, `${PLAN_WB_REF_PREFIX}${identity}`, title, markdown, "plan");
9449
+ const key = typeof stored === "string" && stored !== "" ? stored : planWorkbenchKey(sessionId, identity);
9450
+ const sidebar = contextSidebar();
9451
+ if (sidebar === null) return false;
9452
+ try {
9453
+ sidebar.openResource(planWorkbenchAddress(key));
9454
+ return true;
9455
+ } catch {
9456
+ return false;
9457
+ }
9458
+ }
9459
+
9460
+ /** Leave the workbench once its review feedback has landed: replace this tab with
9461
+ the overview page so the approval tab closes and the column returns to 概览.
9462
+ Falls back to closing alone, and reports whether anything happened so the
9463
+ caller can keep the inline notice when no tab surface can act. */
9464
+ function finishPlanReviewTab(tab) {
9465
+ const actions = isRecord(tab) && isRecord(tab.actions) ? tab.actions : null;
9466
+ if (actions === null) return false;
9467
+ try {
9468
+ if (typeof actions.openTab === "function") {
9469
+ actions.openTab(TAB_KIND, { replaceTab: true });
9470
+ return true;
9471
+ }
9472
+ } catch {
9473
+ /* the overview page type is not registered in this placement: close instead */
9474
+ }
9475
+ try {
9476
+ if (typeof actions.close === "function") {
9477
+ actions.close();
9478
+ return true;
9479
+ }
9480
+ } catch {
9481
+ /* nothing left to act on */
9482
+ }
9483
+ return false;
9484
+ }
9485
+
9486
+ /** The review card's footer button row, found without knowing the host's hashed
9487
+ CSS-module prefix. The card root carries `data-plan-review-key`. */
9488
+ function planReviewFooterActions(card) {
9489
+ if (card === null || typeof card !== "object") return null;
9490
+ if (typeof card.querySelector === "function") {
9491
+ const direct = card.querySelector('[class*="_footer"] [class*="_actions"]');
9492
+ if (direct !== null && direct !== undefined) return direct;
9493
+ }
9494
+ const groups = typeof card.querySelectorAll === "function" ? card.querySelectorAll('[class*="_actions"]') : null;
9495
+ if (groups !== null && typeof groups[Symbol.iterator] === "function") {
9496
+ for (const group of groups) {
9497
+ const buttons = typeof group.querySelectorAll === "function" ? group.querySelectorAll("button") : null;
9498
+ if (buttons !== null && buttons !== undefined && typeof buttons.length === "number" && buttons.length >= 2) return group;
9499
+ }
9500
+ }
9501
+ return null;
9502
+ }
9503
+
9504
+ /** Review-card action: a pill in the shipped plan-review footer row. The slot seat
9505
+ itself is the warning strip's preview-actions area, so the pill is portalled into
9506
+ the card footer to sit beside 「要求修改」/「同意执行」 with the host's own button
9507
+ styling (falls back to the strip pill when that footer cannot be located). */
9508
+ function PlanReviewAnnotateAction(props) {
9509
+ const t = typeof props.t === "function" ? props.t : translate;
9510
+ const review = isRecord(props.review) ? props.review : null;
9511
+ const requestKey = typeof props.requestKey === "string" ? props.requestKey : "";
9512
+ const [failed, setFailed] = React.useState(false);
9513
+ /* false = render in the slot seat (first paint and no-DOM fallback) */
9514
+ const [mount, setMount] = React.useState(false);
9515
+ const useIsoLayoutEffect = typeof React.useLayoutEffect === "function" ? React.useLayoutEffect : React.useEffect;
9516
+ const open = () => {
9517
+ const ok = openPlanWorkbench({
9518
+ sessionId: props.sessionId,
9519
+ callId: review !== null && typeof review.callId === "string" ? review.callId : undefined,
9520
+ requestKey: props.requestKey,
9521
+ title: review !== null ? planTitleOf(review.plan) : "",
9522
+ markdown: review !== null && typeof review.plan === "string" ? review.plan : "",
9523
+ });
9524
+ setFailed(ok !== true);
9525
+ };
9526
+ useIsoLayoutEffect(() => {
9527
+ if (createPortal === null || requestKey === "" || typeof document === "undefined" || !document.body || typeof document.querySelectorAll !== "function") {
9528
+ return undefined;
9529
+ }
9530
+ let card = null;
9531
+ for (const node of document.querySelectorAll("[data-plan-review-key]")) {
9532
+ if (node.getAttribute("data-plan-review-key") === requestKey) {
9533
+ card = node;
9534
+ break;
9535
+ }
9536
+ }
9537
+ if (card === null) return undefined;
9538
+ const actions = planReviewFooterActions(card);
9539
+ if (actions === null || typeof actions.insertBefore !== "function") return undefined;
9540
+ const host = document.createElement("div");
9541
+ host.className = "dsov-planActionHost";
9542
+ try {
9543
+ actions.insertBefore(host, actions.lastElementChild);
9544
+ } catch {
9545
+ return undefined;
9546
+ }
9547
+ setMount(host);
9548
+ return () => {
9549
+ if (typeof host.remove === "function") host.remove();
9550
+ else if (host.parentNode !== null) host.parentNode.removeChild(host);
9551
+ };
9552
+ }, [requestKey]);
9553
+ const pill =
9554
+ primitives !== null && primitives.Button !== undefined && primitives.Button !== null
9555
+ ? h(
9556
+ primitives.Button,
9557
+ { type: "button", variant: "outline", icon: h(IconPlan, { size: 14 }), title: t("planWorkbench.actionHint"), onClick: open },
9558
+ t("planWorkbench.action")
9559
+ )
9560
+ : h(
9561
+ "button",
9562
+ { type: "button", className: "dsov-planAction", onClick: open, title: t("planWorkbench.actionHint") },
9563
+ h(IconPlan, { size: 14 }),
9564
+ h("span", null, t("planWorkbench.action"))
9565
+ );
9566
+ const wrap = h(
9567
+ "span",
9568
+ { className: "dsov-planActionWrap", "data-in-footer": mount === false ? undefined : "true" },
9569
+ pill,
9570
+ failed ? h("span", { className: "dsov-planActionError" }, t("planWorkbench.openFailed")) : null
9571
+ );
9572
+ return mount === false ? wrap : createPortal(wrap, mount);
9573
+ }
9574
+
9575
+ /** Row menu beside one overview plan artifact. */
9576
+ function PlanRowMenu({ menu, t, onClose }) {
9577
+ const clamp = (value, max) => Math.max(8, Math.min(value, max));
9578
+ const style = {
9579
+ left: `${clamp(menu.x, (typeof window !== "undefined" ? window.innerWidth : 1200) - 220)}px`,
9580
+ top: `${clamp(menu.y, (typeof window !== "undefined" ? window.innerHeight : 800) - 170)}px`,
9581
+ };
9582
+ const artifact = isRecord(menu.artifact) ? menu.artifact : {};
9583
+ const run = (action) => {
9584
+ try {
9585
+ action();
9586
+ } finally {
9587
+ onClose();
9588
+ }
9589
+ };
9590
+ const entry = typeof artifact.previewKey === "string" && artifact.previewKey !== "" ? readContextPreview(artifact.previewKey) : null;
9591
+ return h(
9592
+ "div",
9593
+ { className: "dsov-fileMenu", style, onMouseDown: (event) => event.stopPropagation(), onContextMenu: (event) => event.preventDefault() },
9594
+ h(
9595
+ "button",
9596
+ {
9597
+ type: "button",
9598
+ className: "dsov-fileMenuItem",
9599
+ onClick: () =>
9600
+ run(() =>
9601
+ openPlanWorkbench({
9602
+ sessionId: menu.sessionId,
9603
+ callId: artifact.planCallId,
9604
+ previewKey: artifact.previewKey,
9605
+ title: artifact.planTitle,
9606
+ markdown: entry !== null ? entry.text : "",
9607
+ })
9608
+ ),
9609
+ },
9610
+ t("planWorkbench.menuAnnotate")
9611
+ ),
9612
+ h("div", { className: "dsov-fileMenuSep" }),
9613
+ h(
9614
+ "button",
9615
+ {
9616
+ type: "button",
9617
+ className: "dsov-fileMenuItem",
9618
+ disabled: entry === null,
9619
+ onClick: () =>
9620
+ run(() => {
9621
+ if (entry === null) return;
9622
+ const result = insertIntoComposer(entry.text);
9623
+ void result;
9624
+ }),
9625
+ },
9626
+ t("planWorkbench.menuCopy")
9627
+ ),
9628
+ h(
9629
+ "button",
9630
+ { type: "button", className: "dsov-fileMenuItem", onClick: () => run(() => openNativePlan(menu.sessionId, artifact.planCallId)) },
9631
+ t("planWorkbench.openOriginal")
9632
+ )
9633
+ );
9634
+ }
9635
+
9636
+ /* ---------------------------------------------------------------- */
9637
+ /* Workbench tab body */
9638
+ /* ---------------------------------------------------------------- */
9639
+
9640
+ function PlanWorkbenchBody(props) {
9641
+ const t = typeof props.t === "function" ? props.t : translate;
9642
+ const info = typeof props.useTabInfo === "function" ? props.useTabInfo() : null;
9643
+ const address = info !== null && isRecord(info.tab) ? info.tab.contentId : undefined;
9644
+ const key = parsePlanWorkbenchAddress(address);
9645
+ const identity = key === undefined ? undefined : parsePlanWorkbenchKey(key);
9646
+ const snapshot = key === undefined ? null : readContextPreview(key);
9647
+ const callId = identity !== undefined && identity.kind === "call" ? identity.id : undefined;
9648
+ const resourceAddress = identity !== undefined && callId !== undefined ? planResourceAddress(identity.sessionId, callId) : PLAN_NO_RESOURCE;
9649
+ const resource = typeof props.useResource === "function" ? props.useResource(resourceAddress) : null;
9650
+ const resourceValue = isRecord(resource) && resource.status === "live" && isRecord(resource.value) ? resource.value : null;
9651
+ const text = resourceValue !== null && typeof resourceValue.markdown === "string" ? resourceValue.markdown : snapshot !== null ? snapshot.text : "";
9652
+ const title =
9653
+ resourceValue !== null && typeof resourceValue.title === "string" && resourceValue.title !== ""
9654
+ ? resourceValue.title
9655
+ : snapshot !== null
9656
+ ? snapshot.title
9657
+ : t("planWorkbench.tabTitle");
9658
+ const foreign =
9659
+ identity !== undefined && typeof props.sessionId === "string" && props.sessionId !== "" && identity.sessionId !== props.sessionId;
9660
+ const pending = useSessionPendingInteraction(identity === undefined ? undefined : identity.sessionId);
9661
+ const live = findLivePlanReview(pending, key === undefined ? "" : key, text);
9662
+ const hasInputActions = isRecord(props.inputActions) && typeof props.inputActions.setDraft === "function";
9663
+ const delivery = planDelivery({ live, hasInputActions });
9664
+
9665
+ const [mode, setMode] = React.useState("annotate");
9666
+ const [book, setBook] = React.useState(() => (key === undefined ? emptyPlanBook() : readPlanBook(key)));
9667
+ const [draftEdit, setDraftEdit] = React.useState(null);
9668
+ const [editingAt, setEditingAt] = React.useState(null);
9669
+ const [commentDraft, setCommentDraft] = React.useState("");
9670
+ const [busy, setBusy] = React.useState(false);
9671
+ const [error, setError] = React.useState(null);
9672
+ const [notice, setNotice] = React.useState(null);
9673
+
9674
+ React.useEffect(() => {
9675
+ setBook(key === undefined ? emptyPlanBook() : readPlanBook(key));
9676
+ setDraftEdit(null);
9677
+ setEditingAt(null);
9678
+ setCommentDraft("");
9679
+ setBusy(false);
9680
+ setError(null);
9681
+ setNotice(null);
9682
+ }, [key]);
9683
+
9684
+ const sections = React.useMemo(() => planSections(text), [text]);
9685
+ const editedValue = draftEdit !== null ? draftEdit : book.edited !== "" ? book.edited : text;
9686
+ const editedText = draftEdit !== null ? draftEdit : book.edited;
9687
+ const annotationCount = book.annotations.length;
9688
+ const saveBook = (next) => {
9689
+ const clean = sanitizePlanBook(next);
9690
+ setBook(clean);
9691
+ if (key !== undefined) writePlanBook(key, clean);
9692
+ };
9693
+
9694
+ const openOriginal = () => {
9695
+ if (identity === undefined || identity.kind !== "call") return false;
9696
+ return openNativePlan(identity.sessionId, identity.id);
9697
+ };
9698
+
9699
+ const markdownOf = (value) => (ChatMarkdown !== null ? h(ChatMarkdown, { text: value, labels: chatMarkdownLabels(t) }) : h(MarkdownBody, { text: value }));
9700
+
9701
+ const addAnnotation = (sectionIndex, heading, quote) => {
9702
+ const comment = commentDraft.trim();
9703
+ if (comment === "") return;
9704
+ const item = {
9705
+ id: `a${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
9706
+ sectionIndex,
9707
+ heading,
9708
+ quote,
9709
+ comment,
9710
+ createdAt: Date.now(),
9711
+ };
9712
+ saveBook({ annotations: [...book.annotations, item], edited: book.edited });
9713
+ setCommentDraft("");
9714
+ setEditingAt(null);
9715
+ setError(null);
9716
+ setNotice(null);
9717
+ };
9718
+
9719
+ const removeAnnotation = (id) => {
9720
+ saveBook({ annotations: book.annotations.filter((item) => item.id !== id), edited: book.edited });
9721
+ };
9722
+
9723
+ const submit = () => {
9724
+ const feedback = buildPlanFeedback({ title, annotations: book.annotations, edited: editedText }, t);
9725
+ if (feedback === null) {
9726
+ setError(t("planWorkbench.empty"));
9727
+ return;
9728
+ }
9729
+ setError(null);
9730
+ setNotice(null);
9731
+ if (live !== null) {
9732
+ setBusy(true);
9733
+ let answer;
9734
+ try {
9735
+ answer = live.interaction.answer({ answers: [{ id: live.reviewId, selected: [], custom: feedback }] });
9736
+ } catch (cause) {
9737
+ setBusy(false);
9738
+ setError(planErrorMessage(cause));
9739
+ return;
9740
+ }
9741
+ Promise.resolve(answer).then(
9742
+ () => {
9743
+ setBusy(false);
9744
+ /* the feedback reached the live review: retire the approval tab and land
9745
+ back on the overview instead of leaving the stale plan on screen */
9746
+ const tab = info !== null && isRecord(info.tab) ? info.tab : null;
9747
+ if (!finishPlanReviewTab(tab)) setNotice(t("planWorkbench.submitted"));
9748
+ },
9749
+ (cause) => {
9750
+ setBusy(false);
9751
+ setError(planErrorMessage(cause));
9752
+ }
9753
+ );
9754
+ return;
9755
+ }
9756
+ if (hasInputActions) {
9757
+ try {
9758
+ props.inputActions.setDraft(feedback);
9759
+ if (typeof props.inputActions.persistDraft === "function") props.inputActions.persistDraft();
9760
+ setNotice(t("planWorkbench.drafted"));
9761
+ } catch (cause) {
9762
+ setError(planErrorMessage(cause));
9763
+ }
9764
+ return;
9765
+ }
9766
+ const result = insertIntoComposer(feedback);
9767
+ if (result === "failed") setError(t("planWorkbench.copyFailed"));
9768
+ else setNotice(result === "copied" ? t("planWorkbench.copied") : t("planWorkbench.drafted"));
9769
+ };
9770
+
9771
+ const copyFeedback = () => {
9772
+ const feedback = buildPlanFeedback({ title, annotations: book.annotations, edited: editedText }, t);
9773
+ if (feedback === null) {
9774
+ setError(t("planWorkbench.empty"));
9775
+ return;
9776
+ }
9777
+ setError(null);
9778
+ try {
9779
+ if (typeof navigator !== "undefined" && navigator.clipboard !== undefined && typeof navigator.clipboard.writeText === "function") {
9780
+ navigator.clipboard.writeText(feedback).then(
9781
+ () => setNotice(t("planWorkbench.copied")),
9782
+ () => setError(t("planWorkbench.copyFailed"))
9783
+ );
9784
+ return;
9785
+ }
9786
+ } catch {
9787
+ /* fall through to the error notice */
9788
+ }
9789
+ setError(t("planWorkbench.copyFailed"));
9790
+ };
9791
+
9792
+ if (key === undefined || identity === undefined || text === "") {
9793
+ return h(
9794
+ "div",
9795
+ { className: "dsov-planWb" },
9796
+ h("div", { className: "dsov-planWbHead" }, h("span", { className: "dsov-planWbTitle" }, title)),
9797
+ h(
9798
+ "div",
9799
+ { className: "dsov-planWbUnavailable" },
9800
+ h("span", null, t("planWorkbench.unavailable")),
9801
+ identity !== undefined && identity.kind === "call"
9802
+ ? h("button", { type: "button", className: "dsov-headAction", onClick: openOriginal }, h(IconPlan, { size: 13 }), t("planWorkbench.openOriginal"))
9803
+ : null
9804
+ )
9805
+ );
9806
+ }
9807
+
9808
+ const label = (section) => (section.heading !== "" ? section.heading : t("planWorkbench.preface")) || t("planWorkbench.whole");
9809
+
9810
+ const noteCards = (sectionIndex) =>
9811
+ book.annotations
9812
+ .filter((item) => item.sectionIndex === sectionIndex)
9813
+ .map((item) =>
9814
+ h(
9815
+ "div",
9816
+ { className: "dsov-planWbNote", key: item.id },
9817
+ h(
9818
+ "div",
9819
+ { className: "dsov-planWbNoteHead" },
9820
+ h("span", { className: "dsov-planWbNoteQuote" }, item.quote !== "" ? item.quote : item.heading !== "" ? item.heading : t("planWorkbench.whole")),
9821
+ h(
9822
+ "button",
9823
+ { type: "button", className: "dsov-planWbNoteDel", title: t("planWorkbench.remove"), "aria-label": t("planWorkbench.remove"), onClick: () => removeAnnotation(item.id) },
9824
+ "×"
9825
+ )
9826
+ ),
9827
+ h("div", { className: "dsov-planWbNoteText" }, item.comment)
9828
+ )
9829
+ );
9830
+
9831
+ const editorFor = (sectionIndex, heading, quote) =>
9832
+ h(
9833
+ "div",
9834
+ { className: "dsov-planWbEditor" },
9835
+ h("textarea", {
9836
+ className: "dsov-planWbTextarea",
9837
+ rows: 3,
9838
+ value: commentDraft,
9839
+ placeholder: t("planWorkbench.commentPlaceholder"),
9840
+ "aria-label": t("planWorkbench.commentAria", { heading: heading !== "" ? heading : t("planWorkbench.whole") }),
9841
+ onChange: (event) => setCommentDraft(event.target.value),
9842
+ onKeyDown: (event) => {
9843
+ if (event.key === "Escape") {
9844
+ setEditingAt(null);
9845
+ setCommentDraft("");
9846
+ }
9847
+ },
9848
+ }),
9849
+ h(
9850
+ "div",
9851
+ { className: "dsov-planWbEditorFoot" },
9852
+ h("button", { type: "button", className: "dsov-planWbPrimary", disabled: commentDraft.trim() === "", onClick: () => addAnnotation(sectionIndex, heading, quote) }, t("planWorkbench.save")),
9853
+ h(
9854
+ "button",
9855
+ {
9856
+ type: "button",
9857
+ className: "dsov-planWbGhost",
9858
+ onClick: () => {
9859
+ setEditingAt(null);
9860
+ setCommentDraft("");
9861
+ },
9862
+ },
9863
+ t("planWorkbench.cancel")
9864
+ )
9865
+ )
9866
+ );
9867
+
9868
+ const addButton = (sectionIndex, heading, quote) =>
9869
+ h(
9870
+ "button",
9871
+ {
9872
+ type: "button",
9873
+ className: "dsov-planWbAdd",
9874
+ title: t("planWorkbench.addHere"),
9875
+ "aria-label": t("planWorkbench.addAria", { heading: heading !== "" ? heading : t("planWorkbench.whole") }),
9876
+ onClick: () => {
9877
+ setEditingAt(editingAt === sectionIndex ? null : sectionIndex);
9878
+ setCommentDraft("");
9879
+ },
9880
+ },
9881
+ "+"
9882
+ );
9883
+
9884
+ const wholeLabel = t("planWorkbench.whole");
9885
+ const sectionsNode = h(
9886
+ "div",
9887
+ { className: "dsov-planWbSections" },
9888
+ h(
9889
+ "section",
9890
+ { className: "dsov-planWbSection", "data-whole": "true" },
9891
+ h("div", { className: "dsov-planWbSectionBody" }, h("div", { className: "dsov-planWbWhole" }, wholeLabel)),
9892
+ addButton(PLAN_WHOLE, wholeLabel, ""),
9893
+ noteCards(PLAN_WHOLE),
9894
+ editingAt === PLAN_WHOLE ? editorFor(PLAN_WHOLE, wholeLabel, "") : null
9895
+ ),
9896
+ sections.map((section) =>
9897
+ h(
9898
+ "section",
9899
+ { className: "dsov-planWbSection", key: `s${section.index}`, "data-level": String(section.level) },
9900
+ h("div", { className: "dsov-planWbSectionBody" }, markdownOf(section.text)),
9901
+ addButton(section.index, section.heading, section.quote),
9902
+ noteCards(section.index),
9903
+ editingAt === section.index ? editorFor(section.index, section.heading, section.quote) : null
9904
+ )
9905
+ )
9906
+ );
9907
+ const annotateBody =
9908
+ ChatMarkdown !== null && ChatMarkdownProvider !== null
9909
+ ? h(ChatMarkdownProvider, {
9910
+ openFile: (path) => openDocLinkedFile(identity.sessionId, path),
9911
+ openExternalLink: openDocExternalLink,
9912
+ children: sectionsNode,
9913
+ })
9914
+ : sectionsNode;
9915
+
9916
+ const editorBody = h(
9917
+ "div",
9918
+ { className: "dsov-planWbEdit" },
9919
+ h("textarea", {
9920
+ className: "dsov-planWbEditText",
9921
+ value: editedValue,
9922
+ readOnly: foreign,
9923
+ "aria-label": t("planWorkbench.editAria"),
9924
+ onChange: (event) => setDraftEdit(event.target.value),
9925
+ }),
9926
+ h(
9927
+ "div",
9928
+ { className: "dsov-planWbEditorFoot" },
9929
+ h("span", { className: "dsov-planWbHint" }, t("planWorkbench.editHint")),
9930
+ h(
9931
+ "button",
9932
+ {
9933
+ type: "button",
9934
+ className: "dsov-planWbGhost",
9935
+ disabled: book.edited === "",
9936
+ onClick: () => {
9937
+ setDraftEdit("");
9938
+ saveBook({ annotations: book.annotations, edited: "" });
9939
+ },
9940
+ },
9941
+ t("planWorkbench.revert")
9942
+ ),
9943
+ h(
9944
+ "button",
9945
+ {
9946
+ type: "button",
9947
+ className: "dsov-planWbPrimary",
9948
+ onClick: () => {
9949
+ saveBook({ annotations: book.annotations, edited: editedValue });
9950
+ setDraftEdit(null);
9951
+ setNotice(t("planWorkbench.editSaved"));
9952
+ },
9953
+ },
9954
+ t("planWorkbench.editSave")
9955
+ )
9956
+ )
9957
+ );
9958
+
9959
+ const submitLabel =
9960
+ delivery === "review" ? t("planWorkbench.submitReview") : delivery === "draft" ? t("planWorkbench.submitDraft") : t("planWorkbench.submitComposer");
9961
+ const canSubmit = foreign !== true && busy !== true && (annotationCount > 0 || editedText !== "");
9962
+
9963
+ return h(
9964
+ "div",
9965
+ { className: "dsov-planWb", "data-foreign": foreign === true ? "true" : "false" },
9966
+ h(
9967
+ "div",
9968
+ { className: "dsov-planWbHead" },
9969
+ h("span", { className: "dsov-planWbTitle", title }, title),
9970
+ h("span", { className: "dsov-planWbState", "data-tone": live !== null ? "live" : "quiet" }, live !== null ? t("planWorkbench.reviewPending") : t("planWorkbench.reviewClosed")),
9971
+ h(
9972
+ "div",
9973
+ { className: "dsov-planWbModes", role: "group", "aria-label": t("planWorkbench.modes") },
9974
+ h(
9975
+ "button",
9976
+ { type: "button", className: "dsov-planWbMode", "data-active": mode === "annotate" ? "true" : "false", onClick: () => setMode("annotate") },
9977
+ t("planWorkbench.modeAnnotate")
9978
+ ),
9979
+ h("button", { type: "button", className: "dsov-planWbMode", "data-active": mode === "edit" ? "true" : "false", onClick: () => setMode("edit") }, t("planWorkbench.modeEdit"))
9980
+ ),
9981
+ identity.kind === "call"
9982
+ ? h("button", { type: "button", className: "dsov-headAction", onClick: () => void openOriginal() }, h(IconPlan, { size: 13 }), t("planWorkbench.openOriginal"))
9983
+ : null
9984
+ ),
9985
+ h("div", { className: "dsov-planWbBody" }, mode === "edit" ? editorBody : annotateBody),
9986
+ h(
9987
+ "div",
9988
+ { className: "dsov-planWbFoot" },
9989
+ h("span", { className: "dsov-planWbCount" }, t("planWorkbench.count", { n: annotationCount })),
9990
+ error !== null ? h("span", { className: "dsov-planWbErr" }, error) : notice !== null ? h("span", { className: "dsov-planWbNotice" }, notice) : null,
9991
+ h("button", { type: "button", className: "dsov-planWbPrimary", disabled: canSubmit !== true, onClick: submit }, busy === true ? t("planWorkbench.submitting") : submitLabel),
9992
+ h("button", { type: "button", className: "dsov-planWbGhost", onClick: copyFeedback }, t("planWorkbench.copyFeedback"))
9993
+ )
9994
+ );
9995
+ }
9996
+
9997
+ /** Tab chip for one plan workbench: the plan glyph plus the plan title. */
9998
+ function PlanTabTitle(props) {
9999
+ const t = typeof props.t === "function" ? props.t : translate;
10000
+ const info = typeof props.useTabInfo === "function" ? props.useTabInfo() : null;
10001
+ const address = info !== null && isRecord(info.tab) ? info.tab.contentId : undefined;
10002
+ const key = parsePlanWorkbenchAddress(address);
10003
+ const entry = key === undefined ? null : readContextPreview(key);
10004
+ const title = entry !== null ? entry.title : t("planWorkbench.tabTitle");
10005
+ return h(
10006
+ "span",
10007
+ { className: "dsov-vikingHead" },
10008
+ h("span", { className: "dsov-vikingIcon" }, h(IconPlan, { size: 14 })),
10009
+ h("span", { className: "dsov-vikingHeadText" }, h("span", { className: "dsov-vikingName" }, title))
10010
+ );
10011
+ }
10012
+
10013
+ /* ------------------------------------------------------------------ */
10014
+ /* Plugin body */
10015
+ /* ------------------------------------------------------------------ */
10016
+
10017
+ const inject = ["slots", "locale", "sessions", "sidebarRight", "sidebarRightTabs", "uiSession"];
10018
+
10019
+ function apply(ctx) {
10020
+ runtime = { ctx, t: ctx.locale.bind(NS) };
10021
+ mountDesktopRemote(ctx);
10022
+
10023
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "conversation-overview: dictionaries");
7995
10024
 
7996
10025
  ctx.effect(() => {
7997
10026
  if (typeof document === "undefined") return undefined;
@@ -8003,6 +10032,8 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
8003
10032
  document.head.appendChild(style);
8004
10033
  return () => {
8005
10034
  style.remove();
10035
+ if (overlayHost !== null && typeof overlayHost.remove === "function") overlayHost.remove();
10036
+ overlayHost = null;
8006
10037
  };
8007
10038
  }, "conversation-overview: styles");
8008
10039
 
@@ -8216,6 +10247,72 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
8216
10247
  "conversation-overview: context tab title"
8217
10248
  );
8218
10249
 
10250
+ ctx.effect(
10251
+ () =>
10252
+ ctx.sidebarRightTabs.register({
10253
+ id: PLAN_TAB_ID,
10254
+ kind: PLAN_TAB_KIND,
10255
+ patterns: [`${PLAN_ADDRESS_PREFIX}**`],
10256
+ priority: "builtin",
10257
+ multiple: true,
10258
+ canOpen: (address) => parsePlanWorkbenchAddress(address) !== undefined,
10259
+ title: (address) => {
10260
+ const key = parsePlanWorkbenchAddress(address);
10261
+ if (key === undefined) return String(address);
10262
+ const entry = readContextPreview(key);
10263
+ return entry !== null ? entry.title : translate("planWorkbench.tabTitle");
10264
+ },
10265
+ }),
10266
+ "conversation-overview: plan workbench tab type"
10267
+ );
10268
+
10269
+ ctx.effect(
10270
+ () =>
10271
+ ctx.slots.inject(
10272
+ "sidebar.right.pane.tab",
10273
+ () =>
10274
+ ctx.slots.register(
10275
+ {
10276
+ name: "sidebar.right.pane.tab",
10277
+ key: PLAN_TAB_ID,
10278
+ locale: NS,
10279
+ },
10280
+ PlanWorkbenchBody
10281
+ )
10282
+ ),
10283
+ "conversation-overview: plan workbench tab body"
10284
+ );
10285
+
10286
+ ctx.effect(
10287
+ () =>
10288
+ ctx.slots.inject(
10289
+ "sidebar.right.pane.tab.title",
10290
+ () => ctx.slots.register({ name: "sidebar.right.pane.tab.title", key: PLAN_TAB_ID }, PlanTabTitle)
10291
+ ),
10292
+ "conversation-overview: plan workbench tab title"
10293
+ );
10294
+
10295
+ /* The shipped plan-review footer keeps 「要求修改」(dismiss) and 「同意执行」;
10296
+ this pill opens the annotation workbench, whose submit path feeds the same
10297
+ PendingQuestion so the model revises inside the pending turn. */
10298
+ ctx.effect(
10299
+ () =>
10300
+ ctx.slots.inject(
10301
+ "conversation.plan-review.actions",
10302
+ () =>
10303
+ ctx.slots.register(
10304
+ {
10305
+ name: "conversation.plan-review.actions",
10306
+ id: PLAN_REVIEW_ACTION_ID,
10307
+ order: 10,
10308
+ locale: NS,
10309
+ },
10310
+ PlanReviewAnnotateAction
10311
+ )
10312
+ ),
10313
+ "conversation-overview: plan review annotation action"
10314
+ );
10315
+
8219
10316
  ctx.effect(() => {
8220
10317
  if (typeof document === "undefined" || !document.documentElement) return undefined;
8221
10318
  const applyAttr = () => {
@@ -8347,6 +10444,7 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
8347
10444
  zh,
8348
10445
  en,
8349
10446
  createFolder,
10447
+ createSummaryStore,
8350
10448
  foldEvent,
8351
10449
  foldEntry,
8352
10450
  buildState,
@@ -8355,6 +10453,14 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
8355
10453
  planTokens,
8356
10454
  planActiveAt,
8357
10455
  recentStepWindow,
10456
+ stepIdentityOf,
10457
+ stepWindowDelta,
10458
+ completedStepKeys,
10459
+ TAB_KIND,
10460
+ liveStepOf,
10461
+ liveScopeMatches,
10462
+ summarizeTodos,
10463
+ finishPlanReviewTab,
8358
10464
  vikingLabel,
8359
10465
  vikingRefKind,
8360
10466
  vikingIsDir,
@@ -8417,6 +10523,24 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
8417
10523
  preferSessionOverview,
8418
10524
  planTitleOf,
8419
10525
  openNativePlan,
10526
+ planResourceAddress,
10527
+ planWorkbenchIdentity,
10528
+ planWorkbenchKey,
10529
+ parsePlanWorkbenchKey,
10530
+ planWorkbenchAddress,
10531
+ parsePlanWorkbenchAddress,
10532
+ planSections,
10533
+ planSectionQuote,
10534
+ sanitizePlanBook,
10535
+ readPlanBook,
10536
+ writePlanBook,
10537
+ buildPlanFeedback,
10538
+ findLivePlanReview,
10539
+ planDelivery,
10540
+ openPlanWorkbench,
10541
+ PlanWorkbenchBody,
10542
+ PlanReviewAnnotateAction,
10543
+ planReviewFooterActions,
8420
10544
  documentAddress,
8421
10545
  openNativeDocument,
8422
10546
  openArtifact,
@@ -8447,6 +10571,15 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
8447
10571
  stepFull,
8448
10572
  teamStepKey,
8449
10573
  formatDuration,
10574
+ formatDurationPrecise,
10575
+ formatTokensCompact,
10576
+ formatTokenGroups,
10577
+ formatTokensPerSecond,
10578
+ cacheHitPercentOf,
10579
+ turnDetailRows,
10580
+ turnDetailFor,
10581
+ turnDetailOf,
10582
+ foldTurnMetrics,
8450
10583
  rerunPrompt,
8451
10584
  insertIntoComposer,
8452
10585
  requestNavScope,