dsh-activity-pane 0.1.0 → 0.2.2

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.
@@ -1174,13 +1174,15 @@ function escapeCssString(value) {
1174
1174
  * windowComplete(R-01-009/AC-06、R-01-012/AC-12 冷窗口兜底):快照已就绪但窗口缺
1175
1175
  * 锚点数据(开放回合起点或可锚用户行在窗口外)时为 false——此时仍发起一次 history
1176
1176
  * 补读,供进度锚点与指令锚行兜底。previewFallbackNeeded 表示最近卡的快照预览
1177
- * 不完整,同样补读一次 history(R-01-013/AC-03、AC-04)。 */
1177
+ * 不完整,同样补读一次 history(R-01-013/AC-03、AC-04);durationFallbackNeeded 表示等待卡
1178
+ * 需要在已加载的旧 history 之后再取一次最新回合边界(R-01-009/AC-12)。 */
1178
1179
  function detailLoadPlan({
1179
1180
  detail = {},
1180
1181
  isSubagent = false,
1181
1182
  snapshotReady = false,
1182
1183
  historyNeeded = false,
1183
1184
  previewFallbackNeeded = false,
1185
+ durationFallbackNeeded = false,
1184
1186
  windowComplete = true,
1185
1187
  modelInflight = false,
1186
1188
  historyInflight = false,
@@ -1190,7 +1192,8 @@ function detailLoadPlan({
1190
1192
  model: !isSubagent && !detail.model && !modelInflight,
1191
1193
  history:
1192
1194
  !historyInflight &&
1193
- ((previewFallbackNeeded && detail.previewFallbackLoaded !== true) ||
1195
+ ((durationFallbackNeeded && detail.durationFallbackLoaded !== true) ||
1196
+ (previewFallbackNeeded && detail.previewFallbackLoaded !== true) ||
1194
1197
  (!detail.history && ((!snapshotReady && historyNeeded) || (snapshotReady === true && windowComplete === false)))),
1195
1198
  };
1196
1199
  }
@@ -1740,7 +1743,7 @@ function movedToActiveIds(prevRecentIds, active, recent) {
1740
1743
  function lastTurnEndFromEvents(events) {
1741
1744
  const list = Array.isArray(events) ? events : [];
1742
1745
  for (let i = list.length - 1; i >= 0; i -= 1) {
1743
- const event = list[i]?.event;
1746
+ const event = eventOf(list[i]);
1744
1747
  if (event?.type !== "turn/end") continue;
1745
1748
  const time = Number(event.time);
1746
1749
  if (Number.isFinite(time)) return time;
@@ -1759,6 +1762,67 @@ function lastTurnEndFromTimings(turnTimings) {
1759
1762
  return last;
1760
1763
  }
1761
1764
 
1765
+ /** 回合边界时间归一:缺失、空字符串、非数值或非有限值均不可作为耗时端点。 */
1766
+ function durationTime(value) {
1767
+ if (value == null || (typeof value === "string" && value.trim() === "") || (typeof value !== "number" && typeof value !== "string")) return null;
1768
+ const time = Number(value);
1769
+ return Number.isFinite(time) ? time : null;
1770
+ }
1771
+
1772
+ /** 从 history 提取最近完整回合的结束时刻与固定耗时;两者始终来自同一回合。 */
1773
+ function lastTurnDurationCandidateFromEvents(events) {
1774
+ const starts = new Map();
1775
+ let latest = null;
1776
+ for (const entry of Array.isArray(events) ? events : []) {
1777
+ const event = eventOf(entry);
1778
+ const turn = Number(event?.data?.turn);
1779
+ const time = durationTime(event?.time);
1780
+ if (!Number.isFinite(turn) || time === null) continue;
1781
+ if (event.type === "turn/start") {
1782
+ starts.set(turn, time);
1783
+ continue;
1784
+ }
1785
+ if (event.type !== "turn/end") continue;
1786
+ const start = starts.get(turn);
1787
+ if (start === undefined || time < start) continue;
1788
+ if (latest === null || time > latest.end) latest = { end: time, duration: time - start };
1789
+ }
1790
+ return latest;
1791
+ }
1792
+
1793
+ /** 从 turnTimings 提取最近完整回合的结束时刻与固定耗时;两者始终来自同一回合。 */
1794
+ function lastTurnDurationCandidateFromTimings(turnTimings) {
1795
+ if (!(turnTimings instanceof Map)) return null;
1796
+ let latest = null;
1797
+ for (const timing of turnTimings.values()) {
1798
+ const start = durationTime(timing?.startTime);
1799
+ const end = durationTime(timing?.endTime);
1800
+ if (start === null || end === null || end < start) continue;
1801
+ if (latest === null || end > latest.end) latest = { end, duration: end - start };
1802
+ }
1803
+ return latest;
1804
+ }
1805
+
1806
+ /** 从 history 提取最近完整回合的固定耗时;回合起止不完整或逆序时忽略该回合。 */
1807
+ function lastTurnDurationFromEvents(events) {
1808
+ return lastTurnDurationCandidateFromEvents(events)?.duration ?? null;
1809
+ }
1810
+
1811
+ /** 从 turnTimings 提取最近完整回合的固定耗时;全部回合未结束或无效时返回 null。 */
1812
+ function lastTurnDurationFromTimings(turnTimings) {
1813
+ return lastTurnDurationCandidateFromTimings(turnTimings)?.duration ?? null;
1814
+ }
1815
+
1816
+ /** 在快照与 history 中按最近结束时刻选择同一最新完整回合的固定耗时。 */
1817
+ function lastTurnDuration({ turnTimings = null, history = [] } = {}) {
1818
+ const candidates = [lastTurnDurationCandidateFromTimings(turnTimings), lastTurnDurationCandidateFromEvents(history)].filter(Boolean);
1819
+ let latest = null;
1820
+ for (const candidate of candidates) {
1821
+ if (latest === null || candidate.end > latest.end) latest = candidate;
1822
+ }
1823
+ return latest?.duration ?? null;
1824
+ }
1825
+
1762
1826
  /**
1763
1827
  * 构建最近历史区条目:当前非活动、且在历史窗口内最后一次活动过的**主会话**
1764
1828
  * (子代理是临时工作单元,不入最近历史;故需同时排除表白会话与已结束子代理),
@@ -2516,6 +2580,14 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
2516
2580
  display: flex; flex-direction: column; align-items: flex-start; gap: 4px;
2517
2581
  min-width: 0; margin: 2px 0 0;
2518
2582
  }
2583
+ /* 等待类型胶囊与上一轮耗时同行:时长沿用运行统计行的右缘位置,不参与等待脉冲。 */
2584
+ [data-dsh-activity-pane] .dap-await-head {
2585
+ display: flex; align-items: center; gap: 8px; align-self: stretch; min-width: 0;
2586
+ }
2587
+ [data-dsh-activity-pane] .dap-await-head .dap-token-time {
2588
+ margin-left: auto; flex: none; font-size: 10px; line-height: 14px;
2589
+ color: #8f9aaa; font-variant-numeric: tabular-nums;
2590
+ }
2519
2591
  [data-dsh-activity-pane] .dap-note-row {
2520
2592
  display: flex; align-items: center; gap: 6px; min-width: 0; align-self: stretch;
2521
2593
  }
@@ -2727,7 +2799,8 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
2727
2799
  display: flex; align-items: center; justify-content: center; gap: 6px;
2728
2800
  }
2729
2801
  /* 加载指示:列表/卡片字段在途时的活动图标(R-01-014)。 */
2730
- [data-dsh-activity-pane] .dap-spinner {
2802
+ [data-dsh-activity-pane] .dap-spinner,
2803
+ .dap-toggle .dap-toggle-count .dap-spinner {
2731
2804
  width: 10px; height: 10px; flex: none; border-radius: 50%;
2732
2805
  border: 2px solid color-mix(in srgb, currentColor 25%, transparent);
2733
2806
  border-top-color: color-mix(in srgb, currentColor 85%, transparent);
@@ -3332,7 +3405,7 @@ function apply(ctx) {
3332
3405
  syncFromDirectory(); // 目录已被主窗口加载时立即同步,该会话免发一次性 RPC
3333
3406
  }
3334
3407
 
3335
- function loadNativeDetails(ids, previewFallbackIds = new Set()) {
3408
+ function loadNativeDetails(ids, previewFallbackIds = new Set(), durationFallbackIds = new Set()) {
3336
3409
  const api = ctx.get("connection")?.api?.sessions;
3337
3410
  if (!api) return;
3338
3411
  const byId = getSnapshot(sessions, "list")?.byId ?? {};
@@ -3358,12 +3431,14 @@ function apply(ctx) {
3358
3431
  liveStartTime: livenessById.get(id)?.liveness?.startTime ?? null,
3359
3432
  });
3360
3433
  const windowComplete = snapshotReady !== true || (!turnStartMissing && detail.snapshotHasAnchorableUserRow === true);
3434
+ const durationFallbackNeeded = durationFallbackIds.has(id) && detail.durationFallbackLoaded !== true;
3361
3435
  const plan = detailLoadPlan({
3362
3436
  detail,
3363
3437
  isSubagent: subagent,
3364
3438
  snapshotReady,
3365
3439
  historyNeeded: needsHistorySnapshot(detail.snapshot),
3366
3440
  previewFallbackNeeded: previewFallbackIds.has(id),
3441
+ durationFallbackNeeded,
3367
3442
  windowComplete,
3368
3443
  modelInflight: modelLoads.has(id),
3369
3444
  historyInflight: historyLoads.has(id) || sessionOpenLoads.has(id),
@@ -3396,6 +3471,7 @@ function apply(ctx) {
3396
3471
  modelPromises.push(promise);
3397
3472
  }
3398
3473
  if (plan.history && typeof api.history === "function") {
3474
+ if (durationFallbackNeeded) detail.durationFallbackLoaded = true;
3399
3475
  if (previewFallbackIds.has(id)) detail.previewFallbackLoaded = true;
3400
3476
  const promise = enqueueDetailLoad(() => Promise.resolve()
3401
3477
  .then(async () => {
@@ -3717,8 +3793,10 @@ function apply(ctx) {
3717
3793
  // 正文行容器:正文文本 + 「移入历史」按钮(仅完成提醒卡显示,R-01-002/AC-10)。
3718
3794
  const noteRow = makeEl("div", "dap-note-row");
3719
3795
  noteRow.append(makeEl("div", "dap-note"), makeConfirmButton());
3796
+ const awaitHead = makeEl("div", "dap-await-head");
3797
+ awaitHead.append(capsule, makeEl("span", "dap-token-time"));
3720
3798
  const foot = makeEl("div", "dap-foot");
3721
- foot.append(capsule, noteRow);
3799
+ foot.append(awaitHead, noteRow);
3722
3800
  return [head, row, makeEl("div", "dap-trace"), foot];
3723
3801
  }
3724
3802
  const row = makeEl("div", "dap-row");
@@ -4080,6 +4158,56 @@ function apply(ctx) {
4080
4158
  }
4081
4159
  }
4082
4160
 
4161
+ /** 统计行渲染:运行卡写入速率、token 与耗时,旧骨架缺节点时就地补齐。 */
4162
+ function renderTokenStats(el, entry) {
4163
+ let stats = el.querySelector(".dap-token-stats");
4164
+ if (stats === null) {
4165
+ stats = makeEl("div", "dap-token-stats");
4166
+ stats.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
4167
+ stats.hidden = true;
4168
+ el.append(stats);
4169
+ }
4170
+ let mainTextEl = stats.querySelector(".dap-token-main");
4171
+ let timeEl = stats.querySelector(".dap-token-time");
4172
+ if (mainTextEl === null || timeEl === null) {
4173
+ mainTextEl = makeEl("span", "dap-token-main");
4174
+ timeEl = makeEl("span", "dap-token-time");
4175
+ stats.replaceChildren(mainTextEl, timeEl);
4176
+ }
4177
+ const parts = [];
4178
+ if (Number.isFinite(entry.rateTokS) && entry.rateTokS > 0) parts.push(`${Math.round(entry.rateTokS)} tok/s`);
4179
+ if (Number.isFinite(entry.cacheHitPct)) parts.push(`缓存 ${entry.cacheHitPct}%`);
4180
+ if (Number.isFinite(entry.inputTokens) && entry.inputTokens >= 0) parts.push(`输入 ${fmtTokens(entry.inputTokens) ?? entry.inputTokens}`);
4181
+ if (Number.isFinite(entry.outputTokens) && entry.outputTokens >= 0) parts.push(`输出 ${fmtTokens(entry.outputTokens) ?? entry.outputTokens}`);
4182
+ const mainText = parts.join(" · ");
4183
+ const timeText = Number.isFinite(entry.elapsedMs) && entry.elapsedMs >= 0 ? fmtElapsedMs(entry.elapsedMs) : "";
4184
+ if (mainTextEl.textContent !== mainText) mainTextEl.textContent = mainText;
4185
+ if (timeEl.textContent !== timeText) timeEl.textContent = timeText;
4186
+ const statsHidden = mainText === "" && timeText === "";
4187
+ if (stats.hidden !== statsHidden) stats.hidden = statsHidden;
4188
+ }
4189
+
4190
+ /** 等待卡耗时写入:与等待类型胶囊同一行靠右,旧骨架缺该行时就地补齐。 */
4191
+ function renderAwaitingDuration(el, entry) {
4192
+ const foot = el.querySelector(".dap-foot");
4193
+ if (foot === null) return;
4194
+ let head = foot.querySelector(".dap-await-head");
4195
+ if (head === null) {
4196
+ const capsule = foot.querySelector(".dap-capsule");
4197
+ if (capsule === null) return;
4198
+ head = makeEl("div", "dap-await-head");
4199
+ capsule.replaceWith(head);
4200
+ head.append(capsule);
4201
+ }
4202
+ let time = head.querySelector(".dap-token-time");
4203
+ if (time === null) {
4204
+ time = makeEl("span", "dap-token-time");
4205
+ head.append(time);
4206
+ }
4207
+ const text = Number.isFinite(entry.elapsedMs) && entry.elapsedMs >= 0 ? fmtElapsedMs(entry.elapsedMs) : "";
4208
+ if (time.textContent !== text) time.textContent = text;
4209
+ }
4210
+
4083
4211
  /** 时间线区加载指示:数据在途且尚无工作项时显示活动图标行(R-01-014/AC-02)。 */
4084
4212
  function renderTraceLoading(container) {
4085
4213
  if (container.dataset.loading === "true") return;
@@ -4185,28 +4313,7 @@ function apply(ctx) {
4185
4313
  const width = `${Math.min(100, Math.max(0, entry.progress ?? 0))}%`;
4186
4314
  if (fill.style.width !== width) fill.style.width = width;
4187
4315
  }
4188
- const stats = el.querySelector(".dap-token-stats");
4189
- if (stats !== null) {
4190
- const parts = [];
4191
- if (Number.isFinite(entry.rateTokS) && entry.rateTokS > 0) parts.push(`${Math.round(entry.rateTokS)} tok/s`);
4192
- if (Number.isFinite(entry.cacheHitPct)) parts.push(`缓存 ${entry.cacheHitPct}%`);
4193
- if (Number.isFinite(entry.inputTokens) && entry.inputTokens >= 0) parts.push(`输入 ${fmtTokens(entry.inputTokens) ?? entry.inputTokens}`);
4194
- if (Number.isFinite(entry.outputTokens) && entry.outputTokens >= 0) parts.push(`输出 ${fmtTokens(entry.outputTokens) ?? entry.outputTokens}`);
4195
- const mainText = parts.join(" · ");
4196
- const timeText = Number.isFinite(entry.elapsedMs) && entry.elapsedMs >= 0 ? fmtElapsedMs(entry.elapsedMs) : "";
4197
- let mainTextEl = stats.querySelector(".dap-token-main");
4198
- let timeEl = stats.querySelector(".dap-token-time");
4199
- if (mainTextEl === null || timeEl === null) {
4200
- // 热装残留的旧版单文本段骨架:就地重建双段结构再写值。
4201
- mainTextEl = makeEl("span", "dap-token-main");
4202
- timeEl = makeEl("span", "dap-token-time");
4203
- stats.replaceChildren(mainTextEl, timeEl);
4204
- }
4205
- if (mainTextEl.textContent !== mainText) mainTextEl.textContent = mainText;
4206
- if (timeEl.textContent !== timeText) timeEl.textContent = timeText;
4207
- const statsHidden = mainText === "" && timeText === "";
4208
- if (stats.hidden !== statsHidden) stats.hidden = statsHidden;
4209
- }
4316
+ renderTokenStats(el, entry);
4210
4317
  return;
4211
4318
  }
4212
4319
 
@@ -4238,6 +4345,7 @@ function apply(ctx) {
4238
4345
  if (entry.kind === "awaiting") {
4239
4346
  const traceContainer = el.querySelector(".dap-trace");
4240
4347
  if (traceContainer !== null) renderTimelineArea(traceContainer, entry);
4348
+ renderAwaitingDuration(el, entry);
4241
4349
  const confirm = el.querySelector(".dap-confirm");
4242
4350
  if (confirm !== null) {
4243
4351
  // 激活锚点:只在结构重建时绑一次(卡片按 id 复用,kind 变化会重建骨架)。
@@ -4790,6 +4898,7 @@ function apply(ctx) {
4790
4898
  const liveRecord = livenessById.get(entry.id);
4791
4899
  const live = liveRecord?.liveness ?? null;
4792
4900
  const detail = sessionDetailsById.get(entry.id);
4901
+ if (entry.kind === "running" && detail) detail.durationFallbackLoaded = false;
4793
4902
  const detailSnapshot = liveRecord?.snapshot ?? detail?.snapshot ?? null;
4794
4903
  if (detail && detail.memoHistoryAnchorOf !== (detail.history ?? null)) {
4795
4904
  detail.memoHistoryAnchorOf = detail.history ?? null;
@@ -4834,6 +4943,15 @@ function apply(ctx) {
4834
4943
  detail.memoOpenTurnStart = openTurnStartFromEvents(detail.history, hint);
4835
4944
  }
4836
4945
  }
4946
+ if (entry.kind === "awaiting" && detail) {
4947
+ const history = detail.history ?? null;
4948
+ if (detail.memoTurnDurationSnapshotOf !== detailSnapshot || detail.memoTurnDurationHistoryOf !== history) {
4949
+ detail.memoTurnDurationSnapshotOf = detailSnapshot;
4950
+ detail.memoTurnDurationHistoryOf = history;
4951
+ detail.memoTurnDuration = lastTurnDuration({ turnTimings: detailSnapshot?.turnTimings, history });
4952
+ }
4953
+ entry.elapsedMs = detail.memoTurnDuration ?? null;
4954
+ }
4837
4955
  if (detail?.model) {
4838
4956
  entry.model = detail.model.model;
4839
4957
  entry.reasoning = detail.model.reasoning;
@@ -4916,9 +5034,10 @@ function apply(ctx) {
4916
5034
  entry.loadingPreviews = (!entry.userPreview || !entry.agentPreview) && historyLoads.has(entry.id);
4917
5035
  }
4918
5036
  // 补充数据读取优先级:当前会话最优先,活动区先于历史区(区内按显示顺序)。
5037
+ const durationFallbackIds = new Set(active.filter((entry) => entry.kind === "awaiting").map((entry) => entry.id));
4919
5038
  const detailIds = [...active, ...recent].map((entry) => entry.id);
4920
5039
  detailIds.sort((a, b) => Number(String(b) === String(snapshot?.current)) - Number(String(a) === String(snapshot?.current)));
4921
- loadNativeDetails(detailIds, previewFallbackIds);
5040
+ loadNativeDetails(detailIds, previewFallbackIds, durationFallbackIds);
4922
5041
  const visibleIds = new Set([...active, ...recent].map((entry) => entry.id));
4923
5042
  // 详情与 loads 记账同生命周期:离开可见集合即放行,重回可见时允许重拉/重试。
4924
5043
  // 锚点记账不随可见性 prune:瞬时 loading 空帧不得误清(进度重置);陈旧条目靠
package/README.md CHANGED
@@ -1,41 +1,62 @@
1
1
  # dsh-activity-pane
2
2
 
3
- DSH (DeepSeek Harness) 一大痛点是缺少活动会话与历史会话的管理,重度用户在同时运行跨越多个工作区的多个会话时,无法一目了然的掌控全局,尤其当 DSH 原生左边栏工作区的会话积累过多之后,活动会话的信息过于分散,无法解答以下问题:
4
- - 现在有多少个会话在并行跑?
5
- - 哪些会话启动了子代理甚至孙代理会话?它们有多少?
6
- - 每个会话现在正在做什么?它们的进度如何?跑了多长时间?
7
- - 每个会话使用什么模型?什么推理级别?输出速率、缓存命中率和 token 使用情况如何?
8
- - 哪些会话的 agent 轮次最近刚结束,需要我行动?
9
- - 过去一段时间我在哪些会话里交互过?最近的指令与结论是什么?
3
+ English | [简体中文](README.zh-CN.md)
4
+
5
+ One of the pain points of DSH (DeepSeek Harness) is the lack of management for active and historical sessions. Heavy users who run multiple sessions across multiple workspaces at the same time have no way to take in the whole picture at a glance. In particular, once sessions pile up in DSH's native left-sidebar workspaces, the information about active sessions becomes so scattered that it can no longer answer questions like:
6
+ - How many sessions are running in parallel right now?
7
+ - Which sessions have spawned sub-agents or even grandchild sub-agents, and how many are there?
8
+ - What is each session doing right now? What is its progress? How long has it been running?
9
+ - Which model and reasoning level does each session use? What are its output rate, cache hit rate, and token usage?
10
+ - Which sessions have just finished an agent round and are waiting for my action?
11
+ - Which sessions have I interacted with recently? What were the latest instructions and conclusions?
10
12
  - ...
11
13
 
12
- 本插件试图解决这些问题,提供了一个**活动会话总览窗格**:将正在运行的会话、子会话、轮次完成后等待行动的会话、近期活跃过的历史会话,集中在一个窗格内进行整体展示。
14
+ This plugin attempts to answer these questions by providing an **activity session overview pane**: running sessions, sub-sessions, sessions waiting for action after finishing a round, and recently active past sessions are brought together and presented as a whole in a single pane.
13
15
 
14
16
  <p align="center">
15
17
  <picture>
16
18
  <source media="(prefers-color-scheme: dark)" srcset="assets/screenshot-desktop-dark.png">
17
- <img src="assets/screenshot-desktop-light.png" width="1000" alt="隔离演示环境中的活动会话总览窗格:展示运行统计、子代理层级、提问等待、完成提醒、错误提醒和最近历史">
19
+ <img src="assets/screenshot-desktop-light.png" width="1000" alt="Activity session overview pane in an isolated demo environment: showing run stats, sub-agent hierarchy, question prompts, completion reminders, error reminders, and recent history">
18
20
  </picture>
19
21
  </p>
20
- <p align="center"><sub>同一干净隔离环境中模拟编程任务的 <a href="assets/screenshot-mobile-dark.png">移动端深色抽屉</a> · <a href="assets/screenshot-mobile-light.png">移动端浅色抽屉</a></sub></p>
21
-
22
- ## 感谢与声明
23
-
24
- - 本项目灵感来自 [`dsh-answer-pet`](https://github.com/Nanki-nn/dsh-answer-pet) 插件,借鉴了其中会话卡片的设计思路,并按自己的使用习惯与喜好做了调整与重新实现,感谢原作者的创意!
25
- - 折叠时间线的分组语义改编自 MIT 许可的 [`dsh-auto-collapse`](https://github.com/a179-sanae/dsh-auto-collapse) 插件(数据层移植,无运行时依赖),感谢原作者。
26
- - 时间线正文行与最近卡的 agent 角色机器人图标采用 ISC 许可的 [Lucide](https://lucide.dev) `bot` 图标几何,感谢 Lucide 贡献者。
27
- - 本项目代码与文档 99.99% 由 AI 编写与审核,大概率存在 bug 与文档/代码不同步等问题,使用中如遇到问题请提交 issue。
28
-
29
- ## 相比 dsh-answer-pet 的调整
30
-
31
- - [x] **从浮层改为固定窗格**:桌面端在左边栏工作区的右侧增加常驻贴边列;移动端使用默认隐藏的固定抽屉,通过会话头部的「活动」按钮展开,不挤压主会话布局。
32
- - [x] **去除宠物图标功能**:不支持宠物相关功能,界面聚焦于会话活动本身。
33
- - [x] **原生数据源订阅**:直接订阅 DSH 原生 `sessions` / `workspaces` 服务的推送式快照;时间线最多显示 4 个折叠工作项行,保留最近用户指令与真实执行中的工作项。
34
- - [x] **增加历史会话列表**:窗格分为「活动会话」和「最近历史」两个区域,非活动主会话在最近 24 小时内仍可快速找回。
35
- - [x] **强化等待行动提醒**:阻塞等待、完成提醒与错误提醒分别以金色、绿色和红色卡片标识;提问直接预览问题列表,完成提醒经卡片上的「移入历史」按钮显式确认;状态由宿主侧持久化并在所有客户端间同步,刷新页面或另开窗口不会丢失未确认的完成提醒和尚未被新回合覆盖的错误提醒。
36
- - [x] **显示子/孙会话层级**:子代理以连接线和紧凑卡片嵌套在母会话下;母会话自身回合结束但仍有活动后代时继续按运行中呈现,子代理结束且没有活动后代后从活动区消失;历史区只保留主会话。
37
- - [x] **显示工作区名称并参与排序**:会话卡片显示带稳定色彩的工作区徽标,会话排序与左侧边栏中的工作区顺序保持一致。
38
- - [x] **展示当前工作与运行统计**:活动卡以最多 4 行折叠时间线展示最近指令、思考与工具调用;运行中卡片还显示回合进度、输出速率、缓存命中率、输入/输出 token 与运行时长。
39
- - [x] **加入会话导航跳转**:点击或键盘激活会话卡片可跳转到对应会话页面,当前会话保持高亮。
40
- - [x] **增加会话元信息**:会话卡片中显示当前使用的模型名称和推理级别。
41
- - [x] **完善桌面与移动交互**:桌面窗格可折叠、拖拽调宽并记忆宽度;移动端使用不挤压主会话布局的固定抽屉;长列表提供独立滚动与回到顶部按钮。
22
+ <p align="center"><sub>Mobile <a href="assets/screenshot-mobile-dark.png">dark drawer</a> · <a href="assets/screenshot-mobile-light.png">light drawer</a> of the same clean isolated environment running a simulated coding task</sub></p>
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ dsh plugin --profile web add dsh-activity-pane
28
+ ```
29
+
30
+ The npm package ships prebuilt, so no local build step is needed. If the pane does not appear after installing, restart `dsh web` once.
31
+
32
+ ## Requirements
33
+
34
+ - DSH (DeepSeek Harness) web, tested against `@deepseek-ai/dsh@0.1.0-rc.7`.
35
+ - No third-party plugin dependencies: the pane only consumes DSH's native session and workspace services, and uninstalling is fully reversible.
36
+
37
+ ## Acknowledgments & Disclaimers
38
+
39
+ - This project was inspired by the [`dsh-answer-pet`](https://github.com/Nanki-nn/dsh-answer-pet) plugin: it borrows the session-card design idea, adjusted and re-implemented to fit my own usage habits and preferences. Many thanks to the original author for the creativity!
40
+ - The grouping semantics of the collapsed timeline are adapted from the MIT-licensed [`dsh-auto-collapse`](https://github.com/a179-sanae/dsh-auto-collapse) plugin (a data-layer port with no runtime dependency). Thanks to the original author.
41
+ - The agent-role bot icons on the timeline body rows and recent cards adopt the geometry of the ISC-licensed [Lucide](https://lucide.dev) `bot` icon. Thanks to the Lucide contributors.
42
+ - 99.99% of this project's code and documentation was written and reviewed by AI, so bugs and doc/code drift are quite likely. If you run into any problems, please open an issue.
43
+
44
+ ## Adjustments Compared to dsh-answer-pet
45
+
46
+ > This project began as a personal re-take on [`dsh-answer-pet`](https://github.com/Nanki-nn/dsh-answer-pet), so the checklist below is phrased as a comparison against it — if you have never used that plugin, simply read it as the feature list.
47
+
48
+ - [x] **From floating overlay to docked pane**: on desktop, a persistent edge-docked column is added to the right of the left-sidebar workspaces; on mobile, a fixed drawer hidden by default is expanded via the "Activity" button in the session header, without squeezing the main conversation layout.
49
+ - [x] **No pet icon features**: pet-related features are not supported; the UI focuses on session activity itself.
50
+ - [x] **Native data-source subscription**: directly subscribes to the push snapshots of DSH's native `sessions` / `workspaces` services; the timeline shows at most 4 collapsed work-item rows, keeping the latest user instruction and the work item actually being executed.
51
+ - [x] **Recent session list**: the pane is split into "Active sessions" and "Recent history" areas; main sessions that are inactive but were active within the last 24 hours can be quickly found again.
52
+ - [x] **Stronger waiting-for-action reminders**: blocked waits, completion reminders, and error reminders are marked with gold, green, and red cards respectively; questions are previewed directly as a question list, and completion reminders are explicitly acknowledged via the "Move to history" button on the card; the state is persisted on the host side and synced across all clients, so refreshing the page or opening another window never loses unacknowledged completion reminders or error reminders not yet overwritten by a new round.
53
+ - [x] **Sub/grandchild session hierarchy**: sub-agents are nested under their parent session with connector lines and compact cards; a parent whose own round has ended but that still has active descendants keeps rendering as running, and disappears from the active area once its sub-agents have ended and no active descendants remain; the history area keeps main sessions only.
54
+ - [x] **Workspace names displayed and factored into ordering**: session cards show a workspace badge with a stable color, and session ordering follows the workspace order in the left sidebar.
55
+ - [x] **Current work and run stats**: active cards show the latest instruction, thinking, and tool calls in a collapsed timeline of at most 4 rows; running cards also show round progress, output rate, cache hit rate, input/output tokens, and run duration, while completed, blocked, and error waits retain the previous round's duration.
56
+ - [x] **Session navigation**: clicking or keyboard-activating a session card jumps to that session's page, and the current session stays highlighted.
57
+ - [x] **Session metadata**: session cards show the current model name and reasoning level.
58
+ - [x] **Polished desktop & mobile interactions**: the desktop pane can collapse, be resized by dragging, and remember its width; mobile uses a fixed drawer that does not squeeze the main conversation layout; long lists get independent scrolling and a back-to-top button.
59
+
60
+ ## License
61
+
62
+ MIT — see [LICENSE](LICENSE) for the full text.
@@ -0,0 +1,62 @@
1
+ # dsh-activity-pane
2
+
3
+ [English](README.md) | 简体中文
4
+
5
+ DSH (DeepSeek Harness) 一大痛点是缺少活动会话与历史会话的管理,重度用户在同时运行跨越多个工作区的多个会话时,无法一目了然的掌控全局,尤其当 DSH 原生左边栏工作区的会话积累过多之后,活动会话的信息过于分散,无法解答以下问题:
6
+ - 现在有多少个会话在并行跑?
7
+ - 哪些会话启动了子代理甚至孙代理会话?它们有多少?
8
+ - 每个会话现在正在做什么?它们的进度如何?跑了多长时间?
9
+ - 每个会话使用什么模型?什么推理级别?输出速率、缓存命中率和 token 使用情况如何?
10
+ - 哪些会话的 agent 轮次最近刚结束,需要我行动?
11
+ - 过去一段时间我在哪些会话里交互过?最近的指令与结论是什么?
12
+ - ...
13
+
14
+ 本插件试图解决这些问题,提供了一个**活动会话总览窗格**:将正在运行的会话、子会话、轮次完成后等待行动的会话、近期活跃过的历史会话,集中在一个窗格内进行整体展示。
15
+
16
+ <p align="center">
17
+ <picture>
18
+ <source media="(prefers-color-scheme: dark)" srcset="assets/screenshot-desktop-dark.png">
19
+ <img src="assets/screenshot-desktop-light.png" width="1000" alt="隔离演示环境中的活动会话总览窗格:展示运行统计、子代理层级、提问等待、完成提醒、错误提醒和最近历史">
20
+ </picture>
21
+ </p>
22
+ <p align="center"><sub>同一干净隔离环境中模拟编程任务的 <a href="assets/screenshot-mobile-dark.png">移动端深色抽屉</a> · <a href="assets/screenshot-mobile-light.png">移动端浅色抽屉</a></sub></p>
23
+
24
+ ## 安装
25
+
26
+ ```sh
27
+ dsh plugin --profile web add dsh-activity-pane
28
+ ```
29
+
30
+ npm 包内置预构建产物,无需本地构建步骤。安装后如窗格未出现,重启一次 `dsh web` 即可。
31
+
32
+ ## 环境要求
33
+
34
+ - DSH (DeepSeek Harness) Web,经 `@deepseek-ai/dsh@0.1.0-rc.7` 实测验证。
35
+ - 无第三方插件依赖:窗格只消费 DSH 原生会话与工作区服务,卸载可逆。
36
+
37
+ ## 感谢与声明
38
+
39
+ - 本项目灵感来自 [`dsh-answer-pet`](https://github.com/Nanki-nn/dsh-answer-pet) 插件,借鉴了其中会话卡片的设计思路,并按自己的使用习惯与喜好做了调整与重新实现,感谢原作者的创意!
40
+ - 折叠时间线的分组语义改编自 MIT 许可的 [`dsh-auto-collapse`](https://github.com/a179-sanae/dsh-auto-collapse) 插件(数据层移植,无运行时依赖),感谢原作者。
41
+ - 时间线正文行与最近卡的 agent 角色机器人图标采用 ISC 许可的 [Lucide](https://lucide.dev) `bot` 图标几何,感谢 Lucide 贡献者。
42
+ - 本项目代码与文档 99.99% 由 AI 编写与审核,大概率存在 bug 与文档/代码不同步等问题,使用中如遇到问题请提交 issue。
43
+
44
+ ## 相比 dsh-answer-pet 的调整
45
+
46
+ > 本项目始于对 [`dsh-answer-pet`](https://github.com/Nanki-nn/dsh-answer-pet) 的个人再造,下表以该插件为对照写成——如果您没用过它,直接当作功能清单阅读即可。
47
+
48
+ - [x] **从浮层改为固定窗格**:桌面端在左边栏工作区的右侧增加常驻贴边列;移动端使用默认隐藏的固定抽屉,通过会话头部的「活动」按钮展开,不挤压主会话布局。
49
+ - [x] **去除宠物图标功能**:不支持宠物相关功能,界面聚焦于会话活动本身。
50
+ - [x] **原生数据源订阅**:直接订阅 DSH 原生 `sessions` / `workspaces` 服务的推送式快照;时间线最多显示 4 个折叠工作项行,保留最近用户指令与真实执行中的工作项。
51
+ - [x] **增加历史会话列表**:窗格分为「活动会话」和「最近历史」两个区域,非活动主会话在最近 24 小时内仍可快速找回。
52
+ - [x] **强化等待行动提醒**:阻塞等待、完成提醒与错误提醒分别以金色、绿色和红色卡片标识;提问直接预览问题列表,完成提醒经卡片上的「移入历史」按钮显式确认;状态由宿主侧持久化并在所有客户端间同步,刷新页面或另开窗口不会丢失未确认的完成提醒和尚未被新回合覆盖的错误提醒。
53
+ - [x] **显示子/孙会话层级**:子代理以连接线和紧凑卡片嵌套在母会话下;母会话自身回合结束但仍有活动后代时继续按运行中呈现,子代理结束且没有活动后代后从活动区消失;历史区只保留主会话。
54
+ - [x] **显示工作区名称并参与排序**:会话卡片显示带稳定色彩的工作区徽标,会话排序与左侧边栏中的工作区顺序保持一致。
55
+ - [x] **展示当前工作与运行统计**:活动卡以最多 4 行折叠时间线展示最近指令、思考与工具调用;运行中卡片还显示回合进度、输出速率、缓存命中率、输入/输出 token 与运行时长,进入完成、阻塞或错误等待后保留上一轮耗时。
56
+ - [x] **加入会话导航跳转**:点击或键盘激活会话卡片可跳转到对应会话页面,当前会话保持高亮。
57
+ - [x] **增加会话元信息**:会话卡片中显示当前使用的模型名称和推理级别。
58
+ - [x] **完善桌面与移动交互**:桌面窗格可折叠、拖拽调宽并记忆宽度;移动端使用不挤压主会话布局的固定抽屉;长列表提供独立滚动与回到顶部按钮。
59
+
60
+ ## 许可证
61
+
62
+ MIT,完整文本见 [LICENSE](LICENSE)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-activity-pane",
3
- "version": "0.1.0",
3
+ "version": "0.2.2",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "engines": {
6
6
  "node": ">=20"
@@ -64,6 +64,8 @@ const steps = [
64
64
  "让包含 todo_write/ask_user_question/cordis_define/失败工具调用/上下文注入的会话在选中与非选中态间切换,确认时间线中每个动作的标题与摘要两态完全一致(todo 显示 x/y 进度、ask 显示等待/已答/已取消、失败动作显示错误首行、上下文注入显示「上下文注入 · 来源标识」而非注入内容原文)。",
65
65
  // R-01-009/AC-05 统计行布局与缓存命中率
66
66
  "运行中会话流式输出时,确认进度条下方统计行左侧依次显示「tok/s 速率 · 缓存命中率 · 输入 · 输出」(与会话主窗口同序),字段间以小圆点区隔且速率无约等于符号、命中率为百分比;本回合时长固定在该行最右侧;数据缺失的字段隐藏,两段皆空时整行不残留。",
67
+ // R-01-009/AC-12 等待状态保留上一轮耗时
68
+ "让已有至少一个完整已结束回合的会话分别进入完成提醒、待确认/待审查/待回复阻塞等待与错误提醒,确认三类等待卡的等待类型胶囊同行最右侧均继续显示上一轮完整回合耗时;等待数分钟后文字不增长;没有完整上一轮时不显示虚假耗时。",
67
69
  // R-01-009/AC-06 回合进度条(时间驱动、半衰期每帧按最新实测速率校准,C-014、C-025、C-044)
68
70
  "确认运行卡标题后不再显示进度百分比;百分比改为与进度条同行并紧跟其右侧,在固定占位内向右对齐,其文字右缘与下一行最右侧的本回合耗时文字右缘对齐,并相对进度条中心略微上移 1px、视觉上不再偏下;9% 到 100% 的位数变化不改变其占位宽度或右缘,窄卡宽下进度条可收缩而百分比保持完整可见。观察进度条随本回合已耗时平滑爬升(不再出现 10% 级跳变):新会话起步期(无实测速率)按 20 tok/s 保守基准慢爬(约 2 分钟到 18%、6 分钟到 40%);速率实测后进度增速随统计行显示的累计平均速率动态调整——平均速率上升时增速加快(如 ≈45 tok/s 时 4 分钟到 50%),平均速率回落时进度可随之小幅回退(实时估计语义,不承诺单调);回合切换后归零重爬;中途才接入的会话进度从该回合已耗时对应值起算;回退时条纹动画不中断。",
69
71
  // R-01-009/AC-07 工作项时间线状态与摘要(无行级耗时)
package/scripts/check.mjs CHANGED
@@ -55,6 +55,9 @@ import {
55
55
  needsHistorySnapshot,
56
56
  lastTurnEndFromEvents,
57
57
  lastTurnEndFromTimings,
58
+ lastTurnDurationFromEvents,
59
+ lastTurnDurationFromTimings,
60
+ lastTurnDuration,
58
61
  pendingText,
59
62
  progressHalfLifeSec,
60
63
  progressOf,
@@ -256,6 +259,17 @@ assert.equal(
256
259
  false,
257
260
  "最近卡预览 fallback 已尝试后可见期内不热重试",
258
261
  );
262
+ // R-01-009/AC-12:等待卡转入 awaiting 后,即使旧 history 已加载也必须补读最新边界。
263
+ assert.equal(
264
+ detailLoadPlan({ detail: { history: [{ event: { seq: 1 } }] }, snapshotReady: true, durationFallbackNeeded: true }).history,
265
+ true,
266
+ "等待卡耗时 fallback 在已有旧 history 时仍发起一次最新 history 读取",
267
+ );
268
+ assert.equal(
269
+ detailLoadPlan({ detail: { history: [], durationFallbackLoaded: true }, snapshotReady: true, durationFallbackNeeded: true }).history,
270
+ false,
271
+ "等待卡耗时 fallback 已尝试后可见期内不重复读取",
272
+ );
259
273
  // R-01-009/AC-06、R-01-012/AC-12 冷窗口兜底:快照就绪但窗口缺锚点数据(开放回合起点/用户行在窗口外)时补读 history
260
274
  assert.equal(
261
275
  detailLoadPlan({ detail: {}, snapshotReady: true, windowComplete: false }).history,
@@ -2217,6 +2231,92 @@ assert.equal(
2217
2231
  2500,
2218
2232
  "turnTimings 取最大 endTime,忽略未结束回合",
2219
2233
  );
2234
+ // ---- R-01-009/AC-12 等待卡保留最近完整回合耗时 ----
2235
+ assert.equal(
2236
+ lastTurnDurationFromTimings(new Map([
2237
+ [1, { startTime: 100, endTime: 900 }],
2238
+ [2, { startTime: 1000 }],
2239
+ [3, { startTime: 2000, endTime: 2500 }],
2240
+ ])),
2241
+ 500,
2242
+ "turnTimings 取最近已结束回合的起止差值,不取更早回合或开放回合",
2243
+ );
2244
+ assert.equal(
2245
+ lastTurnDurationFromTimings(new Map([[1, { startTime: 900, endTime: 100 }]])),
2246
+ null,
2247
+ "起点晚于终点时不生成虚假回合耗时",
2248
+ );
2249
+ assert.equal(lastTurnDurationFromTimings(new Map([[1, { startTime: 100 }]])), null, "无 endTime 不生成等待耗时");
2250
+ assert.equal(
2251
+ lastTurnDurationFromEvents([
2252
+ { event: { type: "turn/start", time: 100, data: { turn: 1 } } },
2253
+ { event: { type: "turn/end", time: 900, data: { turn: 1 } } },
2254
+ { event: { type: "turn/start", time: 1000, data: { turn: 2 } } },
2255
+ { event: { type: "turn/end", time: 2500, data: { turn: 2 } } },
2256
+ ]),
2257
+ 1500,
2258
+ "history 按同一 turn 配对并取最近已结束回合的耗时",
2259
+ );
2260
+ assert.equal(
2261
+ lastTurnDurationFromEvents([
2262
+ { event: { type: "turn/start", time: 900, data: { turn: 1 } } },
2263
+ { event: { type: "turn/end", time: 100, data: { turn: 1 } } },
2264
+ ]),
2265
+ null,
2266
+ "history 起止逆序时不生成虚假回合耗时",
2267
+ );
2268
+ assert.equal(
2269
+ lastTurnDurationFromEvents([
2270
+ { event: { type: "turn/start", time: null, data: { turn: 1 } } },
2271
+ { event: { type: "turn/end", time: 100, data: { turn: 1 } } },
2272
+ ]),
2273
+ null,
2274
+ "history 起点缺失时不把 null 当作时间零点",
2275
+ );
2276
+ assert.equal(
2277
+ lastTurnDurationFromTimings(new Map([[1, { startTime: Number.NaN, endTime: 100 }]])),
2278
+ null,
2279
+ "turnTimings 起点非有限时不生成等待耗时",
2280
+ );
2281
+ assert.equal(
2282
+ lastTurnDuration({
2283
+ turnTimings: new Map([
2284
+ [1, { startTime: 100, endTime: 900 }],
2285
+ [2, { endTime: 2500 }],
2286
+ ]),
2287
+ history: [],
2288
+ }),
2289
+ 800,
2290
+ "turnTimings 最新孤立 end 不得与更早完整回合错配",
2291
+ );
2292
+ assert.equal(
2293
+ lastTurnDuration({
2294
+ turnTimings: new Map(),
2295
+ history: [
2296
+ { event: { type: "turn/start", time: 100, data: { turn: 1 } } },
2297
+ { event: { type: "turn/end", time: 900, data: { turn: 1 } } },
2298
+ { event: { type: "turn/end", time: 2500, data: { turn: 2 } } },
2299
+ ],
2300
+ }),
2301
+ 800,
2302
+ "history 最新孤立 end 不得与更早完整回合错配",
2303
+ );
2304
+ assert.equal(
2305
+ lastTurnDuration({
2306
+ turnTimings: new Map([[1, { startTime: 100, endTime: 900 }]]),
2307
+ history: [
2308
+ { event: { type: "turn/start", time: 1000, data: { turn: 2 } } },
2309
+ { event: { type: "turn/end", time: 1300, data: { turn: 2 } } },
2310
+ ],
2311
+ }),
2312
+ 300,
2313
+ "多来源时按最近结束时刻选择同一最新回合耗时",
2314
+ );
2315
+ assert.equal(
2316
+ lastTurnDuration({ turnTimings: new Map([[1, { startTime: 100, endTime: 900 }]]), history: [] }),
2317
+ 800,
2318
+ "无 history 时从 turnTimings 提取回合耗时",
2319
+ );
2220
2320
  const refineSnap = {
2221
2321
  ids: ["sTurn", "sPrompt", "sNone"],
2222
2322
  byId: {
@@ -2665,6 +2765,12 @@ assert.ok(
2665
2765
  bundle.includes('makeEl("span", "dap-token-main")') && bundle.includes('makeEl("span", "dap-token-time")'),
2666
2766
  "统计行双段结构:左列文本 + 右置时长(R-01-009/AC-05)",
2667
2767
  );
2768
+ assert.ok(bundle.includes("function renderTokenStats"), "运行卡统计行继续复用既有渲染逻辑(R-01-009/AC-05)");
2769
+ assert.ok(
2770
+ bundle.includes("function renderAwaitingDuration") && bundle.includes("dap-await-head"),
2771
+ "等待卡耗时与等待类型胶囊共用同行右置渲染(R-01-009/AC-12)",
2772
+ );
2773
+ assert.ok(bundle.includes("lastTurnDuration({"), "等待卡耗时由最近完整回合边界派生(R-01-009/AC-12)");
2668
2774
  assert.ok(bundle.includes("`输入 ${fmtTokens("), "统计行含输入/输出中文短标签(R-01-009/AC-05)");
2669
2775
  assert.ok(
2670
2776
  bundle.indexOf("parts.push(`${Math.round(entry.rateTokS)} tok/s`") <
@@ -2741,8 +2847,9 @@ assert.ok(
2741
2847
  assert.ok(bundle.includes("function activeSessionIds(byId = {})"), "活动子代理沿 parentId 链补齐活动祖先");
2742
2848
  // ---- R-01-016/AC-01 等待卡保留最近工作项时间线 ----
2743
2849
  assert.ok(
2744
- bundle.includes('return [head, row, makeEl("div", "dap-trace"), foot];'),
2745
- "awaiting 骨架在标题行与末行两段(胶囊+正文)之间含时间线容器(R-01-016/AC-01,C-043)",
2850
+ bundle.includes('awaitHead.append(capsule, makeEl("span", "dap-token-time"))') &&
2851
+ bundle.includes('return [head, row, makeEl("div", "dap-trace"), foot];'),
2852
+ "awaiting 骨架在标题行与末行两段(胶囊+正文)之间含时间线,并将耗时放在胶囊同行右侧(R-01-016/AC-01、R-01-009/AC-12,C-043)",
2746
2853
  );
2747
2854
  // ---- R-01-002/AC-10 完成提醒卡「移入历史」按钮 ----
2748
2855
  assert.ok(
@@ -2885,8 +2992,8 @@ assert.ok(bundle.includes("pruneSubscriptions(modelDirectorySubs, new Set())"),
2885
2992
  assert.ok(bundle.includes("detail.modelLive"), "目录订阅已产值时晚到的一次性 RPC 不回写旧值");
2886
2993
  assert.ok(!bundle.includes("events.mux"), "不常驻全局 mux,当前会话使用原生 session subscribe");
2887
2994
  assert.ok(
2888
- bundle.indexOf('makeEl("div", "dap-track")') < bundle.indexOf('makeEl("div", "dap-token-stats")'),
2889
- "token 统计骨架位于进度条骨架之后",
2995
+ bundle.includes('return [head, row, makeEl("div", "dap-trace"), progressRow, statsRow];'),
2996
+ "running 卡 token 统计骨架位于进度条骨架之后",
2890
2997
  );
2891
2998
  assert.ok(
2892
2999
  bundle.indexOf('statsRow.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"))') > -1,
package/src/client.mjs CHANGED
@@ -484,6 +484,14 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
484
484
  display: flex; flex-direction: column; align-items: flex-start; gap: 4px;
485
485
  min-width: 0; margin: 2px 0 0;
486
486
  }
487
+ /* 等待类型胶囊与上一轮耗时同行:时长沿用运行统计行的右缘位置,不参与等待脉冲。 */
488
+ [data-dsh-activity-pane] .dap-await-head {
489
+ display: flex; align-items: center; gap: 8px; align-self: stretch; min-width: 0;
490
+ }
491
+ [data-dsh-activity-pane] .dap-await-head .dap-token-time {
492
+ margin-left: auto; flex: none; font-size: 10px; line-height: 14px;
493
+ color: #8f9aaa; font-variant-numeric: tabular-nums;
494
+ }
487
495
  [data-dsh-activity-pane] .dap-note-row {
488
496
  display: flex; align-items: center; gap: 6px; min-width: 0; align-self: stretch;
489
497
  }
@@ -695,7 +703,8 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
695
703
  display: flex; align-items: center; justify-content: center; gap: 6px;
696
704
  }
697
705
  /* 加载指示:列表/卡片字段在途时的活动图标(R-01-014)。 */
698
- [data-dsh-activity-pane] .dap-spinner {
706
+ [data-dsh-activity-pane] .dap-spinner,
707
+ .dap-toggle .dap-toggle-count .dap-spinner {
699
708
  width: 10px; height: 10px; flex: none; border-radius: 50%;
700
709
  border: 2px solid color-mix(in srgb, currentColor 25%, transparent);
701
710
  border-top-color: color-mix(in srgb, currentColor 85%, transparent);
@@ -1300,7 +1309,7 @@ function apply(ctx) {
1300
1309
  syncFromDirectory(); // 目录已被主窗口加载时立即同步,该会话免发一次性 RPC
1301
1310
  }
1302
1311
 
1303
- function loadNativeDetails(ids, previewFallbackIds = new Set()) {
1312
+ function loadNativeDetails(ids, previewFallbackIds = new Set(), durationFallbackIds = new Set()) {
1304
1313
  const api = ctx.get("connection")?.api?.sessions;
1305
1314
  if (!api) return;
1306
1315
  const byId = getSnapshot(sessions, "list")?.byId ?? {};
@@ -1326,12 +1335,14 @@ function apply(ctx) {
1326
1335
  liveStartTime: livenessById.get(id)?.liveness?.startTime ?? null,
1327
1336
  });
1328
1337
  const windowComplete = snapshotReady !== true || (!turnStartMissing && detail.snapshotHasAnchorableUserRow === true);
1338
+ const durationFallbackNeeded = durationFallbackIds.has(id) && detail.durationFallbackLoaded !== true;
1329
1339
  const plan = detailLoadPlan({
1330
1340
  detail,
1331
1341
  isSubagent: subagent,
1332
1342
  snapshotReady,
1333
1343
  historyNeeded: needsHistorySnapshot(detail.snapshot),
1334
1344
  previewFallbackNeeded: previewFallbackIds.has(id),
1345
+ durationFallbackNeeded,
1335
1346
  windowComplete,
1336
1347
  modelInflight: modelLoads.has(id),
1337
1348
  historyInflight: historyLoads.has(id) || sessionOpenLoads.has(id),
@@ -1364,6 +1375,7 @@ function apply(ctx) {
1364
1375
  modelPromises.push(promise);
1365
1376
  }
1366
1377
  if (plan.history && typeof api.history === "function") {
1378
+ if (durationFallbackNeeded) detail.durationFallbackLoaded = true;
1367
1379
  if (previewFallbackIds.has(id)) detail.previewFallbackLoaded = true;
1368
1380
  const promise = enqueueDetailLoad(() => Promise.resolve()
1369
1381
  .then(async () => {
@@ -1685,8 +1697,10 @@ function apply(ctx) {
1685
1697
  // 正文行容器:正文文本 + 「移入历史」按钮(仅完成提醒卡显示,R-01-002/AC-10)。
1686
1698
  const noteRow = makeEl("div", "dap-note-row");
1687
1699
  noteRow.append(makeEl("div", "dap-note"), makeConfirmButton());
1700
+ const awaitHead = makeEl("div", "dap-await-head");
1701
+ awaitHead.append(capsule, makeEl("span", "dap-token-time"));
1688
1702
  const foot = makeEl("div", "dap-foot");
1689
- foot.append(capsule, noteRow);
1703
+ foot.append(awaitHead, noteRow);
1690
1704
  return [head, row, makeEl("div", "dap-trace"), foot];
1691
1705
  }
1692
1706
  const row = makeEl("div", "dap-row");
@@ -2048,6 +2062,56 @@ function apply(ctx) {
2048
2062
  }
2049
2063
  }
2050
2064
 
2065
+ /** 统计行渲染:运行卡写入速率、token 与耗时,旧骨架缺节点时就地补齐。 */
2066
+ function renderTokenStats(el, entry) {
2067
+ let stats = el.querySelector(".dap-token-stats");
2068
+ if (stats === null) {
2069
+ stats = makeEl("div", "dap-token-stats");
2070
+ stats.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
2071
+ stats.hidden = true;
2072
+ el.append(stats);
2073
+ }
2074
+ let mainTextEl = stats.querySelector(".dap-token-main");
2075
+ let timeEl = stats.querySelector(".dap-token-time");
2076
+ if (mainTextEl === null || timeEl === null) {
2077
+ mainTextEl = makeEl("span", "dap-token-main");
2078
+ timeEl = makeEl("span", "dap-token-time");
2079
+ stats.replaceChildren(mainTextEl, timeEl);
2080
+ }
2081
+ const parts = [];
2082
+ if (Number.isFinite(entry.rateTokS) && entry.rateTokS > 0) parts.push(`${Math.round(entry.rateTokS)} tok/s`);
2083
+ if (Number.isFinite(entry.cacheHitPct)) parts.push(`缓存 ${entry.cacheHitPct}%`);
2084
+ if (Number.isFinite(entry.inputTokens) && entry.inputTokens >= 0) parts.push(`输入 ${fmtTokens(entry.inputTokens) ?? entry.inputTokens}`);
2085
+ if (Number.isFinite(entry.outputTokens) && entry.outputTokens >= 0) parts.push(`输出 ${fmtTokens(entry.outputTokens) ?? entry.outputTokens}`);
2086
+ const mainText = parts.join(" · ");
2087
+ const timeText = Number.isFinite(entry.elapsedMs) && entry.elapsedMs >= 0 ? fmtElapsedMs(entry.elapsedMs) : "";
2088
+ if (mainTextEl.textContent !== mainText) mainTextEl.textContent = mainText;
2089
+ if (timeEl.textContent !== timeText) timeEl.textContent = timeText;
2090
+ const statsHidden = mainText === "" && timeText === "";
2091
+ if (stats.hidden !== statsHidden) stats.hidden = statsHidden;
2092
+ }
2093
+
2094
+ /** 等待卡耗时写入:与等待类型胶囊同一行靠右,旧骨架缺该行时就地补齐。 */
2095
+ function renderAwaitingDuration(el, entry) {
2096
+ const foot = el.querySelector(".dap-foot");
2097
+ if (foot === null) return;
2098
+ let head = foot.querySelector(".dap-await-head");
2099
+ if (head === null) {
2100
+ const capsule = foot.querySelector(".dap-capsule");
2101
+ if (capsule === null) return;
2102
+ head = makeEl("div", "dap-await-head");
2103
+ capsule.replaceWith(head);
2104
+ head.append(capsule);
2105
+ }
2106
+ let time = head.querySelector(".dap-token-time");
2107
+ if (time === null) {
2108
+ time = makeEl("span", "dap-token-time");
2109
+ head.append(time);
2110
+ }
2111
+ const text = Number.isFinite(entry.elapsedMs) && entry.elapsedMs >= 0 ? fmtElapsedMs(entry.elapsedMs) : "";
2112
+ if (time.textContent !== text) time.textContent = text;
2113
+ }
2114
+
2051
2115
  /** 时间线区加载指示:数据在途且尚无工作项时显示活动图标行(R-01-014/AC-02)。 */
2052
2116
  function renderTraceLoading(container) {
2053
2117
  if (container.dataset.loading === "true") return;
@@ -2153,28 +2217,7 @@ function apply(ctx) {
2153
2217
  const width = `${Math.min(100, Math.max(0, entry.progress ?? 0))}%`;
2154
2218
  if (fill.style.width !== width) fill.style.width = width;
2155
2219
  }
2156
- const stats = el.querySelector(".dap-token-stats");
2157
- if (stats !== null) {
2158
- const parts = [];
2159
- if (Number.isFinite(entry.rateTokS) && entry.rateTokS > 0) parts.push(`${Math.round(entry.rateTokS)} tok/s`);
2160
- if (Number.isFinite(entry.cacheHitPct)) parts.push(`缓存 ${entry.cacheHitPct}%`);
2161
- if (Number.isFinite(entry.inputTokens) && entry.inputTokens >= 0) parts.push(`输入 ${fmtTokens(entry.inputTokens) ?? entry.inputTokens}`);
2162
- if (Number.isFinite(entry.outputTokens) && entry.outputTokens >= 0) parts.push(`输出 ${fmtTokens(entry.outputTokens) ?? entry.outputTokens}`);
2163
- const mainText = parts.join(" · ");
2164
- const timeText = Number.isFinite(entry.elapsedMs) && entry.elapsedMs >= 0 ? fmtElapsedMs(entry.elapsedMs) : "";
2165
- let mainTextEl = stats.querySelector(".dap-token-main");
2166
- let timeEl = stats.querySelector(".dap-token-time");
2167
- if (mainTextEl === null || timeEl === null) {
2168
- // 热装残留的旧版单文本段骨架:就地重建双段结构再写值。
2169
- mainTextEl = makeEl("span", "dap-token-main");
2170
- timeEl = makeEl("span", "dap-token-time");
2171
- stats.replaceChildren(mainTextEl, timeEl);
2172
- }
2173
- if (mainTextEl.textContent !== mainText) mainTextEl.textContent = mainText;
2174
- if (timeEl.textContent !== timeText) timeEl.textContent = timeText;
2175
- const statsHidden = mainText === "" && timeText === "";
2176
- if (stats.hidden !== statsHidden) stats.hidden = statsHidden;
2177
- }
2220
+ renderTokenStats(el, entry);
2178
2221
  return;
2179
2222
  }
2180
2223
 
@@ -2206,6 +2249,7 @@ function apply(ctx) {
2206
2249
  if (entry.kind === "awaiting") {
2207
2250
  const traceContainer = el.querySelector(".dap-trace");
2208
2251
  if (traceContainer !== null) renderTimelineArea(traceContainer, entry);
2252
+ renderAwaitingDuration(el, entry);
2209
2253
  const confirm = el.querySelector(".dap-confirm");
2210
2254
  if (confirm !== null) {
2211
2255
  // 激活锚点:只在结构重建时绑一次(卡片按 id 复用,kind 变化会重建骨架)。
@@ -2758,6 +2802,7 @@ function apply(ctx) {
2758
2802
  const liveRecord = livenessById.get(entry.id);
2759
2803
  const live = liveRecord?.liveness ?? null;
2760
2804
  const detail = sessionDetailsById.get(entry.id);
2805
+ if (entry.kind === "running" && detail) detail.durationFallbackLoaded = false;
2761
2806
  const detailSnapshot = liveRecord?.snapshot ?? detail?.snapshot ?? null;
2762
2807
  if (detail && detail.memoHistoryAnchorOf !== (detail.history ?? null)) {
2763
2808
  detail.memoHistoryAnchorOf = detail.history ?? null;
@@ -2802,6 +2847,15 @@ function apply(ctx) {
2802
2847
  detail.memoOpenTurnStart = openTurnStartFromEvents(detail.history, hint);
2803
2848
  }
2804
2849
  }
2850
+ if (entry.kind === "awaiting" && detail) {
2851
+ const history = detail.history ?? null;
2852
+ if (detail.memoTurnDurationSnapshotOf !== detailSnapshot || detail.memoTurnDurationHistoryOf !== history) {
2853
+ detail.memoTurnDurationSnapshotOf = detailSnapshot;
2854
+ detail.memoTurnDurationHistoryOf = history;
2855
+ detail.memoTurnDuration = lastTurnDuration({ turnTimings: detailSnapshot?.turnTimings, history });
2856
+ }
2857
+ entry.elapsedMs = detail.memoTurnDuration ?? null;
2858
+ }
2805
2859
  if (detail?.model) {
2806
2860
  entry.model = detail.model.model;
2807
2861
  entry.reasoning = detail.model.reasoning;
@@ -2884,9 +2938,10 @@ function apply(ctx) {
2884
2938
  entry.loadingPreviews = (!entry.userPreview || !entry.agentPreview) && historyLoads.has(entry.id);
2885
2939
  }
2886
2940
  // 补充数据读取优先级:当前会话最优先,活动区先于历史区(区内按显示顺序)。
2941
+ const durationFallbackIds = new Set(active.filter((entry) => entry.kind === "awaiting").map((entry) => entry.id));
2887
2942
  const detailIds = [...active, ...recent].map((entry) => entry.id);
2888
2943
  detailIds.sort((a, b) => Number(String(b) === String(snapshot?.current)) - Number(String(a) === String(snapshot?.current)));
2889
- loadNativeDetails(detailIds, previewFallbackIds);
2944
+ loadNativeDetails(detailIds, previewFallbackIds, durationFallbackIds);
2890
2945
  const visibleIds = new Set([...active, ...recent].map((entry) => entry.id));
2891
2946
  // 详情与 loads 记账同生命周期:离开可见集合即放行,重回可见时允许重拉/重试。
2892
2947
  // 锚点记账不随可见性 prune:瞬时 loading 空帧不得误清(进度重置);陈旧条目靠
package/src/core.mjs CHANGED
@@ -1168,13 +1168,15 @@ export function escapeCssString(value) {
1168
1168
  * windowComplete(R-01-009/AC-06、R-01-012/AC-12 冷窗口兜底):快照已就绪但窗口缺
1169
1169
  * 锚点数据(开放回合起点或可锚用户行在窗口外)时为 false——此时仍发起一次 history
1170
1170
  * 补读,供进度锚点与指令锚行兜底。previewFallbackNeeded 表示最近卡的快照预览
1171
- * 不完整,同样补读一次 history(R-01-013/AC-03、AC-04)。 */
1171
+ * 不完整,同样补读一次 history(R-01-013/AC-03、AC-04);durationFallbackNeeded 表示等待卡
1172
+ * 需要在已加载的旧 history 之后再取一次最新回合边界(R-01-009/AC-12)。 */
1172
1173
  export function detailLoadPlan({
1173
1174
  detail = {},
1174
1175
  isSubagent = false,
1175
1176
  snapshotReady = false,
1176
1177
  historyNeeded = false,
1177
1178
  previewFallbackNeeded = false,
1179
+ durationFallbackNeeded = false,
1178
1180
  windowComplete = true,
1179
1181
  modelInflight = false,
1180
1182
  historyInflight = false,
@@ -1184,7 +1186,8 @@ export function detailLoadPlan({
1184
1186
  model: !isSubagent && !detail.model && !modelInflight,
1185
1187
  history:
1186
1188
  !historyInflight &&
1187
- ((previewFallbackNeeded && detail.previewFallbackLoaded !== true) ||
1189
+ ((durationFallbackNeeded && detail.durationFallbackLoaded !== true) ||
1190
+ (previewFallbackNeeded && detail.previewFallbackLoaded !== true) ||
1188
1191
  (!detail.history && ((!snapshotReady && historyNeeded) || (snapshotReady === true && windowComplete === false)))),
1189
1192
  };
1190
1193
  }
@@ -1734,7 +1737,7 @@ export function movedToActiveIds(prevRecentIds, active, recent) {
1734
1737
  export function lastTurnEndFromEvents(events) {
1735
1738
  const list = Array.isArray(events) ? events : [];
1736
1739
  for (let i = list.length - 1; i >= 0; i -= 1) {
1737
- const event = list[i]?.event;
1740
+ const event = eventOf(list[i]);
1738
1741
  if (event?.type !== "turn/end") continue;
1739
1742
  const time = Number(event.time);
1740
1743
  if (Number.isFinite(time)) return time;
@@ -1753,6 +1756,67 @@ export function lastTurnEndFromTimings(turnTimings) {
1753
1756
  return last;
1754
1757
  }
1755
1758
 
1759
+ /** 回合边界时间归一:缺失、空字符串、非数值或非有限值均不可作为耗时端点。 */
1760
+ function durationTime(value) {
1761
+ if (value == null || (typeof value === "string" && value.trim() === "") || (typeof value !== "number" && typeof value !== "string")) return null;
1762
+ const time = Number(value);
1763
+ return Number.isFinite(time) ? time : null;
1764
+ }
1765
+
1766
+ /** 从 history 提取最近完整回合的结束时刻与固定耗时;两者始终来自同一回合。 */
1767
+ function lastTurnDurationCandidateFromEvents(events) {
1768
+ const starts = new Map();
1769
+ let latest = null;
1770
+ for (const entry of Array.isArray(events) ? events : []) {
1771
+ const event = eventOf(entry);
1772
+ const turn = Number(event?.data?.turn);
1773
+ const time = durationTime(event?.time);
1774
+ if (!Number.isFinite(turn) || time === null) continue;
1775
+ if (event.type === "turn/start") {
1776
+ starts.set(turn, time);
1777
+ continue;
1778
+ }
1779
+ if (event.type !== "turn/end") continue;
1780
+ const start = starts.get(turn);
1781
+ if (start === undefined || time < start) continue;
1782
+ if (latest === null || time > latest.end) latest = { end: time, duration: time - start };
1783
+ }
1784
+ return latest;
1785
+ }
1786
+
1787
+ /** 从 turnTimings 提取最近完整回合的结束时刻与固定耗时;两者始终来自同一回合。 */
1788
+ function lastTurnDurationCandidateFromTimings(turnTimings) {
1789
+ if (!(turnTimings instanceof Map)) return null;
1790
+ let latest = null;
1791
+ for (const timing of turnTimings.values()) {
1792
+ const start = durationTime(timing?.startTime);
1793
+ const end = durationTime(timing?.endTime);
1794
+ if (start === null || end === null || end < start) continue;
1795
+ if (latest === null || end > latest.end) latest = { end, duration: end - start };
1796
+ }
1797
+ return latest;
1798
+ }
1799
+
1800
+ /** 从 history 提取最近完整回合的固定耗时;回合起止不完整或逆序时忽略该回合。 */
1801
+ export function lastTurnDurationFromEvents(events) {
1802
+ return lastTurnDurationCandidateFromEvents(events)?.duration ?? null;
1803
+ }
1804
+
1805
+ /** 从 turnTimings 提取最近完整回合的固定耗时;全部回合未结束或无效时返回 null。 */
1806
+ export function lastTurnDurationFromTimings(turnTimings) {
1807
+ return lastTurnDurationCandidateFromTimings(turnTimings)?.duration ?? null;
1808
+ }
1809
+
1810
+ /** 在快照与 history 中按最近结束时刻选择同一最新完整回合的固定耗时。 */
1811
+ export function lastTurnDuration({ turnTimings = null, history = [] } = {}) {
1812
+ const candidates = [lastTurnDurationCandidateFromTimings(turnTimings), lastTurnDurationCandidateFromEvents(history)].filter(Boolean);
1813
+ let latest = null;
1814
+ for (const candidate of candidates) {
1815
+ if (latest === null || candidate.end > latest.end) latest = candidate;
1816
+ }
1817
+ return latest?.duration ?? null;
1818
+ }
1819
+
1756
1820
  /**
1757
1821
  * 构建最近历史区条目:当前非活动、且在历史窗口内最后一次活动过的**主会话**
1758
1822
  * (子代理是临时工作单元,不入最近历史;故需同时排除表白会话与已结束子代理),