dsh-activity-pane 0.1.0 → 0.2.1
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/.dsh-plugin/client.js +146 -28
- package/README.md +52 -31
- package/README.zh-CN.md +62 -0
- package/package.json +1 -1
- package/scripts/acceptance.mjs +2 -0
- package/scripts/check.mjs +111 -4
- package/src/client.mjs +79 -25
- package/src/core.mjs +67 -3
package/.dsh-plugin/client.js
CHANGED
|
@@ -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
|
-
((
|
|
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]
|
|
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
|
}
|
|
@@ -3332,7 +3404,7 @@ function apply(ctx) {
|
|
|
3332
3404
|
syncFromDirectory(); // 目录已被主窗口加载时立即同步,该会话免发一次性 RPC
|
|
3333
3405
|
}
|
|
3334
3406
|
|
|
3335
|
-
function loadNativeDetails(ids, previewFallbackIds = new Set()) {
|
|
3407
|
+
function loadNativeDetails(ids, previewFallbackIds = new Set(), durationFallbackIds = new Set()) {
|
|
3336
3408
|
const api = ctx.get("connection")?.api?.sessions;
|
|
3337
3409
|
if (!api) return;
|
|
3338
3410
|
const byId = getSnapshot(sessions, "list")?.byId ?? {};
|
|
@@ -3358,12 +3430,14 @@ function apply(ctx) {
|
|
|
3358
3430
|
liveStartTime: livenessById.get(id)?.liveness?.startTime ?? null,
|
|
3359
3431
|
});
|
|
3360
3432
|
const windowComplete = snapshotReady !== true || (!turnStartMissing && detail.snapshotHasAnchorableUserRow === true);
|
|
3433
|
+
const durationFallbackNeeded = durationFallbackIds.has(id) && detail.durationFallbackLoaded !== true;
|
|
3361
3434
|
const plan = detailLoadPlan({
|
|
3362
3435
|
detail,
|
|
3363
3436
|
isSubagent: subagent,
|
|
3364
3437
|
snapshotReady,
|
|
3365
3438
|
historyNeeded: needsHistorySnapshot(detail.snapshot),
|
|
3366
3439
|
previewFallbackNeeded: previewFallbackIds.has(id),
|
|
3440
|
+
durationFallbackNeeded,
|
|
3367
3441
|
windowComplete,
|
|
3368
3442
|
modelInflight: modelLoads.has(id),
|
|
3369
3443
|
historyInflight: historyLoads.has(id) || sessionOpenLoads.has(id),
|
|
@@ -3396,6 +3470,7 @@ function apply(ctx) {
|
|
|
3396
3470
|
modelPromises.push(promise);
|
|
3397
3471
|
}
|
|
3398
3472
|
if (plan.history && typeof api.history === "function") {
|
|
3473
|
+
if (durationFallbackNeeded) detail.durationFallbackLoaded = true;
|
|
3399
3474
|
if (previewFallbackIds.has(id)) detail.previewFallbackLoaded = true;
|
|
3400
3475
|
const promise = enqueueDetailLoad(() => Promise.resolve()
|
|
3401
3476
|
.then(async () => {
|
|
@@ -3717,8 +3792,10 @@ function apply(ctx) {
|
|
|
3717
3792
|
// 正文行容器:正文文本 + 「移入历史」按钮(仅完成提醒卡显示,R-01-002/AC-10)。
|
|
3718
3793
|
const noteRow = makeEl("div", "dap-note-row");
|
|
3719
3794
|
noteRow.append(makeEl("div", "dap-note"), makeConfirmButton());
|
|
3795
|
+
const awaitHead = makeEl("div", "dap-await-head");
|
|
3796
|
+
awaitHead.append(capsule, makeEl("span", "dap-token-time"));
|
|
3720
3797
|
const foot = makeEl("div", "dap-foot");
|
|
3721
|
-
foot.append(
|
|
3798
|
+
foot.append(awaitHead, noteRow);
|
|
3722
3799
|
return [head, row, makeEl("div", "dap-trace"), foot];
|
|
3723
3800
|
}
|
|
3724
3801
|
const row = makeEl("div", "dap-row");
|
|
@@ -4080,6 +4157,56 @@ function apply(ctx) {
|
|
|
4080
4157
|
}
|
|
4081
4158
|
}
|
|
4082
4159
|
|
|
4160
|
+
/** 统计行渲染:运行卡写入速率、token 与耗时,旧骨架缺节点时就地补齐。 */
|
|
4161
|
+
function renderTokenStats(el, entry) {
|
|
4162
|
+
let stats = el.querySelector(".dap-token-stats");
|
|
4163
|
+
if (stats === null) {
|
|
4164
|
+
stats = makeEl("div", "dap-token-stats");
|
|
4165
|
+
stats.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
|
|
4166
|
+
stats.hidden = true;
|
|
4167
|
+
el.append(stats);
|
|
4168
|
+
}
|
|
4169
|
+
let mainTextEl = stats.querySelector(".dap-token-main");
|
|
4170
|
+
let timeEl = stats.querySelector(".dap-token-time");
|
|
4171
|
+
if (mainTextEl === null || timeEl === null) {
|
|
4172
|
+
mainTextEl = makeEl("span", "dap-token-main");
|
|
4173
|
+
timeEl = makeEl("span", "dap-token-time");
|
|
4174
|
+
stats.replaceChildren(mainTextEl, timeEl);
|
|
4175
|
+
}
|
|
4176
|
+
const parts = [];
|
|
4177
|
+
if (Number.isFinite(entry.rateTokS) && entry.rateTokS > 0) parts.push(`${Math.round(entry.rateTokS)} tok/s`);
|
|
4178
|
+
if (Number.isFinite(entry.cacheHitPct)) parts.push(`缓存 ${entry.cacheHitPct}%`);
|
|
4179
|
+
if (Number.isFinite(entry.inputTokens) && entry.inputTokens >= 0) parts.push(`输入 ${fmtTokens(entry.inputTokens) ?? entry.inputTokens}`);
|
|
4180
|
+
if (Number.isFinite(entry.outputTokens) && entry.outputTokens >= 0) parts.push(`输出 ${fmtTokens(entry.outputTokens) ?? entry.outputTokens}`);
|
|
4181
|
+
const mainText = parts.join(" · ");
|
|
4182
|
+
const timeText = Number.isFinite(entry.elapsedMs) && entry.elapsedMs >= 0 ? fmtElapsedMs(entry.elapsedMs) : "";
|
|
4183
|
+
if (mainTextEl.textContent !== mainText) mainTextEl.textContent = mainText;
|
|
4184
|
+
if (timeEl.textContent !== timeText) timeEl.textContent = timeText;
|
|
4185
|
+
const statsHidden = mainText === "" && timeText === "";
|
|
4186
|
+
if (stats.hidden !== statsHidden) stats.hidden = statsHidden;
|
|
4187
|
+
}
|
|
4188
|
+
|
|
4189
|
+
/** 等待卡耗时写入:与等待类型胶囊同一行靠右,旧骨架缺该行时就地补齐。 */
|
|
4190
|
+
function renderAwaitingDuration(el, entry) {
|
|
4191
|
+
const foot = el.querySelector(".dap-foot");
|
|
4192
|
+
if (foot === null) return;
|
|
4193
|
+
let head = foot.querySelector(".dap-await-head");
|
|
4194
|
+
if (head === null) {
|
|
4195
|
+
const capsule = foot.querySelector(".dap-capsule");
|
|
4196
|
+
if (capsule === null) return;
|
|
4197
|
+
head = makeEl("div", "dap-await-head");
|
|
4198
|
+
capsule.replaceWith(head);
|
|
4199
|
+
head.append(capsule);
|
|
4200
|
+
}
|
|
4201
|
+
let time = head.querySelector(".dap-token-time");
|
|
4202
|
+
if (time === null) {
|
|
4203
|
+
time = makeEl("span", "dap-token-time");
|
|
4204
|
+
head.append(time);
|
|
4205
|
+
}
|
|
4206
|
+
const text = Number.isFinite(entry.elapsedMs) && entry.elapsedMs >= 0 ? fmtElapsedMs(entry.elapsedMs) : "";
|
|
4207
|
+
if (time.textContent !== text) time.textContent = text;
|
|
4208
|
+
}
|
|
4209
|
+
|
|
4083
4210
|
/** 时间线区加载指示:数据在途且尚无工作项时显示活动图标行(R-01-014/AC-02)。 */
|
|
4084
4211
|
function renderTraceLoading(container) {
|
|
4085
4212
|
if (container.dataset.loading === "true") return;
|
|
@@ -4185,28 +4312,7 @@ function apply(ctx) {
|
|
|
4185
4312
|
const width = `${Math.min(100, Math.max(0, entry.progress ?? 0))}%`;
|
|
4186
4313
|
if (fill.style.width !== width) fill.style.width = width;
|
|
4187
4314
|
}
|
|
4188
|
-
|
|
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
|
-
}
|
|
4315
|
+
renderTokenStats(el, entry);
|
|
4210
4316
|
return;
|
|
4211
4317
|
}
|
|
4212
4318
|
|
|
@@ -4238,6 +4344,7 @@ function apply(ctx) {
|
|
|
4238
4344
|
if (entry.kind === "awaiting") {
|
|
4239
4345
|
const traceContainer = el.querySelector(".dap-trace");
|
|
4240
4346
|
if (traceContainer !== null) renderTimelineArea(traceContainer, entry);
|
|
4347
|
+
renderAwaitingDuration(el, entry);
|
|
4241
4348
|
const confirm = el.querySelector(".dap-confirm");
|
|
4242
4349
|
if (confirm !== null) {
|
|
4243
4350
|
// 激活锚点:只在结构重建时绑一次(卡片按 id 复用,kind 变化会重建骨架)。
|
|
@@ -4790,6 +4897,7 @@ function apply(ctx) {
|
|
|
4790
4897
|
const liveRecord = livenessById.get(entry.id);
|
|
4791
4898
|
const live = liveRecord?.liveness ?? null;
|
|
4792
4899
|
const detail = sessionDetailsById.get(entry.id);
|
|
4900
|
+
if (entry.kind === "running" && detail) detail.durationFallbackLoaded = false;
|
|
4793
4901
|
const detailSnapshot = liveRecord?.snapshot ?? detail?.snapshot ?? null;
|
|
4794
4902
|
if (detail && detail.memoHistoryAnchorOf !== (detail.history ?? null)) {
|
|
4795
4903
|
detail.memoHistoryAnchorOf = detail.history ?? null;
|
|
@@ -4834,6 +4942,15 @@ function apply(ctx) {
|
|
|
4834
4942
|
detail.memoOpenTurnStart = openTurnStartFromEvents(detail.history, hint);
|
|
4835
4943
|
}
|
|
4836
4944
|
}
|
|
4945
|
+
if (entry.kind === "awaiting" && detail) {
|
|
4946
|
+
const history = detail.history ?? null;
|
|
4947
|
+
if (detail.memoTurnDurationSnapshotOf !== detailSnapshot || detail.memoTurnDurationHistoryOf !== history) {
|
|
4948
|
+
detail.memoTurnDurationSnapshotOf = detailSnapshot;
|
|
4949
|
+
detail.memoTurnDurationHistoryOf = history;
|
|
4950
|
+
detail.memoTurnDuration = lastTurnDuration({ turnTimings: detailSnapshot?.turnTimings, history });
|
|
4951
|
+
}
|
|
4952
|
+
entry.elapsedMs = detail.memoTurnDuration ?? null;
|
|
4953
|
+
}
|
|
4837
4954
|
if (detail?.model) {
|
|
4838
4955
|
entry.model = detail.model.model;
|
|
4839
4956
|
entry.reasoning = detail.model.reasoning;
|
|
@@ -4916,9 +5033,10 @@ function apply(ctx) {
|
|
|
4916
5033
|
entry.loadingPreviews = (!entry.userPreview || !entry.agentPreview) && historyLoads.has(entry.id);
|
|
4917
5034
|
}
|
|
4918
5035
|
// 补充数据读取优先级:当前会话最优先,活动区先于历史区(区内按显示顺序)。
|
|
5036
|
+
const durationFallbackIds = new Set(active.filter((entry) => entry.kind === "awaiting").map((entry) => entry.id));
|
|
4919
5037
|
const detailIds = [...active, ...recent].map((entry) => entry.id);
|
|
4920
5038
|
detailIds.sort((a, b) => Number(String(b) === String(snapshot?.current)) - Number(String(a) === String(snapshot?.current)));
|
|
4921
|
-
loadNativeDetails(detailIds, previewFallbackIds);
|
|
5039
|
+
loadNativeDetails(detailIds, previewFallbackIds, durationFallbackIds);
|
|
4922
5040
|
const visibleIds = new Set([...active, ...recent].map((entry) => entry.id));
|
|
4923
5041
|
// 详情与 loads 记账同生命周期:离开可见集合即放行,重回可见时允许重拉/重试。
|
|
4924
5042
|
// 锚点记账不随可见性 prune:瞬时 loading 空帧不得误清(进度重置);陈旧条目靠
|
package/README.md
CHANGED
|
@@ -1,41 +1,62 @@
|
|
|
1
1
|
# dsh-activity-pane
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
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
|
|
21
|
-
|
|
22
|
-
##
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
- [
|
|
38
|
-
- [
|
|
39
|
-
- [
|
|
40
|
-
-
|
|
41
|
-
|
|
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.
|
package/README.zh-CN.md
ADDED
|
@@ -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
package/scripts/acceptance.mjs
CHANGED
|
@@ -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('
|
|
2745
|
-
|
|
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.
|
|
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
|
}
|
|
@@ -1300,7 +1308,7 @@ function apply(ctx) {
|
|
|
1300
1308
|
syncFromDirectory(); // 目录已被主窗口加载时立即同步,该会话免发一次性 RPC
|
|
1301
1309
|
}
|
|
1302
1310
|
|
|
1303
|
-
function loadNativeDetails(ids, previewFallbackIds = new Set()) {
|
|
1311
|
+
function loadNativeDetails(ids, previewFallbackIds = new Set(), durationFallbackIds = new Set()) {
|
|
1304
1312
|
const api = ctx.get("connection")?.api?.sessions;
|
|
1305
1313
|
if (!api) return;
|
|
1306
1314
|
const byId = getSnapshot(sessions, "list")?.byId ?? {};
|
|
@@ -1326,12 +1334,14 @@ function apply(ctx) {
|
|
|
1326
1334
|
liveStartTime: livenessById.get(id)?.liveness?.startTime ?? null,
|
|
1327
1335
|
});
|
|
1328
1336
|
const windowComplete = snapshotReady !== true || (!turnStartMissing && detail.snapshotHasAnchorableUserRow === true);
|
|
1337
|
+
const durationFallbackNeeded = durationFallbackIds.has(id) && detail.durationFallbackLoaded !== true;
|
|
1329
1338
|
const plan = detailLoadPlan({
|
|
1330
1339
|
detail,
|
|
1331
1340
|
isSubagent: subagent,
|
|
1332
1341
|
snapshotReady,
|
|
1333
1342
|
historyNeeded: needsHistorySnapshot(detail.snapshot),
|
|
1334
1343
|
previewFallbackNeeded: previewFallbackIds.has(id),
|
|
1344
|
+
durationFallbackNeeded,
|
|
1335
1345
|
windowComplete,
|
|
1336
1346
|
modelInflight: modelLoads.has(id),
|
|
1337
1347
|
historyInflight: historyLoads.has(id) || sessionOpenLoads.has(id),
|
|
@@ -1364,6 +1374,7 @@ function apply(ctx) {
|
|
|
1364
1374
|
modelPromises.push(promise);
|
|
1365
1375
|
}
|
|
1366
1376
|
if (plan.history && typeof api.history === "function") {
|
|
1377
|
+
if (durationFallbackNeeded) detail.durationFallbackLoaded = true;
|
|
1367
1378
|
if (previewFallbackIds.has(id)) detail.previewFallbackLoaded = true;
|
|
1368
1379
|
const promise = enqueueDetailLoad(() => Promise.resolve()
|
|
1369
1380
|
.then(async () => {
|
|
@@ -1685,8 +1696,10 @@ function apply(ctx) {
|
|
|
1685
1696
|
// 正文行容器:正文文本 + 「移入历史」按钮(仅完成提醒卡显示,R-01-002/AC-10)。
|
|
1686
1697
|
const noteRow = makeEl("div", "dap-note-row");
|
|
1687
1698
|
noteRow.append(makeEl("div", "dap-note"), makeConfirmButton());
|
|
1699
|
+
const awaitHead = makeEl("div", "dap-await-head");
|
|
1700
|
+
awaitHead.append(capsule, makeEl("span", "dap-token-time"));
|
|
1688
1701
|
const foot = makeEl("div", "dap-foot");
|
|
1689
|
-
foot.append(
|
|
1702
|
+
foot.append(awaitHead, noteRow);
|
|
1690
1703
|
return [head, row, makeEl("div", "dap-trace"), foot];
|
|
1691
1704
|
}
|
|
1692
1705
|
const row = makeEl("div", "dap-row");
|
|
@@ -2048,6 +2061,56 @@ function apply(ctx) {
|
|
|
2048
2061
|
}
|
|
2049
2062
|
}
|
|
2050
2063
|
|
|
2064
|
+
/** 统计行渲染:运行卡写入速率、token 与耗时,旧骨架缺节点时就地补齐。 */
|
|
2065
|
+
function renderTokenStats(el, entry) {
|
|
2066
|
+
let stats = el.querySelector(".dap-token-stats");
|
|
2067
|
+
if (stats === null) {
|
|
2068
|
+
stats = makeEl("div", "dap-token-stats");
|
|
2069
|
+
stats.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
|
|
2070
|
+
stats.hidden = true;
|
|
2071
|
+
el.append(stats);
|
|
2072
|
+
}
|
|
2073
|
+
let mainTextEl = stats.querySelector(".dap-token-main");
|
|
2074
|
+
let timeEl = stats.querySelector(".dap-token-time");
|
|
2075
|
+
if (mainTextEl === null || timeEl === null) {
|
|
2076
|
+
mainTextEl = makeEl("span", "dap-token-main");
|
|
2077
|
+
timeEl = makeEl("span", "dap-token-time");
|
|
2078
|
+
stats.replaceChildren(mainTextEl, timeEl);
|
|
2079
|
+
}
|
|
2080
|
+
const parts = [];
|
|
2081
|
+
if (Number.isFinite(entry.rateTokS) && entry.rateTokS > 0) parts.push(`${Math.round(entry.rateTokS)} tok/s`);
|
|
2082
|
+
if (Number.isFinite(entry.cacheHitPct)) parts.push(`缓存 ${entry.cacheHitPct}%`);
|
|
2083
|
+
if (Number.isFinite(entry.inputTokens) && entry.inputTokens >= 0) parts.push(`输入 ${fmtTokens(entry.inputTokens) ?? entry.inputTokens}`);
|
|
2084
|
+
if (Number.isFinite(entry.outputTokens) && entry.outputTokens >= 0) parts.push(`输出 ${fmtTokens(entry.outputTokens) ?? entry.outputTokens}`);
|
|
2085
|
+
const mainText = parts.join(" · ");
|
|
2086
|
+
const timeText = Number.isFinite(entry.elapsedMs) && entry.elapsedMs >= 0 ? fmtElapsedMs(entry.elapsedMs) : "";
|
|
2087
|
+
if (mainTextEl.textContent !== mainText) mainTextEl.textContent = mainText;
|
|
2088
|
+
if (timeEl.textContent !== timeText) timeEl.textContent = timeText;
|
|
2089
|
+
const statsHidden = mainText === "" && timeText === "";
|
|
2090
|
+
if (stats.hidden !== statsHidden) stats.hidden = statsHidden;
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
/** 等待卡耗时写入:与等待类型胶囊同一行靠右,旧骨架缺该行时就地补齐。 */
|
|
2094
|
+
function renderAwaitingDuration(el, entry) {
|
|
2095
|
+
const foot = el.querySelector(".dap-foot");
|
|
2096
|
+
if (foot === null) return;
|
|
2097
|
+
let head = foot.querySelector(".dap-await-head");
|
|
2098
|
+
if (head === null) {
|
|
2099
|
+
const capsule = foot.querySelector(".dap-capsule");
|
|
2100
|
+
if (capsule === null) return;
|
|
2101
|
+
head = makeEl("div", "dap-await-head");
|
|
2102
|
+
capsule.replaceWith(head);
|
|
2103
|
+
head.append(capsule);
|
|
2104
|
+
}
|
|
2105
|
+
let time = head.querySelector(".dap-token-time");
|
|
2106
|
+
if (time === null) {
|
|
2107
|
+
time = makeEl("span", "dap-token-time");
|
|
2108
|
+
head.append(time);
|
|
2109
|
+
}
|
|
2110
|
+
const text = Number.isFinite(entry.elapsedMs) && entry.elapsedMs >= 0 ? fmtElapsedMs(entry.elapsedMs) : "";
|
|
2111
|
+
if (time.textContent !== text) time.textContent = text;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2051
2114
|
/** 时间线区加载指示:数据在途且尚无工作项时显示活动图标行(R-01-014/AC-02)。 */
|
|
2052
2115
|
function renderTraceLoading(container) {
|
|
2053
2116
|
if (container.dataset.loading === "true") return;
|
|
@@ -2153,28 +2216,7 @@ function apply(ctx) {
|
|
|
2153
2216
|
const width = `${Math.min(100, Math.max(0, entry.progress ?? 0))}%`;
|
|
2154
2217
|
if (fill.style.width !== width) fill.style.width = width;
|
|
2155
2218
|
}
|
|
2156
|
-
|
|
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
|
-
}
|
|
2219
|
+
renderTokenStats(el, entry);
|
|
2178
2220
|
return;
|
|
2179
2221
|
}
|
|
2180
2222
|
|
|
@@ -2206,6 +2248,7 @@ function apply(ctx) {
|
|
|
2206
2248
|
if (entry.kind === "awaiting") {
|
|
2207
2249
|
const traceContainer = el.querySelector(".dap-trace");
|
|
2208
2250
|
if (traceContainer !== null) renderTimelineArea(traceContainer, entry);
|
|
2251
|
+
renderAwaitingDuration(el, entry);
|
|
2209
2252
|
const confirm = el.querySelector(".dap-confirm");
|
|
2210
2253
|
if (confirm !== null) {
|
|
2211
2254
|
// 激活锚点:只在结构重建时绑一次(卡片按 id 复用,kind 变化会重建骨架)。
|
|
@@ -2758,6 +2801,7 @@ function apply(ctx) {
|
|
|
2758
2801
|
const liveRecord = livenessById.get(entry.id);
|
|
2759
2802
|
const live = liveRecord?.liveness ?? null;
|
|
2760
2803
|
const detail = sessionDetailsById.get(entry.id);
|
|
2804
|
+
if (entry.kind === "running" && detail) detail.durationFallbackLoaded = false;
|
|
2761
2805
|
const detailSnapshot = liveRecord?.snapshot ?? detail?.snapshot ?? null;
|
|
2762
2806
|
if (detail && detail.memoHistoryAnchorOf !== (detail.history ?? null)) {
|
|
2763
2807
|
detail.memoHistoryAnchorOf = detail.history ?? null;
|
|
@@ -2802,6 +2846,15 @@ function apply(ctx) {
|
|
|
2802
2846
|
detail.memoOpenTurnStart = openTurnStartFromEvents(detail.history, hint);
|
|
2803
2847
|
}
|
|
2804
2848
|
}
|
|
2849
|
+
if (entry.kind === "awaiting" && detail) {
|
|
2850
|
+
const history = detail.history ?? null;
|
|
2851
|
+
if (detail.memoTurnDurationSnapshotOf !== detailSnapshot || detail.memoTurnDurationHistoryOf !== history) {
|
|
2852
|
+
detail.memoTurnDurationSnapshotOf = detailSnapshot;
|
|
2853
|
+
detail.memoTurnDurationHistoryOf = history;
|
|
2854
|
+
detail.memoTurnDuration = lastTurnDuration({ turnTimings: detailSnapshot?.turnTimings, history });
|
|
2855
|
+
}
|
|
2856
|
+
entry.elapsedMs = detail.memoTurnDuration ?? null;
|
|
2857
|
+
}
|
|
2805
2858
|
if (detail?.model) {
|
|
2806
2859
|
entry.model = detail.model.model;
|
|
2807
2860
|
entry.reasoning = detail.model.reasoning;
|
|
@@ -2884,9 +2937,10 @@ function apply(ctx) {
|
|
|
2884
2937
|
entry.loadingPreviews = (!entry.userPreview || !entry.agentPreview) && historyLoads.has(entry.id);
|
|
2885
2938
|
}
|
|
2886
2939
|
// 补充数据读取优先级:当前会话最优先,活动区先于历史区(区内按显示顺序)。
|
|
2940
|
+
const durationFallbackIds = new Set(active.filter((entry) => entry.kind === "awaiting").map((entry) => entry.id));
|
|
2887
2941
|
const detailIds = [...active, ...recent].map((entry) => entry.id);
|
|
2888
2942
|
detailIds.sort((a, b) => Number(String(b) === String(snapshot?.current)) - Number(String(a) === String(snapshot?.current)));
|
|
2889
|
-
loadNativeDetails(detailIds, previewFallbackIds);
|
|
2943
|
+
loadNativeDetails(detailIds, previewFallbackIds, durationFallbackIds);
|
|
2890
2944
|
const visibleIds = new Set([...active, ...recent].map((entry) => entry.id));
|
|
2891
2945
|
// 详情与 loads 记账同生命周期:离开可见集合即放行,重回可见时允许重拉/重试。
|
|
2892
2946
|
// 锚点记账不随可见性 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
|
-
((
|
|
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]
|
|
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
|
* (子代理是临时工作单元,不入最近历史;故需同时排除表白会话与已结束子代理),
|