dsh-conversation-overview 1.3.0 → 1.4.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
@@ -5,6 +5,18 @@ window.__ModuleLoader__.load({
5
5
  const { jsx, jsxs } = require("react/jsx-runtime");
6
6
  const h = React.createElement;
7
7
 
8
+ /* The chat surface renders Markdown with the harness' own MarkdownText, so a
9
+ document opened by this plugin borrows that exact component when it can: same
10
+ fonts, spacing, code banners and tables as a message in the conversation. */
11
+ const ChatMarkdown = (() => {
12
+ try {
13
+ const primitives = require("@deepseek-ai/dsh-client-ui-primitives");
14
+ return primitives && typeof primitives.MarkdownText === "function" ? primitives.MarkdownText : null;
15
+ } catch (error) {
16
+ return null;
17
+ }
18
+ })();
19
+
8
20
  function cx(...parts) {
9
21
  return parts.filter((part) => typeof part === "string" && part.length > 0).join(" ");
10
22
  }
@@ -166,7 +178,7 @@ window.__ModuleLoader__.load({
166
178
  }
167
179
  const PLACEMENTS = ["conversation", "sidebar", "both"];
168
180
  const TIME_MODES = ["clock", "elapsed"];
169
- const CONFIG_FIELDS = ["showProgress", "showArtifacts", "showReferences", "showStats", "timeMode", "stepAnchors", "placement", "sectionFullHeight", "todoPrompt", "todoPromptTemplate", "quickNav", "quickNavLink", "vikingPreview", "vikingUrl", "vikingAccount", "vikingUser"];
181
+ const CONFIG_FIELDS = ["showProgress", "showArtifacts", "showReferences", "showStats", "timeMode", "stepAnchors", "placement", "sectionFullHeight", "todoPrompt", "todoPromptTemplate", "quickNav", "quickNavLink", "vikingPreview", "vikingUrl", "vikingAccount", "vikingUser", "conclusionTab"];
170
182
  const VIKING_TAB_ID = "dsh-conversation-overview/viking";
171
183
  const VIKING_TAB_KIND = "viking-memory";
172
184
  const VIKING_ADDRESS_PREFIX = "dsh-resource://viking-memory/";
@@ -194,6 +206,10 @@ window.__ModuleLoader__.load({
194
206
  "progress.emptyTurn": "本轮未创建任务计划",
195
207
  "progress.recentSteps": "本轮关键步骤(最近 6 条)",
196
208
  "step.compact": "上下文压缩",
209
+ "step.approvalApproved": "已批准",
210
+ "step.approvalPending": "待审批",
211
+ "step.approvalGated": "含审批",
212
+ "step.approvalDenied": "已拒绝",
197
213
  "step.approval": "用户审批",
198
214
  "progress.allSteps": "本轮关键步骤(全部)",
199
215
  "progress.showEarlierSteps": "显示更早的步骤",
@@ -249,13 +265,22 @@ window.__ModuleLoader__.load({
249
265
  "settings.quickNavRow": "对话轮次导航条",
250
266
  "settings.quickNavLinkRow": "导航联动概览",
251
267
  "settings.quickNavNote": "对话区右侧的轮次刻度条:悬停预览该轮问答,点击跳转;开启后窄窗口下也保持显示,刻度悬停可见轮次号。",
268
+ "markdown.copy": "复制",
269
+ "markdown.copied": "已复制",
270
+ "markdown.code": "代码",
271
+ "markdown.wrap": "自动换行",
272
+ "markdown.unwrap": "取消换行",
273
+ "markdown.footnotes": "脚注",
252
274
  "settings.quickNavLinkNote": "点击导航刻度跳转对话时,已打开的概览同步切换到对应轮次。",
253
275
  "settings.interaction": "交互",
254
- "settings.todoPromptRow": "双击待办生成提示词",
255
- "settings.todoPromptTemplate": "提示词模板",
256
- "settings.todoPromptNote": "双击进展中的待办行,把重执行提示词填入输入框。",
257
- "settings.todoPromptTemplateNote": "模板留空则使用内置提示词;支持占位符 {index}(步骤序号)与 {content}(步骤内容)。",
276
+ "settings.todoPromptRow": "双击待办",
277
+ "settings.todoPromptOn": "提示词",
278
+ "settings.todoPromptOff": "关闭",
279
+ "settings.todoPromptHint": "查看内置默认模版",
280
+ "settings.todoPromptDefault": "内置默认模版",
281
+ "settings.todoPromptPlaceholder": "双击进展中的待办行,把重执行提示词填入输入框。\n模板留空则使用内置提示词;支持占位符 {index}(步骤序号)与 {content}(步骤内容)。",
258
282
  "settings.placementRow": "概览显示位置",
283
+ "settings.placementNote": "对话下方=在对话里显示一块概览卡片;右侧边栏=在右边栏单独开一页;两处=同时显示,内容一致。",
259
284
  "settings.viking": "OpenViking 记忆预览",
260
285
  "settings.vikingEnable": "启用记忆预览",
261
286
  "settings.vikingUrl": "服务地址",
@@ -274,9 +299,11 @@ window.__ModuleLoader__.load({
274
299
  "viking.field.keywords": "关键词",
275
300
  "viking.field.source": "来源",
276
301
  "reference.sort.time": "时间",
277
- "reference.sort.count": "次数",
302
+ "reference.sort.count": "轮次",
303
+ "reference.sort.hits": "次数",
278
304
  "reference.sort.time.title": "按最近引用排序",
279
- "reference.sort.count.title": "按引用次数排序",
305
+ "reference.sort.count.title": "按出现的轮次数排序",
306
+ "reference.sort.hits.title": "按本轮出现次数排序",
280
307
  "reference.sort.cancel": "点击取消排序",
281
308
  "reference.previewHint": "点击预览注入的内容快照",
282
309
  "scope.label": "概览范围",
@@ -287,9 +314,25 @@ window.__ModuleLoader__.load({
287
314
  "viking.revealHint": "打开目录",
288
315
  "viking.revealFailed": "打开目录失败",
289
316
  "viking.revealRestart": "宿主路由未就绪,请重启 DSH 后重试",
317
+ "artifact.conclusion": "结论",
318
+ "artifact.conclusionTurn": "第 {n} 轮结论",
319
+ "artifact.conclusionChars": "{n} 字",
320
+ "artifact.conclusionHint": "本轮结论文档,点击在新标签中打开",
321
+ "context.subtitleConclusion": "本轮结论 · Markdown 文档",
322
+ "settings.conclusionTabRow": "本轮结束后自动打开结论",
323
+ "settings.conclusionTabOn": "新标签页",
324
+ "settings.conclusionTabOff": "关闭",
325
+ "settings.conclusionTabNote": "一轮对话结束时,把最终答复作为 Markdown 文档自动开在新标签里;之后仍可在「产物」里点开。",
326
+ "settings.conclusionTabOffNote": "不自动打开标签;本轮结论仍会出现在「产物」里,点一下即可查看。",
327
+ "viking.eventMemory": "记忆",
328
+ "viking.prefMemory": "偏好",
329
+ "viking.entityMemory": "实体",
330
+ "viking.sessionMemory": "会话记忆",
331
+ "viking.genericMemory": "记忆",
290
332
  "viking.revealed": "已在系统中打开",
291
333
  "context.tab": "上下文预览",
292
334
  "context.subtitle": "注入内容快照",
335
+ "context.subtitlePlan": "计划内容快照",
293
336
  "context.unavailable": "内容快照不可用(页面刷新后需回到对应会话重新加载)",
294
337
  "skill.revealTitle": "在系统中打开技能 {name} 的目录",
295
338
  "skill.revealFailed": "打开技能目录失败",
@@ -330,6 +373,7 @@ window.__ModuleLoader__.load({
330
373
  "goal.blocked": "受阻",
331
374
  "goal.complete": "已完成",
332
375
  "artifact.presented": "交付",
376
+ "artifact.plan": "计划",
333
377
  "artifact.memory": "记忆",
334
378
  "artifact.menu.reveal": "在上层文件夹中显示",
335
379
  "artifact.menu.openWith": "打开方式",
@@ -357,8 +401,32 @@ window.__ModuleLoader__.load({
357
401
  "reference.producer.agentInstructions": "Agent 指令",
358
402
  "reference.more": "+{n} 条",
359
403
  "reference.times": "×{n}",
404
+ "reference.turns": "{n} 轮",
405
+ "reference.turnsTitle": "出现在 {turns} 轮对话中,共 {times} 次",
406
+ "reference.timesTitle": "本轮出现 {times} 次",
360
407
  "action.openInSidebar": "在侧边栏打开",
361
408
  "state.loadingHistory": "正在载入更早的记录…",
409
+ "state.reloadHistory": "重新加载",
410
+ "state.loadingStalled": "更早的记录未能载入",
411
+ "state.reloadHistory.title": "重新开始载入更早的记录",
412
+ "intro.newTurn": "第 {n} 轮对话开始",
413
+ "progress.stepCommand": "命令 / 参数",
414
+ "progress.stepResult": "输出",
415
+ "progress.stepError": "错误",
416
+ "progress.stepDuration": "耗时 {v}",
417
+ "progress.stepChars": "{n} 字符",
418
+ "progress.stepTruncated": "输出较长,仅显示前段",
419
+ "progress.stepSkipped": "输出过大,未保留内容",
420
+ "progress.expandStep": "展开查看命令与输出",
421
+ "progress.collapseStep": "收起命令与输出",
422
+ "role.main": "主代理",
423
+ "role.subagent": "子代理",
424
+ "role.children": "子代理 {n}",
425
+ "role.childrenToggle": "展开/收起本会话的子代理",
426
+ "role.switchTitle": "切换到该子代理的概览",
427
+ "role.openParent": "查看主会话",
428
+ "role.openParentTitle": "切换到父会话的概览",
429
+ "role.parentPrefix": "·",
362
430
  "state.noSession": "当前没有打开的会话",
363
431
  "state.error": "概览渲染失败",
364
432
  };
@@ -375,6 +443,10 @@ window.__ModuleLoader__.load({
375
443
  "progress.emptyTurn": "No task plan was created in this turn",
376
444
  "progress.recentSteps": "Key steps in this turn (latest 6)",
377
445
  "step.compact": "Context compaction",
446
+ "step.approvalApproved": "Approved",
447
+ "step.approvalPending": "Awaiting approval",
448
+ "step.approvalGated": "Approval",
449
+ "step.approvalDenied": "Denied",
378
450
  "step.approval": "User approval",
379
451
  "progress.allSteps": "Key steps in this turn (all)",
380
452
  "progress.showEarlierSteps": "Show earlier steps",
@@ -430,13 +502,22 @@ window.__ModuleLoader__.load({
430
502
  "settings.quickNavRow": "Conversation turn rail",
431
503
  "settings.quickNavLinkRow": "Link rail with overview",
432
504
  "settings.quickNavNote": "The turn rail on the right edge of the chat: hover for a turn preview, click to jump. When enabled it stays visible in narrow windows too, and ticks show their turn number on hover.",
505
+ "markdown.copy": "Copy",
506
+ "markdown.copied": "Copied",
507
+ "markdown.code": "Code",
508
+ "markdown.wrap": "Wrap",
509
+ "markdown.unwrap": "No wrap",
510
+ "markdown.footnotes": "Footnotes",
433
511
  "settings.quickNavLinkNote": "When you click a rail tick to jump, an open overview switches to that turn as well.",
434
512
  "settings.interaction": "Interaction",
435
- "settings.todoPromptRow": "Double-click todo prompt",
436
- "settings.todoPromptTemplate": "Prompt template",
437
- "settings.todoPromptNote": "Double-click a todo row in Progress to fill the rerun prompt into the composer.",
438
- "settings.todoPromptTemplateNote": "Leave empty for the built-in prompt; placeholders: {index} (step number), {content} (step text).",
513
+ "settings.todoPromptRow": "Double-click todo",
514
+ "settings.todoPromptOn": "Prompt",
515
+ "settings.todoPromptOff": "Off",
516
+ "settings.todoPromptHint": "Show the built-in default template",
517
+ "settings.todoPromptDefault": "Built-in default template",
518
+ "settings.todoPromptPlaceholder": "Double-click a todo row in Progress to fill the rerun prompt into the composer.\nLeave empty for the built-in prompt; placeholders: {index} (step number), {content} (step text).",
439
519
  "settings.placementRow": "Show the overview in",
520
+ "settings.placementNote": "Below the conversation = one overview card inside the chat; Right sidebar = its own page in the right sidebar; Both = shown in both places with the same content.",
440
521
  "settings.viking": "OpenViking memory preview",
441
522
  "settings.vikingEnable": "Enable memory preview",
442
523
  "settings.vikingUrl": "Server URL",
@@ -455,9 +536,11 @@ window.__ModuleLoader__.load({
455
536
  "viking.field.keywords": "Keywords",
456
537
  "viking.field.source": "Source",
457
538
  "reference.sort.time": "Time",
458
- "reference.sort.count": "Count",
539
+ "reference.sort.count": "Turns",
540
+ "reference.sort.hits": "Count",
459
541
  "reference.sort.time.title": "Sort by most recently referenced",
460
- "reference.sort.count.title": "Sort by reference count",
542
+ "reference.sort.count.title": "Sort by how many turns mention it",
543
+ "reference.sort.hits.title": "Sort by mentions in this turn",
461
544
  "reference.sort.cancel": "Click again to clear",
462
545
  "reference.previewHint": "Click to preview the injected content snapshot",
463
546
  "scope.label": "Scope",
@@ -468,9 +551,25 @@ window.__ModuleLoader__.load({
468
551
  "viking.revealHint": "Open directory",
469
552
  "viking.revealFailed": "Could not open the directory",
470
553
  "viking.revealRestart": "Host route not ready — restart DSH and try again",
554
+ "artifact.conclusion": "Conclusion",
555
+ "artifact.conclusionTurn": "Turn {n} conclusion",
556
+ "artifact.conclusionChars": "{n} chars",
557
+ "artifact.conclusionHint": "This turn's closing answer as a document — click to open in a tab",
558
+ "context.subtitleConclusion": "Turn conclusion · Markdown document",
559
+ "settings.conclusionTabRow": "Open the conclusion when a turn ends",
560
+ "settings.conclusionTabOn": "New tab",
561
+ "settings.conclusionTabOff": "Off",
562
+ "settings.conclusionTabNote": "When a turn finishes, its closing answer opens as a Markdown tab; it also stays one click away in Artifacts.",
563
+ "settings.conclusionTabOffNote": "No tab opens. The turn conclusion still appears in Artifacts, one click away.",
564
+ "viking.eventMemory": "Memory",
565
+ "viking.prefMemory": "Preference",
566
+ "viking.entityMemory": "Entity",
567
+ "viking.sessionMemory": "Session memory",
568
+ "viking.genericMemory": "Memory",
471
569
  "viking.revealed": "Opened in the system",
472
570
  "context.tab": "Context preview",
473
571
  "context.subtitle": "Injected content snapshot",
572
+ "context.subtitlePlan": "Plan snapshot",
474
573
  "context.unavailable": "Content snapshot unavailable (return to the session to reload after a page refresh)",
475
574
  "skill.revealTitle": "Open the directory of skill {name} in the system",
476
575
  "skill.revealFailed": "Could not open the skill directory",
@@ -511,6 +610,7 @@ window.__ModuleLoader__.load({
511
610
  "goal.blocked": "Blocked",
512
611
  "goal.complete": "Completed",
513
612
  "artifact.presented": "Delivered",
613
+ "artifact.plan": "Plan",
514
614
  "artifact.memory": "Memory",
515
615
  "artifact.menu.reveal": "Show in Enclosing Folder",
516
616
  "artifact.menu.openWith": "Open With",
@@ -538,8 +638,32 @@ window.__ModuleLoader__.load({
538
638
  "reference.producer.agentInstructions": "Agent instructions",
539
639
  "reference.more": "+{n} more",
540
640
  "reference.times": "×{n}",
641
+ "reference.turns": "{n} turns",
642
+ "reference.turnsTitle": "Seen in {turns} turns · {times} mentions",
643
+ "reference.timesTitle": "{times} mentions in this turn",
541
644
  "action.openInSidebar": "Open in sidebar",
542
645
  "state.loadingHistory": "Loading earlier history…",
646
+ "state.reloadHistory": "Reload",
647
+ "state.loadingStalled": "Earlier history could not be loaded",
648
+ "state.reloadHistory.title": "Restart loading earlier history",
649
+ "intro.newTurn": "Turn {n} started",
650
+ "progress.stepCommand": "Command / args",
651
+ "progress.stepResult": "Output",
652
+ "progress.stepError": "Error",
653
+ "progress.stepDuration": "took {v}",
654
+ "progress.stepChars": "{n} chars",
655
+ "progress.stepTruncated": "long output, showing the head",
656
+ "progress.stepSkipped": "output too large, not kept",
657
+ "progress.expandStep": "Show command and output",
658
+ "progress.collapseStep": "Hide command and output",
659
+ "role.main": "Main agent",
660
+ "role.subagent": "Subagent",
661
+ "role.children": "Subagents {n}",
662
+ "role.childrenToggle": "Show/hide this session's subagents",
663
+ "role.switchTitle": "Switch to this subagent's overview",
664
+ "role.openParent": "Open main session",
665
+ "role.openParentTitle": "Switch to the parent session's overview",
666
+ "role.parentPrefix": "·",
543
667
  "state.noSession": "No session is open",
544
668
  "state.error": "Overview failed to render",
545
669
  };
@@ -577,6 +701,7 @@ window.__ModuleLoader__.load({
577
701
  quickNav: typeof source.quickNav === "boolean" ? source.quickNav : true,
578
702
  quickNavLink: typeof source.quickNavLink === "boolean" ? source.quickNavLink : true,
579
703
  vikingPreview: typeof source.vikingPreview === "boolean" ? source.vikingPreview : true,
704
+ conclusionTab: typeof source.conclusionTab === "boolean" ? source.conclusionTab : true,
580
705
  vikingUrl: text(source.vikingUrl, VIKING_DEFAULTS.vikingUrl).replace(/\/+$/, ""),
581
706
  vikingAccount: text(source.vikingAccount, VIKING_DEFAULTS.vikingAccount),
582
707
  vikingUser: text(source.vikingUser, VIKING_DEFAULTS.vikingUser),
@@ -1068,9 +1193,35 @@ window.__ModuleLoader__.load({
1068
1193
  return match !== null ? match[1] : null;
1069
1194
  }
1070
1195
 
1196
+ /** Which memory tree a viking:// URI lives in; a locale key for its label prefix. */
1197
+ function vikingMemoryBucket(uri) {
1198
+ const value = String(uri);
1199
+ if (/\/memories\/preferences(\/|$)/i.test(value)) return "viking.prefMemory";
1200
+ if (/\/memories\/entities(\/|$)/i.test(value)) return "viking.entityMemory";
1201
+ if (/\/memories\/sessions(\/|$)/i.test(value) || /^viking:\/\/(?:user\/[^/]+|agent)\/sessions(\/|$)/i.test(value)) return "viking.sessionMemory";
1202
+ if (/\/memories\/events(\/|$)/i.test(value)) return "viking.eventMemory";
1203
+ if (/\/memories(\/|$)/i.test(value)) return "viking.genericMemory";
1204
+ return null;
1205
+ }
1206
+
1207
+ /** Display name for a viking:// URI, read from the memory tree it lives in: a dated
1208
+ folder must not show up as its last segment ("29"). */
1071
1209
  function vikingLabel(uri) {
1072
- const skill = vikingSkillName(uri);
1073
- return skill !== null ? skill : vikingName(uri);
1210
+ const value = String(uri).replace(/\/+$/, "");
1211
+ const skill = vikingSkillName(value);
1212
+ if (skill !== null) return skill;
1213
+ const dated = /\/memories\/events\/(\d{4})\/(\d{2})\/(\d{2})(\/.+)?$/i.exec(value);
1214
+ if (dated !== null) {
1215
+ const day = `${dated[1]}-${dated[2]}-${dated[3]}`;
1216
+ const tail = typeof dated[4] === "string" ? vikingName(dated[4]) : "";
1217
+ if (tail !== "" && tail !== day) return `${tail} · ${day}`;
1218
+ return `${translate("viking.eventMemory")} · ${day}`;
1219
+ }
1220
+ const bucket = vikingMemoryBucket(value);
1221
+ const name = vikingName(value);
1222
+ if (bucket === null) return name;
1223
+ /* entities keep a category folder above the leaf; show only the leaf, prefixed */
1224
+ return `${translate(bucket)} · ${name}`;
1074
1225
  }
1075
1226
 
1076
1227
  /** Ref kind: anything under a skills/ root reads as SKILL, the rest stays a memory file. */
@@ -1261,6 +1412,7 @@ window.__ModuleLoader__.load({
1261
1412
  const lines = escapeHtml(markdown).split("\n");
1262
1413
  const out = [];
1263
1414
  let fence = null;
1415
+ let fenceLang = "";
1264
1416
  let list = null; // "ul" | "ol"
1265
1417
  let paragraph = [];
1266
1418
  const closeList = () => {
@@ -1279,8 +1431,15 @@ window.__ModuleLoader__.load({
1279
1431
  const trimmed = line.trim();
1280
1432
  if (fence !== null) {
1281
1433
  if (trimmed.startsWith("```")) {
1282
- out.push(`<pre class="dsov-md-pre"><code>${fence.join("\n")}</code></pre>`);
1434
+ const body = fence.join("\n");
1435
+ if (fenceLang === "mermaid") {
1436
+ /* the inner <pre> is the fallback view until mermaid replaces it */
1437
+ out.push(`<div class="dsov-mermaid" data-src="${body}"><pre class="dsov-md-pre"><code>${body}</code></pre></div>`);
1438
+ } else {
1439
+ out.push(`<pre class="dsov-md-pre"><code>${body}</code></pre>`);
1440
+ }
1283
1441
  fence = null;
1442
+ fenceLang = "";
1284
1443
  } else {
1285
1444
  fence.push(line);
1286
1445
  }
@@ -1290,6 +1449,7 @@ window.__ModuleLoader__.load({
1290
1449
  flushParagraph();
1291
1450
  closeList();
1292
1451
  fence = [];
1452
+ fenceLang = trimmed.slice(3).trim().toLowerCase();
1293
1453
  continue;
1294
1454
  }
1295
1455
  if (trimmed === "") {
@@ -1348,7 +1508,79 @@ window.__ModuleLoader__.load({
1348
1508
  return root === "" ? normalized : `${root}/${normalized}`;
1349
1509
  }
1350
1510
 
1511
+ /** Open the host's own plan viewer — the exact page the turn-tail plan banner
1512
+ opens. The plan plugin re-resolves the markdown from the session log by
1513
+ tool-call id, so this address stays valid across reloads. */
1514
+ function openNativePlan(sessionId, callId) {
1515
+ if (typeof sessionId !== "string" || sessionId === "" || typeof callId !== "string" || callId === "") return false;
1516
+ const sidebar = runtime && runtime.ctx ? runtime.ctx.sidebarRight : undefined;
1517
+ if (sidebar === undefined || typeof sidebar.openResource !== "function") return false;
1518
+ let segments;
1519
+ try {
1520
+ const sessions = runtime.ctx.sessions;
1521
+ const child = sessions !== undefined && typeof sessions.subagentAddress === "function" ? sessions.subagentAddress(sessionId) : undefined;
1522
+ segments =
1523
+ child === undefined
1524
+ ? [sessionId, callId]
1525
+ : ["subagent", child.parentSessionId, child.childSessionId, child.mode, callId];
1526
+ } catch {
1527
+ segments = [sessionId, callId]; /* uninjected ctx access throws; plain sessions are the common case */
1528
+ }
1529
+ try {
1530
+ sidebar.openResource(`dsh-resource://plan/${segments.map(encodeURIComponent).join("/")}`);
1531
+ return true;
1532
+ } catch {
1533
+ return false;
1534
+ }
1535
+ }
1536
+
1537
+ /* The plan plugin's preview page is also the product's read-only Markdown
1538
+ document page: besides logged plans it serves a temporary address whose
1539
+ body renders caller-supplied Markdown through the harness' own MarkdownText
1540
+ and chrome — the very page a turn-tail plan banner's 「打开」 reveals. Sending
1541
+ our documents there makes 「点击打开」 the same page instead of a lookalike. */
1542
+ const DOCUMENT_ADDRESS_PREFIX = "dsh-resource://plan-review/";
1543
+
1544
+ /** Address of the host document page for one caller-supplied Markdown snapshot. */
1545
+ function documentAddress(sessionId, docKey) {
1546
+ if (typeof sessionId !== "string" || sessionId === "" || typeof docKey !== "string" || docKey === "") return undefined;
1547
+ return `${DOCUMENT_ADDRESS_PREFIX}${encodeURIComponent(sessionId)}/${encodeURIComponent(docKey)}`;
1548
+ }
1549
+
1550
+ /** Open a Markdown snapshot on the host's own document page; false when unavailable. */
1551
+ function openNativeDocument(sessionId, docKey, title, markdown) {
1552
+ const address = documentAddress(sessionId, docKey);
1553
+ if (address === undefined || typeof markdown !== "string" || markdown === "") return false;
1554
+ const sidebar = contextSidebar();
1555
+ if (sidebar === null) return false;
1556
+ try {
1557
+ sidebar.openResource(address, { params: { planReview: { markdown, title: typeof title === "string" && title !== "" ? title : docKey } } });
1558
+ return true;
1559
+ } catch (_unavailable) {
1560
+ /* no tab type claims the address (plan plugin disabled): caller falls back */
1561
+ return false;
1562
+ }
1563
+ }
1564
+
1351
1565
  function openArtifact(binding, artifact, stats) {
1566
+ const sessionId = binding ? binding.sessionId : undefined;
1567
+ if (artifact.kind === "conclusion") {
1568
+ const entry = typeof artifact.previewKey === "string" ? readContextPreview(artifact.previewKey) : null;
1569
+ const docKey = `conclusion:${typeof artifact.turn === "number" ? artifact.turn : artifact.previewKey}`;
1570
+ if (openNativeDocument(sessionId, docKey, artifact.title, entry === null ? undefined : entry.text)) return;
1571
+ if (typeof artifact.previewKey === "string" && artifact.previewKey !== "") openContextPreview(artifact.previewKey);
1572
+ return;
1573
+ }
1574
+ if (artifact.plan === true) {
1575
+ if (openNativePlan(sessionId, artifact.planCallId)) return;
1576
+ /* host plan viewer unavailable (plugin disabled, no sidebar): the document
1577
+ page over the same snapshot, then the plugin's own tab */
1578
+ const entry = typeof artifact.previewKey === "string" ? readContextPreview(artifact.previewKey) : null;
1579
+ const docKey = `plan:${typeof artifact.planCallId === "string" && artifact.planCallId !== "" ? artifact.planCallId : artifact.previewKey}`;
1580
+ if (openNativeDocument(sessionId, docKey, entry !== null ? entry.title : artifact.planTitle, entry === null ? undefined : entry.text)) return;
1581
+ if (typeof artifact.previewKey === "string" && artifact.previewKey !== "") openContextPreview(artifact.previewKey);
1582
+ return;
1583
+ }
1352
1584
  if (typeof artifact.path === "string" && artifact.path.startsWith("viking://")) {
1353
1585
  openVikingPreview(artifact.path);
1354
1586
  return;
@@ -1524,6 +1756,7 @@ window.__ModuleLoader__.load({
1524
1756
  teamTasks: new Map(),
1525
1757
  subagents: new Map(),
1526
1758
  stats: createStats(),
1759
+ conclusion: null,
1527
1760
  changed: false,
1528
1761
  };
1529
1762
  }
@@ -1535,7 +1768,7 @@ window.__ModuleLoader__.load({
1535
1768
  }
1536
1769
 
1537
1770
  function createTurnBucket() {
1538
- return { artifacts: new Map(), refs: new Map(), refOrder: [], changes: [], steps: [], anchors: [], members: new Map(), teamTasks: new Map(), subagents: new Map(), stats: createStats() };
1771
+ 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 };
1539
1772
  }
1540
1773
 
1541
1774
  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()) });
@@ -1554,10 +1787,72 @@ window.__ModuleLoader__.load({
1554
1787
  const CONTEXT_PREVIEW_LIMIT = 400;
1555
1788
  const CONTEXT_TEXT_LIMIT = 120000;
1556
1789
 
1557
- function rememberContextPreview(sessionId, refKey, title, text) {
1790
+ /* Mermaid is loaded lazily from a CDN the first time a preview contains a
1791
+ diagram; if the load fails (offline / blocked) the raw source <pre>
1792
+ fallback inside every .dsov-mermaid block simply stays visible. */
1793
+ let mermaidPromise = null;
1794
+ let mermaidSeq = 0;
1795
+ function loadMermaid() {
1796
+ if (mermaidPromise === null) {
1797
+ mermaidPromise = import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs")
1798
+ .then((mod) => {
1799
+ const mermaid = mod !== null && typeof mod === "object" && typeof mod.default === "object" ? mod.default : mod;
1800
+ const dark = typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-color-scheme: dark)").matches === true;
1801
+ mermaid.initialize({ startOnLoad: false, theme: dark === true ? "dark" : "default", fontFamily: "inherit" });
1802
+ return mermaid;
1803
+ })
1804
+ .catch((error) => {
1805
+ mermaidPromise = null;
1806
+ throw error;
1807
+ });
1808
+ }
1809
+ return mermaidPromise;
1810
+ }
1811
+
1812
+ async function renderMermaidNodes(root) {
1813
+ if (root === null || typeof root.querySelectorAll !== "function") return;
1814
+ const nodes = root.querySelectorAll(".dsov-mermaid[data-src]");
1815
+ if (nodes.length === 0) return;
1816
+ let mermaid;
1817
+ try {
1818
+ mermaid = await loadMermaid();
1819
+ } catch {
1820
+ return;
1821
+ }
1822
+ for (const node of nodes) {
1823
+ if (node.getAttribute("data-rendered") === "true") continue;
1824
+ const source = node.getAttribute("data-src") || "";
1825
+ if (source.trim() === "") continue;
1826
+ try {
1827
+ mermaidSeq += 1;
1828
+ const result = await mermaid.render(`dsov-mmd-${mermaidSeq}`, source);
1829
+ const svg = result !== null && typeof result === "object" && typeof result.svg === "string" ? result.svg : "";
1830
+ if (svg === "") continue;
1831
+ node.innerHTML = svg;
1832
+ node.setAttribute("data-rendered", "true");
1833
+ } catch {
1834
+ node.classList.add("dsov-mermaidFailed");
1835
+ }
1836
+ }
1837
+ }
1838
+
1839
+ /** Markdown block that upgrades ```mermaid fences to rendered diagrams after mount. */
1840
+ function MarkdownBody({ text }) {
1841
+ const ref = React.useRef(null);
1842
+ const html = React.useMemo(() => mdToHtml(typeof text === "string" ? text : ""), [text]);
1843
+ React.useEffect(() => {
1844
+ const node = ref.current;
1845
+ if (node === null) return undefined;
1846
+ const timer = setTimeout(() => renderMermaidNodes(node), 0);
1847
+ return () => clearTimeout(timer);
1848
+ }, [html]);
1849
+ return h("div", { className: "dsov-md", ref, dangerouslySetInnerHTML: { __html: html } });
1850
+ }
1851
+
1852
+ function rememberContextPreview(sessionId, refKey, title, text, kind) {
1558
1853
  if (typeof sessionId !== "string" || sessionId === "" || typeof text !== "string" || text === "") return undefined;
1559
1854
  const globalKey = `${sessionId}|${refKey}`;
1560
- contextPreviews.set(globalKey, { title: typeof title === "string" && title !== "" ? title : refKey, text: text.length > CONTEXT_TEXT_LIMIT ? `${text.slice(0, CONTEXT_TEXT_LIMIT)}\n…` : text });
1855
+ contextPreviews.set(globalKey, { title: typeof title === "string" && title !== "" ? title : refKey, text: text.length > CONTEXT_TEXT_LIMIT ? `${text.slice(0, CONTEXT_TEXT_LIMIT)}\n…` : text, ...(typeof kind === "string" && kind !== "" ? { kind } : {}) });
1561
1856
  while (contextPreviews.size > CONTEXT_PREVIEW_LIMIT) {
1562
1857
  const oldest = contextPreviews.keys().next();
1563
1858
  if (oldest.done === true) break;
@@ -1587,10 +1882,32 @@ window.__ModuleLoader__.load({
1587
1882
  }
1588
1883
  }
1589
1884
 
1885
+ /** The host surface that holds preview tabs; null when this context has none. */
1886
+ function contextSidebar() {
1887
+ try {
1888
+ const sidebar = runtime && runtime.ctx ? runtime.ctx.sidebarRight : undefined;
1889
+ return sidebar && typeof sidebar.openResource === "function" ? sidebar : null;
1890
+ } catch {
1891
+ return null;
1892
+ }
1893
+ }
1894
+
1590
1895
  function openContextPreview(globalKey) {
1591
- const sidebar = runtime && runtime.ctx ? runtime.ctx.sidebarRight : undefined;
1592
- if (!sidebar || typeof sidebar.openResource !== "function") return;
1896
+ const sidebar = contextSidebar();
1897
+ if (sidebar === null) return false;
1593
1898
  sidebar.openResource(contextPreviewAddress(globalKey));
1899
+ return true;
1900
+ }
1901
+
1902
+ /* An overview can be mounted twice (inline card + sidebar tab); only the first
1903
+ instance may pop the turn-conclusion tab. */
1904
+ const conclusionAutoOpened = new Set();
1905
+
1906
+ function claimConclusionTab(key) {
1907
+ if (conclusionAutoOpened.has(key)) return false;
1908
+ if (conclusionAutoOpened.size > 200) conclusionAutoOpened.clear();
1909
+ conclusionAutoOpened.add(key);
1910
+ return true;
1594
1911
  }
1595
1912
 
1596
1913
  /* Quick-nav linkage: a click on the built-in turn rail broadcasts a scope request
@@ -1725,12 +2042,27 @@ window.__ModuleLoader__.load({
1725
2042
  folder.changed = true;
1726
2043
  }
1727
2044
 
2045
+ /** First heading (or first non-empty line) of a plan markdown, trimmed for row display. */
2046
+ function planTitleOf(text) {
2047
+ for (const line of String(text).split("\n")) {
2048
+ const trimmed = line.trim();
2049
+ if (trimmed === "") continue;
2050
+ const heading = /^#{1,6}\s+(.*)$/.exec(trimmed);
2051
+ return (heading ? heading[1] : trimmed).replace(/[#*`]/g, "").trim().slice(0, 80);
2052
+ }
2053
+ return "";
2054
+ }
2055
+
1728
2056
  function upsertArtifact(folder, bucket, path, update) {
1729
2057
  const existing = folder.artifacts.get(path);
1730
2058
  if (existing) {
1731
2059
  if (typeof update.description === "string" && update.description.length > 0) existing.description = update.description;
1732
2060
  if (update.presented) existing.presented = true;
1733
2061
  if (update.produced) existing.produced = true;
2062
+ if (update.plan === true) existing.plan = true;
2063
+ if (typeof update.planTitle === "string" && update.planTitle !== "") existing.planTitle = update.planTitle;
2064
+ if (typeof update.previewKey === "string" && update.previewKey !== "") existing.previewKey = update.previewKey;
2065
+ if (typeof update.planCallId === "string" && update.planCallId !== "") existing.planCallId = update.planCallId;
1734
2066
  if (typeof update.time === "number" && update.time > 0) existing.time = update.time;
1735
2067
  if (typeof update.seq === "number") existing.seq = Math.max(existing.seq, update.seq);
1736
2068
  const bucketExisting = bucket.artifacts.get(path);
@@ -1738,6 +2070,10 @@ window.__ModuleLoader__.load({
1738
2070
  if (typeof update.description === "string" && update.description.length > 0) bucketExisting.description = update.description;
1739
2071
  if (update.presented) bucketExisting.presented = true;
1740
2072
  if (update.produced) bucketExisting.produced = true;
2073
+ if (update.plan === true) bucketExisting.plan = true;
2074
+ if (typeof update.planTitle === "string" && update.planTitle !== "") bucketExisting.planTitle = update.planTitle;
2075
+ if (typeof update.previewKey === "string" && update.previewKey !== "") bucketExisting.previewKey = update.previewKey;
2076
+ if (typeof update.planCallId === "string" && update.planCallId !== "") bucketExisting.planCallId = update.planCallId;
1741
2077
  if (typeof update.time === "number" && update.time > 0) bucketExisting.time = update.time;
1742
2078
  if (typeof update.seq === "number") bucketExisting.seq = Math.max(bucketExisting.seq, update.seq);
1743
2079
  } else {
@@ -1751,6 +2087,10 @@ window.__ModuleLoader__.load({
1751
2087
  description: typeof update.description === "string" ? update.description : undefined,
1752
2088
  presented: update.presented === true,
1753
2089
  produced: update.produced === true,
2090
+ ...(update.plan === true ? { plan: true } : {}),
2091
+ ...(typeof update.planTitle === "string" && update.planTitle !== "" ? { planTitle: update.planTitle } : {}),
2092
+ ...(typeof update.previewKey === "string" && update.previewKey !== "" ? { previewKey: update.previewKey } : {}),
2093
+ ...(typeof update.planCallId === "string" && update.planCallId !== "" ? { planCallId: update.planCallId } : {}),
1754
2094
  time: typeof update.time === "number" ? update.time : 0,
1755
2095
  firstTime: typeof update.time === "number" ? update.time : 0,
1756
2096
  seq: typeof update.seq === "number" ? update.seq : 0,
@@ -1761,6 +2101,21 @@ window.__ModuleLoader__.load({
1761
2101
  }
1762
2102
 
1763
2103
  /** Record that one global ref appeared in the folder's current turn (unique, ascending). */
2104
+ /** A turn bucket keeps its own mention count and turn list: the folder entry spans
2105
+ every turn, while one bucket must answer "in THIS turn" for the per-turn view. */
2106
+ function withOwnTurns(stored, folder, current) {
2107
+ const turn = folder.currentTurn;
2108
+ const at = isRecord(current) && typeof current.time === "number" && current.time > 0 ? current.time : 0;
2109
+ const seq = isRecord(current) && typeof current.seq === "number" ? current.seq : null;
2110
+ return {
2111
+ ...stored,
2112
+ count: 1,
2113
+ turns: typeof turn === "number" && turn >= 1 ? [turn] : [],
2114
+ ...(at > 0 ? { time: at, firstTime: at } : {}),
2115
+ ...(seq === null ? {} : { seq }),
2116
+ };
2117
+ }
2118
+
1764
2119
  function noteRefTurn(stored, folder) {
1765
2120
  const turn = folder.currentTurn;
1766
2121
  if (typeof turn !== "number" || turn < 1) return;
@@ -1782,13 +2137,14 @@ window.__ModuleLoader__.load({
1782
2137
  const bucketExisting = bucket.refs.get(ref.key);
1783
2138
  if (bucketExisting) {
1784
2139
  bucketExisting.count += 1;
2140
+ noteRefTurn(bucketExisting, folder);
1785
2141
  if (typeof ref.time === "number" && ref.time >= bucketExisting.time) {
1786
2142
  bucketExisting.time = ref.time;
1787
2143
  bucketExisting.seq = ref.seq;
1788
2144
  }
1789
2145
  if (typeof ref.previewKey === "string") bucketExisting.previewKey = ref.previewKey;
1790
2146
  } else {
1791
- bucket.refs.set(ref.key, { ...existing });
2147
+ bucket.refs.set(ref.key, withOwnTurns(existing, folder, ref));
1792
2148
  bucket.refOrder.push(ref.key);
1793
2149
  }
1794
2150
  touch(folder);
@@ -1798,7 +2154,7 @@ window.__ModuleLoader__.load({
1798
2154
  noteRefTurn(stored, folder);
1799
2155
  folder.refs.set(ref.key, stored);
1800
2156
  folder.refOrder.push(ref.key);
1801
- bucket.refs.set(ref.key, { ...stored });
2157
+ bucket.refs.set(ref.key, withOwnTurns(stored, folder, ref));
1802
2158
  bucket.refOrder.push(ref.key);
1803
2159
  touch(folder);
1804
2160
  }
@@ -1918,6 +2274,26 @@ window.__ModuleLoader__.load({
1918
2274
  return text.length > 4000 ? text.slice(0, 4000) : text;
1919
2275
  }
1920
2276
 
2277
+ /** Shorter assistant messages are asides (status lines, "done."), not documents. */
2278
+ const CONCLUSION_MIN_CHARS = 32;
2279
+ /** Chars kept per step for the expanded detail (kept small: every step carries one). */
2280
+ const STEP_RESULT_LIMIT = 1600;
2281
+ /** Stop collecting once a result is clearly huge; only the size is remembered. */
2282
+ const STEP_RESULT_SKIP = 40000;
2283
+ function stepResultOf(content) {
2284
+ if (!Array.isArray(content)) return null;
2285
+ let text = "";
2286
+ let chars = 0;
2287
+ for (const part of content) {
2288
+ if (!isRecord(part) || part.type !== "text" || typeof part.text !== "string") continue;
2289
+ chars += part.text.length;
2290
+ if (chars > STEP_RESULT_SKIP) return { text: "", chars, truncated: true, skipped: true };
2291
+ if (text.length < STEP_RESULT_LIMIT) text += part.text.slice(0, STEP_RESULT_LIMIT - text.length);
2292
+ }
2293
+ if (chars === 0) return null;
2294
+ return { text, chars, truncated: chars > text.length, skipped: false };
2295
+ }
2296
+
1921
2297
  const ANCHOR_CAP = 8;
1922
2298
  function addStep(folder, bucket, step) {
1923
2299
  if (typeof step.teamKey === "string") {
@@ -1956,6 +2332,12 @@ window.__ModuleLoader__.load({
1956
2332
  function foldEvent(folder, event, cwd) {
1957
2333
  if (!isRecord(event) || typeof event.type !== "string") return;
1958
2334
  const data = event.data;
2335
+ /* Tool work after a reply proves that reply was not the turn's closing word:
2336
+ the candidate stays provisional until a fresh reply replaces it. */
2337
+ if ((event.type === "tool/call" || event.type === "tool/result") && folder.currentTurn >= 1) {
2338
+ const running = folder.byTurn.get(folder.currentTurn);
2339
+ if (running !== undefined && isRecord(running.lastReply)) running.lastReply.provisional = true;
2340
+ }
1959
2341
  if (typeof event.time === "number" && Number.isFinite(event.time)) {
1960
2342
  bumpStatTime(folder.stats, event.time);
1961
2343
  const statsTurn =
@@ -1975,6 +2357,45 @@ window.__ModuleLoader__.load({
1975
2357
  touch(folder);
1976
2358
  return;
1977
2359
  }
2360
+ case "assistant/message": {
2361
+ /* A reply is only a candidate while the turn runs: 「本轮结论」 is published
2362
+ at turn/end, so 产物 never shows a provisional document mid-turn. */
2363
+ if (!isRecord(data) || !isAppendSurfaceEvent(event)) return;
2364
+ /* Two shapes carry the reply: plugin-level {content} and the durable log's
2365
+ {message:{content}}. Reading only the first left every real turn without a
2366
+ conclusion document, so the auto-open never fired. */
2367
+ const body = isRecord(data.message) ? data.message : data;
2368
+ const text = (typeof body.text === "string" && body.text !== "" ? body.text : textOf(body.content)).trim();
2369
+ if (text === "") return;
2370
+ const turn = folder.currentTurn >= 1 ? folder.currentTurn : Number.isSafeInteger(data.turn) && data.turn >= 1 ? data.turn : 0;
2371
+ const bucket = folder.byTurn.get(turn) ?? turnBucket(folder, folder.currentTurn);
2372
+ bucket.lastReply = {
2373
+ turn,
2374
+ text,
2375
+ seq: typeof event.seq === "number" ? event.seq : 0,
2376
+ time: typeof event.time === "number" ? event.time : 0,
2377
+ provisional: false,
2378
+ };
2379
+ touch(folder);
2380
+ return;
2381
+ }
2382
+ case "turn/end": {
2383
+ if (!isRecord(data) || typeof data.turn !== "number" || !Number.isSafeInteger(data.turn) || data.turn < 1) return;
2384
+ const bucket = folder.byTurn.get(data.turn);
2385
+ if (bucket === undefined) return;
2386
+ bucket.ended = true;
2387
+ const reply = isRecord(bucket.lastReply) ? bucket.lastReply : null;
2388
+ /* Publish the closing reply only now that the turn is over. A provisional
2389
+ reply (tool work followed it) still counts when it is substantive, so a
2390
+ turn whose last action was a tool call keeps its readable document. */
2391
+ if (reply !== null && typeof reply.text === "string" && reply.text !== "" && (reply.provisional !== true || reply.text.length >= CONCLUSION_MIN_CHARS)) {
2392
+ const record = { turn: data.turn, text: reply.text, seq: reply.seq, time: reply.time, ended: true };
2393
+ bucket.conclusion = record;
2394
+ folder.conclusion = record;
2395
+ }
2396
+ touch(folder);
2397
+ return;
2398
+ }
1978
2399
  case "todo/write": {
1979
2400
  if (!isRecord(data) || !Array.isArray(data.todos)) return;
1980
2401
  const todos = [];
@@ -2048,8 +2469,13 @@ window.__ModuleLoader__.load({
2048
2469
  addStep(folder, approvalBucket, {
2049
2470
  callId: `approval-${data.id}`,
2050
2471
  name: "approval",
2472
+ /* the tool call this gate blocks: the approval renders nested under it so
2473
+ a child never appears to finish before its own parent */
2474
+ ...(typeof data.callId === "string" && data.callId !== "" ? { parentCallId: data.callId } : {}),
2051
2475
  detail: toolName,
2052
2476
  full: typeof data.reason === "string" && data.reason !== "" ? `${toolName} · ${data.reason}` : toolName,
2477
+ approvalTool: toolName,
2478
+ ...(typeof data.reason === "string" && data.reason !== "" ? { approvalReason: data.reason } : {}),
2053
2479
  milestone: true,
2054
2480
  done: false,
2055
2481
  seq: event.seq,
@@ -2061,6 +2487,7 @@ window.__ModuleLoader__.load({
2061
2487
  if (!isRecord(data) || typeof data.id !== "string" || data.id === "") return;
2062
2488
  const outcome = typeof data.outcome === "string" ? data.outcome : "";
2063
2489
  const approvalStep = folder.stepByCall.get(`approval-${data.id}`);
2490
+ if (approvalStep !== undefined && outcome !== "") approvalStep.approvalOutcome = outcome;
2064
2491
  if (approvalStep !== undefined && outcome !== "" && typeof approvalStep.detail === "string" && !approvalStep.detail.includes(outcome)) {
2065
2492
  approvalStep.detail = approvalStep.detail === "" ? outcome : `${approvalStep.detail} · ${outcome}`;
2066
2493
  }
@@ -2112,6 +2539,10 @@ window.__ModuleLoader__.load({
2112
2539
  folder.pending.set(callId, { type: "artifact", path: producedPath });
2113
2540
  return;
2114
2541
  }
2542
+ if (name === "exit_plan_mode") {
2543
+ folder.pending.set(callId, { type: "plan", callId, text: args !== null && isRecord(args) && typeof args.plan === "string" ? args.plan : "" });
2544
+ return;
2545
+ }
2115
2546
  if (name === "skill" && args && typeof args.name === "string" && args.name.length > 0) {
2116
2547
  folder.pending.set(callId, {
2117
2548
  type: "reference",
@@ -2188,6 +2619,16 @@ window.__ModuleLoader__.load({
2188
2619
  const callId = isRecord(source) && typeof source.callId === "string" ? source.callId : undefined;
2189
2620
  if (callId === undefined) return;
2190
2621
  markStepDone(folder, callId, event.time, message.isError === true, message.isError === true ? textOf(message.content) : undefined);
2622
+ const resultStep = folder.stepByCall.get(callId);
2623
+ if (resultStep !== undefined && resultStep.result === undefined) {
2624
+ const captured = stepResultOf(message.content);
2625
+ if (captured !== null) {
2626
+ resultStep.result = captured.text;
2627
+ resultStep.resultChars = captured.chars;
2628
+ resultStep.resultTruncated = captured.truncated;
2629
+ resultStep.resultSkipped = captured.skipped;
2630
+ }
2631
+ }
2191
2632
  if (message.isError === true) {
2192
2633
  folder.stats.errors += 1;
2193
2634
  const errorBucket = folder.byTurn.get(folder.currentTurn);
@@ -2205,6 +2646,18 @@ window.__ModuleLoader__.load({
2205
2646
  seq: event.seq,
2206
2647
  time: event.time,
2207
2648
  });
2649
+ } else if (pending.type === "plan" && typeof pending.text === "string" && pending.text.trim() !== "") {
2650
+ const planTitle = planTitleOf(pending.text);
2651
+ const planRefKey = `plan|${typeof event.seq === "number" ? event.seq : folder.currentTurn}`;
2652
+ const previewKey = rememberContextPreview(folder.sessionId, planRefKey, planTitle, pending.text, "plan");
2653
+ upsertArtifact(folder, bucket, `plan:${planTitle === "" ? planRefKey : planTitle}`, {
2654
+ plan: true,
2655
+ ...(planTitle === "" ? {} : { planTitle }),
2656
+ ...(previewKey === undefined ? {} : { previewKey }),
2657
+ ...(typeof pending.callId === "string" && pending.callId !== "" ? { planCallId: pending.callId } : {}),
2658
+ seq: event.seq,
2659
+ time: event.time,
2660
+ });
2208
2661
  } else {
2209
2662
  const resultText = textOf(message.content);
2210
2663
  for (const ref of pending.refs) {
@@ -2338,16 +2791,41 @@ window.__ModuleLoader__.load({
2338
2791
  todosTime = own !== null ? own.time : 0;
2339
2792
  }
2340
2793
  const artifacts = [...source.artifacts.values()].sort((left, right) => (right.time || right.seq) - (left.time || left.seq) || right.seq - left.seq);
2794
+ /* 「本轮结论」: the closing reply is offered as a document, newest scope first. */
2795
+ const rawConclusion = isRecord(source.conclusion) ? source.conclusion : null;
2796
+ let conclusion = null;
2797
+ if (rawConclusion !== null && typeof rawConclusion.text === "string" && rawConclusion.text !== "") {
2798
+ const title = translate("artifact.conclusionTurn", { n: rawConclusion.turn });
2799
+ const previewKey = rememberContextPreview(folder.sessionId, `conclusion|${rawConclusion.turn}`, title, rawConclusion.text, "conclusion");
2800
+ conclusion = {
2801
+ path: `conclusion:${rawConclusion.turn}`,
2802
+ kind: "conclusion",
2803
+ title,
2804
+ description: "",
2805
+ chars: rawConclusion.text.length,
2806
+ turn: rawConclusion.turn,
2807
+ seq: rawConclusion.seq,
2808
+ time: rawConclusion.time,
2809
+ ended: rawConclusion.ended === true,
2810
+ ...(previewKey === undefined ? {} : { previewKey }),
2811
+ };
2812
+ artifacts.unshift(conclusion);
2813
+ }
2341
2814
  /* A ref is ubiquitous when it shows up in at least two turns covering >= 60% of
2342
2815
  all observed turns — the per-turn OpenViking/runtime injections match this. */
2343
2816
  const turnCount = turns.length;
2344
2817
  const ubiquitousFrom = turnCount >= 2 ? Math.max(2, Math.ceil(turnCount * 0.6)) : Infinity;
2345
2818
  const references = [];
2346
2819
  for (let index = source.refOrder.length - 1; index >= 0; index -= 1) {
2347
- const ref = source.refs.get(source.refOrder[index]);
2820
+ const key = source.refOrder[index];
2821
+ const ref = source.refs.get(key);
2348
2822
  if (ref === undefined) continue;
2349
2823
  const refTurns = Array.isArray(ref.turns) ? ref.turns.length : 0;
2350
- references.push(refTurns > 0 ? { ...ref, refTurns, ubiquitous: refTurns >= ubiquitousFrom } : { ...ref, refTurns: 0, ubiquitous: false });
2824
+ /* "common" is a session-wide property: a ref injected every turn stays common
2825
+ even while the view is narrowed to one turn. */
2826
+ const global = source === folder ? ref : folder.refs.get(key);
2827
+ const globalTurns = global !== undefined && Array.isArray(global.turns) ? global.turns.length : refTurns;
2828
+ references.push({ ...ref, refTurns, ubiquitous: globalTurns >= 2 && globalTurns >= ubiquitousFrom });
2351
2829
  }
2352
2830
  const st = isRecord(source.stats) ? source.stats : EMPTY_BUCKET.stats;
2353
2831
  const stats = {
@@ -2396,10 +2874,13 @@ window.__ModuleLoader__.load({
2396
2874
  turns,
2397
2875
  latestTurn,
2398
2876
  scope,
2877
+ /* how many turns this scope spans: "all" covers the session, a turn view spans 1 */
2878
+ scopeTurns: source === folder ? turns.length : 1,
2879
+ conclusion,
2399
2880
  };
2400
2881
  }
2401
2882
 
2402
- const EMPTY_STATE = { todos: null, todosTime: 0, 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" };
2883
+ const EMPTY_STATE = { todos: null, todosTime: 0, 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 };
2403
2884
 
2404
2885
  function createSummaryStore(binding) {
2405
2886
  const source = binding.eventSource;
@@ -2562,33 +3043,82 @@ window.__ModuleLoader__.load({
2562
3043
  return isRecord(goal) && typeof goal.objective === "string" ? goal : null;
2563
3044
  }
2564
3045
 
2565
- function useBackfill(binding, hasMore) {
3046
+ /** Backfills older history. Returns true once several consecutive loads made no
3047
+ progress, so the UI can surface 「重新加载」 instead of an endless spinner. */
3048
+ function useBackfill(binding, hasMore, retryToken) {
3049
+ const [stalled, setStalled] = React.useState(false);
2566
3050
  React.useEffect(() => {
3051
+ setStalled(false);
2567
3052
  if (!binding || !hasMore) return undefined;
2568
3053
  let cancelled = false;
2569
3054
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3055
+ /* every host read is guarded: a throwing getSnapshot would kill the loop and
3056
+ leave the status line up with no way back */
3057
+ const readSnapshot = () => {
3058
+ try {
3059
+ const source = binding.eventSource;
3060
+ return source !== undefined && typeof source.getSnapshot === "function" ? source.getSnapshot() : null;
3061
+ } catch {
3062
+ return null;
3063
+ }
3064
+ };
3065
+ const sizeOf = (snapshot) => (isRecord(snapshot) && Array.isArray(snapshot.entries) ? snapshot.entries.length : 0);
3066
+ const session = binding.session;
3067
+ const canLoadThrough = isRecord(session) && typeof session.loadThrough === "function";
3068
+ const canLoadOlder = isRecord(session) && typeof session.loadOlder === "function";
2570
3069
  const run = async () => {
2571
- /* Prefer the jump loader: it pages back until the window covers seq and
2572
- survives concurrent loaders. loadOlder() is a no-op while another load
2573
- is in flight, so retry until the window actually stops reporting more. */
2574
- for (let attempt = 0; attempt < 200 && !cancelled; attempt += 1) {
2575
- const snapshot = binding.eventSource.getSnapshot();
3070
+ if (!canLoadThrough && !canLoadOlder) {
3071
+ /* no loader at all: this can never converge, so say so instead of spinning */
3072
+ setStalled(true);
3073
+ return;
3074
+ }
3075
+ /* The jump loader pages back inside one call until the window covers the
3076
+ target seq. Event seqs are dense and ZERO-based, so the target must be
3077
+ 0: aiming at 1 leaves baseSeq stuck at 1, and from then on the host's
3078
+ own `baseSeq <= seq` guard makes every retry a permanent no-op. */
3079
+ let lastSize = sizeOf(readSnapshot());
3080
+ let noProgress = 0;
3081
+ let usedOlder = 0;
3082
+ let resynced = false;
3083
+ const deadline = Date.now() + 45000;
3084
+ for (let attempt = 0; attempt < 400 && !cancelled && Date.now() < deadline; attempt += 1) {
3085
+ const snapshot = readSnapshot();
2576
3086
  if (!isRecord(snapshot) || snapshot.hasMore !== true) return;
2577
- const session = binding.session;
2578
- if (isRecord(session) && typeof session.loadThrough === "function") await session.loadThrough(1);
2579
- else if (isRecord(session) && typeof session.loadOlder === "function") await session.loadOlder();
2580
- else return;
3087
+ try {
3088
+ /* A no-op page means the jump loader is blocked (another loader holds
3089
+ the lock, or the seq target is already covered): loadOlder() is
3090
+ seq-free and still prepends a real page, so alternate to it. If even
3091
+ that stalls, rebuild the history source once. */
3092
+ if (usedOlder < 2 || !canLoadOlder) {
3093
+ if (canLoadThrough) await session.loadThrough(0);
3094
+ else await session.loadOlder();
3095
+ } else if (!resynced && typeof session.resync === "function") {
3096
+ resynced = true;
3097
+ await session.resync();
3098
+ } else {
3099
+ await session.loadOlder();
3100
+ }
3101
+ } catch {
3102
+ /* a rejected page must not kill the loop; count it as no progress */
3103
+ }
2581
3104
  if (cancelled) return;
2582
- const after = binding.eventSource.getSnapshot();
3105
+ const after = readSnapshot();
2583
3106
  if (isRecord(after) && after.hasMore !== true) return;
2584
- await sleep(200);
3107
+ const size = sizeOf(after);
3108
+ const grew = size > lastSize;
3109
+ lastSize = size;
3110
+ noProgress = grew ? 0 : noProgress + 1;
3111
+ usedOlder = grew ? 0 : usedOlder + 1;
3112
+ if (noProgress === 4) setStalled(true);
3113
+ await sleep(grew ? 60 : 200);
2585
3114
  }
2586
3115
  };
2587
3116
  run().catch(() => {});
2588
3117
  return () => {
2589
3118
  cancelled = true;
2590
3119
  };
2591
- }, [binding, hasMore]);
3120
+ }, [binding, hasMore, retryToken]);
3121
+ return stalled;
2592
3122
  }
2593
3123
 
2594
3124
  /** Class map mirroring the copied FileTypeIcon.module.css, renamed under dsov-ft-*. */
@@ -3319,6 +3849,49 @@ function FileTypeIcon(props) {
3319
3849
  ]);
3320
3850
  }
3321
3851
 
3852
+ function IconPlan({ size = 16, className }) {
3853
+ return svg(size, className, [
3854
+ h("rect", { key: "a", x: 3.2, y: 2.2, width: 9.6, height: 11.6, rx: 1.5, stroke: "currentColor", fill: "none" }),
3855
+ h("path", { key: "b", d: "M5.6 5.6h4.8M5.6 8.1h4.8M5.6 10.6h2.9", stroke: "currentColor", strokeLinecap: "round" }),
3856
+ ]);
3857
+ }
3858
+
3859
+ function IconInfo({ size = 14, className }) {
3860
+ return svg(size, className, [
3861
+ h("circle", { key: "a", cx: 8, cy: 8, r: 6.1, stroke: "currentColor", fill: "none" }),
3862
+ h("path", { key: "b", d: "M8 7.2v3.9", stroke: "currentColor", strokeLinecap: "round" }),
3863
+ h("circle", { key: "c", cx: 8, cy: 5.1, r: 0.85, fill: "currentColor" }),
3864
+ ], "0 0 16 16");
3865
+ }
3866
+
3867
+ /** Chrome copy the chat Markdown component expects (code banner, footnotes). */
3868
+ function chatMarkdownLabels(t) {
3869
+ return {
3870
+ code: {
3871
+ copyLabel: t("markdown.copy"),
3872
+ copiedLabel: t("markdown.copied"),
3873
+ toolbarLabels: {
3874
+ codeLabel: t("markdown.code"),
3875
+ wrapLabel: t("markdown.wrap"),
3876
+ unwrapLabel: t("markdown.unwrap"),
3877
+ },
3878
+ },
3879
+ footnotes: t("markdown.footnotes"),
3880
+ };
3881
+ }
3882
+
3883
+ /** Turn conclusion: a written page with a sealed tick on its corner. */
3884
+ function IconConclusion({ size = 16, className }) {
3885
+ return svg(size, className, [
3886
+ h("path", { key: "a", d: "M9.2 1.6H4.3c-.85 0-1.5.65-1.5 1.5v9.8c0 .85.65 1.5 1.5 1.5h4.6", stroke: "currentColor", strokeLinejoin: "round", fill: "none", strokeWidth: 1.25 }),
3887
+ h("path", { key: "b", d: "M9.1 1.8v3.7h3.9", stroke: "currentColor", strokeLinejoin: "round", fill: "none", strokeWidth: 1.25 }),
3888
+ h("path", { key: "c", d: "M9.1 1.8v3.7h3.6V5.6L9.1 1.8Z", stroke: "currentColor", strokeLinejoin: "round", fill: "none", strokeWidth: 1.25 }),
3889
+ h("path", { key: "d", d: "M5.2 7.3h4.7M5.2 9.4h3", stroke: "currentColor", strokeLinecap: "round", strokeWidth: 1.25 }),
3890
+ h("circle", { key: "e", cx: 11.7, cy: 12.1, r: 2.55, fill: "currentColor" }),
3891
+ h("path", { key: "f", d: "M10.5 12.1l.85.9 1.6-1.8", stroke: "#fff", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 1.15, fill: "none" }),
3892
+ ], "0 0 16 16");
3893
+ }
3894
+
3322
3895
  function IconSkill({ size = 16, className }) {
3323
3896
  return svg(size, className, [
3324
3897
  h("path", { key: "a", d: "M8.8 1.6 3.4 9h4l-.8 5.4L12.6 7h-4.2l.4-5.4Z", stroke: "currentColor", strokeLinejoin: "round" }),
@@ -3563,13 +4136,34 @@ function FileTypeIcon(props) {
3563
4136
  }
3564
4137
 
3565
4138
  /** Incoming order is newest-first; kind/count modes re-rank, breaking ties by recency. */
3566
- function sortReferences(refs, mode) {
4139
+ /** Mentions of one reference: folds can count one turn twice, so this is not turn coverage. */
4140
+ function refHitsOf(ref) {
4141
+ return isRecord(ref) && typeof ref.count === "number" && ref.count > 0 ? ref.count : 1;
4142
+ }
4143
+
4144
+ /** Distinct turns that mentioned one reference; a single turn can fold it twice. */
4145
+ function refTurnsOf(ref) {
4146
+ if (isRecord(ref) && typeof ref.refTurns === "number") return ref.refTurns;
4147
+ return isRecord(ref) && Array.isArray(ref.turns) ? ref.turns.length : 0;
4148
+ }
4149
+
4150
+ function sortReferences(refs, mode, multiTurn) {
3567
4151
  const byRecency = (left, right) => (right.time || right.seq || 0) - (left.time || left.seq || 0) || (right.seq || 0) - (left.seq || 0);
3568
- if (mode === "count") return refs.slice().sort((left, right) => (right.count || 1) - (left.count || 1) || byRecency(left, right));
4152
+ if (mode === "count") {
4153
+ if (multiTurn !== true) return refs.slice().sort((left, right) => refHitsOf(right) - refHitsOf(left) || byRecency(left, right));
4154
+ return refs.slice().sort((left, right) => refTurnsOf(right) - refTurnsOf(left) || refHitsOf(right) - refHitsOf(left) || byRecency(left, right));
4155
+ }
3569
4156
  return refs;
3570
4157
  }
3571
4158
 
3572
- function Section({ id, icon, title, badge, empty, action, children }) {
4159
+ /** Alternating animation names let the CSS entrance replay without remounting
4160
+ (a keyed remount would drop collapse/scroll state on every new turn). */
4161
+ function sectionIntroProps(introSeq, delayMs) {
4162
+ if (!Number.isFinite(introSeq) || introSeq <= 0) return {};
4163
+ return { introAttr: introSeq % 2 === 0 ? "even" : "odd", introDelay: delayMs };
4164
+ }
4165
+
4166
+ function Section({ id, icon, title, badge, empty, action, children, introAttr, introDelay }) {
3573
4167
  const [open, setOpen] = React.useState(() => collapsedSections.get(id) !== true);
3574
4168
  const cfg = useConfig();
3575
4169
  const kids = Array.isArray(children) ? children.filter((child) => child !== null && child !== undefined && child !== false) : children ? [children] : [];
@@ -3580,7 +4174,12 @@ function FileTypeIcon(props) {
3580
4174
  };
3581
4175
  return h(
3582
4176
  "section",
3583
- { className: "dsov-section", "data-full": cfg.sectionFullHeight === true ? "true" : "false" },
4177
+ {
4178
+ className: "dsov-section",
4179
+ "data-full": cfg.sectionFullHeight === true ? "true" : "false",
4180
+ "data-intro": introAttr,
4181
+ style: typeof introDelay === "number" ? { animationDelay: `${introDelay}ms` } : undefined,
4182
+ },
3584
4183
  h(
3585
4184
  "div",
3586
4185
  { className: "dsov-sectionHead" },
@@ -3626,7 +4225,7 @@ function FileTypeIcon(props) {
3626
4225
  complete: "success",
3627
4226
  };
3628
4227
 
3629
- function ProgressSection({ summary, goal, t, waiting, cfg, asOf }) {
4228
+ function ProgressSection({ summary, goal, t, waiting, cfg, asOf, introSeq }) {
3630
4229
  const fmtTime = (ts) => formatTime(ts, cfg.timeMode, summary.turnStart);
3631
4230
  const timeTitle = (ts) => (cfg.timeMode === "elapsed" ? formatTime(ts) : undefined);
3632
4231
  /* timeline scrubbing: only what already happened at `asOf` is shown */
@@ -3739,14 +4338,59 @@ function FileTypeIcon(props) {
3739
4338
  if (!stepAt(candidate)) continue;
3740
4339
  const key = milestoneKeyOf(candidate);
3741
4340
  if (baseKeys.has(key)) continue;
4341
+ if (typeof candidate.callId === "string" && nestedApprovalCallIds.has(candidate.callId)) continue;
3742
4342
  baseKeys.add(key);
3743
4343
  milestoneExtras.push(stepClamped(candidate));
3744
4344
  }
3745
4345
  milestoneExtras.sort((left, right) => (left.time || 0) - (right.time || 0));
3746
4346
  }
3747
4347
  const displaySteps = milestoneExtras.length > 0 ? [...milestoneExtras, ...(stepsOpen ? expandedSteps : steps)] : stepsOpen ? expandedSteps : steps;
4348
+ /* An approval gates one tool call; render it directly under that call so a
4349
+ child row can never look like it finished before its own parent. */
4350
+ const approvalChildren = new Map();
4351
+ const nestedApprovalKeys = new Set();
4352
+ const nestedApprovalCallIds = new Set();
4353
+ {
4354
+ const keyByCall = new Map();
4355
+ displaySteps.forEach((step, index) => {
4356
+ if (typeof step.callId === "string" && step.callId !== "") keyByCall.set(step.callId, stepKeyOf(step, index));
4357
+ });
4358
+ displaySteps.forEach((step, index) => {
4359
+ if (step.name !== "approval" || typeof step.parentCallId !== "string" || step.parentCallId === "") return;
4360
+ const parentKey = keyByCall.get(step.parentCallId);
4361
+ const ownKey = stepKeyOf(step, index);
4362
+ if (parentKey === undefined || parentKey === ownKey) return;
4363
+ const list = approvalChildren.get(parentKey);
4364
+ if (list === undefined) approvalChildren.set(parentKey, [step]);
4365
+ else list.push(step);
4366
+ nestedApprovalKeys.add(ownKey);
4367
+ if (typeof step.callId === "string") nestedApprovalCallIds.add(step.callId);
4368
+ });
4369
+ }
4370
+ const renderSteps = nestedApprovalKeys.size === 0 ? displaySteps : displaySteps.filter((step, index) => !nestedApprovalKeys.has(stepKeyOf(step, index)));
4371
+ /* A step the user expanded while it ran closes itself once it succeeds; a failed
4372
+ step stays open so the error stays readable. */
4373
+ const runningKeys = [];
4374
+ const stepByKey = new Map();
4375
+ renderSteps.forEach((step, index) => {
4376
+ const key = stepKeyOf(step, index);
4377
+ stepByKey.set(key, step);
4378
+ if (step.done !== true && step.error !== true) runningKeys.push(key);
4379
+ });
4380
+ const runningSig = runningKeys.join("|");
4381
+ const prevRunningKeys = React.useRef(new Set());
4382
+ React.useEffect(() => {
4383
+ const now = new Set(runningKeys);
4384
+ for (const key of prevRunningKeys.current) {
4385
+ if (now.has(key)) continue;
4386
+ const step = stepByKey.get(key);
4387
+ if (step === undefined || step.done !== true || step.error === true) continue;
4388
+ if (overrideOf(`step:${key}`, false)) toggleKey(`step:${key}`, false);
4389
+ }
4390
+ prevRunningKeys.current = now;
4391
+ }, [runningSig]);
3748
4392
  if (seenSteps.current === null || seenSteps.current.scope !== summary.scope) {
3749
- seenSteps.current = { scope: summary.scope, keys: new Set(displaySteps.map(stepKeyOf)) };
4393
+ seenSteps.current = { scope: summary.scope, keys: new Set(renderSteps.map(stepKeyOf)) };
3750
4394
  }
3751
4395
  React.useEffect(
3752
4396
  () => () => {
@@ -3959,6 +4603,106 @@ function FileTypeIcon(props) {
3959
4603
  target.push(subRow(sub, 0));
3960
4604
  });
3961
4605
  };
4606
+ /* What an expanded step reveals: the full command/arg text plus the result. */
4607
+ const stepBodyOf = (step) => {
4608
+ if (step.name === "approval") return null;
4609
+ const command = typeof step.full === "string" ? step.full.trim() : "";
4610
+ const rawResult = step.error === true && typeof step.err === "string" && step.err !== "" ? step.err : typeof step.result === "string" ? step.result : "";
4611
+ const result = rawResult.trim();
4612
+ if (command === "" && result === "") return null;
4613
+ const duration = typeof step.endTime === "number" && typeof step.time === "number" ? Math.max(0, step.endTime - step.time) : null;
4614
+ return {
4615
+ command,
4616
+ result,
4617
+ error: step.error === true,
4618
+ duration,
4619
+ chars: typeof step.resultChars === "number" ? step.resultChars : null,
4620
+ truncated: step.resultTruncated === true,
4621
+ skipped: step.resultSkipped === true,
4622
+ };
4623
+ };
4624
+ /* 审批 is part of the step it gates, not a neighbour: it leads the detail panel
4625
+ so the gate reads before the command it unlocked, and the row carries a chip. */
4626
+ const approvalToneOf = (child) => {
4627
+ if (child.done !== true && child.error !== true) return "warn";
4628
+ return /denied|reject|deny/i.test(typeof child.approvalOutcome === "string" ? child.approvalOutcome : "") ? "err" : "ok";
4629
+ };
4630
+ const approvalTitleOf = (approvals, tr) => {
4631
+ const parts = [];
4632
+ for (const child of approvals) {
4633
+ const tool = typeof child.approvalTool === "string" && child.approvalTool !== "" ? child.approvalTool : typeof child.detail === "string" ? child.detail : "";
4634
+ const reason = typeof child.approvalReason === "string" ? child.approvalReason : "";
4635
+ const outcome = typeof child.approvalOutcome === "string" ? child.approvalOutcome : "";
4636
+ const tone = approvalToneOf(child);
4637
+ const state = tone === "warn" ? tr("step.approvalPending") : outcome;
4638
+ const line = [tool, reason, state].filter((part) => typeof part === "string" && part !== "").join(" · ");
4639
+ if (line !== "") parts.push(line);
4640
+ }
4641
+ return parts.length === 0 ? undefined : parts.join("\n");
4642
+ };
4643
+ const approvalBlock = (approvals) => {
4644
+ if (!Array.isArray(approvals) || approvals.length === 0) return null;
4645
+ return h(
4646
+ "div",
4647
+ { className: "dsov-stepApproval", key: "approval" },
4648
+ approvals.map((child) => {
4649
+ const tone = approvalToneOf(child);
4650
+ const tool = typeof child.approvalTool === "string" && child.approvalTool !== "" ? child.approvalTool : typeof child.detail === "string" ? child.detail : "";
4651
+ const reason = typeof child.approvalReason === "string" ? child.approvalReason : "";
4652
+ const outcome = typeof child.approvalOutcome === "string" ? child.approvalOutcome : "";
4653
+ const label = tone === "warn" ? t("step.approvalPending") : tone === "err" ? t("step.approvalDenied") : t("step.approvalApproved");
4654
+ const stamp = typeof child.endTime === "number" ? child.endTime : child.time;
4655
+ return h(
4656
+ "div",
4657
+ { className: "dsov-stepApprovalItem", key: typeof child.callId === "string" ? child.callId : "approval", "data-tone": tone },
4658
+ h(
4659
+ "div",
4660
+ { className: "dsov-stepApprovalHead" },
4661
+ h("span", { className: "dsov-stepApprovalIcon" }, h(IconTarget, { size: 11 })),
4662
+ h("span", { className: "dsov-stepApprovalName" }, t("step.approval")),
4663
+ tool !== "" ? h("span", { className: "dsov-stepApprovalTool" }, tool) : null,
4664
+ h("span", { className: "dsov-stepApprovalBadge", "data-tone": tone }, label),
4665
+ h("span", { className: "dsov-stepApprovalTime" }, fmtTime(stamp))
4666
+ ),
4667
+ reason !== "" ? h("div", { className: "dsov-stepApprovalReason" }, reason) : null,
4668
+ outcome !== "" && tone !== "warn" ? h("div", { className: "dsov-stepApprovalOutcome" }, outcome) : null
4669
+ );
4670
+ })
4671
+ );
4672
+ };
4673
+ const stepBodyBlock = (step, key, approvals) => {
4674
+ const body = stepBodyOf(step);
4675
+ const gated = approvalBlock(approvals);
4676
+ if (body === null && gated === null) return null;
4677
+ const meta = [];
4678
+ if (body !== null && body.duration !== null) meta.push(t("progress.stepDuration", { v: formatElapsed(body.duration) }));
4679
+ if (body !== null && body.chars !== null && body.chars > 0) meta.push(t("progress.stepChars", { n: body.chars }));
4680
+ if (body !== null && body.skipped) meta.push(t("progress.stepSkipped"));
4681
+ else if (body !== null && body.truncated) meta.push(t("progress.stepTruncated"));
4682
+ return h(
4683
+ "div",
4684
+ { className: "dsov-stepBody", key: `${key}-body` },
4685
+ gated,
4686
+ body !== null && body.command !== ""
4687
+ ? h(
4688
+ "div",
4689
+ { className: "dsov-stepBodySec" },
4690
+ h("div", { className: "dsov-stepBodyLabel" }, t("progress.stepCommand")),
4691
+ h("pre", { className: "dsov-stepBodyText" }, body.command)
4692
+ )
4693
+ : null,
4694
+ body !== null && body.result !== ""
4695
+ ? h(
4696
+ "div",
4697
+ { className: "dsov-stepBodySec" },
4698
+ h("div", { className: "dsov-stepBodyLabel" }, t(body.error ? "progress.stepError" : "progress.stepResult")),
4699
+ h("pre", { className: body.error ? "dsov-stepBodyText dsov-stepBodyErr" : "dsov-stepBodyText" }, body.result)
4700
+ )
4701
+ : null,
4702
+ meta.length > 0 ? h("div", { className: "dsov-stepBodyMeta" }, meta.join(" · ")) : null
4703
+ );
4704
+ };
4705
+
3962
4706
  const stepRow = (step, key, extraProps, isWaiting) => {
3963
4707
  const stepRunning = step.done !== true;
3964
4708
  const failed = step.error === true;
@@ -3967,8 +4711,79 @@ function FileTypeIcon(props) {
3967
4711
  const full = typeof step.full === "string" && step.full !== "" ? step.full : detail !== "" ? detail : typeof step.name === "string" ? step.name : "";
3968
4712
  const flash = stepFlash !== null && isRecord(stepFlash) && stepFlash.key === (typeof step.callId === "string" && step.callId !== "" ? step.callId : `${step.name}|${step.seq}`) ? stepFlash.state : null;
3969
4713
  const attached = stepAttachments.get(key);
3970
- const expandable = Array.isArray(attached) && attached.length > 0;
3971
- const open = expandable ? overrideOf(`step:${key}`, attached.some((item) => item.unfinished)) : false;
4714
+ const approvals = approvalChildren.get(key);
4715
+ const dying = extraProps !== undefined && extraProps["data-exit"] === "true";
4716
+ const body = dying ? null : stepBodyOf(step);
4717
+ const gated = !dying && Array.isArray(approvals) && approvals.length > 0;
4718
+ const pendingApproval = gated && approvals.some((child) => approvalToneOf(child) === "warn");
4719
+ const expandable = (Array.isArray(attached) && attached.length > 0) || body !== null || gated;
4720
+ /* team rows keep their chevron visible; a plain step reveals it on hover only */
4721
+ const persistentChevron = Array.isArray(attached) && attached.length > 0;
4722
+ const open = expandable ? overrideOf(`step:${key}`, (Array.isArray(attached) && attached.some((item) => item.unfinished)) || pendingApproval) : false;
4723
+ const statusIcon =
4724
+ step.name === "compact-checkpoint"
4725
+ ? h("span", { className: "dsov-todoIcon dsov-compactIcon" }, h(IconCompact, { size: 15 }))
4726
+ : waitingRow
4727
+ ? h(
4728
+ "span",
4729
+ { className: "dsov-todoIcon", "data-status": "waiting" },
4730
+ h(
4731
+ "svg",
4732
+ { width: 15, height: 15, viewBox: "0 0 15 15", fill: "none", "aria-hidden": "true" },
4733
+ h("circle", { cx: 7.5, cy: 7.5, r: 6, stroke: "currentColor", strokeWidth: 1.3, opacity: 0.45 }),
4734
+ h("circle", { cx: 7.5, cy: 7.5, r: 2.4, fill: "currentColor", className: "dsov-todoPulse" })
4735
+ )
4736
+ )
4737
+ : h(
4738
+ "span",
4739
+ { className: "dsov-todoIcon", "data-status": stepRunning ? "in_progress" : "completed" },
4740
+ h(TodoStatusIcon, { status: stepRunning ? "in_progress" : "completed" })
4741
+ );
4742
+ /* The expand affordance is the status icon itself: hovering swaps it for a
4743
+ chevron in place, so nothing shifts and no second column appears. Team rows
4744
+ keep their own always-visible chevron ahead of the status icon. */
4745
+ const swapChevron = h(
4746
+ "span",
4747
+ { className: "dsov-chevron dsov-stepSwapChevron", "data-open": open ? "true" : "false", "aria-hidden": "true" },
4748
+ h(IconChevron, { size: 12 })
4749
+ );
4750
+ const leading = !expandable
4751
+ ? statusIcon
4752
+ : persistentChevron
4753
+ ? h(
4754
+ React.Fragment,
4755
+ { key: "lead" },
4756
+ h(
4757
+ "span",
4758
+ {
4759
+ className: "dsov-chevron dsov-stepChevron",
4760
+ key: "chevron",
4761
+ "data-open": open ? "true" : "false",
4762
+ title: t(open ? "progress.collapseTeam" : "progress.expandTeam"),
4763
+ onClick: (event) => {
4764
+ event.stopPropagation();
4765
+ toggleKey(`step:${key}`, !open);
4766
+ },
4767
+ },
4768
+ h(IconChevron, { size: 11 })
4769
+ ),
4770
+ statusIcon
4771
+ )
4772
+ : h(
4773
+ "span",
4774
+ {
4775
+ className: "dsov-stepIconSwap",
4776
+ key: "swap",
4777
+ "data-open": open ? "true" : "false",
4778
+ title: t(open ? "progress.collapseStep" : "progress.expandStep"),
4779
+ onClick: (event) => {
4780
+ event.stopPropagation();
4781
+ toggleKey(`step:${key}`, !open);
4782
+ },
4783
+ },
4784
+ statusIcon,
4785
+ swapChevron
4786
+ );
3972
4787
  const row = h(
3973
4788
  "div",
3974
4789
  {
@@ -3981,24 +4796,67 @@ function FileTypeIcon(props) {
3981
4796
  ...(extraProps === undefined ? {} : extraProps),
3982
4797
  onClick: () => onStepClick(step),
3983
4798
  },
3984
- expandable
4799
+ leading,
4800
+ h(
4801
+ "span",
4802
+ { className: "dsov-stepName" },
4803
+ step.name === "compact-checkpoint" ? t("step.compact") : step.name === "approval" ? t("step.approval") : typeof step.name === "string" ? step.name : ""
4804
+ ),
4805
+ h("span", { className: "dsov-stepDetail" }, detail),
4806
+ gated
3985
4807
  ? h(
3986
4808
  "span",
3987
4809
  {
3988
- className: "dsov-chevron dsov-stepChevron",
3989
- "data-open": open ? "true" : "false",
3990
- title: t(open ? "progress.collapseTeam" : "progress.expandTeam"),
3991
- onClick: (event) => {
3992
- event.stopPropagation();
3993
- toggleKey(`step:${key}`, !open);
3994
- },
4810
+ className: "dsov-stepChip",
4811
+ "data-tone": approvalToneOf(approvals[0]),
4812
+ title: approvalTitleOf(approvals, t),
3995
4813
  },
3996
- h(IconChevron, { size: 11 })
4814
+ approvalToneOf(approvals[0]) === "warn" ? t("step.approvalPending") : t("step.approvalGated")
3997
4815
  )
3998
4816
  : null,
3999
- step.name === "compact-checkpoint"
4000
- ? h("span", { className: "dsov-todoIcon dsov-compactIcon" }, h(IconCompact, { size: 15 }))
4001
- : waitingRow
4817
+ h("span", { className: "dsov-time", title: timeTitle(typeof step.endTime === "number" ? step.endTime : step.time) }, fmtTime(typeof step.endTime === "number" ? step.endTime : step.time))
4818
+ );
4819
+ if (!open) return row;
4820
+ const extras = [];
4821
+ if (body !== null || gated) {
4822
+ const block = stepBodyBlock(step, key, approvals);
4823
+ if (block !== null) extras.push(block);
4824
+ }
4825
+ if (Array.isArray(attached)) for (const item of attached) extras.push(item.row);
4826
+ return extras.length === 0 ? row : [row, ...extras];
4827
+ };
4828
+ /* Nested approval row: its own two-line layout, because reusing the flat step
4829
+ row squeezed the reason into the ellipsised detail column (~38px in a 332px
4830
+ sidebar) and the text looked clipped away. The reason now wraps on its own
4831
+ line; the outcome becomes a small badge instead of trailing detail text. */
4832
+ const approvalChildRow = (step, key, isWaiting) => {
4833
+ const running = step.done !== true;
4834
+ const failed = step.error === true;
4835
+ const waitingRow = isWaiting === true && running && !failed;
4836
+ const tool = typeof step.approvalTool === "string" && step.approvalTool !== "" ? step.approvalTool : typeof step.detail === "string" ? step.detail : "";
4837
+ const reason = typeof step.approvalReason === "string" ? step.approvalReason : "";
4838
+ const outcome = typeof step.approvalOutcome === "string" ? step.approvalOutcome : "";
4839
+ const outcomeText =
4840
+ outcome === ""
4841
+ ? ""
4842
+ : /approved|allow/i.test(outcome)
4843
+ ? t("step.approvalApproved")
4844
+ : /denied|reject|deny/i.test(outcome)
4845
+ ? t("step.approvalDenied")
4846
+ : outcome;
4847
+ const outcomeOk = !/denied|reject|deny/i.test(outcome);
4848
+ const stamp = typeof step.endTime === "number" ? step.endTime : step.time;
4849
+ const hoverText = [tool, reason, outcome].filter((part) => typeof part === "string" && part !== "").join(" · ");
4850
+ return h(
4851
+ "div",
4852
+ {
4853
+ className: "dsov-step dsov-stepChild",
4854
+ key,
4855
+ "data-error": failed ? "true" : "false",
4856
+ "data-waiting": waitingRow ? "true" : "false",
4857
+ title: hoverText === "" ? undefined : hoverText,
4858
+ },
4859
+ waitingRow
4002
4860
  ? h(
4003
4861
  "span",
4004
4862
  { className: "dsov-todoIcon", "data-status": "waiting" },
@@ -4009,17 +4867,29 @@ function FileTypeIcon(props) {
4009
4867
  h("circle", { cx: 7.5, cy: 7.5, r: 2.4, fill: "currentColor", className: "dsov-todoPulse" })
4010
4868
  )
4011
4869
  )
4012
- : h("span", { className: "dsov-todoIcon", "data-status": stepRunning ? "in_progress" : "completed" }, h(TodoStatusIcon, { status: stepRunning ? "in_progress" : "completed" })),
4870
+ : h(
4871
+ "span",
4872
+ { className: "dsov-todoIcon", "data-status": running ? "in_progress" : "completed" },
4873
+ h(TodoStatusIcon, { status: running ? "in_progress" : "completed" })
4874
+ ),
4013
4875
  h(
4014
- "span",
4015
- { className: "dsov-stepName" },
4016
- step.name === "compact-checkpoint" ? t("step.compact") : step.name === "approval" ? t("step.approval") : typeof step.name === "string" ? step.name : ""
4017
- ),
4018
- h("span", { className: "dsov-stepDetail" }, detail),
4019
- h("span", { className: "dsov-time", title: timeTitle(typeof step.endTime === "number" ? step.endTime : step.time) }, fmtTime(typeof step.endTime === "number" ? step.endTime : step.time))
4876
+ "div",
4877
+ { className: "dsov-stepChildBody" },
4878
+ h(
4879
+ "div",
4880
+ { className: "dsov-stepChildTop" },
4881
+ h("span", { className: "dsov-stepName" }, t("step.approval")),
4882
+ tool !== "" ? h("span", { className: "dsov-stepChildTool" }, tool) : null,
4883
+ outcomeText !== ""
4884
+ ? h("span", { className: "dsov-stepChildOutcome", "data-ok": outcomeOk ? "true" : "false" }, outcomeText)
4885
+ : null,
4886
+ h("span", { className: "dsov-time", title: timeTitle(stamp) }, fmtTime(stamp))
4887
+ ),
4888
+ reason !== "" ? h("div", { className: "dsov-stepChildNote" }, reason) : null
4889
+ )
4020
4890
  );
4021
- return expandable && open ? [row, ...attached.map((item) => item.row)] : row;
4022
4891
  };
4892
+
4023
4893
  const pushStepRow = (target, step, key, extraProps, isWaiting) => {
4024
4894
  const rendered = stepRow(step, key, extraProps, isWaiting);
4025
4895
  if (Array.isArray(rendered)) target.push(...rendered);
@@ -4142,10 +5012,17 @@ function FileTypeIcon(props) {
4142
5012
  if (hasTeam && !showSteps) pushFlatTeam(kids);
4143
5013
  if (showSteps) {
4144
5014
  const markWaiting = waiting === true && summary.scope === "latest";
5015
+ /* a step blocked on an approval is not the thing being waited on: the
5016
+ nested approval row carries the waiting state instead */
5017
+ const approvalBlocked = new Set();
5018
+ for (const [parentKey, list] of approvalChildren) {
5019
+ if (list.some((child) => child.done !== true && child.error !== true)) approvalBlocked.add(parentKey);
5020
+ }
4145
5021
  let waitingIndex = -1;
4146
5022
  if (markWaiting) {
4147
- for (let index = displaySteps.length - 1; index >= 0; index -= 1) {
4148
- if (displaySteps[index].done !== true && displaySteps[index].error !== true) {
5023
+ for (let index = renderSteps.length - 1; index >= 0; index -= 1) {
5024
+ if (approvalBlocked.has(stepKeyOf(renderSteps[index], index))) continue;
5025
+ if (renderSteps[index].done !== true && renderSteps[index].error !== true) {
4149
5026
  waitingIndex = index;
4150
5027
  break;
4151
5028
  }
@@ -4153,13 +5030,18 @@ function FileTypeIcon(props) {
4153
5030
  }
4154
5031
  const rows = [];
4155
5032
  let freshCount = 0;
4156
- displaySteps.forEach((step, index) => {
5033
+ renderSteps.forEach((step, index) => {
4157
5034
  const key = stepKeyOf(step, index);
4158
5035
  const fresh = asOfTime === null && !seenSteps.current.keys.has(key);
4159
5036
  if (fresh) {
4160
5037
  seenSteps.current.keys.add(key);
4161
5038
  freshCount += 1;
4162
5039
  }
5040
+ if (step.name === "approval") {
5041
+ /* an approval whose tool call fell outside the window still needs a row */
5042
+ rows.push(approvalChildRow(step, key, step.done !== true && step.error !== true));
5043
+ return;
5044
+ }
4163
5045
  pushStepRow(rows, step, key, fresh ? { "data-enter": "true", style: { animationDelay: `${(freshCount - 1) * 45}ms` } } : undefined, index === waitingIndex);
4164
5046
  });
4165
5047
  if (ghost !== null && isRecord(ghost) && !stepsOpen) {
@@ -4188,10 +5070,14 @@ function FileTypeIcon(props) {
4188
5070
  );
4189
5071
  kids.push(h("div", { className: "dsov-steps", key: "steps" }, rows));
4190
5072
  }
4191
- return h(Section, { id: "progress", icon: h(IconProgress, { size: 15 }), title: t("section.progress"), empty: t(summary.scope === "all" ? "progress.empty" : "progress.emptyTurn") }, kids);
5073
+ return h(
5074
+ Section,
5075
+ { id: "progress", ...sectionIntroProps(introSeq, 0), icon: h(IconProgress, { size: 15 }), title: t("section.progress"), empty: t(summary.scope === "all" ? "progress.empty" : "progress.emptyTurn") },
5076
+ kids
5077
+ );
4192
5078
  }
4193
5079
 
4194
- function ArtifactsSection({ summary, t, binding, stats, asOf }) {
5080
+ function ArtifactsSection({ summary, t, binding, stats, asOf, introSeq }) {
4195
5081
  const cfg = useConfig();
4196
5082
  const asOfTime = typeof asOf === "number" && asOf > 0 ? asOf : null;
4197
5083
  const visibleArtifacts = asOfTime === null ? summary.artifacts : summary.artifacts.filter((artifact) => (artifact.firstTime || artifact.time || 0) <= asOfTime);
@@ -4233,24 +5119,35 @@ function FileTypeIcon(props) {
4233
5119
  const stat = stats ? stats.get(absPath(cwd, artifact.path)) : undefined;
4234
5120
  const showCounts = stat !== undefined && !stat.binary && !stat.oversized && (stat.added > 0 || stat.deleted > 0);
4235
5121
  const isMemory = typeof artifact.path === "string" && artifact.path.startsWith("viking://");
5122
+ const isConclusion = artifact.kind === "conclusion";
5123
+ const isPlan = artifact.plan === true;
5124
+ const planName = isPlan ? (typeof artifact.planTitle === "string" && artifact.planTitle !== "" ? artifact.planTitle : t("artifact.plan")) : "";
4236
5125
  return h(
4237
5126
  "button",
4238
5127
  {
4239
5128
  type: "button",
4240
5129
  className: "dsov-row",
4241
5130
  key: `${artifact.path}#${index}`,
4242
- title: artifact.description ? `${artifact.path} — ${artifact.description}` : artifact.path,
5131
+ title: isConclusion ? `${artifact.title} · ${t("artifact.conclusionHint")}` : isPlan ? planName : artifact.description ? `${artifact.path} — ${artifact.description}` : artifact.path,
4243
5132
  onClick: () => openArtifact(binding, artifact, stats),
4244
5133
  onContextMenu:
4245
- canFileMenu && !isMemory
5134
+ canFileMenu && !isMemory && !isPlan && !isConclusion
4246
5135
  ? (event) => {
4247
5136
  event.preventDefault();
4248
5137
  setFileMenu({ x: event.clientX, y: event.clientY, path: absPath(cwd, artifact.path) });
4249
5138
  }
4250
5139
  : undefined,
4251
5140
  },
4252
- h("span", { className: "dsov-fileIcon" }, h(FileTypeIcon, { path: artifact.path, size: 20 })),
4253
- h("span", { className: "dsov-artifactName" }, basename(artifact.path)),
5141
+ h(
5142
+ "span",
5143
+ { className: "dsov-fileIcon" },
5144
+ isConclusion
5145
+ ? h("span", { className: "dsov-fileMark", "data-kind": "conclusion" }, h(IconConclusion, { size: 15 }))
5146
+ : isPlan
5147
+ ? h(IconPlan, { size: 20 })
5148
+ : h(FileTypeIcon, { path: artifact.path, size: 20 })
5149
+ ),
5150
+ h("span", { className: "dsov-artifactName" }, isConclusion ? artifact.title : isPlan ? planName : basename(artifact.path)),
4254
5151
  showCounts
4255
5152
  ? h(
4256
5153
  "span",
@@ -4263,6 +5160,9 @@ function FileTypeIcon(props) {
4263
5160
  : null,
4264
5161
  isMemory ? h("span", { className: "dsov-badge", "data-tone": "quiet" }, t("artifact.memory")) : null,
4265
5162
  artifact.presented ? h("span", { className: "dsov-badge", "data-tone": "brand" }, t("artifact.presented")) : null,
5163
+ isPlan ? h("span", { className: "dsov-badge", "data-tone": "plan" }, t("artifact.plan")) : null,
5164
+ isConclusion ? h("span", { className: "dsov-badge", "data-tone": "brand" }, t("artifact.conclusion")) : null,
5165
+ isConclusion && typeof artifact.chars === "number" ? h("span", { className: "dsov-badge", "data-tone": "quiet" }, t("artifact.conclusionChars", { n: artifact.chars })) : null,
4266
5166
  h("span", { className: "dsov-rowTail" }, h("span", { className: "dsov-time", title: timeTitle(artifact.time) }, fmtTime(artifact.time)))
4267
5167
  );
4268
5168
  });
@@ -4271,6 +5171,7 @@ function FileTypeIcon(props) {
4271
5171
  Section,
4272
5172
  {
4273
5173
  id: "artifacts",
5174
+ ...sectionIntroProps(introSeq, 70),
4274
5175
  icon: h(IconArtifact, { size: 15 }),
4275
5176
  title: t("section.artifacts"),
4276
5177
  badge: visibleArtifacts.length > 0 ? String(visibleArtifacts.length) : undefined,
@@ -4388,7 +5289,23 @@ function FileTypeIcon(props) {
4388
5289
  return h(RefIcon, { kind: ref.kind, size: 14 });
4389
5290
  }
4390
5291
 
4391
- function ReferencesSection({ summary, t, sessionId, asOf }) {
5292
+ /* per-session memory of reference keys whose entrance already animated
5293
+ (page lifetime): collapsing, scope switching and tab remounts never replay */
5294
+ const seenRefKeys = new Map();
5295
+ function seenRefKeysFor(sessionId) {
5296
+ const storeKey = typeof sessionId === "string" && sessionId !== "" ? sessionId : "-";
5297
+ let set = seenRefKeys.get(storeKey);
5298
+ if (set === undefined) {
5299
+ set = new Set();
5300
+ seenRefKeys.set(storeKey, set);
5301
+ }
5302
+ return set;
5303
+ }
5304
+ function refKeyOf(ref) {
5305
+ return isRecord(ref) && typeof ref.key === "string" && ref.key !== "" ? ref.key : `label:${isRecord(ref) ? ref.label : ""}`;
5306
+ }
5307
+
5308
+ function ReferencesSection({ summary, t, sessionId, asOf, introSeq }) {
4392
5309
  const cfg = useConfig();
4393
5310
  const asOfTime = typeof asOf === "number" && asOf > 0 ? asOf : null;
4394
5311
  const allRefs = asOfTime === null ? summary.references : summary.references.filter((ref) => (ref.firstTime || ref.time || 0) <= asOfTime);
@@ -4396,6 +5313,11 @@ function FileTypeIcon(props) {
4396
5313
  const timeTitle = (ts) => (cfg.timeMode === "elapsed" ? formatTime(ts) : undefined);
4397
5314
  const sortMode = useRefSort();
4398
5315
  const hideCommon = useHideCommonRefs();
5316
+ /* "all" spans the session (count turns); a turn view reports that turn's mentions. */
5317
+ const multiTurn = summary.scope === "all";
5318
+ /* "count" means turn coverage across the session, or mentions inside one turn. */
5319
+ const sortLabelOf = (mode) =>
5320
+ mode === "count" && multiTurn === false ? t("reference.sort.hits.title") : t(`reference.sort.${mode}.title`);
4399
5321
  const typeFilter = useRefTypes();
4400
5322
  const [revealState, setRevealState] = React.useState({});
4401
5323
  const revealTimers = React.useRef({});
@@ -4432,7 +5354,32 @@ function FileTypeIcon(props) {
4432
5354
  const scoped = hideCommon ? allRefs.filter((ref) => ref.ubiquitous !== true) : allRefs;
4433
5355
  const selectedTypes = Array.isArray(typeFilter) ? typeFilter : [];
4434
5356
  const filtered = selectedTypes.length === 0 ? scoped : scoped.filter((ref) => selectedTypes.indexOf(refDisplayType(ref)) !== -1);
4435
- const sorted = sortReferences(filtered, sortMode);
5357
+ const sorted = sortReferences(filtered, sortMode, multiTurn);
5358
+ /* first-appearance animation: brand-new rows rise in with a small stagger;
5359
+ scrubbing the timeline never animates (rows would flicker in and out) */
5360
+ const seenRefs = seenRefKeysFor(sessionId);
5361
+ const enterDelays = new Map();
5362
+ if (asOfTime === null) {
5363
+ let batch = 0;
5364
+ for (const ref of sorted) {
5365
+ const refKey = refKeyOf(ref);
5366
+ if (seenRefs.has(refKey)) continue;
5367
+ enterDelays.set(refKey, Math.min(batch, 11) * 45);
5368
+ batch += 1;
5369
+ }
5370
+ }
5371
+ React.useEffect(() => {
5372
+ if (asOfTime !== null) {
5373
+ for (const ref of sorted) seenRefs.add(refKeyOf(ref));
5374
+ return undefined;
5375
+ }
5376
+ /* mark after the longest animation finished so quick re-renders keep the
5377
+ attribute (a CSS animation never restarts while the attribute stays) */
5378
+ const timer = setTimeout(() => {
5379
+ for (const ref of sorted) seenRefs.add(refKeyOf(ref));
5380
+ }, 900);
5381
+ return () => clearTimeout(timer);
5382
+ });
4436
5383
  const rows = sorted.map((ref, index) => {
4437
5384
  const detail = Array.isArray(ref.detail) ? ref.detail : [];
4438
5385
  const shown = detail.slice(0, 8);
@@ -4449,7 +5396,19 @@ function FileTypeIcon(props) {
4449
5396
  h("span", { className: "dsov-rowName" }, refTitle(ref, t)),
4450
5397
  h("span", { className: "dsov-badge" }, t(KIND_KEYS[ref.kind] ?? "reference.kind.context")),
4451
5398
  ref.form ? h("span", { className: "dsov-badge", "data-tone": "quiet" }, t(`reference.form.${ref.form}`) === `reference.form.${ref.form}` ? ref.form : t(`reference.form.${ref.form}`)) : null,
4452
- ref.count > 1 ? h("span", { className: "dsov-badge", "data-tone": "quiet" }, t("reference.times", { n: ref.count })) : null
5399
+ (multiTurn ? refTurnsOf(ref) > 1 : refHitsOf(ref) > 1)
5400
+ ? h(
5401
+ "span",
5402
+ {
5403
+ className: "dsov-badge",
5404
+ "data-tone": "quiet",
5405
+ title: multiTurn
5406
+ ? t("reference.turnsTitle", { turns: refTurnsOf(ref), times: refHitsOf(ref) })
5407
+ : t("reference.timesTitle", { times: refHitsOf(ref) }),
5408
+ },
5409
+ multiTurn ? t("reference.turns", { n: refTurnsOf(ref) }) : t("reference.times", { n: refHitsOf(ref) })
5410
+ )
5411
+ : null
4453
5412
  ),
4454
5413
  shown.length > 0
4455
5414
  ? h(
@@ -4493,13 +5452,15 @@ function FileTypeIcon(props) {
4493
5452
  h("span", { className: "dsov-time", key: "time", title: timeTitle(ref.time) }, fmtTime(ref.time)),
4494
5453
  ];
4495
5454
  const isFile = ref.kind === "file";
5455
+ const enterDelay = enterDelays.get(refKeyOf(ref));
5456
+ const enterProps = enterDelay === undefined ? { key: `ref-${refKeyOf(ref)}` } : { key: `ref-${refKeyOf(ref)}`, "data-enter": "true", style: { animationDelay: `${enterDelay}ms` } };
4496
5457
  if (isFile) {
4497
5458
  return h(
4498
5459
  "button",
4499
5460
  {
4500
5461
  type: "button",
4501
5462
  className: "dsov-row",
4502
- key: `ref-${index}`,
5463
+ ...enterProps,
4503
5464
  title: ref.label,
4504
5465
  onClick: () => openSessionFile(sessionId, ref.label),
4505
5466
  },
@@ -4512,7 +5473,7 @@ function FileTypeIcon(props) {
4512
5473
  {
4513
5474
  type: "button",
4514
5475
  className: "dsov-row",
4515
- key: `ref-${index}`,
5476
+ ...enterProps,
4516
5477
  title: ref.uri,
4517
5478
  onClick: () => openVikingPreview(ref.uri),
4518
5479
  },
@@ -4525,7 +5486,7 @@ function FileTypeIcon(props) {
4525
5486
  {
4526
5487
  type: "button",
4527
5488
  className: "dsov-row",
4528
- key: `ref-${index}`,
5489
+ ...enterProps,
4529
5490
  title: t("reference.previewHint"),
4530
5491
  onClick: () => openContextPreview(ref.previewKey),
4531
5492
  },
@@ -4534,7 +5495,7 @@ function FileTypeIcon(props) {
4534
5495
  }
4535
5496
  return h(
4536
5497
  "div",
4537
- { className: "dsov-row dsov-rowStatic", key: `ref-${index}`, title: isVikingUri(ref.uri) ? ref.uri : ref.label },
5498
+ { className: "dsov-row dsov-rowStatic", ...enterProps, title: isVikingUri(ref.uri) ? ref.uri : ref.label },
4538
5499
  ...content
4539
5500
  );
4540
5501
  });
@@ -4571,6 +5532,7 @@ function FileTypeIcon(props) {
4571
5532
  Section,
4572
5533
  {
4573
5534
  id: "references",
5535
+ ...sectionIntroProps(introSeq, 140),
4574
5536
  icon: h(IconReference, { size: 15 }),
4575
5537
  title: t("section.references"),
4576
5538
  badge: filtered.length > 0 ? String(filtered.length) : undefined,
@@ -4608,10 +5570,11 @@ function FileTypeIcon(props) {
4608
5570
  key: mode,
4609
5571
  className: "dsov-segBtn",
4610
5572
  "data-active": sortMode === mode ? "true" : "false",
4611
- title: sortMode === mode ? `${t(`reference.sort.${mode}.title`)} · ${t("reference.sort.cancel")}` : t(`reference.sort.${mode}.title`),
5573
+ title:
5574
+ (sortMode === mode ? `${sortLabelOf(mode)} · ${t("reference.sort.cancel")}` : sortLabelOf(mode)),
4612
5575
  onClick: () => setRefSort(sortMode === mode ? "" : mode),
4613
5576
  },
4614
- t(`reference.sort.${mode}`)
5577
+ mode === "count" && multiTurn === false ? t("reference.sort.hits") : t(`reference.sort.${mode}`)
4615
5578
  )
4616
5579
  )
4617
5580
  )
@@ -4646,6 +5609,19 @@ function FileTypeIcon(props) {
4646
5609
  const [errPop, setErrPop] = React.useState(null);
4647
5610
  const errOpen = isRecord(errPop);
4648
5611
  const errRef = React.useRef(null);
5612
+ const errBtnRef = React.useRef(null);
5613
+ /* Place the fixed popover under its pill, clamped into the viewport. */
5614
+ const placePopover = React.useCallback(() => {
5615
+ const button = errBtnRef.current;
5616
+ if (button === null || button.isConnected !== true) return false;
5617
+ const rect = button.getBoundingClientRect();
5618
+ if (rect.bottom < 0 || rect.top > window.innerHeight) return false;
5619
+ setErrPop({
5620
+ top: Math.min(rect.bottom + 5, Math.max(8, window.innerHeight - 292)),
5621
+ right: Math.max(8, window.innerWidth - rect.right),
5622
+ });
5623
+ return true;
5624
+ }, []);
4649
5625
  React.useEffect(() => {
4650
5626
  if (!errOpen) return undefined;
4651
5627
  const close = () => setErrPop(null);
@@ -4655,17 +5631,30 @@ function FileTypeIcon(props) {
4655
5631
  const onKey = (event) => {
4656
5632
  if (event.key === "Escape") close();
4657
5633
  };
5634
+ /* Following the anchor instead of dismissing: the chat auto-scrolls on every
5635
+ streamed event, so closing on scroll made the popover impossible to read
5636
+ while a turn was running. Reposition, and only close once the pill itself
5637
+ left the viewport (or unmounted). */
5638
+ let frame = 0;
5639
+ const follow = () => {
5640
+ if (frame !== 0) return;
5641
+ frame = requestAnimationFrame(() => {
5642
+ frame = 0;
5643
+ if (!placePopover()) setErrPop(null);
5644
+ });
5645
+ };
4658
5646
  document.addEventListener("mousedown", onDown);
4659
5647
  document.addEventListener("keydown", onKey);
4660
- window.addEventListener("scroll", close, true);
4661
- window.addEventListener("resize", close);
5648
+ window.addEventListener("scroll", follow, true);
5649
+ window.addEventListener("resize", follow);
4662
5650
  return () => {
5651
+ if (frame !== 0) cancelAnimationFrame(frame);
4663
5652
  document.removeEventListener("mousedown", onDown);
4664
5653
  document.removeEventListener("keydown", onKey);
4665
- window.removeEventListener("scroll", close, true);
4666
- window.removeEventListener("resize", close);
5654
+ window.removeEventListener("scroll", follow, true);
5655
+ window.removeEventListener("resize", follow);
4667
5656
  };
4668
- }, [errOpen]);
5657
+ }, [errOpen, placePopover]);
4669
5658
  if (!isRecord(stats)) return null;
4670
5659
  const duration = typeof stats.durationMs === "number" ? stats.durationMs : 0;
4671
5660
  const toolCalls = typeof stats.toolCalls === "number" ? stats.toolCalls : 0;
@@ -4720,20 +5709,17 @@ function FileTypeIcon(props) {
4720
5709
  className: "dsov-stat dsov-statBtn",
4721
5710
  "data-tone": "err",
4722
5711
  "data-active": errOpen ? "true" : undefined,
5712
+ ref: errBtnRef,
4723
5713
  disabled: failed.length === 0,
4724
5714
  title: failed.length > 0 ? t("stats.errors.popTitle") : t("stats.errors.title"),
4725
5715
  onClick:
4726
5716
  failed.length > 0
4727
- ? (event) => {
5717
+ ? () => {
4728
5718
  if (errOpen) {
4729
5719
  setErrPop(null);
4730
5720
  return;
4731
5721
  }
4732
- const rect = event.currentTarget.getBoundingClientRect();
4733
- setErrPop({
4734
- top: Math.min(rect.bottom + 5, Math.max(8, window.innerHeight - 292)),
4735
- right: Math.max(8, window.innerWidth - rect.right),
4736
- });
5722
+ if (!placePopover()) setErrPop(null);
4737
5723
  }
4738
5724
  : undefined,
4739
5725
  },
@@ -4777,6 +5763,175 @@ function FileTypeIcon(props) {
4777
5763
  );
4778
5764
  }
4779
5765
 
5766
+ /** Which side of a subagent lineage the bound session is on, read from catalog
5767
+ metadata only (never an Agent-bound RPC). */
5768
+ function sessionRoleOf(sessionId) {
5769
+ const empty = { kind: "main", parentId: null, parentTitle: "", mode: "", children: [] };
5770
+ if (typeof sessionId !== "string" || sessionId === "") return empty;
5771
+ let ctx = null;
5772
+ try {
5773
+ ctx = runtime === null ? null : runtime.ctx;
5774
+ } catch {
5775
+ ctx = null;
5776
+ }
5777
+ let snapshot = null;
5778
+ try {
5779
+ snapshot = ctx === null || ctx === undefined ? null : ctx.sessions.list.getSnapshot();
5780
+ } catch {
5781
+ snapshot = null;
5782
+ }
5783
+ let address = null;
5784
+ try {
5785
+ address = ctx === null || ctx === undefined ? null : ctx.sessions.subagentAddress(sessionId);
5786
+ } catch {
5787
+ address = null;
5788
+ }
5789
+ const byId = isRecord(snapshot) && isRecord(snapshot.byId) ? snapshot.byId : {};
5790
+ const self = isRecord(byId[sessionId]) ? byId[sessionId] : null;
5791
+ const parentId =
5792
+ isRecord(address) && typeof address.parentSessionId === "string" && address.parentSessionId !== ""
5793
+ ? address.parentSessionId
5794
+ : self !== null && typeof self.parentId === "string" && self.parentId !== ""
5795
+ ? self.parentId
5796
+ : null;
5797
+ const parent = parentId !== null && isRecord(byId[parentId]) ? byId[parentId] : null;
5798
+ const ids = isRecord(snapshot) && Array.isArray(snapshot.ids) ? snapshot.ids : [];
5799
+ const children = [];
5800
+ const seen = new Set();
5801
+ for (const id of ids) {
5802
+ const entry = isRecord(byId[id]) ? byId[id] : null;
5803
+ if (entry === null || entry.parentId !== sessionId) continue;
5804
+ seen.add(id);
5805
+ children.push({
5806
+ id,
5807
+ title: typeof entry.displayTitle === "string" && entry.displayTitle !== "" ? entry.displayTitle : id,
5808
+ running: entry.running === true,
5809
+ });
5810
+ }
5811
+ /* A child can exist in the parent's catalog projection before the list is
5812
+ projected; the catalog is the same metadata the host itself reads. */
5813
+ const projections = isRecord(snapshot) && isRecord(snapshot.projectionsBySession) ? snapshot.projectionsBySession : null;
5814
+ const parentProjection = projections !== null && isRecord(projections[sessionId]) ? projections[sessionId] : null;
5815
+ const catalog = parentProjection !== null && isRecord(parentProjection.values) && Array.isArray(parentProjection.values.subagentCatalog) ? parentProjection.values.subagentCatalog : [];
5816
+ for (const entry of catalog) {
5817
+ if (!isRecord(entry) || typeof entry.id !== "string" || entry.id === "" || seen.has(entry.id)) continue;
5818
+ if (entry.mode === "external") continue;
5819
+ seen.add(entry.id);
5820
+ const listed = isRecord(byId[entry.id]) ? byId[entry.id] : null;
5821
+ const label = typeof entry.title === "string" && entry.title !== "" ? entry.title : typeof entry.label === "string" ? entry.label : "";
5822
+ children.push({
5823
+ id: entry.id,
5824
+ title:
5825
+ listed !== null && typeof listed.displayTitle === "string" && listed.displayTitle !== ""
5826
+ ? listed.displayTitle
5827
+ : label !== ""
5828
+ ? label
5829
+ : entry.id,
5830
+ running: listed !== null ? listed.running === true : false,
5831
+ });
5832
+ }
5833
+ if (parentId === null) return { ...empty, children };
5834
+ return {
5835
+ kind: "sub",
5836
+ parentId,
5837
+ parentTitle: parent !== null && typeof parent.displayTitle === "string" ? parent.displayTitle : "",
5838
+ mode: isRecord(address) && typeof address.mode === "string" ? address.mode : "",
5839
+ children,
5840
+ };
5841
+ }
5842
+
5843
+ /** Whether the workspace service can move the main conversation to another session. */
5844
+ function canNavigateSession() {
5845
+ try {
5846
+ const ctx = runtime === null ? null : runtime.ctx;
5847
+ const workspace = ctx === null || ctx === undefined ? null : ctx.uiWorkspace;
5848
+ return isRecord(workspace) && typeof workspace.openSession === "function";
5849
+ } catch {
5850
+ return false;
5851
+ }
5852
+ }
5853
+
5854
+ function openSessionById(sessionId) {
5855
+ if (typeof sessionId !== "string" || sessionId === "") return false;
5856
+ try {
5857
+ const ctx = runtime === null ? null : runtime.ctx;
5858
+ const workspace = ctx === null || ctx === undefined ? null : ctx.uiWorkspace;
5859
+ if (!isRecord(workspace) || typeof workspace.openSession !== "function") return false;
5860
+ workspace.openSession(sessionId);
5861
+ return true;
5862
+ } catch {
5863
+ return false;
5864
+ }
5865
+ }
5866
+
5867
+ /** One compact line that says whether this overview belongs to the main agent or to
5868
+ a subagent, and offers to switch to the other side of that lineage. */
5869
+ function SessionRoleBar({ t, sessionId, hasWork }) {
5870
+ const role = sessionRoleOf(sessionId);
5871
+ const [open, setOpen] = React.useState(false);
5872
+ const canSwitch = canNavigateSession();
5873
+ const children =
5874
+ role.children.length === 0
5875
+ ? null
5876
+ : h(
5877
+ "button",
5878
+ {
5879
+ type: "button",
5880
+ className: "dsov-roleBtn",
5881
+ "data-open": open ? "true" : "false",
5882
+ title: t("role.childrenToggle"),
5883
+ onClick: () => setOpen((value) => !value),
5884
+ },
5885
+ t("role.children", { n: role.children.length }),
5886
+ h(IconChevron, { size: 11, className: "dsov-roleChevron" })
5887
+ );
5888
+ const list =
5889
+ !open || role.children.length === 0
5890
+ ? null
5891
+ : h(
5892
+ "div",
5893
+ { className: "dsov-roleList" },
5894
+ role.children.map((child) =>
5895
+ h(
5896
+ "button",
5897
+ {
5898
+ type: "button",
5899
+ className: "dsov-roleItem",
5900
+ key: child.id,
5901
+ title: canSwitch ? t("role.switchTitle") : child.title,
5902
+ disabled: !canSwitch,
5903
+ onClick: canSwitch ? () => openSessionById(child.id) : undefined,
5904
+ },
5905
+ h("span", { className: "dsov-roleDot", "data-running": child.running ? "true" : "false" }),
5906
+ h("span", { className: "dsov-roleItemTitle" }, child.title)
5907
+ )
5908
+ )
5909
+ );
5910
+ if (role.kind === "sub") {
5911
+ const parentName = role.parentTitle !== "" ? role.parentTitle : role.parentId;
5912
+ return h(
5913
+ "div",
5914
+ { className: "dsov-role", "data-kind": "sub" },
5915
+ h("span", { className: "dsov-roleBadge" }, t("role.subagent")),
5916
+ role.mode !== "" && role.mode !== "unknown" ? h("span", { className: "dsov-roleMode" }, role.mode) : null,
5917
+ canSwitch
5918
+ ? h(
5919
+ "button",
5920
+ { type: "button", className: "dsov-roleBtn", "data-link": "true", title: t("role.openParentTitle"), onClick: () => openSessionById(role.parentId) },
5921
+ t("role.openParent"),
5922
+ h("span", { className: "dsov-roleParent" }, parentName)
5923
+ )
5924
+ : h("span", { className: "dsov-roleHint" }, t("role.openParent"), h("span", { className: "dsov-roleParent" }, parentName)),
5925
+ children,
5926
+ list
5927
+ );
5928
+ }
5929
+ /* A plain main session with no subagents in this scope has no identity to
5930
+ announce: the badge, the child list, and the parent link are all noise. */
5931
+ if (role.children.length === 0 || hasWork === false) return null;
5932
+ return h("div", { className: "dsov-role", "data-kind": "main" }, h("span", { className: "dsov-roleBadge" }, t("role.main")), children, list);
5933
+ }
5934
+
4780
5935
  function OverviewContent({ binding, t, variant }) {
4781
5936
  const [selection, setSelection] = React.useState("latest");
4782
5937
  /* timeline scrubbing: a null asOf always means "live/latest state" */
@@ -4796,11 +5951,53 @@ function FileTypeIcon(props) {
4796
5951
  setAsOf(null);
4797
5952
  }, [selection]);
4798
5953
  const summary = useSummary(binding, selection);
4799
- useBackfill(binding, summary.hasMore);
5954
+ /* 「重新加载」restarts the backfill loop from zero — the escape hatch when the
5955
+ host loader silently no-ops (another loader holds the lock) or a page fails. */
5956
+ const [historyRetry, setHistoryRetry] = React.useState(0);
5957
+ const historyStalled = useBackfill(binding, summary.hasMore, historyRetry);
4800
5958
  const goal = useGoal(binding);
4801
5959
  const stats = useArtifactStats(binding, summary.changeTurns);
4802
5960
  const cfg = useConfig();
4803
5961
  const sessionId = binding ? binding.sessionId : undefined;
5962
+ /* 本轮结论 as a document: a turn that finishes while this overview is open pops
5963
+ its closing reply into a tab once. A turn that had already finished before the
5964
+ overview mounted is never re-opened (no tab storm on refresh or scope switch). */
5965
+ const conclusionGuard = React.useRef({ ready: false, opened: new Set() });
5966
+ React.useEffect(() => {
5967
+ const conclusion = isRecord(summary.conclusion) ? summary.conclusion : null;
5968
+ const key = `${sessionId}|${conclusion === null ? "" : conclusion.turn}`;
5969
+ const guard = conclusionGuard.current;
5970
+ if (guard.ready !== true) {
5971
+ /* first paint: a turn that had already finished is never re-opened */
5972
+ guard.ready = true;
5973
+ if (conclusion !== null && conclusion.ended === true) guard.opened.add(key);
5974
+ return;
5975
+ }
5976
+ if (conclusion === null || conclusion.ended !== true || guard.opened.has(key)) return;
5977
+ guard.opened.add(key);
5978
+ if (cfg.conclusionTab !== true) return;
5979
+ if (typeof conclusion.previewKey !== "string" || conclusion.previewKey === "") return;
5980
+ if (contextSidebar() === null) return;
5981
+ if (!claimConclusionTab(key)) return;
5982
+ /* a failed open must not eat the claim: another instance may still show the tab */
5983
+ const entry = readContextPreview(conclusion.previewKey);
5984
+ const docKey = `conclusion:${typeof conclusion.turn === "number" ? conclusion.turn : conclusion.previewKey}`;
5985
+ const opened = openNativeDocument(sessionId, docKey, conclusion.title, entry === null ? undefined : entry.text) || openContextPreview(conclusion.previewKey);
5986
+ if (!opened) conclusionAutoOpened.delete(key);
5987
+ }, [summary, cfg.conclusionTab, sessionId]);
5988
+ /* Agent identity only earns its row when a subagent actually took part in the
5989
+ visible scope — otherwise the overview is a single-agent conversation. */
5990
+ const roleWork = React.useMemo(() => {
5991
+ const team = isRecord(summary.team) ? summary.team : null;
5992
+ if (team !== null && (Array.isArray(team.subagents) && team.subagents.length > 0)) return true;
5993
+ if (team !== null && (Array.isArray(team.members) && team.members.length > 0)) return true;
5994
+ const steps = Array.isArray(summary.stepsAll) ? summary.stepsAll : [];
5995
+ return steps.some(
5996
+ (step) =>
5997
+ isRecord(step) &&
5998
+ (step.name === "subagent" || step.name === "subagent_fork" || step.name === "spawn_teammate" || step.name === "team_task_create" || typeof step.teamKey === "string")
5999
+ );
6000
+ }, [summary]);
4804
6001
  const rootClass = variant === "panel" ? "dsov-panel" : "dsov-view";
4805
6002
  const rootRef = React.useRef(null);
4806
6003
  const scope = summary.scope === selection || selection === "latest" ? summary.scope : selection;
@@ -4835,6 +6032,29 @@ function FileTypeIcon(props) {
4835
6032
  errors: list.filter((step) => step.error === true && (step.endTime || step.time || 0) <= asOf).length,
4836
6033
  };
4837
6034
  }, [asOf, summary]);
6035
+ /* A new turn gets a staged entrance for the three sections plus a short
6036
+ 「第 N 轮对话开始」 cue. First paint of an already-running turn stays silent,
6037
+ and nothing replays on re-renders, scope switches or tab remounts. */
6038
+ const [introSeq, setIntroSeq] = React.useState(0);
6039
+ const [introBanner, setIntroBanner] = React.useState(null);
6040
+ const introTurn = React.useRef(null);
6041
+ React.useEffect(() => {
6042
+ const turn = Number.isFinite(summary.latestTurn) ? summary.latestTurn : null;
6043
+ if (turn === null || asOf !== null) return;
6044
+ if (introTurn.current === null) {
6045
+ introTurn.current = turn;
6046
+ return;
6047
+ }
6048
+ if (turn === introTurn.current) return;
6049
+ introTurn.current = turn;
6050
+ setIntroSeq((value) => value + 1);
6051
+ setIntroBanner(turn);
6052
+ }, [summary.latestTurn, asOf]);
6053
+ React.useEffect(() => {
6054
+ if (introBanner === null) return undefined;
6055
+ const timer = setTimeout(() => setIntroBanner(null), 2200);
6056
+ return () => clearTimeout(timer);
6057
+ }, [introBanner, introSeq]);
4838
6058
  const canTimeline = typeof summary.turnStart === "number" && typeof summary.turnEnd === "number" && summary.turnEnd > summary.turnStart;
4839
6059
  /* the errors pill drills into these: failed steps of the scope, honoring the scrub point */
4840
6060
  const failedSteps = React.useMemo(() => {
@@ -5013,10 +6233,40 @@ function FileTypeIcon(props) {
5013
6233
  ),
5014
6234
  timelineRow
5015
6235
  ),
5016
- summary.hasMore ? h("div", { className: "dsov-loading" }, t("state.loadingHistory")) : null,
5017
- cfg.showProgress ? h(ProgressSection, { summary, goal, t, waiting: sessionWaiting && asOf === null, cfg, asOf }) : null,
5018
- cfg.showArtifacts ? h(ArtifactsSection, { summary, t, binding, stats, asOf }) : null,
5019
- cfg.showReferences ? h(ReferencesSection, { summary, t, sessionId, asOf }) : null
6236
+ binding && typeof binding.sessionId === "string" && binding.sessionId !== "" ? h(SessionRoleBar, { key: "roleBar", t, sessionId: binding.sessionId, hasWork: roleWork }) : null,
6237
+ summary.hasMore
6238
+ ? h(
6239
+ "div",
6240
+ { className: "dsov-loading" },
6241
+ h("span", { className: "dsov-loadingText" }, t(historyStalled ? "state.loadingStalled" : "state.loadingHistory")),
6242
+ h(
6243
+ "button",
6244
+ {
6245
+ type: "button",
6246
+ className: "dsov-loadingReload",
6247
+ title: t("state.reloadHistory.title"),
6248
+ onClick: () => {
6249
+ /* Re-arming alone is not enough: the loop would issue the same
6250
+ call that just no-oped. Pull one page directly first, using the
6251
+ seq-free loader when available, then let the loop take over. */
6252
+ const session = binding ? binding.session : undefined;
6253
+ try {
6254
+ if (isRecord(session) && typeof session.loadOlder === "function") Promise.resolve(session.loadOlder()).catch(() => {});
6255
+ else if (isRecord(session) && typeof session.loadThrough === "function") Promise.resolve(session.loadThrough(0)).catch(() => {});
6256
+ } catch {
6257
+ /* the re-arm below still gives the loop another chance */
6258
+ }
6259
+ setHistoryRetry((value) => value + 1);
6260
+ },
6261
+ },
6262
+ t("state.reloadHistory")
6263
+ )
6264
+ )
6265
+ : null,
6266
+ introBanner !== null ? h("div", { className: "dsov-turnStart", key: `turnStart-${introSeq}` }, h("span", { className: "dsov-turnStartDot" }), t("intro.newTurn", { n: introBanner })) : null,
6267
+ cfg.showProgress ? h(ProgressSection, { summary, goal, t, waiting: sessionWaiting && asOf === null, cfg, asOf, introSeq }) : null,
6268
+ cfg.showArtifacts ? h(ArtifactsSection, { summary, t, binding, stats, asOf, introSeq }) : null,
6269
+ cfg.showReferences ? h(ReferencesSection, { summary, t, sessionId, asOf, introSeq }) : null
5020
6270
  );
5021
6271
  }
5022
6272
 
@@ -5200,7 +6450,7 @@ function FileTypeIcon(props) {
5200
6450
  view.status === "ready" && view.mode === "file" && source === true ? h("pre", { className: "dsov-vikingSource" }, view.text) : null,
5201
6451
  view.status === "ready" && view.mode === "file" && source !== true && parsed ? renderFrontmatterFields(parsed, t) : null,
5202
6452
  view.status === "ready" && view.mode === "file" && source !== true && parsed
5203
- ? h("div", { className: "dsov-md", dangerouslySetInnerHTML: { __html: mdToHtml(parsed.body) } })
6453
+ ? h(MarkdownBody, { text: parsed.body })
5204
6454
  : null
5205
6455
  );
5206
6456
  }
@@ -5308,24 +6558,56 @@ function FileTypeIcon(props) {
5308
6558
  if (entry === null) {
5309
6559
  return h("div", { className: "dsov-viking" }, h("div", { className: "dsov-vikingError" }, t("context.unavailable")));
5310
6560
  }
6561
+ if (entry.kind === "conclusion") {
6562
+ /* Just the turn's closing answer, rendered exactly as the chat renders it;
6563
+ the page geometry mirrors the host's own Markdown document page so the
6564
+ fallback tab reads the same as the banner-opened one. */
6565
+ return h(
6566
+ "section",
6567
+ { className: "dsov-docPage", "data-doc-preview": "conclusion", "aria-label": entry.title },
6568
+ h(
6569
+ "div",
6570
+ { className: "dsov-docDocument" },
6571
+ ChatMarkdown
6572
+ ? h(ChatMarkdown, { text: entry.text, labels: chatMarkdownLabels(t) })
6573
+ : h(MarkdownBody, { text: entry.text })
6574
+ )
6575
+ );
6576
+ }
5311
6577
  return h(
5312
6578
  "div",
5313
6579
  { className: "dsov-viking" },
5314
6580
  h(
5315
6581
  "div",
5316
6582
  { className: "dsov-vikingHead" },
5317
- h("span", { className: "dsov-vikingIcon" }, h(IconReference, { size: 15 })),
6583
+ h("span", { className: "dsov-vikingIcon" }, entry.kind === "plan" ? h(IconPlan, { size: 15 }) : h(IconReference, { size: 15 })),
5318
6584
  h(
5319
6585
  "span",
5320
6586
  { className: "dsov-vikingHeadText" },
5321
6587
  h("span", { className: "dsov-vikingName" }, entry.title),
5322
- h("span", { className: "dsov-vikingPathText" }, t("context.subtitle"))
6588
+ h(
6589
+ "span",
6590
+ { className: "dsov-vikingPathText" },
6591
+ entry.kind === "plan" ? t("context.subtitlePlan") : t("context.subtitle")
6592
+ )
5323
6593
  )
5324
6594
  ),
5325
- h("div", { className: "dsov-md", dangerouslySetInnerHTML: { __html: mdToHtml(entry.text) } })
6595
+ h(MarkdownBody, { text: entry.text })
5326
6596
  );
5327
6597
  }
5328
6598
 
6599
+ /** Tab chip for a captured snapshot: the file glyph the host's document tabs carry. */
6600
+ function ContextTabTitle(props) {
6601
+ const t = typeof props.t === "function" ? props.t : translate;
6602
+ const info = typeof props.useTabInfo === "function" ? props.useTabInfo() : null;
6603
+ const key = info && isRecord(info.tab) ? parseContextAddress(info.tab.contentId) : undefined;
6604
+ const entry = key === undefined ? null : readContextPreview(key);
6605
+ const title = entry !== null ? entry.title : t("context.tab");
6606
+ /* only documents carry the glyph; reference snapshots keep a plain caption */
6607
+ if (entry === null || (entry.kind !== "conclusion" && entry.kind !== "plan")) return title;
6608
+ return h(React.Fragment, null, h(FileTypeIcon, { kind: "other", size: 16, className: "dsov-docTitleIcon" }), title);
6609
+ }
6610
+
5329
6611
  /* ------------------------------------------------------------------ */
5330
6612
  /* Settings form (settings page + Plugins row config slot) */
5331
6613
  /* ------------------------------------------------------------------ */
@@ -5380,7 +6662,7 @@ function FileTypeIcon(props) {
5380
6662
  }
5381
6663
 
5382
6664
  /* dsh-context style settings rows: label left, pill-select control right. */
5383
- function SettingSelect({ label, value, options, onPick }) {
6665
+ function SettingSelect({ label, value, options, onPick, hint }) {
5384
6666
  const [open, setOpen] = React.useState(false);
5385
6667
  const rootRef = React.useRef(null);
5386
6668
  React.useEffect(() => {
@@ -5396,7 +6678,7 @@ function FileTypeIcon(props) {
5396
6678
  return h(
5397
6679
  "div",
5398
6680
  { className: "dsov-set-row" },
5399
- h("span", { className: "dsov-set-label" }, label),
6681
+ h("span", { className: "dsov-set-label" }, h("span", { className: "dsov-set-labelText" }, label), hint ?? null),
5400
6682
  h(
5401
6683
  "div",
5402
6684
  { className: "dsov-set-control", ref: rootRef },
@@ -5489,7 +6771,7 @@ function FileTypeIcon(props) {
5489
6771
  return h(
5490
6772
  "div",
5491
6773
  { className: "dsov-set-row dsov-set-arearow" },
5492
- h("span", { className: "dsov-set-label" }, label),
6774
+ label === "" ? null : h("span", { className: "dsov-set-label" }, label),
5493
6775
  h("textarea", {
5494
6776
  className: "dsov-set-textarea",
5495
6777
  rows: 7,
@@ -5511,6 +6793,7 @@ function FileTypeIcon(props) {
5511
6793
  function SettingsForm(props) {
5512
6794
  const t = typeof props.t === "function" ? props.t : translate;
5513
6795
  const hookForm = useSettingsForm();
6796
+ const [showDefaultPrompt, setShowDefaultPrompt] = React.useState(false);
5514
6797
  if (props.view === "summary") {
5515
6798
  return h("span", null, t("settings.summary"));
5516
6799
  }
@@ -5540,6 +6823,14 @@ function FileTypeIcon(props) {
5540
6823
  { id: "clock", label: t("settings.timeMode.clock") },
5541
6824
  { id: "elapsed", label: t("settings.timeMode.elapsed") },
5542
6825
  ];
6826
+ const todoPromptOptions = [
6827
+ { id: "prompt", label: t("settings.todoPromptOn") },
6828
+ { id: "off", label: t("settings.todoPromptOff") },
6829
+ ];
6830
+ const conclusionTabOptions = [
6831
+ { id: "newtab", label: t("settings.conclusionTabOn") },
6832
+ { id: "off", label: t("settings.conclusionTabOff") },
6833
+ ];
5543
6834
  const boolRow = (label, field) =>
5544
6835
  h(SettingSelect, {
5545
6836
  label,
@@ -5568,23 +6859,53 @@ function FileTypeIcon(props) {
5568
6859
  note(t("settings.timeModeNote")),
5569
6860
  boolRow(t("settings.stepAnchors"), "stepAnchors"),
5570
6861
  note(t("settings.stepAnchorsNote")),
5571
- boolRow(t("settings.todoPromptRow"), "todoPrompt"),
6862
+ h(SettingSelect, {
6863
+ label: t("settings.todoPromptRow"),
6864
+ value: cfg.todoPrompt ? "prompt" : "off",
6865
+ options: todoPromptOptions,
6866
+ onPick: (id) => set("todoPrompt", id === "prompt"),
6867
+ hint: h(
6868
+ "button",
6869
+ {
6870
+ type: "button",
6871
+ className: "dsov-set-hint",
6872
+ title: t("settings.todoPromptHint"),
6873
+ "aria-label": t("settings.todoPromptHint"),
6874
+ "data-active": showDefaultPrompt ? "true" : "false",
6875
+ onClick: () => setShowDefaultPrompt(!showDefaultPrompt),
6876
+ },
6877
+ h(IconInfo, { size: 13 })
6878
+ ),
6879
+ }),
5572
6880
  cfg.todoPrompt
5573
6881
  ? h(SettingTextarea, {
5574
- label: t("settings.todoPromptTemplate"),
6882
+ label: "",
5575
6883
  value: cfg.todoPromptTemplate,
5576
- placeholder: DEFAULT_TODO_TEMPLATE,
6884
+ placeholder: t("settings.todoPromptPlaceholder"),
5577
6885
  onCommit: (value) => set("todoPromptTemplate", value),
5578
6886
  })
6887
+ : h("div", { className: "dsov-set-note dsov-set-noteWrap" }, t("settings.todoPromptPlaceholder")),
6888
+ showDefaultPrompt
6889
+ ? h(
6890
+ "div",
6891
+ { className: "dsov-set-defaultPrompt" },
6892
+ h("div", { className: "dsov-set-defaultPromptHead" }, t("settings.todoPromptDefault")),
6893
+ h("pre", { className: "dsov-set-defaultPromptBody" }, DEFAULT_TODO_TEMPLATE.trim())
6894
+ )
5579
6895
  : null,
5580
- note(t("settings.todoPromptNote")),
5581
- cfg.todoPrompt ? note(t("settings.todoPromptTemplateNote")) : null
6896
+ h(SettingSelect, {
6897
+ label: t("settings.conclusionTabRow"),
6898
+ value: cfg.conclusionTab ? "newtab" : "off",
6899
+ options: conclusionTabOptions,
6900
+ onPick: (id) => set("conclusionTab", id === "newtab"),
6901
+ }),
6902
+ note(t(cfg.conclusionTab ? "settings.conclusionTabNote" : "settings.conclusionTabOffNote"))
5582
6903
  ),
5583
6904
  group(
5584
6905
  t("settings.quickNav"),
5585
6906
  boolRow(t("settings.quickNavRow"), "quickNav"),
5586
- boolRow(t("settings.quickNavLinkRow"), "quickNavLink"),
5587
6907
  note(t("settings.quickNavNote")),
6908
+ boolRow(t("settings.quickNavLinkRow"), "quickNavLink"),
5588
6909
  note(t("settings.quickNavLinkNote"))
5589
6910
  ),
5590
6911
  group(
@@ -5595,6 +6916,7 @@ function FileTypeIcon(props) {
5595
6916
  options: placeOptions,
5596
6917
  onPick: (id) => set("placement", id),
5597
6918
  }),
6919
+ note(t("settings.placementNote")),
5598
6920
  boolRow(t("settings.fullHeightRow"), "sectionFullHeight"),
5599
6921
  note(t("settings.fullHeightNote"))
5600
6922
  ),
@@ -5662,9 +6984,9 @@ function FileTypeIcon(props) {
5662
6984
  .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}
5663
6985
  .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)}
5664
6986
  .dsov-empty{font-size:12.5px;line-height:1.6;color:var(--dsw-alias-label-secondary);padding:4px 2px}
5665
- .dsov-secBody{max-height:320px;overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;margin:0 -12px 0 -8px;padding:0 12px 0 8px}
6987
+ .dsov-secBody{max-height:320px;overflow-y:auto;overflow-x:hidden;margin:0 -12px 0 -8px;padding:0 12px 0 8px}
5666
6988
  .dsov-section[data-full=true] .dsov-secBody{max-height:none;overflow:visible;margin:0;padding:0}
5667
- .dsov-loading{font-size:12px;color:var(--dsw-alias-label-secondary)}
6989
+ .dsov-loading{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-secondary)}
5668
6990
  .dsov-goal{display:flex;align-items:flex-start;gap:8px;font-size:13px;color:var(--dsw-alias-label-primary);padding:2px}
5669
6991
  .dsov-goalIcon{flex:none;display:inline-flex;margin-top:2px;color:var(--dsw-alias-brand-primary)}
5670
6992
  .dsov-goalObjective{flex:1;min-width:0;line-height:1.55;overflow-wrap:anywhere}
@@ -5716,7 +7038,39 @@ function FileTypeIcon(props) {
5716
7038
  .dsov-todo[data-depth]::before{content:"";position:absolute;left:-15px;top:-4px;bottom:50%;width:9px;border-left:1px solid var(--dsw-alias-border-l2);border-bottom:1px solid var(--dsw-alias-border-l2);border-bottom-left-radius:6px}
5717
7039
  .dsov-member[data-expandable=false]{cursor:default}
5718
7040
  .dsov-member[data-expandable=false]:hover{background:transparent}
5719
- .dsov-stepChevron{flex:none;margin-right:-2px}
7041
+ .dsov-stepChevron{flex:none;margin-right:-2px;position:relative}
7042
+ .dsov-stepChevron::after{content:"";position:absolute;inset:-5px -4px}
7043
+ .dsov-stepIconSwap{position:relative;flex:none;display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;cursor:pointer;border-radius:5px}
7044
+ .dsov-stepIconSwap::after{content:"";position:absolute;inset:-4px}
7045
+ .dsov-stepIconSwap>.dsov-todoIcon{transition:opacity .12s ease}
7046
+ .dsov-stepSwapChevron{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .12s ease,transform .15s ease;pointer-events:none}
7047
+ .dsov-step:hover .dsov-stepIconSwap>.dsov-todoIcon,.dsov-stepIconSwap:focus-visible>.dsov-todoIcon{opacity:0}
7048
+ .dsov-step:hover .dsov-stepSwapChevron{opacity:1}
7049
+ .dsov-stepIconSwap[data-open=true]>.dsov-todoIcon{opacity:0}
7050
+ .dsov-stepIconSwap[data-open=true] .dsov-stepSwapChevron{opacity:1}
7051
+ .dsov-stepBody{display:flex;flex-direction:column;gap:6px;margin:1px 6px 5px 21px;padding:7px 9px;border-radius:9px;background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l1)}
7052
+ .dsov-stepBodySec{display:flex;flex-direction:column;gap:2px;min-width:0}
7053
+ .dsov-stepBodyLabel{font-size:10px;line-height:14px;letter-spacing:.02em;color:var(--dsw-alias-label-tertiary)}
7054
+ .dsov-stepBodyText{margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.5;color:var(--dsw-alias-label-primary);white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;max-height:168px;overflow-y:auto;overflow-x:hidden}
7055
+ .dsov-stepBodyErr{color:var(--dsw-alias-state-error-primary,#ff453a)}
7056
+ .dsov-stepBodyMeta{font-size:10px;line-height:14px;color:var(--dsw-alias-label-tertiary)}
7057
+ .dsov-stepChip{flex:none;display:inline-flex;align-items:center;height:16px;padding:0 5px;border-radius:5px;font-size:10px;line-height:1;letter-spacing:.01em;white-space:nowrap}
7058
+ .dsov-stepChip[data-tone=warn]{color:var(--dsw-alias-state-warn-primary,#ff7d00);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#ff7d00) 13%,transparent)}
7059
+ .dsov-stepChip[data-tone=ok]{color:var(--dsw-alias-state-success-primary,#00b42a);background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#00b42a) 12%,transparent)}
7060
+ .dsov-stepChip[data-tone=err]{color:var(--dsw-alias-state-error-primary,#f53f3f);background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#f53f3f) 12%,transparent)}
7061
+ .dsov-stepApproval{display:flex;flex-direction:column;gap:4px;padding:6px 8px;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#ff7d00) 7%,transparent);border:.5px solid color-mix(in srgb,var(--dsw-alias-state-warn-primary,#ff7d00) 26%,transparent)}
7062
+ .dsov-stepApprovalItem[data-tone=ok]{background:transparent}
7063
+ .dsov-stepApprovalHead{display:flex;align-items:center;gap:5px;min-width:0}
7064
+ .dsov-stepApprovalIcon{flex:none;display:inline-flex;color:var(--dsw-alias-label-secondary)}
7065
+ .dsov-stepApprovalName{flex:none;font-size:11px;font-weight:500;color:var(--dsw-alias-label-primary)}
7066
+ .dsov-stepApprovalTool{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;color:var(--dsw-alias-label-tertiary)}
7067
+ .dsov-stepApprovalBadge{flex:none;padding:0 5px;height:15px;display:inline-flex;align-items:center;border-radius:4px;font-size:10px;white-space:nowrap}
7068
+ .dsov-stepApprovalBadge[data-tone=warn]{color:var(--dsw-alias-state-warn-primary,#ff7d00);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#ff7d00) 16%,transparent)}
7069
+ .dsov-stepApprovalBadge[data-tone=ok]{color:var(--dsw-alias-state-success-primary,#00b42a);background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#00b42a) 14%,transparent)}
7070
+ .dsov-stepApprovalBadge[data-tone=err]{color:var(--dsw-alias-state-error-primary,#f53f3f);background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#f53f3f) 14%,transparent)}
7071
+ .dsov-stepApprovalTime{flex:none;margin-left:auto;font-size:10px;color:var(--dsw-alias-label-tertiary)}
7072
+ .dsov-stepApprovalReason{margin-left:16px;font-size:11px;line-height:1.5;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;overflow-wrap:anywhere}
7073
+ .dsov-stepApprovalOutcome{margin-left:16px;font-size:10.5px;line-height:1.45;color:var(--dsw-alias-label-tertiary);white-space:pre-wrap;overflow-wrap:anywhere;max-height:60px;overflow-y:auto}
5720
7074
  .dsov-compactIcon{color:var(--dsw-alias-label-secondary)}
5721
7075
  .dsov-todoIconSlot{position:relative;flex:none;display:inline-flex;margin-top:2px}
5722
7076
  .dsov-todoIconSlot .dsov-todoIcon{margin-top:0}
@@ -5728,7 +7082,8 @@ function FileTypeIcon(props) {
5728
7082
  .dsov-rowStatic{cursor:default}
5729
7083
  .dsov-rowStatic:hover{background:transparent}
5730
7084
  .dsov-tile{flex:none;width:28px;height:28px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l1);display:flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-secondary)}
5731
- .dsov-fileIcon{flex:none;width:22px;display:inline-flex;justify-content:center}
7085
+ .dsov-fileIcon{flex:none;width:22px;display:inline-flex;justify-content:center;align-items:center}
7086
+ .dsov-fileMark{display:inline-flex;align-items:center;justify-content:center;flex:none;width:20px;height:20px;border-radius:6px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent);color:var(--dsw-alias-brand-primary)}
5732
7087
  .dsov-fileIcon svg{display:block}
5733
7088
  .dsov-artifactName{flex:1;min-width:0;font-size:13px;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
5734
7089
  .dsov-counts{flex:none;display:inline-flex;gap:6px;font-family:var(--ds-font-family-code,ui-monospace,SFMono-Regular,Menlo,Consolas,monospace);font-size:11.5px;font-variant-numeric:tabular-nums}
@@ -5751,7 +7106,8 @@ function FileTypeIcon(props) {
5751
7106
  .dsov-set-link:hover{text-decoration:underline}
5752
7107
  .dsov-set-group-title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:18px;letter-spacing:.02em;margin-bottom:10px}
5753
7108
  .dsov-set-row{align-items:center;gap:10px;padding:8px 0;display:flex}
5754
- .dsov-set-label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:400;line-height:20px}
7109
+ .dsov-set-label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:400;line-height:20px;display:flex;align-items:center;gap:6px}
7110
+ .dsov-set-labelText{min-width:0;overflow-wrap:anywhere}
5755
7111
  .dsov-set-control{position:relative;flex:none;display:inline-flex}
5756
7112
  .dsov-set-select{background:var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-layer-2));height:32px;font:inherit;color:var(--dsw-alias-label-primary);cursor:pointer;transition:background-color var(--ds-transition-duration,.2s) var(--ds-ease-in-out,ease-in-out);border:none;border-radius:16px;align-items:center;gap:10px;padding:0 12px;font-size:13px;line-height:20px;display:inline-flex}
5757
7113
  .dsov-set-select:hover{background:var(--dsw-alias-interactive-bg-hover,color-mix(in srgb,var(--dsw-alias-label-primary) 8%,transparent))}
@@ -5764,6 +7120,13 @@ function FileTypeIcon(props) {
5764
7120
  .dsov-set-menuItem[data-selected=true]{color:var(--dsw-alias-brand-primary);font-weight:500}
5765
7121
  .dsov-set-note{color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));margin:4px 0 0;font-size:12px;line-height:1.55}
5766
7122
  .dsov-set-note+.dsov-set-note{margin-top:2px}
7123
+ .dsov-set-labelText{min-width:0}
7124
+ .dsov-set-hint{display:inline-flex;align-items:center;justify-content:center;flex:none;width:16px;height:16px;margin:0;padding:0;border:none;border-radius:50%;background:transparent;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));cursor:pointer;transition:color .15s ease,background-color .15s ease}
7125
+ .dsov-set-noteWrap{white-space:pre-line}
7126
+ .dsov-set-hint:hover,.dsov-set-hint[data-active=true]{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent)}
7127
+ .dsov-set-defaultPrompt{margin:2px 0 6px;border:.5px solid var(--dsw-alias-border-l4,var(--dsw-alias-border-l2));border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-layer-2));padding:8px 12px}
7128
+ .dsov-set-defaultPromptHead{font-size:11.5px;font-weight:600;color:var(--dsw-alias-label-secondary);margin-bottom:4px}
7129
+ .dsov-set-defaultPromptBody{max-height:220px;overflow-y:auto;margin:0;white-space:pre-wrap;overflow-wrap:anywhere;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px;line-height:1.6;color:var(--dsw-alias-label-secondary)}
5767
7130
  .dsov-set-textrow{flex-direction:column;align-items:stretch;gap:6px}
5768
7131
  .dsov-set-input{box-sizing:border-box;border:.5px solid var(--dsw-alias-border-l4,var(--dsw-alias-border-l2));border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-layer-2));width:100%;color:var(--dsw-alias-label-primary);font:inherit;padding:6px 12px;font-size:13px;line-height:1.5}
5769
7132
  .dsov-set-input:focus{outline:none;border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 55%,transparent)}
@@ -5775,6 +7138,10 @@ function FileTypeIcon(props) {
5775
7138
  html[data-dsov-quicknav="on"] nav:has(button[data-index][aria-label]),html[data-dsov-quicknav="on"] nav[class*="_frame"]{display:block!important}
5776
7139
  html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data-dsov-quicknav="off"] nav[class*="_frame"]{display:none!important}
5777
7140
  .dsov-viking{box-sizing:border-box;height:100%;min-height:0;overflow-y:auto;padding:14px 16px 28px;display:flex;flex-direction:column;gap:10px}
7141
+ .dsov-docPage{box-sizing:border-box;height:100%;padding:20px 24px 40px;position:relative;overflow:auto}
7142
+ .dsov-docDocument{min-width:0;color:var(--dsw-alias-label-primary);overflow-wrap:anywhere;font-size:14px;line-height:1.75}
7143
+ .dsov-docTitleIcon{flex:none}
7144
+ @media (width<=767px){.dsov-docPage{padding:16px 18px 32px}}
5778
7145
  .dsov-vikingHead{display:flex;align-items:flex-start;gap:8px}
5779
7146
  .dsov-vikingIcon{flex:none;display:inline-flex;margin-top:2px;color:var(--dsw-alias-brand-primary)}
5780
7147
  .dsov-vikingHeadText{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
@@ -5854,6 +7221,51 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
5854
7221
  .dsov-errPopTime{color:var(--dsw-alias-label-tertiary);font-size:10px;white-space:nowrap}
5855
7222
  .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}
5856
7223
  .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}
7224
+ .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)}
7225
+ .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)}
7226
+ .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)}
7227
+ .dsov-roleMode{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10px;color:var(--dsw-alias-label-tertiary)}
7228
+ .dsov-roleBtn{display:inline-flex;align-items:center;gap:3px;flex:none;max-width:100%;min-width:0;border:none;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;cursor:pointer;padding:1px 6px;border-radius:999px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:background-color .16s ease,color .16s ease}
7229
+ .dsov-roleBtn:hover{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary)}
7230
+ .dsov-roleBtn[data-link=true]{color:var(--dsw-alias-brand-primary,#247bbf)}
7231
+ .dsov-roleBtn:disabled{cursor:default;opacity:.65}
7232
+ .dsov-roleChevron{flex:none;transition:transform .16s ease}
7233
+ .dsov-roleBtn[data-open=true] .dsov-roleChevron{transform:rotate(90deg)}
7234
+ .dsov-roleParent{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary)}
7235
+ .dsov-roleHint{display:inline-flex;align-items:center;gap:4px;min-width:0;color:var(--dsw-alias-label-tertiary)}
7236
+ .dsov-roleList{display:flex;flex-direction:column;gap:1px;width:100%;margin:1px 0 0}
7237
+ .dsov-roleItem{display:flex;align-items:center;gap:6px;width:100%;min-width:0;border:none;background:transparent;font:inherit;color:inherit;text-align:left;cursor:pointer;padding:3px 6px;border-radius:8px}
7238
+ .dsov-roleItem:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-label-primary) 6%,transparent)}
7239
+ .dsov-roleItem:disabled{cursor:default}
7240
+ .dsov-roleDot{flex:none;width:5px;height:5px;border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-label-secondary) 45%,transparent)}
7241
+ .dsov-roleDot[data-running=true]{background:var(--dsw-alias-state-success-primary,#00b42a)}
7242
+ .dsov-roleItemTitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
7243
+ .dsov-loadingReload{border:.5px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:1.4;padding:2px 8px;border-radius:999px;cursor:pointer;transition:background-color .16s ease,color .16s ease}
7244
+ .dsov-loadingReload:hover{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary)}
7245
+ @keyframes dsov-secIntroA{0%{opacity:0;transform:translateY(9px);box-shadow:0 0 0 1px color-mix(in srgb,var(--dsw-alias-brand-primary,#247bbf) 60%,transparent)}55%{opacity:1;transform:none;box-shadow:0 0 0 1px color-mix(in srgb,var(--dsw-alias-brand-primary,#247bbf) 45%,transparent)}100%{opacity:1;transform:none;box-shadow:0 0 0 0 transparent}}
7246
+ @keyframes dsov-secIntroB{0%{opacity:0;transform:translateY(9px);box-shadow:0 0 0 1px color-mix(in srgb,var(--dsw-alias-brand-primary,#247bbf) 60%,transparent)}55%{opacity:1;transform:none;box-shadow:0 0 0 1px color-mix(in srgb,var(--dsw-alias-brand-primary,#247bbf) 45%,transparent)}100%{opacity:1;transform:none;box-shadow:0 0 0 0 transparent}}
7247
+ .dsov-section[data-intro=even]{animation:dsov-secIntroA .44s cubic-bezier(.22,.61,.36,1) both}
7248
+ .dsov-section[data-intro=odd]{animation:dsov-secIntroB .44s cubic-bezier(.22,.61,.36,1) both}
7249
+ @keyframes dsov-turnStart{0%{opacity:0;transform:translateY(-4px)}12%{opacity:1;transform:none}80%{opacity:1;transform:none}100%{opacity:0;transform:translateY(-2px)}}
7250
+ .dsov-turnStart{display:flex;align-items:center;gap:6px;font-size:11px;line-height:1.5;color:var(--dsw-alias-brand-primary,#247bbf);padding:0 2px;animation:dsov-turnStart 2.2s ease both}
7251
+ .dsov-turnStartDot{flex:none;width:6px;height:6px;border-radius:50%;background:currentColor;box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary,#247bbf) 20%,transparent)}
7252
+ .dsov-stepChild{margin-left:21px;padding:3px 6px;border-radius:8px;align-items:flex-start;cursor:default;font-size:11.5px}
7253
+ .dsov-stepChild:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 3.5%,transparent)}
7254
+ .dsov-stepChild[data-waiting=true]{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#ff9f0a) 8%,transparent)}
7255
+ .dsov-stepChild .dsov-stepName{font-weight:400;color:var(--dsw-alias-label-secondary)}
7256
+ .dsov-stepChild .dsov-time{font-size:10.5px;color:var(--dsw-alias-label-tertiary)}
7257
+ .dsov-stepChild>.dsov-todoIcon{margin-top:1px;flex:none}
7258
+ .dsov-stepChildBody{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
7259
+ .dsov-stepChildTop{display:flex;align-items:center;gap:6px;min-width:0}
7260
+ .dsov-stepChildTool{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10.5px;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
7261
+ .dsov-stepChildOutcome{flex:none;font-size:10px;line-height:1.6;padding:0 5px;border-radius:999px;color:var(--dsw-alias-state-success-primary,#00b42a);background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#00b42a) 13%,transparent)}
7262
+ .dsov-stepChildOutcome[data-ok=false]{color:var(--dsw-alias-state-error-primary,#ff453a);background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#ff453a) 13%,transparent)}
7263
+ .dsov-stepChildNote{font-size:11px;line-height:1.5;color:var(--dsw-alias-label-secondary);white-space:normal;overflow-wrap:anywhere;word-break:break-word}
7264
+ .dsov-mermaid{margin:6px 0;text-align:center;overflow-x:auto}
7265
+ .dsov-mermaid svg{max-width:100%;height:auto}
7266
+ .dsov-mermaid .dsov-md-pre{text-align:left}
7267
+ .dsov-mermaidFailed>.dsov-md-pre{border-left:2px solid var(--dsw-alias-state-warning-primary,#e6a23c)}
7268
+ .dsov-badge[data-tone=plan]{color:var(--dsw-alias-state-success-primary,#2ea36a);border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary,#2ea36a) 45%,transparent)}
5857
7269
  .dsov-renderErr{margin:8px 12px 0;padding:6px 8px;border-radius:6px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-error-primary,#ff453a);font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-all;text-align:left;max-height:140px;overflow:auto}
5858
7270
  .dsov-turnSelect{flex:none;max-width:150px;height:24px;padding:0 22px 0 9px;border:.5px solid var(--dsw-alias-border-l2);border-radius:12px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;cursor:pointer;appearance:none;-webkit-appearance:none;background-image:linear-gradient(45deg,transparent 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,transparent 50%);background-position:calc(100% - 12px) 10px,calc(100% - 8px) 10px;background-size:4px 4px,4px 4px;background-repeat:no-repeat}
5859
7271
  .dsov-turnSelect:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3,var(--dsw-alias-border-l2))}
@@ -5861,11 +7273,13 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
5861
7273
  @keyframes dsov-pulse{0%,100%{opacity:1}50%{opacity:.3}}
5862
7274
  @keyframes dsov-stepIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
5863
7275
  @keyframes dsov-stepOut{from{opacity:1;transform:none}to{opacity:0;transform:translateY(-6px)}}
7276
+ @keyframes dsov-refIn{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}
7277
+ .dsov-row[data-enter=true]{animation:dsov-refIn .32s ease both}
5864
7278
  .dsov-step[data-enter=true]{animation:dsov-stepIn .34s ease both}
5865
7279
  .dsov-step[data-exit=true]{position:absolute;top:0;left:0;right:0;animation:dsov-stepOut .4s ease both;pointer-events:none}
5866
7280
  .dsov-todoSpin{transform-origin:7.5px 7.5px;animation:dsov-spin 1.1s linear infinite}
5867
7281
  .dsov-todoPulse{animation:dsov-pulse 1.4s ease-in-out infinite}
5868
- @media (prefers-reduced-motion: reduce){.dsov-todoSpin,.dsov-todoPulse,.dsov-step[data-enter=true]{animation:none}.dsov-step[data-exit=true]{animation:none;opacity:0}}
7282
+ @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}}
5869
7283
  .dsov-vikingMode{align-self:flex-start;margin-left:auto}
5870
7284
  .dsov-seg{flex:none;display:inline-flex;gap:2px;padding:2px;border-radius:8px;background:var(--dsw-alias-bg-layer-2)}
5871
7285
  .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}
@@ -6107,6 +7521,7 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
6107
7521
  kind: CONTEXT_TAB_KIND,
6108
7522
  patterns: [`${CONTEXT_ADDRESS_PREFIX}**`],
6109
7523
  priority: "builtin",
7524
+ multiple: true,
6110
7525
  canOpen: (address) => parseContextAddress(address) !== undefined,
6111
7526
  title: (address) => {
6112
7527
  const key = parseContextAddress(address);
@@ -6135,6 +7550,22 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
6135
7550
  "conversation-overview: context tab body"
6136
7551
  );
6137
7552
 
7553
+ ctx.effect(
7554
+ () =>
7555
+ ctx.slots.inject(
7556
+ "sidebar.right.pane.tab.title",
7557
+ () =>
7558
+ ctx.slots.register(
7559
+ {
7560
+ name: "sidebar.right.pane.tab.title",
7561
+ key: CONTEXT_TAB_ID,
7562
+ },
7563
+ ContextTabTitle
7564
+ )
7565
+ ),
7566
+ "conversation-overview: context tab title"
7567
+ );
7568
+
6138
7569
  ctx.effect(() => {
6139
7570
  if (typeof document === "undefined" || !document.documentElement) return undefined;
6140
7571
  const applyAttr = () => {
@@ -6297,10 +7728,31 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
6297
7728
  ProgressSection,
6298
7729
  ArtifactsSection,
6299
7730
  ReferencesSection,
7731
+ ContextPreviewBody,
7732
+ SettingsForm,
7733
+ chatMarkdown: ChatMarkdown,
7734
+ chatMarkdownLabels,
6300
7735
  StatsBar,
6301
7736
  ArtifactFileMenu,
6302
7737
  OverviewGlyph,
6303
7738
  OverviewContent,
7739
+ MarkdownBody,
7740
+ mdToHtml,
7741
+ Section,
7742
+ sectionIntroProps,
7743
+ SessionRoleBar,
7744
+ sessionRoleOf,
7745
+ canNavigateSession,
7746
+ openSessionById,
7747
+ planTitleOf,
7748
+ openNativePlan,
7749
+ documentAddress,
7750
+ openNativeDocument,
7751
+ openArtifact,
7752
+ ContextTabTitle,
7753
+ __setRuntime: (value) => {
7754
+ runtime = value;
7755
+ },
6304
7756
  OverviewBoundary,
6305
7757
  SettingsForm,
6306
7758
  SettingsSection,