dsh-conversation-overview 1.4.2 → 1.5.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
@@ -7,15 +7,22 @@ window.__ModuleLoader__.load({
7
7
 
8
8
  /* The chat surface renders Markdown with the harness' own MarkdownText, so a
9
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 = (() => {
10
+ fonts, spacing, code banners, tables and file links as a message in the
11
+ conversation. React.memo/forwardRef components are OBJECTS, not functions, so
12
+ the component check must accept both — a typeof-function test silently fell
13
+ back to the plugin renderer (raw markdown links, wrong type scale). */
14
+ const primitives = (() => {
12
15
  try {
13
- const primitives = require("@deepseek-ai/dsh-client-ui-primitives");
14
- return primitives && typeof primitives.MarkdownText === "function" ? primitives.MarkdownText : null;
16
+ const module = require("@deepseek-ai/dsh-client-ui-primitives");
17
+ return module && typeof module === "object" ? module : null;
15
18
  } catch (error) {
16
19
  return null;
17
20
  }
18
21
  })();
22
+ const ChatMarkdown = primitives !== null && primitives.MarkdownText !== undefined && primitives.MarkdownText !== null ? primitives.MarkdownText : null;
23
+ /* The chat wraps its flow in MarkdownDelegateProvider so inline file links render
24
+ as clickable chips and external links open in a new tab; borrow the provider too. */
25
+ const ChatMarkdownProvider = primitives !== null && typeof primitives.MarkdownDelegateProvider === "function" ? primitives.MarkdownDelegateProvider : null;
19
26
 
20
27
  function cx(...parts) {
21
28
  return parts.filter((part) => typeof part === "string" && part.length > 0).join(" ");
@@ -372,6 +379,14 @@ window.__ModuleLoader__.load({
372
379
  "goal.paused": "已暂停",
373
380
  "goal.blocked": "受阻",
374
381
  "goal.complete": "已完成",
382
+ "goal.mode": "目标模式",
383
+ "goal.modeTip": "会话级目标:每轮结束后自动续跑,直到目标达成、受阻、暂停或到达轮次上限",
384
+ "goal.modeLine": "每轮结束后自动续跑,直到达成、受阻、暂停或到达轮次上限",
385
+ "goal.rounds": "自动续跑 {n} / 上限 {max} 轮",
386
+ "goal.roundsNoLimit": "已自动续跑 {n} 轮",
387
+ "goal.done": "目标已达成",
388
+ "goal.pausedLine": "目标已暂停,恢复后继续自动续跑",
389
+ "goal.blockedReason": "受阻原因:{reason}",
375
390
  "artifact.presented": "交付",
376
391
  "artifact.plan": "计划",
377
392
  "artifact.memory": "记忆",
@@ -424,7 +439,9 @@ window.__ModuleLoader__.load({
424
439
  "role.children": "子代理 {n}",
425
440
  "role.childrenToggle": "展开/收起本会话的子代理",
426
441
  "role.switchTitle": "切换到该子代理的概览",
427
- "role.openParent": "查看主会话",
442
+ "role.openParent": "返回主代理",
443
+ "role.openParentHint": "查看主会话",
444
+ "doc.openFile": "在侧边栏打开 {name}",
428
445
  "role.openParentTitle": "切换到父会话的概览",
429
446
  "role.parentPrefix": "·",
430
447
  "state.noSession": "当前没有打开的会话",
@@ -609,6 +626,14 @@ window.__ModuleLoader__.load({
609
626
  "goal.paused": "Paused",
610
627
  "goal.blocked": "Blocked",
611
628
  "goal.complete": "Completed",
629
+ "goal.mode": "Goal mode",
630
+ "goal.modeTip": "Session goal: rounds run automatically until the goal completes, blocks, pauses, or spends its round budget",
631
+ "goal.modeLine": "Rounds run automatically until the goal completes, blocks, pauses, or hits the round cap",
632
+ "goal.rounds": "Auto rounds {n} / max {max}",
633
+ "goal.roundsNoLimit": "{n} auto rounds",
634
+ "goal.done": "Goal reached",
635
+ "goal.pausedLine": "Goal paused — resume to continue the auto rounds",
636
+ "goal.blockedReason": "Blocked: {reason}",
612
637
  "artifact.presented": "Delivered",
613
638
  "artifact.plan": "Plan",
614
639
  "artifact.memory": "Memory",
@@ -661,7 +686,9 @@ window.__ModuleLoader__.load({
661
686
  "role.children": "Subagents {n}",
662
687
  "role.childrenToggle": "Show/hide this session's subagents",
663
688
  "role.switchTitle": "Switch to this subagent's overview",
664
- "role.openParent": "Open main session",
689
+ "role.openParent": "Back to main agent",
690
+ "role.openParentHint": "Open main session",
691
+ "doc.openFile": "Open {name} in sidebar",
665
692
  "role.openParentTitle": "Switch to the parent session's overview",
666
693
  "role.parentPrefix": "·",
667
694
  "state.noSession": "No session is open",
@@ -674,6 +701,29 @@ window.__ModuleLoader__.load({
674
701
 
675
702
  let runtime = null; // { ctx, t }
676
703
 
704
+ /* Cordis' context proxy throws for services this entry never injected, and the
705
+ optional ones here are enhancements (desktop actions, session navigation,
706
+ conversation view switching) that must degrade instead of break. `ctx.get()`
707
+ reads a provided service without the inject requirement. */
708
+ function hostService(name) {
709
+ const ctx = runtime === null || runtime === undefined ? null : runtime.ctx;
710
+ if (ctx === null || ctx === undefined || typeof name !== "string" || name === "") return null;
711
+ try {
712
+ if (typeof ctx.get === "function") {
713
+ const service = ctx.get(name);
714
+ if (service !== undefined && service !== null) return service;
715
+ }
716
+ } catch (_notProvided) {
717
+ /* fall through to the proxy read best effort */
718
+ }
719
+ try {
720
+ const service = ctx[name];
721
+ return service === undefined || service === null ? null : service;
722
+ } catch (_notInjected) {
723
+ return null;
724
+ }
725
+ }
726
+
677
727
  function translate(key, params) {
678
728
  const t = runtime && runtime.t;
679
729
  return typeof t === "function" ? t(key, params) : key;
@@ -855,6 +905,19 @@ window.__ModuleLoader__.load({
855
905
  return parts.join("\n");
856
906
  }
857
907
 
908
+ /** Closing reply text: the chat renders each text block as its own paragraph with a
909
+ 16px gap, so a document reusing the same body must separate them by a blank line. */
910
+ function conclusionTextOf(content) {
911
+ if (!Array.isArray(content)) return "";
912
+ const parts = [];
913
+ for (const block of content) {
914
+ if (!isRecord(block) || block.type !== "text" || typeof block.text !== "string") continue;
915
+ const text = block.text.trim();
916
+ if (text !== "") parts.push(text);
917
+ }
918
+ return parts.join("\n\n");
919
+ }
920
+
858
921
  function collect(source, member, field) {
859
922
  const list = isRecord(source) ? source[member] : null;
860
923
  if (!Array.isArray(list)) return [];
@@ -974,20 +1037,60 @@ window.__ModuleLoader__.load({
974
1037
  /* ------------------------------------------------------------------ */
975
1038
 
976
1039
  const desktopOpenState = { value: null, inflight: null };
977
-
978
- function hostSessionRemote() {
979
- const ctx = runtime && runtime.ctx;
980
- if (!ctx) return null;
1040
+ /* `remote.session` is a cordis ACCESSOR: reading it without a declared inject
1041
+ throws ("cannot get property \"remote.session\" without inject"), and the artifact
1042
+ file menu reads it during render. Mount it as an optional injected dependency
1043
+ once, keep the resolved namespace here, and degrade to no desktop actions. */
1044
+ const desktopRemote = { session: null };
1045
+
1046
+ function mountDesktopRemote(ctx) {
1047
+ /* a fresh mount owns the cache: never inherit a previous host's namespace */
1048
+ desktopRemote.session = null;
1049
+ desktopOpenState.value = null;
1050
+ desktopOpenState.inflight = null;
1051
+ if (ctx === null || ctx === undefined || typeof ctx.inject !== "function") return;
1052
+ const mount = (scoped) => {
1053
+ let session = null;
1054
+ /* the accessor is reachable as a flat property name, and as the nested member
1055
+ of the `remote` service; read whichever this cordis build exposes */
1056
+ try {
1057
+ const direct = scoped["remote.session"];
1058
+ if (isRecord(direct)) session = direct;
1059
+ } catch (_flatNotInjected) {
1060
+ session = null;
1061
+ }
1062
+ if (!isRecord(session)) {
1063
+ try {
1064
+ const remote = scoped.remote;
1065
+ if (isRecord(remote) && isRecord(remote.session)) session = remote.session;
1066
+ } catch (_nestedNotInjected) {
1067
+ session = null;
1068
+ }
1069
+ }
1070
+ desktopRemote.session = isRecord(session) ? session : null;
1071
+ /* reset the cached capability probe so a re-mount re-asks the new host */
1072
+ desktopOpenState.value = null;
1073
+ desktopOpenState.inflight = null;
1074
+ return () => {
1075
+ desktopRemote.session = null;
1076
+ desktopOpenState.value = null;
1077
+ desktopOpenState.inflight = null;
1078
+ };
1079
+ };
981
1080
  try {
982
- /* the cordis context proxy THROWS for services this entry has not
983
- injected; degrade to "no desktop actions" instead of crashing render */
984
- const remote = ctx.remote;
985
- return isRecord(remote) && isRecord(remote.session) ? remote.session : null;
986
- } catch {
987
- return null;
1081
+ const fiber = ctx.inject(["remote", "remote.session"], mount);
1082
+ if (fiber !== null && fiber !== undefined && typeof fiber.catch === "function") fiber.catch(() => {});
1083
+ } catch (_unavailable) {
1084
+ desktopRemote.session = null;
988
1085
  }
989
1086
  }
990
1087
 
1088
+ /** The desktop RPC namespace, or null when the host/profile has no such route. */
1089
+ function hostSessionRemote() {
1090
+ const session = desktopRemote.session;
1091
+ return isRecord(session) ? session : null;
1092
+ }
1093
+
991
1094
  function desktopCanOpen() {
992
1095
  const remote = hostSessionRemote();
993
1096
  if (remote === null || typeof remote.canOpenWorkspacePath !== "function") return Promise.resolve(false);
@@ -1401,7 +1504,14 @@ window.__ModuleLoader__.load({
1401
1504
  .replace(
1402
1505
  /\[([^\]]+)\]\((https?:[^)\s]+)\)/g,
1403
1506
  '<a href="$2" target="_blank" rel="noreferrer noopener">$1</a>'
1404
- );
1507
+ )
1508
+ /* file destinations (relative or absolute paths): never print the raw
1509
+ `[label](path)` syntax — render the label and let the body open it */
1510
+ .replace(/\[([^\]]+)\]\(([^)#][^)\s]*)\)/g, (match, label, destination) => {
1511
+ if (/^[a-z][a-z\d+.-]*:/i.test(destination) && !/^[a-z]:[\\/]/i.test(destination)) return match;
1512
+ if (!looksLikePath(destination)) return match;
1513
+ return `<span class="dsov-md-fileLink" data-file-path="${destination.replace(/"/g, "&quot;")}">${label}</span>`;
1514
+ });
1405
1515
  }
1406
1516
 
1407
1517
  /**
@@ -1760,6 +1870,8 @@ window.__ModuleLoader__.load({
1760
1870
  subagents: new Map(),
1761
1871
  stats: createStats(),
1762
1872
  conclusion: null,
1873
+ goal: null,
1874
+ goalRoundSeen: 0,
1763
1875
  changed: false,
1764
1876
  };
1765
1877
  }
@@ -1840,7 +1952,7 @@ window.__ModuleLoader__.load({
1840
1952
  }
1841
1953
 
1842
1954
  /** Markdown block that upgrades ```mermaid fences to rendered diagrams after mount. */
1843
- function MarkdownBody({ text }) {
1955
+ function MarkdownBody({ text, onOpenFile }) {
1844
1956
  const ref = React.useRef(null);
1845
1957
  const html = React.useMemo(() => mdToHtml(typeof text === "string" ? text : ""), [text]);
1846
1958
  React.useEffect(() => {
@@ -1849,13 +1961,39 @@ window.__ModuleLoader__.load({
1849
1961
  const timer = setTimeout(() => renderMermaidNodes(node), 0);
1850
1962
  return () => clearTimeout(timer);
1851
1963
  }, [html]);
1852
- return h("div", { className: "dsov-md", ref, dangerouslySetInnerHTML: { __html: html } });
1964
+ const onClick = (event) => {
1965
+ if (typeof onOpenFile !== "function") return;
1966
+ const target = event.target;
1967
+ const link = target !== null && target !== undefined && typeof target.closest === "function" ? target.closest("[data-file-path]") : null;
1968
+ if (link === null) return;
1969
+ event.preventDefault();
1970
+ onOpenFile(link.getAttribute("data-file-path") || "");
1971
+ };
1972
+ return h("div", { className: "dsov-md", ref, onClick, dangerouslySetInnerHTML: { __html: html } });
1853
1973
  }
1854
1974
 
1855
- function rememberContextPreview(sessionId, refKey, title, text, kind) {
1975
+ /** File paths a turn produced, capped for the document tab's file chips. */
1976
+ function docFilesOf(artifacts) {
1977
+ const files = [];
1978
+ for (const artifact of Array.isArray(artifacts) ? artifacts : []) {
1979
+ if (!isRecord(artifact) || typeof artifact.path !== "string" || artifact.path === "") continue;
1980
+ if (artifact.path.startsWith("viking://") || artifact.path.startsWith("plan:") || artifact.path.startsWith("conclusion:")) continue;
1981
+ if (files.includes(artifact.path) || files.length >= 40) continue;
1982
+ files.push(artifact.path);
1983
+ }
1984
+ return files;
1985
+ }
1986
+
1987
+ function rememberContextPreview(sessionId, refKey, title, text, kind, files) {
1856
1988
  if (typeof sessionId !== "string" || sessionId === "" || typeof text !== "string" || text === "") return undefined;
1857
1989
  const globalKey = `${sessionId}|${refKey}`;
1858
- const entry = { 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 } : {}) };
1990
+ const docFiles = Array.isArray(files) ? files.filter((file) => typeof file === "string" && file !== "").slice(0, 40) : [];
1991
+ const entry = {
1992
+ title: typeof title === "string" && title !== "" ? title : refKey,
1993
+ text: text.length > CONTEXT_TEXT_LIMIT ? `${text.slice(0, CONTEXT_TEXT_LIMIT)}\n…` : text,
1994
+ ...(typeof kind === "string" && kind !== "" ? { kind } : {}),
1995
+ ...(docFiles.length === 0 ? {} : { files: docFiles }),
1996
+ };
1859
1997
  contextPreviews.set(globalKey, entry);
1860
1998
  /* Documents (turn conclusion, plan snapshot) open as tabs the host restores
1861
1999
  after a reload, and their body lives only here — mirror them into
@@ -1888,7 +2026,7 @@ window.__ModuleLoader__.load({
1888
2026
  try {
1889
2027
  const kept = readDocPreviewStore().filter((item) => item[0] !== globalKey);
1890
2028
  const text = entry.text.length > DOC_PREVIEW_TEXT_LIMIT ? `${entry.text.slice(0, DOC_PREVIEW_TEXT_LIMIT)}\n…` : entry.text;
1891
- kept.push([globalKey, { title: entry.title, text, kind: entry.kind }]);
2029
+ kept.push([globalKey, { title: entry.title, text, kind: entry.kind, ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files.slice(0, 40) } : {}) }]);
1892
2030
  window.localStorage.setItem(DOC_PREVIEW_STORE_KEY, JSON.stringify(kept.slice(-DOC_PREVIEW_STORE_LIMIT)));
1893
2031
  } catch {
1894
2032
  /* storage full or unavailable: the in-memory copy still serves this page */
@@ -1903,7 +2041,12 @@ window.__ModuleLoader__.load({
1903
2041
  if (stored === undefined) return null;
1904
2042
  const revived = isRecord(stored[1]) && typeof stored[1].text === "string" ? stored[1] : null;
1905
2043
  if (revived === null || revived.text === "") return null;
1906
- const restored = { title: typeof revived.title === "string" && revived.title !== "" ? revived.title : globalKey, text: revived.text, ...(typeof revived.kind === "string" && revived.kind !== "" ? { kind: revived.kind } : {}) };
2044
+ const restored = {
2045
+ title: typeof revived.title === "string" && revived.title !== "" ? revived.title : globalKey,
2046
+ text: revived.text,
2047
+ ...(typeof revived.kind === "string" && revived.kind !== "" ? { kind: revived.kind } : {}),
2048
+ ...(Array.isArray(revived.files) ? { files: revived.files.filter((file) => typeof file === "string" && file !== "").slice(0, 40) } : {}),
2049
+ };
1907
2050
  contextPreviews.set(globalKey, restored);
1908
2051
  return restored;
1909
2052
  }
@@ -2250,47 +2393,115 @@ window.__ModuleLoader__.load({
2250
2393
  /* Key-step stream: every tool call is one step; the result marks it done. Shown by
2251
2394
  the Progress section when a turn has no todo plan of its own. */
2252
2395
  const STEP_CAP = 200;
2396
+ /** Steps shown when the progress list is collapsed: the last six *gated* steps
2397
+ (approval rows are gates of a tool call, not steps of their own) plus the
2398
+ approvals that belong to them. Slicing the raw stream put an orphan approval
2399
+ at the top of the window whenever the cut fell between a call and its gate. */
2400
+ const STEP_WINDOW = 6;
2401
+ function recentStepWindow(allSteps) {
2402
+ const source = Array.isArray(allSteps) ? allSteps : [];
2403
+ const picked = new Set();
2404
+ let budget = STEP_WINDOW;
2405
+ for (let index = source.length - 1; index >= 0 && budget > 0; index -= 1) {
2406
+ const step = source[index];
2407
+ if (!isRecord(step) || step.name === "approval") continue;
2408
+ picked.add(step);
2409
+ budget -= 1;
2410
+ }
2411
+ const pickedCalls = new Set();
2412
+ for (const step of picked) if (typeof step.callId === "string" && step.callId !== "") pickedCalls.add(step.callId);
2413
+ return source.filter((step) => {
2414
+ if (picked.has(step)) return true;
2415
+ return isRecord(step) && step.name === "approval" && typeof step.parentCallId === "string" && pickedCalls.has(step.parentCallId);
2416
+ });
2417
+ }
2253
2418
 
2254
2419
  /** One-line human target for a tool call: file, command, query, url, name… */
2255
2420
  /** Team-record key a tool call creates: teammate name / task subject / subagent label. */
2256
- /** Match team delegates (members, subagents, tasks) to todo items by normalized text
2257
- containment, so each delegate renders under the todo it belongs to. */
2258
- function planTodoAttachments(todos, members, teamTasks, subagents) {
2421
+ /** Word/bigram tokens of one todo or delegate text: latin words plus CJK bigrams, so
2422
+ 「直播域底稿」/「直播域技术底稿分析」 share evidence without full containment. */
2423
+ function planTokens(text) {
2424
+ const tokens = new Set();
2425
+ const source = String(typeof text === "string" ? text : "").toLowerCase();
2426
+ for (const word of source.match(/[a-z][a-z0-9]{2,}/g) || []) tokens.add(word);
2427
+ for (const run of source.match(/[\u4e00-\u9fff]+/g) || []) {
2428
+ if (run.length === 1) tokens.add(run);
2429
+ for (let index = 0; index + 1 < run.length; index += 1) tokens.add(run.slice(index, index + 2));
2430
+ }
2431
+ return tokens;
2432
+ }
2433
+
2434
+ /** Index of the todo that was open (not yet completed) at `time`, from the todo log. */
2435
+ function planActiveAt(todoHistory, time) {
2436
+ if (!Array.isArray(todoHistory) || typeof time !== "number" || time <= 0) return null;
2437
+ let active = null;
2438
+ for (const record of todoHistory) {
2439
+ if (!isRecord(record) || !Array.isArray(record.todos)) continue;
2440
+ if (typeof record.time === "number" && record.time > time) break;
2441
+ active = new Set();
2442
+ record.todos.forEach((todo, index) => {
2443
+ if (isRecord(todo) && todo.status !== "completed") active.add(index);
2444
+ });
2445
+ }
2446
+ return active;
2447
+ }
2448
+
2449
+ /** Match team delegates (members, subagents, tasks) to todos: IDF-weighted token
2450
+ coverage, preferring the todo that was in progress when the delegate appeared, so
2451
+ each delegate renders under the todo it belongs to instead of a flat tail list. */
2452
+ function planTodoAttachments(todos, members, teamTasks, subagents, todoHistory) {
2259
2453
  const attachments = new Map();
2260
2454
  if (!Array.isArray(todos) || todos.length === 0) return attachments;
2261
- const normText = (value) =>
2262
- typeof value === "string"
2263
- ? value
2264
- .toLowerCase()
2265
- .replace(/[-_/]+/g, " ")
2266
- .replace(/[^a-z0-9\u4e00-\u9fff ]+/g, "")
2267
- .replace(/\s+/g, " ")
2268
- .trim()
2269
- : "";
2270
- const matchScore = (a, b) => {
2271
- const x = normText(a);
2272
- const y = normText(b);
2273
- if (x === "" || y === "") return 0;
2274
- if (x === y) return x.length;
2275
- if (x.length >= 6 && y.includes(x)) return x.length;
2276
- if (y.length >= 6 && x.includes(y)) return y.length;
2277
- return 0;
2455
+ const todoTokens = todos.map((todo) => planTokens(isRecord(todo) ? todo.content : ""));
2456
+ /* rare words are the evidence: 「直播域」 in one todo says more than 「多端」 in four */
2457
+ const docFrequency = new Map();
2458
+ for (const set of todoTokens) for (const token of set) docFrequency.set(token, (docFrequency.get(token) || 0) + 1);
2459
+ const weightOf = (token) => Math.log((todos.length + 1) / ((docFrequency.get(token) || 0) + 0.5)) + 0.2;
2460
+ const coverage = (tokens, index) => {
2461
+ let covered = 0;
2462
+ let total = 0;
2463
+ for (const token of tokens) {
2464
+ const weight = weightOf(token);
2465
+ total += weight;
2466
+ if (todoTokens[index].has(token)) covered += weight;
2467
+ }
2468
+ return total === 0 ? 0 : covered / total;
2278
2469
  };
2279
- const ownedBy = (id) => teamTasks.filter((task) => isRecord(task) && task.ownerId === id);
2280
- const bestFor = (texts) => {
2470
+ const MATCH_MIN = 0.08;
2471
+ const bestFor = (texts, time) => {
2472
+ const scores = new Array(todos.length).fill(0);
2281
2473
  let best = -1;
2282
- let bestScore = 5;
2283
- todos.forEach((todo, index) => {
2284
- const content = isRecord(todo) ? todo.content : "";
2285
- let score = 0;
2286
- for (const text of texts) score = Math.max(score, matchScore(text, content));
2287
- if (score > bestScore) {
2288
- bestScore = score;
2289
- best = index;
2474
+ let bestScore = MATCH_MIN;
2475
+ for (const text of texts) {
2476
+ const tokens = planTokens(text);
2477
+ for (let index = 0; index < todos.length; index += 1) {
2478
+ if (todos[index] === undefined) continue;
2479
+ scores[index] = Math.max(scores[index], coverage(tokens, index));
2480
+ if (scores[index] > bestScore) {
2481
+ bestScore = scores[index];
2482
+ best = index;
2483
+ }
2290
2484
  }
2291
- });
2485
+ }
2486
+ if (best < 0) return -1;
2487
+ /* the delegate appeared while one plan row was being worked on: prefer it */
2488
+ const active = planActiveAt(todoHistory, time);
2489
+ if (active !== null && active.size > 0 && !active.has(best)) {
2490
+ let activeBest = -1;
2491
+ let activeScore = bestScore * 0.6;
2492
+ for (const index of active) {
2493
+ if (todos[index] === undefined) continue;
2494
+ if (scores[index] > activeScore) {
2495
+ activeScore = scores[index];
2496
+ activeBest = index;
2497
+ }
2498
+ }
2499
+ if (activeBest >= 0) return activeBest;
2500
+ }
2292
2501
  return best;
2293
2502
  };
2503
+ const ownedBy = (id) => teamTasks.filter((task) => isRecord(task) && task.ownerId === id);
2504
+ const timeOf = (record) => (isRecord(record) && typeof record.time === "number" ? record.time : 0);
2294
2505
  const attach = (index, item) => {
2295
2506
  const list = attachments.get(index);
2296
2507
  if (list === undefined) attachments.set(index, [item]);
@@ -2298,11 +2509,11 @@ window.__ModuleLoader__.load({
2298
2509
  };
2299
2510
  for (const member of members.filter(isRecord)) {
2300
2511
  const owned = ownedBy(member.id);
2301
- const index = bestFor([member.name, member.description, ...owned.map((task) => task.subject)]);
2512
+ const index = bestFor([member.name, member.description, ...owned.map((task) => task.subject)], timeOf(member));
2302
2513
  if (index >= 0) attach(index, { kind: "member", record: member, owned });
2303
2514
  }
2304
2515
  for (const sub of subagents.filter(isRecord)) {
2305
- const index = bestFor([sub.label]);
2516
+ const index = bestFor([sub.label], timeOf(sub));
2306
2517
  if (index >= 0) attach(index, { kind: "sub", record: sub, owned: [] });
2307
2518
  }
2308
2519
  const memberOwned = new Set();
@@ -2314,11 +2525,10 @@ window.__ModuleLoader__.load({
2314
2525
  for (const task of teamTasks.filter(isRecord)) {
2315
2526
  if (memberOwned.has(task.id)) continue;
2316
2527
  if (typeof task.ownerId === "string" && members.some((member) => isRecord(member) && member.id === task.ownerId)) continue;
2317
- const index = bestFor([task.subject]);
2528
+ const index = bestFor([task.subject], timeOf(task));
2318
2529
  if (index >= 0) attach(index, { kind: "task", record: task, owned: [] });
2319
2530
  }
2320
- const timeOf = (item) => (isRecord(item.record) && typeof item.record.time === "number" ? item.record.time : 0);
2321
- for (const list of attachments.values()) list.sort((left, right) => timeOf(left) - timeOf(right));
2531
+ for (const list of attachments.values()) list.sort((left, right) => timeOf(left.record) - timeOf(right.record));
2322
2532
  return attachments;
2323
2533
  }
2324
2534
 
@@ -2453,7 +2663,7 @@ window.__ModuleLoader__.load({
2453
2663
  {message:{content}}. Reading only the first left every real turn without a
2454
2664
  conclusion document, so the auto-open never fired. */
2455
2665
  const body = isRecord(data.message) ? data.message : data;
2456
- const text = (typeof body.text === "string" && body.text !== "" ? body.text : textOf(body.content)).trim();
2666
+ const text = (typeof body.text === "string" && body.text !== "" ? body.text : conclusionTextOf(body.content)).trim();
2457
2667
  if (text === "") return;
2458
2668
  const turn = folder.currentTurn >= 1 ? folder.currentTurn : Number.isSafeInteger(data.turn) && data.turn >= 1 ? data.turn : 0;
2459
2669
  const bucket = folder.byTurn.get(turn) ?? turnBucket(folder, folder.currentTurn);
@@ -2499,6 +2709,29 @@ window.__ModuleLoader__.load({
2499
2709
  touch(folder);
2500
2710
  return;
2501
2711
  }
2712
+ case "goal/change": {
2713
+ /* A session-level goal: every round is driven automatically until the goal
2714
+ completes, blocks, pauses, or spends its round budget. Folding the durable
2715
+ change lets a historical session show the same banner the live projection does. */
2716
+ if (!isRecord(data)) return;
2717
+ const goal = isRecord(data.goal) ? data.goal : null;
2718
+ if (goal === null || typeof goal.objective !== "string" || goal.objective.trim() === "" || typeof goal.phase !== "string") return;
2719
+ folder.goal = {
2720
+ id: typeof goal.id === "string" ? goal.id : "",
2721
+ revision: typeof goal.revision === "number" ? goal.revision : 0,
2722
+ objective: goal.objective,
2723
+ phase: goal.phase,
2724
+ maxGoalRounds: typeof goal.maxGoalRounds === "number" ? goal.maxGoalRounds : 0,
2725
+ blockedReason:
2726
+ isRecord(goal.blockedReason) && typeof goal.blockedReason.message === "string" && goal.blockedReason.message !== "" ? goal.blockedReason.message : "",
2727
+ roundsStarted: typeof data.roundsStarted === "number" ? data.roundsStarted : 0,
2728
+ createdAt: typeof data.createdAt === "number" ? data.createdAt : 0,
2729
+ updatedAt: typeof data.updatedAt === "number" ? data.updatedAt : 0,
2730
+ turn: folder.currentTurn,
2731
+ };
2732
+ touch(folder);
2733
+ return;
2734
+ }
2502
2735
  case "team/member": {
2503
2736
  if (!isRecord(data) || !isRecord(data.member)) return;
2504
2737
  const member = data.member;
@@ -2766,6 +2999,16 @@ window.__ModuleLoader__.load({
2766
2999
  /* a compact checkpoint replaces the event span it summarizes; every other message must append */
2767
3000
  if (kind !== "compact-checkpoint" && !isAppendSurfaceEvent(event)) return;
2768
3001
  const bucket = turnBucket(folder, folder.currentTurn);
3002
+ if (kind === "goal" && source !== null) {
3003
+ /* an admitted goal round: keep the banner's round counter live even when
3004
+ the projection (or a paused goal) lags the event log */
3005
+ const round = typeof source.round === "number" && Number.isSafeInteger(source.round) && source.round > 0 ? source.round : 0;
3006
+ if (round > folder.goalRoundSeen) folder.goalRoundSeen = round;
3007
+ if (folder.goal !== null && typeof folder.goal === "object" && round > folder.goal.roundsStarted) {
3008
+ folder.goal = { ...folder.goal, roundsStarted: round };
3009
+ touch(folder);
3010
+ }
3011
+ }
2769
3012
  if (kind === "user") {
2770
3013
  const mentions = mentionsOfUserText(textOf(data.content));
2771
3014
  for (const label of mentions.sessions) {
@@ -2884,7 +3127,7 @@ window.__ModuleLoader__.load({
2884
3127
  let conclusion = null;
2885
3128
  if (rawConclusion !== null && typeof rawConclusion.text === "string" && rawConclusion.text !== "") {
2886
3129
  const title = translate("artifact.conclusionTurn", { n: rawConclusion.turn });
2887
- const previewKey = rememberContextPreview(folder.sessionId, `conclusion|${rawConclusion.turn}`, title, rawConclusion.text, "conclusion");
3130
+ const previewKey = rememberContextPreview(folder.sessionId, `conclusion|${rawConclusion.turn}`, title, rawConclusion.text, "conclusion", docFilesOf(artifacts));
2888
3131
  conclusion = {
2889
3132
  path: `conclusion:${rawConclusion.turn}`,
2890
3133
  kind: "conclusion",
@@ -2934,7 +3177,7 @@ window.__ModuleLoader__.load({
2934
3177
  /* team anchor steps (spawn/task-create/subagent) stay pinned above the recent window
2935
3178
  so member/task rows can nest under their creating step */
2936
3179
  const allSteps = Array.isArray(source.steps) ? source.steps : [];
2937
- const recentSteps = allSteps.slice(-6);
3180
+ const recentSteps = recentStepWindow(allSteps);
2938
3181
  const anchorCandidates = [...(Array.isArray(source.anchors) ? source.anchors : []), ...allSteps].filter(
2939
3182
  (step, index, list) =>
2940
3183
  isRecord(step) && typeof step.teamKey === "string" && step.milestone !== true && list.indexOf(step) === index && recentSteps.indexOf(step) === -1
@@ -2943,9 +3186,14 @@ window.__ModuleLoader__.load({
2943
3186
  /* milestone steps (compaction, approvals) ride the anchor lane for retention only;
2944
3187
  whether they pin above the window is a display choice (cfg.stepAnchors) */
2945
3188
  const milestoneSteps = (Array.isArray(source.anchors) ? source.anchors : []).filter((step) => isRecord(step) && step.milestone === true);
3189
+ const goalState =
3190
+ isRecord(folder.goal) && typeof folder.goal.objective === "string"
3191
+ ? { ...folder.goal, roundsStarted: Math.max(typeof folder.goal.roundsStarted === "number" ? folder.goal.roundsStarted : 0, folder.goalRoundSeen || 0) }
3192
+ : null;
2946
3193
  return {
2947
3194
  todos,
2948
3195
  todosTime,
3196
+ goal: goalState,
2949
3197
  artifacts,
2950
3198
  references,
2951
3199
  steps: [...anchorSteps, ...recentSteps],
@@ -2968,7 +3216,7 @@ window.__ModuleLoader__.load({
2968
3216
  };
2969
3217
  }
2970
3218
 
2971
- 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 };
3219
+ const EMPTY_STATE = { todos: null, todosTime: 0, goal: null, artifacts: [], references: [], steps: [], stepsAll: [], team: { members: [], tasks: [], subagents: [] }, stats: { durationMs: 0, toolCalls: 0, commands: 0, errors: 0, files: 0 }, turnStart: null, turnEnd: null, milestones: [], todoHistory: [], changeTurns: [], hasMore: false, localeRev: 0, turns: [], latestTurn: null, scope: "latest", scopeTurns: 1, conclusion: null };
2972
3220
 
2973
3221
  function createSummaryStore(binding) {
2974
3222
  const source = binding.eventSource;
@@ -3128,7 +3376,49 @@ window.__ModuleLoader__.load({
3128
3376
  );
3129
3377
  if (!isRecord(value)) return null;
3130
3378
  const goal = value.goal;
3131
- return isRecord(goal) && typeof goal.objective === "string" ? goal : null;
3379
+ if (!isRecord(goal) || typeof goal.objective !== "string" || goal.objective.trim() === "") return null;
3380
+ return {
3381
+ ...goal,
3382
+ roundsStarted: typeof value.roundsStarted === "number" ? value.roundsStarted : 0,
3383
+ createdAt: typeof value.createdAt === "number" ? value.createdAt : 0,
3384
+ updatedAt: typeof value.updatedAt === "number" ? value.updatedAt : 0,
3385
+ };
3386
+ }
3387
+
3388
+ /** Merge the folded goal (history) with the live projection (current truth): the
3389
+ projection owns phase/rounds while the event log keeps a historical session's
3390
+ banner readable. */
3391
+ function mergeGoalState(folded, live) {
3392
+ const recorded = isRecord(folded) && typeof folded.objective === "string" && folded.objective.trim() !== "" ? folded : null;
3393
+ const projected = isRecord(live) && typeof live.objective === "string" && live.objective.trim() !== "" ? live : null;
3394
+ if (recorded === null && projected === null) return null;
3395
+ const number = (value) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
3396
+ const source = projected !== null ? projected : recorded;
3397
+ const roundsStarted = Math.max(
3398
+ number(projected === null ? undefined : projected.roundsStarted),
3399
+ number(recorded === null ? undefined : recorded.roundsStarted)
3400
+ );
3401
+ const maxGoalRounds = Math.max(
3402
+ number(projected === null ? undefined : projected.maxGoalRounds),
3403
+ number(recorded === null ? undefined : recorded.maxGoalRounds)
3404
+ );
3405
+ const blockedReason =
3406
+ projected !== null && typeof projected.blockedReason === "string" && projected.blockedReason !== ""
3407
+ ? projected.blockedReason
3408
+ : recorded !== null && typeof recorded.blockedReason === "string"
3409
+ ? recorded.blockedReason
3410
+ : "";
3411
+ return {
3412
+ id: typeof source.id === "string" ? source.id : "",
3413
+ objective: source.objective,
3414
+ phase: typeof source.phase === "string" ? source.phase : "",
3415
+ maxGoalRounds,
3416
+ roundsStarted,
3417
+ blockedReason,
3418
+ createdAt: Math.max(number(projected === null ? undefined : projected.createdAt), number(recorded === null ? undefined : recorded.createdAt)),
3419
+ updatedAt: Math.max(number(projected === null ? undefined : projected.updatedAt), number(recorded === null ? undefined : recorded.updatedAt)),
3420
+ turn: recorded !== null && typeof recorded.turn === "number" ? recorded.turn : 0,
3421
+ };
3132
3422
  }
3133
3423
 
3134
3424
  /** Backfills older history. Returns true once several consecutive loads made no
@@ -4392,7 +4682,7 @@ function FileTypeIcon(props) {
4392
4682
  ? stepsRaw.filter(stepAt).map(stepClamped)
4393
4683
  : (() => {
4394
4684
  const visible = stepsAllRaw.filter(stepAt);
4395
- const recent = visible.slice(-6);
4685
+ const recent = recentStepWindow(visible);
4396
4686
  const anchors = stepsRaw.filter(
4397
4687
  (step) => stepAt(step) && typeof step.teamKey === "string" && step.milestone !== true && recent.indexOf(step) === -1
4398
4688
  );
@@ -4616,7 +4906,7 @@ function FileTypeIcon(props) {
4616
4906
  const consumedTasks = new Set();
4617
4907
  const consumedSubs = new Set();
4618
4908
  /* delegates matched to a todo render nested under it and skip the flat tail */
4619
- const todoAttachments = hasTodos && hasTeam ? planTodoAttachments(todos, members, teamTasks, subagents) : new Map();
4909
+ const todoAttachments = hasTodos && hasTeam ? planTodoAttachments(todos, members, teamTasks, subagents, todoHistory) : new Map();
4620
4910
  for (const list of todoAttachments.values()) {
4621
4911
  for (const item of list) {
4622
4912
  if (item.kind === "member") {
@@ -4985,14 +5275,37 @@ function FileTypeIcon(props) {
4985
5275
  };
4986
5276
  const kids = [];
4987
5277
  if (goal) {
5278
+ /* A session goal is a mode, not a todo: rounds are re-driven automatically.
5279
+ The banner says so, carries the phase and the round budget, and names the
5280
+ blocker/pause instead of leaving a bare objective line. */
4988
5281
  const phase = typeof goal.phase === "string" ? goal.phase : "";
5282
+ const roundsStarted = typeof goal.roundsStarted === "number" && goal.roundsStarted > 0 ? goal.roundsStarted : 0;
5283
+ const maxRounds = typeof goal.maxGoalRounds === "number" && goal.maxGoalRounds > 0 ? goal.maxGoalRounds : 0;
5284
+ const blockedReason = phase === "blocked" && typeof goal.blockedReason === "string" ? goal.blockedReason.trim() : "";
5285
+ const phaseLabel = GOAL_PHASE_KEYS[phase] ? t(GOAL_PHASE_KEYS[phase]) : phase || "—";
5286
+ const fact = maxRounds > 0 ? t("goal.rounds", { n: roundsStarted, max: maxRounds }) : roundsStarted > 0 ? t("goal.roundsNoLimit", { n: roundsStarted }) : "";
5287
+ const metaLine =
5288
+ phase === "complete"
5289
+ ? t("goal.done")
5290
+ : phase === "blocked"
5291
+ ? (blockedReason !== "" ? t("goal.blockedReason", { reason: blockedReason }) : t("goal.modeTip"))
5292
+ : phase === "paused"
5293
+ ? t("goal.pausedLine")
5294
+ : t("goal.modeLine");
4989
5295
  kids.push(
4990
5296
  h(
4991
5297
  "div",
4992
- { className: "dsov-goal", key: "goal" },
4993
- h("span", { className: "dsov-goalIcon" }, h(IconTarget, { size: 15 })),
4994
- h("span", { className: "dsov-goalObjective" }, goal.objective),
4995
- h("span", { className: "dsov-badge", "data-tone": GOAL_PHASE_TONES[phase] ?? "idle" }, GOAL_PHASE_KEYS[phase] ? t(GOAL_PHASE_KEYS[phase]) : phase || "—")
5298
+ { className: "dsov-goal", key: "goal", "data-phase": phase === "" ? "unknown" : phase, title: t("goal.modeTip") },
5299
+ h(
5300
+ "div",
5301
+ { className: "dsov-goalHead" },
5302
+ h("span", { className: "dsov-goalIcon" }, h(IconTarget, { size: 13 })),
5303
+ h("span", { className: "dsov-goalMode" }, t("goal.mode")),
5304
+ h("span", { className: "dsov-goalPhase", "data-tone": GOAL_PHASE_TONES[phase] ?? "idle" }, phaseLabel),
5305
+ fact === "" ? null : h("span", { className: "dsov-goalFacts" }, fact)
5306
+ ),
5307
+ h("div", { className: "dsov-goalObjective" }, typeof goal.objective === "string" ? goal.objective : ""),
5308
+ h("div", { className: "dsov-goalMeta", "data-phase": phase === "" ? "unknown" : phase }, metaLine)
4996
5309
  )
4997
5310
  );
4998
5311
  }
@@ -5939,28 +6252,73 @@ function FileTypeIcon(props) {
5939
6252
 
5940
6253
  /** Whether the workspace service can move the main conversation to another session. */
5941
6254
  function canNavigateSession() {
6255
+ const workspace = hostService("uiWorkspace");
6256
+ return isRecord(workspace) && typeof workspace.openSession === "function";
6257
+ }
6258
+
6259
+ function openSessionById(sessionId) {
6260
+ if (typeof sessionId !== "string" || sessionId === "") return false;
6261
+ const workspace = hostService("uiWorkspace");
6262
+ if (!isRecord(workspace) || typeof workspace.openSession !== "function") return false;
5942
6263
  try {
5943
- const ctx = runtime === null ? null : runtime.ctx;
5944
- const workspace = ctx === null || ctx === undefined ? null : ctx.uiWorkspace;
5945
- return isRecord(workspace) && typeof workspace.openSession === "function";
5946
- } catch {
6264
+ workspace.openSession(sessionId);
6265
+ return true;
6266
+ } catch (_navigationRejected) {
5947
6267
  return false;
5948
6268
  }
5949
6269
  }
5950
6270
 
5951
- function openSessionById(sessionId) {
6271
+ /* The host remembers the active conversation view per session under
6272
+ `<conversation store key>.<sessionId>`, and restores it when the session is
6273
+ revealed. Writing the overview there is what makes a subagent link land on
6274
+ its 概览 tab instead of whatever view that session last showed. */
6275
+ function preferSessionOverview(sessionId) {
5952
6276
  if (typeof sessionId !== "string" || sessionId === "") return false;
5953
6277
  try {
5954
- const ctx = runtime === null ? null : runtime.ctx;
5955
- const workspace = ctx === null || ctx === undefined ? null : ctx.uiWorkspace;
5956
- if (!isRecord(workspace) || typeof workspace.openSession !== "function") return false;
5957
- workspace.openSession(sessionId);
6278
+ if (typeof window === "undefined" || window.localStorage === undefined) return false;
6279
+ const key = `dsh.conversation.${sessionId}`;
6280
+ const raw = window.localStorage.getItem(key);
6281
+ const stored = raw === null ? {} : JSON.parse(raw);
6282
+ const next = isRecord(stored) ? { ...stored, view: VIEW_ID } : { view: VIEW_ID };
6283
+ window.localStorage.setItem(key, JSON.stringify(next));
5958
6284
  return true;
5959
- } catch {
6285
+ } catch (_unavailable) {
5960
6286
  return false;
5961
6287
  }
5962
6288
  }
5963
6289
 
6290
+ /** Ask the running conversation to activate a view once that session is bound. */
6291
+ function activateSessionView(sessionId, view, attempts) {
6292
+ const sessions = hostService("sessions");
6293
+ const conversationService = hostService("uiConversation");
6294
+ try {
6295
+ const binding = sessions !== null && typeof sessions.binding === "function" ? sessions.binding(sessionId) : undefined;
6296
+ if (binding !== undefined && conversationService !== null && typeof conversationService.binding === "function") {
6297
+ const conversation = conversationService.binding(sessionId);
6298
+ if (conversation !== null && conversation !== undefined && typeof conversation.activate === "function") {
6299
+ conversation.activate(view);
6300
+ return true;
6301
+ }
6302
+ }
6303
+ } catch (_notBoundYet) {
6304
+ /* the session is still materializing: retry below */
6305
+ }
6306
+ if (attempts <= 0 || typeof setTimeout !== "function") return false;
6307
+ setTimeout(() => {
6308
+ activateSessionView(sessionId, view, attempts - 1);
6309
+ }, 80);
6310
+ return false;
6311
+ }
6312
+
6313
+ /** Open one session and land on its 概览 (main agent ⇄ subagent navigation). */
6314
+ function openSessionOverview(sessionId) {
6315
+ if (typeof sessionId !== "string" || sessionId === "") return false;
6316
+ preferSessionOverview(sessionId);
6317
+ const opened = openSessionById(sessionId);
6318
+ activateSessionView(sessionId, VIEW_ID, 25);
6319
+ return opened;
6320
+ }
6321
+
5964
6322
  /* Whether this session's visible scope actually involved subagents: agent-team
5965
6323
  members, subagent-catalog records, or real subagent/spawn tool calls. A
5966
6324
  context-compaction milestone also carries a teamKey, so teamKey alone is not
@@ -6013,7 +6371,7 @@ function FileTypeIcon(props) {
6013
6371
  key: child.id,
6014
6372
  title: canSwitch ? t("role.switchTitle") : child.title,
6015
6373
  disabled: !canSwitch,
6016
- onClick: canSwitch ? () => openSessionById(child.id) : undefined,
6374
+ onClick: canSwitch ? () => openSessionOverview(child.id) : undefined,
6017
6375
  },
6018
6376
  h("span", { className: "dsov-roleDot", "data-running": child.running ? "true" : "false" }),
6019
6377
  h("span", { className: "dsov-roleItemTitle" }, child.title)
@@ -6030,11 +6388,11 @@ function FileTypeIcon(props) {
6030
6388
  canSwitch
6031
6389
  ? h(
6032
6390
  "button",
6033
- { type: "button", className: "dsov-roleBtn", "data-link": "true", title: t("role.openParentTitle"), onClick: () => openSessionById(role.parentId) },
6391
+ { type: "button", className: "dsov-roleBtn", "data-link": "true", title: t("role.openParentTitle"), onClick: () => openSessionOverview(role.parentId) },
6034
6392
  t("role.openParent"),
6035
6393
  h("span", { className: "dsov-roleParent" }, parentName)
6036
6394
  )
6037
- : h("span", { className: "dsov-roleHint" }, t("role.openParent"), h("span", { className: "dsov-roleParent" }, parentName)),
6395
+ : h("span", { className: "dsov-roleHint" }, t("role.openParentHint"), h("span", { className: "dsov-roleParent" }, parentName)),
6038
6396
  children,
6039
6397
  list
6040
6398
  );
@@ -6070,7 +6428,44 @@ function FileTypeIcon(props) {
6070
6428
  host loader silently no-ops (another loader holds the lock) or a page fails. */
6071
6429
  const [historyRetry, setHistoryRetry] = React.useState(0);
6072
6430
  const historyStalled = useBackfill(binding, summary.hasMore, historyRetry);
6073
- const goal = useGoal(binding);
6431
+ /* A freshly opened conversation can mount this surface before the host installs
6432
+ its event window: the window then reads empty WITH hasMore=false, so the
6433
+ backfill loop has nothing to do and the panel stays blank even though the
6434
+ session already has content. Ask for the first page once in that situation. */
6435
+ const hydratedFor = React.useRef(null);
6436
+ React.useEffect(() => {
6437
+ const id = binding === null || binding === undefined ? undefined : binding.sessionId;
6438
+ if (typeof id !== "string" || id === "" || hydratedFor.current === id) return;
6439
+ const source = binding.eventSource;
6440
+ let window = null;
6441
+ try {
6442
+ window = source !== null && source !== undefined && typeof source.getSnapshot === "function" ? source.getSnapshot() : null;
6443
+ } catch (_unavailable) {
6444
+ window = null;
6445
+ }
6446
+ const size = isRecord(window) && Array.isArray(window.entries) ? window.entries.length : 0;
6447
+ if (size > 0) {
6448
+ hydratedFor.current = id;
6449
+ return;
6450
+ }
6451
+ const row = sessionListRow(id);
6452
+ if (row === null || row.blank === true) return;
6453
+ hydratedFor.current = id;
6454
+ const session = binding.session;
6455
+ if (session === null || session === undefined) return;
6456
+ const load = async () => {
6457
+ try {
6458
+ if (typeof session.loadThrough === "function") await session.loadThrough(0);
6459
+ else if (typeof session.loadOlder === "function") await session.loadOlder();
6460
+ } catch (_pageFailed) {
6461
+ /* the 「重新加载」 escape hatch covers a permanent failure */
6462
+ }
6463
+ };
6464
+ load();
6465
+ }, [binding, summary]);
6466
+ const goalProjection = useGoal(binding);
6467
+ /* the live projection wins; a historical session falls back to the folded goal */
6468
+ const goal = React.useMemo(() => mergeGoalState(summary.goal, goalProjection), [summary.goal, goalProjection]);
6074
6469
  const stats = useArtifactStats(binding, summary.changeTurns);
6075
6470
  const cfg = useConfig();
6076
6471
  const sessionId = binding ? binding.sessionId : undefined;
@@ -6402,9 +6797,58 @@ function FileTypeIcon(props) {
6402
6797
  }
6403
6798
  }
6404
6799
 
6800
+ /** The host's reactive session roster (`sessions.list`), or null when unavailable. */
6801
+ function sessionListStore() {
6802
+ try {
6803
+ const ctx = runtime === null || runtime === undefined ? null : runtime.ctx;
6804
+ const sessions = ctx === null || ctx === undefined ? undefined : ctx.sessions;
6805
+ const list = sessions === undefined || sessions === null ? undefined : sessions.list;
6806
+ return list !== undefined && list !== null && typeof list.subscribe === "function" && typeof list.getSnapshot === "function" ? list : null;
6807
+ } catch (_unavailable) {
6808
+ return null;
6809
+ }
6810
+ }
6811
+
6812
+ /** One roster row for a session id (blank/running/origin/title), or null. */
6813
+ function sessionListRow(sessionId) {
6814
+ const list = sessionListStore();
6815
+ if (list === null || typeof sessionId !== "string" || sessionId === "") return null;
6816
+ try {
6817
+ const snapshot = list.getSnapshot();
6818
+ const byId = isRecord(snapshot) && isRecord(snapshot.byId) ? snapshot.byId : {};
6819
+ return isRecord(byId[sessionId]) ? byId[sessionId] : null;
6820
+ } catch (_unavailable) {
6821
+ return null;
6822
+ }
6823
+ }
6824
+
6825
+ /** Bind the surface to its session, re-resolving while the host materializes a
6826
+ just-opened conversation: a brand-new chat mounts the overview before the
6827
+ session scope exists, and without this the panel stuck on 「未选择会话」. */
6828
+ function useSessionBinding(sessionId) {
6829
+ const list = sessionListStore();
6830
+ const subscribe = React.useCallback((onChange) => (list === null ? () => {} : list.subscribe(onChange)), [list]);
6831
+ const readRoster = React.useCallback(() => (list === null ? null : list.getSnapshot()), [list]);
6832
+ React.useSyncExternalStore(subscribe, readRoster, readRoster);
6833
+ const binding = resolveBinding(sessionId);
6834
+ const [attempt, setAttempt] = React.useState(0);
6835
+ React.useEffect(() => {
6836
+ if (binding !== null || typeof sessionId !== "string" || sessionId === "") return undefined;
6837
+ if (typeof setInterval !== "function") return undefined;
6838
+ let tries = 0;
6839
+ const timer = setInterval(() => {
6840
+ tries += 1;
6841
+ if (resolveBinding(sessionId) !== null) setAttempt((value) => value + 1);
6842
+ if (tries >= 30) clearInterval(timer);
6843
+ }, 100);
6844
+ return () => clearInterval(timer);
6845
+ }, [binding, sessionId, attempt]);
6846
+ return binding;
6847
+ }
6848
+
6405
6849
  function OverviewSurface(props, variant) {
6406
6850
  const t = typeof props.t === "function" ? props.t : translate;
6407
- const binding = resolveBinding(props.sessionId);
6851
+ const binding = useSessionBinding(props.sessionId);
6408
6852
  if (!binding) {
6409
6853
  return h("div", { className: variant === "panel" ? "dsov-panel" : "dsov-view" }, h("div", { className: "dsov-empty" }, t("state.noSession")));
6410
6854
  }
@@ -6652,6 +7096,68 @@ function FileTypeIcon(props) {
6652
7096
  return blocks.length > 0 ? h("div", { className: "dsov-vikingFields" }, ...blocks) : null;
6653
7097
  }
6654
7098
 
7099
+ /** Chat parity: a document's inline file link opens the same sidebar file preview. */
7100
+ function openDocLinkedFile(sessionId, path) {
7101
+ if (typeof path !== "string" || path.trim() === "") return;
7102
+ openSessionFile(typeof sessionId === "string" ? sessionId : undefined, path.trim());
7103
+ }
7104
+
7105
+ /** Chat parity: external links in a document open in a new tab, like the chat's. */
7106
+ function openDocExternalLink(url) {
7107
+ if (typeof url !== "string" || !/^https?:/iu.test(url)) return;
7108
+ try {
7109
+ if (typeof window !== "undefined" && typeof window.open === "function") window.open(url, "_blank", "noopener,noreferrer");
7110
+ } catch (_popupBlocked) {
7111
+ /* a blocked popup must not break rendering */
7112
+ }
7113
+ }
7114
+
7115
+ /** Chat parity: inline `path` code resolves to a file chip when the turn produced it.
7116
+ Mirrors the host resolver: an exact path or a unique basename match, never a guess
7117
+ between two same-named files. */
7118
+ function docFileMentionResolver(entry, sessionId, translator) {
7119
+ const files = isRecord(entry) && Array.isArray(entry.files) ? entry.files.filter((file) => typeof file === "string" && file !== "") : [];
7120
+ if (files.length === 0) return undefined;
7121
+ const t = typeof translator === "function" ? translator : translate;
7122
+ const baseName = (path) => path.replace(/\\/g, "/").split("/").pop();
7123
+ return {
7124
+ resolve: (value) => {
7125
+ const text = String(typeof value === "string" ? value : "").trim().replace(/\\/g, "/").replace(/^\.\//, "");
7126
+ if (text === "") return undefined;
7127
+ const matches = new Set();
7128
+ for (const file of files) if (file === text || baseName(file) === text) matches.add(file);
7129
+ if (matches.size !== 1) return undefined;
7130
+ const path = matches.values().next().value;
7131
+ return {
7132
+ title: path,
7133
+ label: t("doc.openFile", { name: baseName(path) }),
7134
+ open: () => {
7135
+ if (typeof sessionId === "string" && sessionId !== "") openSessionFile(sessionId, path);
7136
+ },
7137
+ };
7138
+ },
7139
+ };
7140
+ }
7141
+
7142
+ /** Chat parity: local image destinations resolve through the host file API. */
7143
+ function docPathImages() {
7144
+ if (typeof document === "undefined" || typeof document.baseURI !== "string") return undefined;
7145
+ const base = document.baseURI;
7146
+ if (!/^https?:/u.test(base) && !base.startsWith("dsh-app://app/")) return undefined;
7147
+ return {
7148
+ resolve: (value) => {
7149
+ try {
7150
+ const path = decodeURIComponent(String(typeof value === "string" ? value : "").split(/[?#]/u)[0] ?? "");
7151
+ if (path === "" || !isAbsoluteWorkspacePath(path) || /^[/\\]{2}/u.test(path)) return undefined;
7152
+ if (/[\u0000-\u001f\u007f]/u.test(path)) return undefined;
7153
+ return new URL(`api/file?path=${encodeURIComponent(path)}`, base).href;
7154
+ } catch (_bad) {
7155
+ return undefined;
7156
+ }
7157
+ },
7158
+ };
7159
+ }
7160
+
6655
7161
  /** Sidebar tab body previewing one captured context injection snapshot as markdown. */
6656
7162
  function ContextPreviewBody(props) {
6657
7163
  const t = typeof props.t === "function" ? props.t : translate;
@@ -6662,19 +7168,37 @@ function FileTypeIcon(props) {
6662
7168
  return h("div", { className: "dsov-viking" }, h("div", { className: "dsov-vikingError" }, t("context.unavailable")));
6663
7169
  }
6664
7170
  if (entry.kind === "conclusion") {
6665
- /* Just the turn's closing answer, rendered exactly as the chat renders it;
6666
- the page geometry mirrors the host's own Markdown document page so the
6667
- fallback tab reads the same as the banner-opened one. */
7171
+ /* Just the turn's closing answer, rendered through the chat's own Markdown
7172
+ component inside the chat's own typographic wrapper and delegate provider,
7173
+ so the document page reads exactly like the message on the left: same font
7174
+ ladder, same line height, same image resolution, same file chips and same
7175
+ external links. */
7176
+ const sessionSeparator = typeof key === "string" ? key.indexOf("|") : -1;
7177
+ const docSessionId = sessionSeparator > 0 ? key.slice(0, sessionSeparator) : undefined;
7178
+ const docFileMentions = docFileMentionResolver(entry, docSessionId, t);
7179
+ const docImages = docPathImages();
7180
+ const markdown = ChatMarkdown
7181
+ ? h(ChatMarkdown, {
7182
+ text: entry.text,
7183
+ labels: chatMarkdownLabels(t),
7184
+ ...(docFileMentions === undefined ? {} : { fileMentions: docFileMentions }),
7185
+ ...(docImages === undefined ? {} : { pathImages: docImages }),
7186
+ })
7187
+ : h(MarkdownBody, { text: entry.text, onOpenFile: (path) => openDocLinkedFile(docSessionId, path) });
7188
+ /* the chat's own link delegates: inline file links become sidebar chips,
7189
+ external links open in a new tab */
7190
+ const content =
7191
+ ChatMarkdown !== null && ChatMarkdownProvider !== null
7192
+ ? h(ChatMarkdownProvider, {
7193
+ openFile: (path) => openDocLinkedFile(docSessionId, path),
7194
+ openExternalLink: openDocExternalLink,
7195
+ children: markdown,
7196
+ })
7197
+ : markdown;
6668
7198
  return h(
6669
7199
  "section",
6670
7200
  { className: "dsov-docPage", "data-doc-preview": "conclusion", "aria-label": entry.title },
6671
- h(
6672
- "div",
6673
- { className: "dsov-docDocument" },
6674
- ChatMarkdown
6675
- ? h(ChatMarkdown, { text: entry.text, labels: chatMarkdownLabels(t) })
6676
- : h(MarkdownBody, { text: entry.text })
6677
- )
7201
+ h("div", { className: "dsov-docDocument" }, h("div", { className: "dsov-docChat" }, content))
6678
7202
  );
6679
7203
  }
6680
7204
  return h(
@@ -7090,9 +7614,29 @@ function FileTypeIcon(props) {
7090
7614
  .dsov-secBody{max-height:320px;overflow-y:auto;overflow-x:hidden;margin:0 -12px 0 -8px;padding:0 12px 0 8px}
7091
7615
  .dsov-section[data-full=true] .dsov-secBody{max-height:none;overflow:visible;margin:0;padding:0}
7092
7616
  .dsov-loading{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-secondary)}
7093
- .dsov-goal{display:flex;align-items:flex-start;gap:8px;font-size:13px;color:var(--dsw-alias-label-primary);padding:2px}
7094
- .dsov-goalIcon{flex:none;display:inline-flex;margin-top:2px;color:var(--dsw-alias-brand-primary)}
7095
- .dsov-goalObjective{flex:1;min-width:0;line-height:1.55;overflow-wrap:anywhere}
7617
+ .dsov-goal{display:flex;flex-direction:column;gap:3px;box-sizing:border-box;border:.5px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 32%,transparent);border-radius:10px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 6%,transparent);padding:7px 9px 8px;color:var(--dsw-alias-label-primary)}
7618
+ .dsov-goal[data-phase=paused]{border-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#ff9f0a) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#ff9f0a) 7%,transparent)}
7619
+ .dsov-goal[data-phase=blocked]{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary,#ff453a) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#ff453a) 7%,transparent)}
7620
+ .dsov-goal[data-phase=complete]{border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary,#2ea36a) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#2ea36a) 7%,transparent)}
7621
+ .dsov-goalHead{display:flex;align-items:center;gap:5px;min-width:0}
7622
+ .dsov-goalIcon{flex:none;display:inline-flex;color:var(--dsw-alias-brand-primary)}
7623
+ .dsov-goal[data-phase=paused] .dsov-goalIcon{color:var(--dsw-alias-state-warn-primary,#ff9f0a)}
7624
+ .dsov-goal[data-phase=blocked] .dsov-goalIcon{color:var(--dsw-alias-state-error-primary,#ff453a)}
7625
+ .dsov-goal[data-phase=complete] .dsov-goalIcon{color:var(--dsw-alias-state-success-primary,#2ea36a)}
7626
+ .dsov-goalMode{flex:none;font-size:10.5px;line-height:16px;font-weight:600;color:var(--dsw-alias-brand-primary)}
7627
+ .dsov-goal[data-phase=paused] .dsov-goalMode{color:var(--dsw-alias-state-warn-primary,#ff9f0a)}
7628
+ .dsov-goal[data-phase=blocked] .dsov-goalMode{color:var(--dsw-alias-state-error-primary,#ff453a)}
7629
+ .dsov-goal[data-phase=complete] .dsov-goalMode{color:var(--dsw-alias-state-success-primary,#2ea36a)}
7630
+ .dsov-goalPhase{flex:none;font-size:9.5px;line-height:14px;padding:0 5px;border-radius:4px;border:.5px solid transparent}
7631
+ .dsov-goalPhase[data-tone=brand]{color:var(--dsw-alias-brand-primary);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 45%,transparent)}
7632
+ .dsov-goalPhase[data-tone=warn]{color:var(--dsw-alias-state-warn-primary,#ff9f0a);border-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#ff9f0a) 45%,transparent)}
7633
+ .dsov-goalPhase[data-tone=error]{color:var(--dsw-alias-state-error-primary,#ff453a);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary,#ff453a) 45%,transparent)}
7634
+ .dsov-goalPhase[data-tone=success]{color:var(--dsw-alias-state-success-primary,#2ea36a);border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary,#2ea36a) 45%,transparent)}
7635
+ .dsov-goalFacts{margin-left:auto;flex:none;font-size:9.5px;line-height:14px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;font-variant-numeric:tabular-nums}
7636
+ .dsov-goalObjective{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-primary);overflow-wrap:anywhere}
7637
+ .dsov-goalMeta{font-size:10px;line-height:1.45;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}
7638
+ .dsov-goalMeta[data-phase=blocked]{color:var(--dsw-alias-state-error-primary,#ff453a)}
7639
+ .dsov-goalMeta[data-phase=complete]{color:var(--dsw-alias-state-success-primary,#2ea36a)}
7096
7640
  .dsov-badge{flex:none;font-size:10.5px;line-height:17px;padding:0 7px;border-radius:999px;border:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap}
7097
7641
  .dsov-badge[data-tone=brand]{color:var(--dsw-alias-brand-primary);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 45%,transparent)}
7098
7642
  .dsov-badge[data-tone=success]{color:var(--dsw-alias-state-success-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 45%,transparent)}
@@ -7243,6 +7787,7 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
7243
7787
  .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}
7244
7788
  .dsov-docPage{box-sizing:border-box;height:100%;padding:20px 24px 40px;position:relative;overflow:auto}
7245
7789
  .dsov-docDocument{min-width:0;color:var(--dsw-alias-label-primary);overflow-wrap:anywhere;font-size:14px;line-height:1.75}
7790
+ .dsov-docChat{display:flex;flex-direction:column;font-size:var(--dsh-content-font-size,14px);line-height:calc(24px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-primary)}
7246
7791
  .dsov-docTitleIcon{flex:none}
7247
7792
  @media (width<=767px){.dsov-docPage{padding:16px 18px 32px}}
7248
7793
  .dsov-vikingHead{display:flex;align-items:flex-start;gap:8px}
@@ -7414,6 +7959,7 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
7414
7959
  .dsov-md-h{font-size:14px;font-weight:600;margin:14px 0 4px;color:var(--dsw-alias-label-primary)}
7415
7960
  .dsov-md p{margin:4px 0}
7416
7961
  .dsov-md-list{margin:4px 0;padding-left:20px}
7962
+ .dsov-md-fileLink{color:var(--dsw-alias-link,var(--dsw-alias-brand-primary));cursor:pointer;text-decoration:none;border-bottom:.5px dotted currentColor}
7417
7963
  .dsov-md-code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;background:var(--dsw-alias-bg-layer-2);border-radius:4px;padding:1px 4px}
7418
7964
  .dsov-md-pre{background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l1);border-radius:8px;padding:10px 12px;overflow-x:auto;margin:6px 0}
7419
7965
  .dsov-md-pre code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.5;background:none;padding:0}
@@ -7443,6 +7989,7 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
7443
7989
 
7444
7990
  function apply(ctx) {
7445
7991
  runtime = { ctx, t: ctx.locale.bind(NS) };
7992
+ mountDesktopRemote(ctx);
7446
7993
 
7447
7994
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "conversation-overview: dictionaries");
7448
7995
 
@@ -7804,6 +8351,10 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
7804
8351
  foldEntry,
7805
8352
  buildState,
7806
8353
  planTodoAttachments,
8354
+ mergeGoalState,
8355
+ planTokens,
8356
+ planActiveAt,
8357
+ recentStepWindow,
7807
8358
  vikingLabel,
7808
8359
  vikingRefKind,
7809
8360
  vikingIsDir,
@@ -7839,19 +8390,31 @@ html[data-dsov-quicknav="off"] nav:has(button[data-index][aria-label]),html[data
7839
8390
  ContextPreviewBody,
7840
8391
  SettingsForm,
7841
8392
  chatMarkdown: ChatMarkdown,
8393
+ chatMarkdownProvider: ChatMarkdownProvider,
8394
+ openDocLinkedFile,
8395
+ openDocExternalLink,
7842
8396
  chatMarkdownLabels,
7843
8397
  StatsBar,
7844
8398
  ArtifactFileMenu,
7845
8399
  OverviewGlyph,
7846
8400
  OverviewContent,
8401
+ OverviewSurface,
8402
+ SessionRoleBar,
7847
8403
  MarkdownBody,
7848
8404
  mdToHtml,
7849
8405
  Section,
7850
8406
  sectionIntroProps,
7851
- SessionRoleBar,
7852
8407
  sessionRoleOf,
7853
8408
  canNavigateSession,
7854
8409
  openSessionById,
8410
+ openSessionOverview,
8411
+ hostService,
8412
+ hostSessionRemote,
8413
+ mountDesktopRemote,
8414
+ desktopCanOpen,
8415
+ sessionListRow,
8416
+ sessionListStore,
8417
+ preferSessionOverview,
7855
8418
  planTitleOf,
7856
8419
  openNativePlan,
7857
8420
  documentAddress,