dsh-workplan 1.5.2 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +123 -1
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/client.js +107 -25
- package/lib/host.js +1 -1
- package/lib/runtime.js +124 -11
- package/package.json +1 -1
- package/src/client.ts +107 -28
- package/src/host.ts +1 -1
- package/src/runtime.ts +118 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,96 @@
|
|
|
1
|
+
## 1.6.0 — 2026-10-29
|
|
2
|
+
|
|
3
|
+
**「会话终止」收敛版**。用户截图圈出 `session-7129faea-d4bd-4ed5-a081-769496e07ceb`:
|
|
4
|
+
这一轮对话早就结束了(没有任何东西在跑),面板红圈的分组仍写着**「进行中」**,卡片还停在
|
|
5
|
+
「预算用完,可续跑」。本次让**宿主会话生命周期**成为「到底有没有在跑」的唯一事实源,并把
|
|
6
|
+
该结论落成新的独立阶段 `ended` / 独立分组「已结束」,同时修掉回放路径上把失败写入计为完成的
|
|
7
|
+
第二个打架点。
|
|
8
|
+
|
|
9
|
+
### 事故与根因
|
|
10
|
+
|
|
11
|
+
事故会话的关键事实(来自会话日志回放):
|
|
12
|
+
|
|
13
|
+
1. 插件**只订阅 `agent/pre-step`**(`src/host.ts:286`),`turn/end`、`session/event`、
|
|
14
|
+
`session/disposed` 一个都不消费 → **会话结束这件事对插件不可见**;
|
|
15
|
+
2. 计划阶段只由「派发动作」写入(`executing` / `paused` / `blocked` …),派发结束后
|
|
16
|
+
再没有任何代码把它改回来,于是阶段永久停在被最后一次派发写下的值;
|
|
17
|
+
3. 唯一存在的兜底只覆盖一种情形:**从未派发**且 `pending` 超过 10 分钟才改判 `cancelled`
|
|
18
|
+
(`src/runtime.ts` 的 `STALE_PENDING_MS` 分支),对「派发过、会话结束」完全无效;
|
|
19
|
+
4. 客户端把**除 done 以外的一切阶段**都塞进「进行中」组
|
|
20
|
+
(分组条件只有 `planPhaseOf(...) !== 'done'`)→ `paused` / `blocked` / `pending` /
|
|
21
|
+
`executing` / `degraded` 全部显示成「进行中」;
|
|
22
|
+
5. 回放路径 `reconstructFromSession` 见到 `dsh_workplan_update` 调用就 `applyStatus`,
|
|
23
|
+
**忽略宿主 `tool/result` 的 `message.isError`** → 该会话里一次失败的写入被记成完成
|
|
24
|
+
(面板显示 7/12,真实完成 6/12),刷新前后又一次「数据源打架」。
|
|
25
|
+
|
|
26
|
+
结论:这是**插件缺陷**,不是上游缺失——宿主侧 `turn/start` / `turn/end`(含
|
|
27
|
+
`aborted.reason.kind` 的 `user` / `parent` / `hook` / `disposed`)事件齐全且已落盘。
|
|
28
|
+
|
|
29
|
+
### 改动
|
|
30
|
+
|
|
31
|
+
- **`src/runtime.ts`**
|
|
32
|
+
- 新增并导出 `sessionTurnState(session)`:从会话日志末尾向前扫最近 200 条
|
|
33
|
+
(`TURN_BOUNDARY_SCAN`),先遇 `turn/start` ⇒ 这一轮还开着,先遇 `turn/end` ⇒
|
|
34
|
+
已结束并带出 `reason.kind` 与 `aborted` 子原因;扫不到 ⇒ `unknown`(**绝不猜**)。
|
|
35
|
+
读日志而不是加事件订阅:日志既是实时事实也是回放事实,实时与刷新后必然同源。
|
|
36
|
+
- `serialisePlan()`(实时与回放共用的唯一出口)在既有的过期 `pending` 兜底之后、
|
|
37
|
+
不变量自检之前收敛:`planPhase` 不是 `done` / `cancelled` 且这一轮已结束 ⇒
|
|
38
|
+
阶段改为 `ended`,原阶段写进 `suspendedPhase`,并附带 `endReason` /
|
|
39
|
+
`endReasonDetail` / `endedAt`。**`done` 不参与改写**(否则直接违反不变量 I8)。
|
|
40
|
+
- **`turn/end` 不等于「取消」或「完成」**:一轮结束后可以随时续跑,因此只把
|
|
41
|
+
「没有东西在跑」这件事表达出来,不动 `record.phase`——用户说「继续」、日志出现
|
|
42
|
+
新的 `turn/start` 后,下一份载荷自然回到原派发阶段,无反向迁移逻辑。
|
|
43
|
+
- 回放改为**先登记、后定案**:`tool/call` 只记 `callId → 参数`,等配对
|
|
44
|
+
`tool/result` 到达才采纳;`message.isError === true` 丢弃,无结果同样不采纳
|
|
45
|
+
(保守优先于乐观)。跨计划的迟到结果按 `planId` 拒绝,不污染新计划。
|
|
46
|
+
- **`src/client.ts`**
|
|
47
|
+
- 新增阶段 `ended`(中「已结束,可续跑」/ 英「Ended (resumable)」)与**第三个独立分组
|
|
48
|
+
「已结束」**,位置在「进行中」与「已完成」之间;分组、组头计数、卡片胶囊继续共用
|
|
49
|
+
`planPhaseOf` 单一结论。
|
|
50
|
+
- 新增 `phaseLabel()`:徽标两段式表达「终态 + 原阶段」(如
|
|
51
|
+
「已结束,可续跑 · 预算用完,可续跑」「已结束,可续跑 · 卡住了」),
|
|
52
|
+
不再用一个词暗示还在跑,也不丢「停在哪儿」这个关键信息。
|
|
53
|
+
- 「已结束」组保留「需处理」角标(判定用后端下发的 `suspendedPhase`),
|
|
54
|
+
组头按 `endedAt` 排序(最近结束在前);有进行中/已结束计划时「已完成」组默认收起。
|
|
55
|
+
- 图表口径随之改名:环图为「未结束 / 已完成」,柱图为「未结束计划的完成度」,
|
|
56
|
+
已结束但未完成的计划一并计入「未结束」。
|
|
57
|
+
- 新增 `.dsh-wp-group--ended` 样式:灰点、**不脉冲**——「没有东西在跑」不允许用动画表达。
|
|
58
|
+
- **版本**:`1.5.2 → 1.6.0`(minor)。载荷新增字段、客户端新增分组,属**向后兼容的对外
|
|
59
|
+
扩展**而非纯 bugfix:旧客户端读新载荷只会忽略 `ended` 相关字段并把计划当 `pending`,
|
|
60
|
+
不会崩溃,但**看不到「已结束」**,所以建议同步升级。
|
|
61
|
+
|
|
62
|
+
### 测试(新增 17 条,全部可复跑)
|
|
63
|
+
|
|
64
|
+
- `tests/session-end-convergence.test.ts`(12 条,后端/载荷级):正常结束、异常中断
|
|
65
|
+
(`aborted/user`)、进程级中断(`aborted/disposed`)、**正在跑不误判**、全部完成仍是
|
|
66
|
+
`done` 且无不变量违规、结束后说「继续」阶段回退、刷新幂等、双会话隔离、
|
|
67
|
+
扫描上限返回 `unknown`、回放只采纳成功写入(isError / 无结果 / 成功三态)。
|
|
68
|
+
- `tests/ended-group.test.ts`(5 条,渲染级):`ended` 计划只进「已结束」组、组名不再出现
|
|
69
|
+
「进行中」、徽标两段式、卡住的计划仍报「需处理」、无 `ended` 时不渲染空组、
|
|
70
|
+
「已完成」组折叠规则。
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
npm test # 137 tests / 137 pass / 0 fail
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 验证方式(任何人都可复跑)
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# 1) 完整回归(含本次新增的 17 条用例)
|
|
80
|
+
cd /Users/open/Documents/Stocks/plugins/dsh-workplan && npm test
|
|
81
|
+
# 2) 只跑本次的两个新用例文件
|
|
82
|
+
node --test dist-tests/tests/session-end-convergence.test.js dist-tests/tests/ended-group.test.js
|
|
83
|
+
# 3) 面板验收(人工):打开一个「已结束、未完成」的会话,
|
|
84
|
+
# 任务进度面板应把它放在「已结束」组,徽标写「已结束,可续跑 · …」;
|
|
85
|
+
# 说一句「继续」后,同一张卡应回到「进行中」。
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 兼容与升级
|
|
89
|
+
|
|
90
|
+
- 已装 1.5.x 的用户:升级后刷新页面即可,「已结束」组立即生效;
|
|
91
|
+
- 若面板在升级后仍显示「进行中」:先确认宿主版本已把 `turn/end` 落盘(1.6.0 依赖它,
|
|
92
|
+
未落盘时会保持原判定而不是误判),再确认浏览器已加载新 `client.js`。
|
|
93
|
+
|
|
1
94
|
## 1.5.2 — 2026-10-29
|
|
2
95
|
|
|
3
96
|
**「完成语义」统一版**。用户截图圈出一张 `14/14` 全部做完、环形进度 `100%` 的计划卡,
|
|
@@ -99,7 +192,36 @@
|
|
|
99
192
|
|
|
100
193
|
### 发布
|
|
101
194
|
|
|
102
|
-
-
|
|
195
|
+
- 两个渠道、同一份制品(2026-10-29 13:22–13:25 +08:00 实测,发布已完成):
|
|
196
|
+
- git:`https://github.com/abbaababa/dsh-workplan.git` 的 annotated tag `v1.5.2` → `666b0fe`;
|
|
197
|
+
- npm:公共 registry `https://registry.npmjs.org/dsh-workplan` 的 `1.5.2`(`dist-tags.latest`)。
|
|
198
|
+
- **状态**:
|
|
199
|
+
- `git push origin main` → `b0e0b76..666b0fe main -> main`(快进,exit 0);
|
|
200
|
+
- `git push origin v1.5.2` → `* [new tag] v1.5.2 -> v1.5.2`(exit 0);
|
|
201
|
+
- 远端复核(`git ls-remote`):`refs/heads/main` = `666b0fee483b…`,
|
|
202
|
+
`refs/tags/v1.5.2` = `155c601c73d7…` → `^{}` = `666b0fee483b…`;
|
|
203
|
+
- `npm publish ./dsh-workplan-1.5.2.tgz --access public --ignore-scripts`
|
|
204
|
+
(在 `git worktree add --detach /tmp/dsh-wp-1.5.2 v1.5.2` 的干净快照上先 `npm pack`,
|
|
205
|
+
再发布该 `.tgz`,22 个文件全部取自 tag 提交)→ `+ dsh-workplan@1.5.2`(exit 0);
|
|
206
|
+
- `https://registry.npmjs.org/dsh-workplan/1.5.2` → **HTTP 200**,
|
|
207
|
+
`time['1.5.2']` = `2026-10-29T05:24:48.346Z`(13:24:48 +08:00),`dist-tags.latest` = `1.5.2`;
|
|
208
|
+
- 消费者视角:空白目录 `npm i dsh-workplan@1.5.2` →
|
|
209
|
+
`meta = {"name":"dsh-workplan","version":"1.5.2",…}`(22 个文件)。
|
|
210
|
+
- **已发布制品的校验值**(registry 实测,与本地 `.tgz` 逐字节一致):
|
|
211
|
+
- npm `shasum`(SHA-1):`2159024d69c05b3aeeefd5e7ff2f208ec05c67a2`
|
|
212
|
+
- npm `integrity`(SHA-512 SRI):`sha512-sY7UgT09JwfRFe61HKCOCH7wLz0xbdAxyLla3WO8xoyZ/T6Yxb6H/IqRFf9LEX9EsnW7+z4jcHICms6RKPmTzQ==`
|
|
213
|
+
- 本地 `.tgz` SHA-256:`4482f202d098bbaec35051e54440d0d2be6e0099b6d6606e3c582b88e1aee9fa`
|
|
214
|
+
(269 264 字节,解包 888 135 字节,22 个文件)
|
|
215
|
+
- 发布顺序:先分支、后标签、再 npm,避免出现「远端有 tag 而 npm 没有」的半发布态。
|
|
216
|
+
- registry 传播延迟实测约 45 秒(直连 404×3 → 200)。
|
|
217
|
+
- **CI 说明**:tag 提交 `666b0fe` 的 verify 运行 `40823115340` 为 failure——20 个用例在
|
|
218
|
+
Node 22 上被 `cancelledByParent`(`Promise resolution is still pending but the event loop
|
|
219
|
+
has already resolved`),与 1.5.1 / 1.5.0 / 1.4.3 的推送运行失败原因完全相同(各 20 条),
|
|
220
|
+
属**既有 CI 缺陷**,与本次改动无关,也不影响制品(测试不入包)。根因是运行时把轮次预算/
|
|
221
|
+
空闲计时器 `unref` 掉后,Node 22 在事件循环清空时取消挂起的测试。已在
|
|
222
|
+
`9f0de34`(只改 `tests/` 两个文件,`git diff v1.5.2 -- . ':!tests' ':!docs' ':!scripts'` 为空)
|
|
223
|
+
用「测试期间持有一个 ref 句柄、`after` 钩子释放」修掉;对应运行 `40823694198` 全绿。
|
|
224
|
+
- 命令原文与发布后验证:见 `docs/release-record-1.5.2-20261029.md`。
|
|
103
225
|
|
|
104
226
|
### 回滚
|
|
105
227
|
|
package/README.en.md
CHANGED
|
@@ -53,7 +53,7 @@ dsh plugin --profile web add "$(pwd)"
|
|
|
53
53
|
4. Open **Task progress** in the sidebar:
|
|
54
54
|
|
|
55
55
|
- five headline numbers: **tasks / work items / done / in progress / blocked**;
|
|
56
|
-
- plan cards grouped into **In progress** and **Completed**, each with a completion rate;
|
|
56
|
+
- plan cards grouped into **In progress**, **Ended** and **Completed**, each with a completion rate. **Ended** means the turn has stopped (nothing is running) while the plan is still unfinished; the card also says where it stopped (budget spent / stuck / not started) and resuming is one sentence away;
|
|
57
57
|
- expand any plan to see per-stage progress and who owns each work item.
|
|
58
58
|
|
|
59
59
|
Every number is derived from the same task state — the panel can no longer claim "0 in progress" while the list below shows work running. That inconsistency is exactly what 1.4.3 fixed.
|
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ dsh plugin --profile web add "$(pwd)"
|
|
|
57
57
|
4. 点左侧栏的**「任务进度」**,就能看到:
|
|
58
58
|
|
|
59
59
|
- 顶部五个数字:**几个任务 / 分配了多少件工作 / 做好了 / 正在做 / 卡住了**;
|
|
60
|
-
-
|
|
60
|
+
- 计划卡:分「进行中」「已结束」「已完成」三组,右上角是完成率。「已结束」= 这一轮已经停了(没有东西在跑)但计划还没做完,卡片会写清停在哪儿(预算用完 / 卡住了 / 还没开始),说一句「继续」即可接着推进;
|
|
61
61
|
- 展开任意计划:每个阶段的进度、每件具体工作由哪个部门负责、当前状态。
|
|
62
62
|
|
|
63
63
|
5. 进度数字背后的每一行,都来自同一份任务状态——**不会再出现「显示 0 个正在做、但下面明明写着正在做」这种自相矛盾**(1.4.3 专门修的就是这个,见下文)。
|
package/client.js
CHANGED
|
@@ -19,21 +19,26 @@ window.__ModuleLoader__.load({
|
|
|
19
19
|
"panel": "任务进度",
|
|
20
20
|
"groupLive": "进行中",
|
|
21
21
|
"groupDone": "已完成",
|
|
22
|
+
"groupEnded": "已结束",
|
|
22
23
|
"groupLiveHint": "还没结束,正在或即将推进",
|
|
23
24
|
"groupDoneHint": "已全部完成并收尾",
|
|
25
|
+
"groupEndedHint": "这一轮已经停了,没有东西在跑;说「续跑」可继续推进",
|
|
24
26
|
"groupCollapse": "收起",
|
|
25
27
|
"groupExpand": "展开",
|
|
26
28
|
"groupNeeds": "需处理",
|
|
27
29
|
"groupLiveEmpty": "没有进行中的任务",
|
|
28
30
|
"groupDoneEmpty": "还没有完成的任务",
|
|
31
|
+
"groupEndedEmpty": "没有已结束但未完成的任务",
|
|
29
32
|
"groupSummary": "未完成 {open} / 已完成 {done}(完成率 {pct}%)",
|
|
30
33
|
"groupSummaryNoRate": "未完成 {open} / 已完成 {done}(完成率暂无数据)",
|
|
31
34
|
"groupAria": "{title}组,{plans} 个计划,共 {total} 件具体工作,已完成 {done} 件,完成率 {pct}%。其中正在做 {active} 件、卡住 {blocked} 件、还没开始 {pending} 件。",
|
|
32
35
|
"groupAriaEmpty": "{title}组,{plans} 个计划,暂无具体工作项。",
|
|
33
36
|
"groupPlansLabel": "个计划",
|
|
34
|
-
"chartGroups": "
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
+
"chartGroups": "计划数量与完成情况",
|
|
38
|
+
"chartOpen": "未结束",
|
|
39
|
+
"chartDone": "已完成",
|
|
40
|
+
"chartLive": "未完成计划的完成度(按工作项)",
|
|
41
|
+
"chartMore": "另有 {n} 个未完成计划未画入图表",
|
|
37
42
|
"title": "任务进度:一眼看懂",
|
|
38
43
|
"subtitle": "用大白话说明:现在要做什么、谁在做、做到哪一步、接下来做什么。",
|
|
39
44
|
"helpTitle": "先看这三句",
|
|
@@ -67,7 +72,8 @@ window.__ModuleLoader__.load({
|
|
|
67
72
|
"blocked": "卡住了",
|
|
68
73
|
"paused": "预算用完,可续跑",
|
|
69
74
|
"degraded": "安排失败",
|
|
70
|
-
"cancelled": "已取消"
|
|
75
|
+
"cancelled": "已取消",
|
|
76
|
+
"ended": "已结束,可续跑"
|
|
71
77
|
},
|
|
72
78
|
"status": {
|
|
73
79
|
"pending": "还没开始",
|
|
@@ -183,6 +189,7 @@ window.__ModuleLoader__.load({
|
|
|
183
189
|
"en": {
|
|
184
190
|
"groupLive": "In progress",
|
|
185
191
|
"groupDone": "Completed",
|
|
192
|
+
"groupEnded": "Ended",
|
|
186
193
|
"groupLiveHint": "Not finished yet — running or about to run",
|
|
187
194
|
"groupDoneHint": "All work finished and wrapped up",
|
|
188
195
|
"groupCollapse": "Collapse",
|
|
@@ -190,14 +197,17 @@ window.__ModuleLoader__.load({
|
|
|
190
197
|
"groupNeeds": "needs attention",
|
|
191
198
|
"groupLiveEmpty": "No work in progress",
|
|
192
199
|
"groupDoneEmpty": "Nothing completed yet",
|
|
200
|
+
"groupEndedEmpty": "No ended-but-unfinished plans",
|
|
193
201
|
"groupSummary": "Open {open} / done {done} ({pct}% complete)",
|
|
194
202
|
"groupSummaryNoRate": "Open {open} / done {done} (completion rate unavailable)",
|
|
195
203
|
"groupAria": "{title} group: {plans} plan(s), {total} work item(s), {done} done, {pct}% complete. Right now {active} in progress, {blocked} blocked, {pending} not started.",
|
|
196
204
|
"groupAriaEmpty": "{title} group: {plans} plan(s), no work items yet.",
|
|
197
205
|
"groupPlansLabel": "plan(s)",
|
|
198
|
-
"chartGroups": "Plan count
|
|
199
|
-
"
|
|
200
|
-
"
|
|
206
|
+
"chartGroups": "Plan count and completion",
|
|
207
|
+
"chartOpen": "Open",
|
|
208
|
+
"chartDone": "Completed",
|
|
209
|
+
"chartLive": "Open plans by work item",
|
|
210
|
+
"chartMore": "{n} more open plans not drawn",
|
|
201
211
|
"panel": "Task progress",
|
|
202
212
|
"title": "Task progress at a glance",
|
|
203
213
|
"subtitle": "Plain language: what this is, who is doing it, how far along it is, and what happens next.",
|
|
@@ -232,7 +242,8 @@ window.__ModuleLoader__.load({
|
|
|
232
242
|
"blocked": "Stuck",
|
|
233
243
|
"paused": "Paused (budget spent)",
|
|
234
244
|
"degraded": "Planning failed",
|
|
235
|
-
"cancelled": "Cancelled"
|
|
245
|
+
"cancelled": "Cancelled",
|
|
246
|
+
"ended": "Ended (resumable)"
|
|
236
247
|
},
|
|
237
248
|
"status": {
|
|
238
249
|
"pending": "Not started",
|
|
@@ -402,6 +413,7 @@ window.__ModuleLoader__.load({
|
|
|
402
413
|
.dsh-wp-group::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 4px; }
|
|
403
414
|
.dsh-wp-group--live::before { background: linear-gradient(180deg, #fb923c, #f97316); }
|
|
404
415
|
.dsh-wp-group--done::before { background: linear-gradient(180deg, #38bdf8, #0284c7); }
|
|
416
|
+
.dsh-wp-group--ended::before { background: linear-gradient(180deg, #94a3b8, #64748b); }
|
|
405
417
|
/* T6 — 组头多出一行完成度摘要,底部内边距 10px → 6px,两组仍在同一屏内读完。 */
|
|
406
418
|
.dsh-wp-group-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 0 4px 6px; }
|
|
407
419
|
.dsh-wp-group-head--btn { width: 100%; background: none; border: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: 8px; }
|
|
@@ -428,6 +440,8 @@ window.__ModuleLoader__.load({
|
|
|
428
440
|
.dsh-wp-group-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
|
|
429
441
|
.dsh-wp-group--live .dsh-wp-group-dot { background: #f97316; box-shadow: 0 0 0 4px rgba(249,115,22,.16); animation: dsh-wp-pulse 1.8s ease-in-out infinite; }
|
|
430
442
|
.dsh-wp-group--done .dsh-wp-group-dot { background: #0ea5e9; box-shadow: 0 0 0 4px rgba(14,165,233,.14); }
|
|
443
|
+
/* [1.6.0] 已结束:灰点、不脉冲——"没有东西在跑"这件事不允许用动画表达。 */
|
|
444
|
+
.dsh-wp-group--ended .dsh-wp-group-dot { background: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.16); }
|
|
431
445
|
.dsh-wp-group-title { font-size: 15px; font-weight: 700; letter-spacing: .2px; color: var(--dsw-alias-label-primary, #0f172a); }
|
|
432
446
|
/* 设计稿 §6.1:橙 #f97316 / 白 ≈2.8:1、蓝 #0ea5e9 / 白 ≈2.77:1,均不足 4.5:1,
|
|
433
447
|
15px 粗体也不算法定「大字号」,所以组头一律用主文本色;品牌色只做「面」——
|
|
@@ -436,6 +450,7 @@ window.__ModuleLoader__.load({
|
|
|
436
450
|
.dsh-wp-group-count { min-width: 22px; padding: 0 7px; height: 20px; border-radius: 999px; font-size: 12px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; color: var(--dsw-alias-label-primary, #0f172a); font-variant-numeric: tabular-nums; }
|
|
437
451
|
.dsh-wp-group--live .dsh-wp-group-count { background: rgba(249,115,22,.14); box-shadow: inset 0 0 0 1px rgba(249,115,22,.34); }
|
|
438
452
|
.dsh-wp-group--done .dsh-wp-group-count { background: rgba(14,165,233,.14); box-shadow: inset 0 0 0 1px rgba(14,165,233,.34); }
|
|
453
|
+
.dsh-wp-group--ended .dsh-wp-group-count { background: rgba(148,163,184,.16); box-shadow: inset 0 0 0 1px rgba(148,163,184,.36); }
|
|
439
454
|
/* WCAG AA:错误色叠 12% 红底后,浅色 #ec1313 只有 3.70:1;与主文本色混合 20%
|
|
440
455
|
让浅色变深、深色变浅,两套主题都 ≥4.5:1(实测 5.16:1 / 5.67:1)。 */
|
|
441
456
|
.dsh-wp-group-alert { font-size: 12px; font-weight: 650; color: var(--dsw-alias-state-error-primary, #ef4444); color: color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 80%, var(--dsw-alias-label-primary, #0f172a) 20%); background: rgba(239,68,68,.12); padding: 1px 8px; border-radius: 999px; }
|
|
@@ -748,6 +763,7 @@ window.__ModuleLoader__.load({
|
|
|
748
763
|
.dsh-wp-group::before { content: ''; position: absolute; left: 0; top: 14px; bottom: 14px; width: 3px; border-radius: 0 3px 3px 0; opacity: .9; }
|
|
749
764
|
.dsh-wp-group--live::before { background: linear-gradient(180deg, #fbbf24, #ea580c); }
|
|
750
765
|
.dsh-wp-group--done::before { background: linear-gradient(180deg, #7dd3fc, #0369a1); }
|
|
766
|
+
.dsh-wp-group--ended::before { background: linear-gradient(180deg, #cbd5e1, #94a3b8); }
|
|
751
767
|
.dsh-wp-group--done { opacity: .97; }
|
|
752
768
|
.dsh-wp-group-head--btn { transition: background-color .16s ease; }
|
|
753
769
|
.dsh-wp-group-head--btn:hover { background: rgba(148,163,184,.10); }
|
|
@@ -980,6 +996,12 @@ window.__ModuleLoader__.load({
|
|
|
980
996
|
function phaseTone(phase) {
|
|
981
997
|
if (phase === 'blocked')
|
|
982
998
|
return 'blocked';
|
|
999
|
+
/*
|
|
1000
|
+
* [1.6.0] `ended` = 这一轮停了,但计划没做完。它既不是成功(done),也不能
|
|
1001
|
+
* 冒充正在干活(active);用中性 idle 色,卡在哪由二级徽标 suspendedPhase 说明。
|
|
1002
|
+
*/
|
|
1003
|
+
if (phase === 'ended')
|
|
1004
|
+
return 'idle';
|
|
983
1005
|
if (phase === 'degraded' || phase === 'cancelled' || phase === 'paused')
|
|
984
1006
|
return 'warn';
|
|
985
1007
|
if (phase === 'integrating' || phase === 'done')
|
|
@@ -988,6 +1010,23 @@ window.__ModuleLoader__.load({
|
|
|
988
1010
|
return 'active';
|
|
989
1011
|
return 'idle';
|
|
990
1012
|
}
|
|
1013
|
+
/*
|
|
1014
|
+
* [1.6.0] 「已结束」徽标要把「停在哪儿」一起说出来。
|
|
1015
|
+
*
|
|
1016
|
+
* 只说「已结束,可续跑」,用户不知道是预算用完、卡住了、还是压根没开始;
|
|
1017
|
+
* 只说原阶段(「正在做」),又会像事故截图那样暗示还在跑。两段式表达:
|
|
1018
|
+
* 终态 + 原阶段,一次说清两个事实。
|
|
1019
|
+
*/
|
|
1020
|
+
function phaseLabel(phase, session, t) {
|
|
1021
|
+
const base = t(`phase.${phase}`);
|
|
1022
|
+
if (phase !== 'ended')
|
|
1023
|
+
return base;
|
|
1024
|
+
const suspended = String(session?.suspendedPhase ?? '');
|
|
1025
|
+
if (!suspended)
|
|
1026
|
+
return base;
|
|
1027
|
+
const detail = t(`phase.${suspended}`);
|
|
1028
|
+
return detail && detail !== base ? `${base} · ${detail}` : base;
|
|
1029
|
+
}
|
|
991
1030
|
function allEntries(session) {
|
|
992
1031
|
const byId = new Map((Array.isArray(session?.statuses) ? session.statuses : []).map((item) => [item.subtaskId, item]));
|
|
993
1032
|
const entries = [];
|
|
@@ -1637,7 +1676,7 @@ window.__ModuleLoader__.load({
|
|
|
1637
1676
|
panelClassName: 'dsh-wp-session-body',
|
|
1638
1677
|
title: session.title || t('panel'),
|
|
1639
1678
|
meta: h(PlanViz, { t, counts, now, rows: stageRows }),
|
|
1640
|
-
badge: h(Pill, { tone },
|
|
1679
|
+
badge: h(Pill, { tone }, phaseLabel(phase, session, t)),
|
|
1641
1680
|
}, session.updatedAt
|
|
1642
1681
|
? h('div', { className: 'dsh-wp-meta dsh-wp-session-updated' }, `${t('updatedAt')} ${formatTime(session.updatedAt)}`)
|
|
1643
1682
|
: null, open === true ? h(PlanOverview, { t, stats, counts, rows: stageRows }) : null, session.degraded ? h('div', { className: 'dsh-wp-banner dsh-wp-banner--degraded' }, h('strong', null, t('degraded')), h('div', null, plainError(session.error || '')), h('div', { className: 'dsh-wp-muted' }, t('degradedHint'))) : null, h(Guide, { session, t }), departments.length ? h('div', { className: 'dsh-wp-section' }, h('div', { className: 'dsh-wp-section-title' }, t('who')), h('div', { className: 'dsh-wp-departments' }, departments.map((dept) => h(Pill, { key: dept.id, tone: dept.blocked > 0 ? 'blocked' : dept.done === dept.total ? 'done' : 'active' }, `${deptName(t, dept.id)} ${dept.done}/${dept.total} ${t('stats.done')}`)))) : null, h('div', { className: 'dsh-wp-section' }, h('div', { className: 'dsh-wp-section-title' }, t('steps')), (session.stages || []).map((stage, index) => h(StageCard, {
|
|
@@ -1722,7 +1761,7 @@ window.__ModuleLoader__.load({
|
|
|
1722
1761
|
charts.push(new lib(canvasA, {
|
|
1723
1762
|
type: 'doughnut',
|
|
1724
1763
|
data: {
|
|
1725
|
-
labels: [t('
|
|
1764
|
+
labels: [t('chartOpen'), t('chartDone')],
|
|
1726
1765
|
datasets: [{ data: [liveCount, doneCount], backgroundColor: [liveFill, doneFill], borderWidth: 0, hoverOffset: 5 }],
|
|
1727
1766
|
},
|
|
1728
1767
|
options: {
|
|
@@ -1783,8 +1822,8 @@ window.__ModuleLoader__.load({
|
|
|
1783
1822
|
// 「无 canvas → 不建图 → 不 ready → 无 canvas」的死循环(T3 实测
|
|
1784
1823
|
// 探针输出 canvasNodes:0 / fallbackBars:1)。改为并存:
|
|
1785
1824
|
// fallback 在 ready 前可见,ready 后由 CSS 隐藏,canvas 反向隐藏。
|
|
1786
|
-
h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'groups', role: 'img', 'aria-label': `${t('
|
|
1787
|
-
? h('div', { className: 'dsh-wp-fallback' }, h('div', { className: 'dsh-wp-fallback-bar', role: 'img', 'aria-label': `${t('
|
|
1825
|
+
h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'groups', role: 'img', 'aria-label': `${t('chartOpen')} ${liveCount} / ${t('chartDone')} ${doneCount}` })), chartState !== 'ready'
|
|
1826
|
+
? h('div', { className: 'dsh-wp-fallback' }, h('div', { className: 'dsh-wp-fallback-bar', role: 'img', 'aria-label': `${t('chartOpen')} ${liveCount} / ${t('chartDone')} ${doneCount}` }, h('span', { className: 'dsh-wp-fallback-live', style: { width: `${livePct}%` } }), h('span', { className: 'dsh-wp-fallback-done', style: { width: `${donePct}%` } })), h('div', { className: 'dsh-wp-fallback-legend' }, h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--live' }), `${t('chartOpen')} ${liveCount}`), h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--done' }), `${t('chartDone')} ${doneCount}`)))
|
|
1788
1827
|
: null), (liveBars || []).length
|
|
1789
1828
|
? h('div', { className: 'dsh-wp-chart-card' }, h('div', { className: 'dsh-wp-chart-title' }, t('chartLive')), h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'live', role: 'img', 'aria-label': t('chartLive') })), hiddenBars > 0 ? h('div', { className: 'dsh-wp-chart-more' }, fill(t('chartMore'), { n: hiddenBars })) : null)
|
|
1790
1829
|
: null);
|
|
@@ -1883,11 +1922,18 @@ window.__ModuleLoader__.load({
|
|
|
1883
1922
|
: null), h('span', { className: 'dsh-wp-group-ring-label' }, value === null ? '—' : `${value}%`));
|
|
1884
1923
|
}
|
|
1885
1924
|
function SessionGroup({ group, list, hasLive, urgent, t, openSessionId, setOpenSessionId }) {
|
|
1925
|
+
/*
|
|
1926
|
+
* [1.6.0] 三个分组共用一套组件:live(真的在跑)、ended(这一轮停了但没做完)、
|
|
1927
|
+
* done(全部完成)。`ended` 是本次事故的修复核心——它必须是一个**独立分组**,
|
|
1928
|
+
* 而不是被塞回「进行中」:会话已经结束的事实不允许用「进行中」这三个字表达。
|
|
1929
|
+
*/
|
|
1886
1930
|
const isDone = group === 'done';
|
|
1931
|
+
const isEnded = group === 'ended';
|
|
1932
|
+
const keySuffix = isDone ? 'Done' : isEnded ? 'Ended' : 'Live';
|
|
1887
1933
|
// 设计稿 §6.4 #1/#6:已完成组在有进行中任务时默认折叠,避免历史任务抢走
|
|
1888
1934
|
// 「进行中」的视觉权重;没有进行中任务时默认展开,空的进度栏仍是空的。
|
|
1889
|
-
//
|
|
1890
|
-
const [open, setOpen] = useState(!(isDone && hasLive));
|
|
1935
|
+
// [1.6.0] 「已结束」组同理:还有东西在跑时收起来,只留组头一行结论。
|
|
1936
|
+
const [open, setOpen] = useState(!((isDone || isEnded) && hasLive));
|
|
1891
1937
|
// §7.3「不打扰」的自动展开:只在这两种情形打开一次,不做反复弹开——
|
|
1892
1938
|
// ① 进行中清零(最后一个任务完成);② 用户正展开着的卡片被移进本组。
|
|
1893
1939
|
// 依赖都是布尔值,轮询刷新不会重复触发。
|
|
@@ -1896,7 +1942,7 @@ window.__ModuleLoader__.load({
|
|
|
1896
1942
|
setOpen(true); }, [hasLive]);
|
|
1897
1943
|
useEffect(() => { if (holdsOpenCard)
|
|
1898
1944
|
setOpen(true); }, [holdsOpenCard]);
|
|
1899
|
-
const collapsible = isDone && list.length > 0;
|
|
1945
|
+
const collapsible = (isDone || isEnded) && list.length > 0;
|
|
1900
1946
|
// 折叠只隐藏、不卸载:`hidden` 必须是布尔 true,不能传空字符串——
|
|
1901
1947
|
// React 对 `hidden` 走 DOM 布尔属性赋值,`el.hidden = ''` 会得到 false,
|
|
1902
1948
|
// 只剩 CSS 类在遮挡;那样「折叠态 DOM 保留 + 无障碍语义」就不成立。
|
|
@@ -1907,8 +1953,8 @@ window.__ModuleLoader__.load({
|
|
|
1907
1953
|
// T6:组头完成度摘要 + 零依赖进度环。数值只来自 groupTotals(list),
|
|
1908
1954
|
// 先用纯文本给出「进行中 N / 已完成 M(完成率 x%)」,不点开、不看颜色也能读出进度。
|
|
1909
1955
|
const totals = useMemo(() => groupTotals(list), [list]);
|
|
1910
|
-
const groupTitle = t(
|
|
1911
|
-
const hintText = t(
|
|
1956
|
+
const groupTitle = t(`group${keySuffix}`);
|
|
1957
|
+
const hintText = t(`group${keySuffix}Hint`);
|
|
1912
1958
|
// 用户 Q3:0/0 时不再显示「未完成 0 / 已完成 0(完成率暂无数据)」——
|
|
1913
1959
|
// 环上已经是「—」,组内空态也已说明没有工作项;再重复一遍 0/0 只是噪声。
|
|
1914
1960
|
// 全部做完(100%)仍保留整句,因为那时「未完成 0」正是用户要找的结论。
|
|
@@ -1973,7 +2019,7 @@ window.__ModuleLoader__.load({
|
|
|
1973
2019
|
open: openSessionId === session.sessionId,
|
|
1974
2020
|
onToggle: (next) => setOpenSessionId(next ? session.sessionId : null),
|
|
1975
2021
|
})))
|
|
1976
|
-
: h('div', { className: 'dsh-wp-group-empty' }, t(
|
|
2022
|
+
: h('div', { className: 'dsh-wp-group-empty' }, t(`group${keySuffix}Empty`)));
|
|
1977
2023
|
}
|
|
1978
2024
|
function Donut({ totals, t }) {
|
|
1979
2025
|
const total = Math.max(0, Number(totals?.total) || 0);
|
|
@@ -2036,7 +2082,7 @@ window.__ModuleLoader__.load({
|
|
|
2036
2082
|
'aria-label': `${t('jumpToTask')} ${session.title || t('panel')}`,
|
|
2037
2083
|
onClick: () => { if (typeof onSelect === 'function')
|
|
2038
2084
|
onSelect(session.sessionId); },
|
|
2039
|
-
}, h('div', { className: 'dsh-wp-bar-head' }, h('span', { className: 'dsh-wp-bar-title' }, session.title || t('panel')), h('span', { className: 'dsh-wp-bar-value' }, `${counts.done}/${counts.total}`)), h('div', { className: 'dsh-wp-bar-track' }, h('div', { className: `dsh-wp-bar-fill dsh-wp-bar-fill--${tone}`, style: { width: `${percent}%` } })), h('div', { className: 'dsh-wp-bar-meta' }, h('span', null,
|
|
2085
|
+
}, h('div', { className: 'dsh-wp-bar-head' }, h('span', { className: 'dsh-wp-bar-title' }, session.title || t('panel')), h('span', { className: 'dsh-wp-bar-value' }, `${counts.done}/${counts.total}`)), h('div', { className: 'dsh-wp-bar-track' }, h('div', { className: `dsh-wp-bar-fill dsh-wp-bar-fill--${tone}`, style: { width: `${percent}%` } })), h('div', { className: 'dsh-wp-bar-meta' }, h('span', null, phaseLabel(phase, session, t)), h('span', null, `${percent}%`)));
|
|
2040
2086
|
}));
|
|
2041
2087
|
}
|
|
2042
2088
|
function Dashboard({ t }) {
|
|
@@ -2084,6 +2130,7 @@ window.__ModuleLoader__.load({
|
|
|
2084
2130
|
}, [activeSessions]);
|
|
2085
2131
|
const groups = useMemo(() => {
|
|
2086
2132
|
const live = [];
|
|
2133
|
+
const ended = [];
|
|
2087
2134
|
const finished = [];
|
|
2088
2135
|
const urgentPhases = ['blocked', 'degraded', 'paused'];
|
|
2089
2136
|
/*
|
|
@@ -2093,10 +2140,17 @@ window.__ModuleLoader__.load({
|
|
|
2093
2140
|
* 14/14、100% 的计划带着 integrating 被塞进 live 组,组头于是出现
|
|
2094
2141
|
* 「未完成 0 / 已完成 14(完成率 100%)」却仍标着「进行中」。
|
|
2095
2142
|
* `planPhaseOf` 让分组、组头计数、卡片胶囊共用同一个结论。
|
|
2143
|
+
*
|
|
2144
|
+
* [1.6.0] 新增 ended 分组:宿主已经 `turn/end`(会话这一轮结束、没有任何
|
|
2145
|
+
* 东西在跑),而计划还没做完的,一律进「已结束」,绝不进「进行中」。
|
|
2146
|
+
* 这正是本次事故的现象:会话结束 4 小时后仍显示「进行中」。
|
|
2096
2147
|
*/
|
|
2097
2148
|
for (const session of activeSessions) {
|
|
2098
|
-
|
|
2149
|
+
const phase = planPhaseOf(session.phase, planCounts(session.stats));
|
|
2150
|
+
if (phase === 'done')
|
|
2099
2151
|
finished.push(session);
|
|
2152
|
+
else if (phase === 'ended')
|
|
2153
|
+
ended.push(session);
|
|
2100
2154
|
else
|
|
2101
2155
|
live.push(session);
|
|
2102
2156
|
}
|
|
@@ -2114,11 +2168,19 @@ window.__ModuleLoader__.load({
|
|
|
2114
2168
|
integrating: 3,
|
|
2115
2169
|
planning: 4,
|
|
2116
2170
|
pending: 5,
|
|
2171
|
+
ended: 6,
|
|
2117
2172
|
};
|
|
2118
2173
|
const rank = (session) => phaseRank[planPhaseOf(session.phase, planCounts(session.stats))] ?? 5;
|
|
2119
2174
|
live.sort((a, b) => (rank(a) - rank(b))
|
|
2120
2175
|
|| ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0))
|
|
2121
2176
|
|| String(a.sessionId).localeCompare(String(b.sessionId)));
|
|
2177
|
+
/*
|
|
2178
|
+
* [1.6.0] 已结束组按「最近结束在前」:`endedAt` 是宿主 turn/end 的时间戳
|
|
2179
|
+
* (后端下发),比 updatedAt 更准确地表达「什么时候停的」。
|
|
2180
|
+
*/
|
|
2181
|
+
const endedAt = (session) => Number(session.endedAt) || Number(session.updatedAt) || Number(session.createdAt) || 0;
|
|
2182
|
+
ended.sort((a, b) => (endedAt(b) - endedAt(a))
|
|
2183
|
+
|| ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0)));
|
|
2122
2184
|
/*
|
|
2123
2185
|
* 已完成组按「最近完成在前」(§3.3 / C8):优先取子任务
|
|
2124
2186
|
* statuses[].finishedAt 的最大值(忽略 null / 非数字),缺失则退回
|
|
@@ -2144,9 +2206,20 @@ window.__ModuleLoader__.load({
|
|
|
2144
2206
|
};
|
|
2145
2207
|
finished.sort((a, b) => (finishedAt(b) - finishedAt(a))
|
|
2146
2208
|
|| ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0)));
|
|
2147
|
-
return {
|
|
2209
|
+
return {
|
|
2210
|
+
live,
|
|
2211
|
+
ended,
|
|
2212
|
+
finished,
|
|
2213
|
+
urgent: live.filter((session) => urgentPhases.includes(session.phase)).length,
|
|
2214
|
+
/*
|
|
2215
|
+
* [1.6.0] 「已结束」组也需要「需处理」角标:卡住的计划停了仍然是卡住的,
|
|
2216
|
+
* 判定用后端下发的 suspendedPhase(原派发阶段),而不是被收敛后的 ended。
|
|
2217
|
+
*/
|
|
2218
|
+
urgentEnded: ended.filter((session) => urgentPhases.includes(String(session.suspendedPhase ?? ''))).length,
|
|
2219
|
+
};
|
|
2148
2220
|
}, [activeSessions]);
|
|
2149
|
-
|
|
2221
|
+
// [1.6.0] 图表口径 = 未结束计划(进行中 + 已结束未完成),与环图标签一致。
|
|
2222
|
+
const liveBars = useMemo(() => [...groups.live, ...groups.ended].slice(0, 6).map((session) => {
|
|
2150
2223
|
const raw = String(session.title || t('panel'));
|
|
2151
2224
|
return {
|
|
2152
2225
|
label: raw.length > 14 ? `${raw.slice(0, 14)}…` : raw,
|
|
@@ -2156,14 +2229,23 @@ window.__ModuleLoader__.load({
|
|
|
2156
2229
|
}), [groups, t]);
|
|
2157
2230
|
return h('div', { className: 'dsh-wp' }, h('header', { className: 'dsh-wp-header' }, h('div', null, h('h1', { className: 'dsh-wp-title' }, t('title')), h('p', { className: 'dsh-wp-subtitle' }, t('subtitle')), h('div', { className: 'dsh-wp-meta' }, updatedAt ? h('span', null, `${t('updatedAt')} ${formatTime(updatedAt)}`) : null, h('span', null, `${t('scopeAll')}:${activeSessions.length} ${t('sessionCount')},${t('taskBreakdown')} ${totals.total} ${t('subtaskCount')}`))), h('button', { type: 'button', className: 'dsh-wp-button', onClick: reload, disabled: loading }, t('refresh'))), error ? h(ErrorBanner, { message: error, onRetry: reload, t }) : null, activeSessions.length > 0 ? h('div', { className: 'dsh-wp-statbar', role: 'list' }, h('div', { className: 'dsh-wp-stat dsh-wp-stat--tasks', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, activeSessions.length), h('div', { className: 'dsh-wp-stat-label' }, t('sessionCount'))), h('div', { className: 'dsh-wp-stat', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.total), h('div', { className: 'dsh-wp-stat-label' }, t('workItems'))), h('div', { className: 'dsh-wp-stat dsh-wp-stat--done', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.done), h('div', { className: 'dsh-wp-stat-label' }, t('stats.done'))), h('div', { className: 'dsh-wp-stat dsh-wp-stat--active', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.inProgress), h('div', { className: 'dsh-wp-stat-label' }, t('stats.inProgress'))), h('div', { className: 'dsh-wp-stat dsh-wp-stat--blocked', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.blocked), h('div', { className: 'dsh-wp-stat-label' }, t('stats.blocked')))) : null, activeSessions.length > 0 ? h('div', { className: 'dsh-wp-scope-note' }, t('allPlansHint')) : null, activeSessions.length === 0
|
|
2158
2231
|
? h('div', { className: 'dsh-wp-empty' }, h('div', { style: { fontWeight: 650, marginBottom: '4px' } }, t('empty')), h('div', null, t('emptyHint')))
|
|
2159
|
-
: h('div', { className: 'dsh-wp-groups' },
|
|
2232
|
+
: h('div', { className: 'dsh-wp-groups' },
|
|
2233
|
+
/*
|
|
2234
|
+
* [1.6.0] 三组顺序:真的在跑 → 已结束(停在半路) → 已完成。
|
|
2235
|
+
* 「已结束」夹在中间是有意为之:它是用户最需要立刻知道的那一档
|
|
2236
|
+
* (刚停、还没做完),不能滑到历史组的下面去。
|
|
2237
|
+
*/
|
|
2238
|
+
h(SessionGroup, { key: 'live', group: 'live', list: groups.live, urgent: groups.urgent, t, openSessionId, setOpenSessionId }), groups.ended.length > 0
|
|
2239
|
+
? h(SessionGroup, { key: 'ended', group: 'ended', list: groups.ended, hasLive: groups.live.length > 0, urgent: groups.urgentEnded, t, openSessionId, setOpenSessionId })
|
|
2240
|
+
: null, h(SessionGroup, { key: 'done', group: 'done', list: groups.finished, hasLive: groups.live.length > 0 || groups.ended.length > 0, urgent: 0, t, openSessionId, setOpenSessionId }),
|
|
2160
2241
|
// 用户选择 B:总览块(完成率环 + 每个计划的进度条)同样下移到两组之后,
|
|
2161
2242
|
// 让开屏第一屏直接是「进行中」组的任务卡(此前统计条 → 总览 → 说明行 →
|
|
2162
2243
|
// 任务卡,第一张卡要下滑约一屏)。顶部只保留 5 格数字统计条与一行口径说明。
|
|
2163
2244
|
activeSessions.length > 0 ? h('div', { className: 'dsh-wp-overview' }, h(Donut, { totals, t }), h(TaskBars, { sessions: activeSessions, t, onSelect: selectSession })) : null,
|
|
2164
2245
|
// 图表放两组之后:两张卡各 ≈246px,排在组前会让首屏看不到任何一张任务卡
|
|
2165
2246
|
// (设计稿 §8 第 12 条,T8 复核时复现在 1.4.2 源码里)。
|
|
2166
|
-
|
|
2247
|
+
// [1.6.0] 「未结束」= 进行中 + 已结束但未完成:两者都还没做完,都是用户要盯的对象。
|
|
2248
|
+
h(GroupCharts, { liveCount: groups.live.length + groups.ended.length, doneCount: groups.finished.length, liveBars, hiddenBars: Math.max(0, groups.live.length + groups.ended.length - 6), t })));
|
|
2167
2249
|
}
|
|
2168
2250
|
function TurnCard({ sessionId, turn, t }) {
|
|
2169
2251
|
const { sessions } = usePlans(sessionId);
|
|
@@ -2188,7 +2270,7 @@ window.__ModuleLoader__.load({
|
|
|
2188
2270
|
const stats = session.stats || {};
|
|
2189
2271
|
const phase = planPhaseOf(session.phase || 'pending', planCounts(stats));
|
|
2190
2272
|
const tone = phaseTone(phase);
|
|
2191
|
-
return h('div', { className: `dsh-wp-turn dsh-wp-tone-${tone}` }, h('div', { className: 'dsh-wp-turn-title' }, h('span', null, session.title || t('panel')), h(Pill, { tone },
|
|
2273
|
+
return h('div', { className: `dsh-wp-turn dsh-wp-tone-${tone}` }, h('div', { className: 'dsh-wp-turn-title' }, h('span', null, session.title || t('panel')), h(Pill, { tone }, phaseLabel(phase, session, t))), h('div', { className: 'dsh-wp-muted', style: { marginTop: '6px' } }, `${t('helpWho')}:${workerText(session, t)}`), h('div', { className: 'dsh-wp-muted', style: { marginTop: '2px' } }, `${t('helpNext')}:${nextText(session, t)}`), h('div', { className: 'dsh-wp-progress-meta' }, h('span', null, `${stats.done ?? 0}/${stats.total ?? 0} ${t('stats.done')} · ${stats.blocked ?? 0} ${t('stats.blocked')}`), h('span', { className: 'dsh-wp-muted' }, t('viewHint'))));
|
|
2192
2274
|
}
|
|
2193
2275
|
function contentText(block) {
|
|
2194
2276
|
if (!block || !Array.isArray(block.content))
|
package/lib/host.js
CHANGED
package/lib/runtime.js
CHANGED
|
@@ -842,6 +842,59 @@ export async function dispatchPlan(ctx, record, options) {
|
|
|
842
842
|
}
|
|
843
843
|
return record;
|
|
844
844
|
}
|
|
845
|
+
/*
|
|
846
|
+
* [1.6.0] 会话「这一轮到底还在不在跑」的唯一事实源。
|
|
847
|
+
*
|
|
848
|
+
* 事故(session-7129faea…):会话早已结束,面板仍把计划列在「进行中」组,
|
|
849
|
+
* 卡片写着「预算用完,可续跑」——没有任何东西在跑,状态却表示正在跑。
|
|
850
|
+
*
|
|
851
|
+
* 根因:插件只订阅 `agent/pre-step`,`turn/end`、`session/disposed` 全都收不到;
|
|
852
|
+
* 计划状态因此只由「派发结果」驱动,而派发结束后不再有任何事件把它收敛。
|
|
853
|
+
*
|
|
854
|
+
* 修复不引入第二个事件订阅(跨 scope 的事件投递受 scope 过滤,不可靠;而且
|
|
855
|
+
* 订阅只覆盖实时路径,刷新/重启后全靠回放,两条链路一旦不同源就会来回翻转)。
|
|
856
|
+
* 这里直接读会话日志本身——它既是实时事实,也是回放事实:
|
|
857
|
+
*
|
|
858
|
+
* - 从末尾向前找最近的一条「轮次边界」事件:先遇到 `turn/end` ⇒ 这一轮已结束;
|
|
859
|
+
* 先遇到 `turn/start` ⇒ 这一轮还开着;都没找到 ⇒ 未知,调用方保持原判定。
|
|
860
|
+
* - 不存在「会话已结束」这种事件:宿主只有每轮 `turn/end`。因此**不能**把
|
|
861
|
+
* `turn/end` 直接等同于「计划取消/完成」——一轮结束后可以随时续跑,这也是
|
|
862
|
+
* 卡片提供「可续跑」的原因。它只证明「此刻没有东西在跑」。
|
|
863
|
+
* - 向后扫描设上限,避免越界;超过上限就返回 unknown,绝不猜测。
|
|
864
|
+
*/
|
|
865
|
+
const TURN_BOUNDARY_SCAN = 200;
|
|
866
|
+
export function sessionTurnState(session) {
|
|
867
|
+
const total = Number(session?.seq);
|
|
868
|
+
if (!Number.isFinite(total) || total <= 0 || typeof session?.eventAt !== 'function') {
|
|
869
|
+
return { state: 'unknown', reason: null, reasonDetail: null, at: null };
|
|
870
|
+
}
|
|
871
|
+
const floor = Math.max(0, Math.floor(total) - TURN_BOUNDARY_SCAN);
|
|
872
|
+
for (let seq = Math.floor(total) - 1; seq >= floor; seq -= 1) {
|
|
873
|
+
let event;
|
|
874
|
+
try {
|
|
875
|
+
event = session.eventAt(seq);
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
return { state: 'unknown', reason: null, reasonDetail: null, at: null };
|
|
879
|
+
}
|
|
880
|
+
if (!event)
|
|
881
|
+
continue;
|
|
882
|
+
if (event.type === 'turn/start')
|
|
883
|
+
return { state: 'open', reason: null, reasonDetail: null, at: null };
|
|
884
|
+
if (event.type === 'turn/end') {
|
|
885
|
+
const reason = event.data?.reason ?? null;
|
|
886
|
+
const detail = reason && reason.kind === 'aborted' ? (reason.reason?.kind ?? null) : null;
|
|
887
|
+
const at = Number(event.time);
|
|
888
|
+
return {
|
|
889
|
+
state: 'ended',
|
|
890
|
+
reason: typeof reason?.kind === 'string' ? reason.kind : 'completed',
|
|
891
|
+
reasonDetail: typeof detail === 'string' ? detail : null,
|
|
892
|
+
at: Number.isFinite(at) ? at : null,
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return { state: 'unknown', reason: null, reasonDetail: null, at: null };
|
|
897
|
+
}
|
|
845
898
|
function serialisePlan(record, session) {
|
|
846
899
|
const plan = record.plan ?? null;
|
|
847
900
|
/*
|
|
@@ -872,6 +925,36 @@ function serialisePlan(record, session) {
|
|
|
872
925
|
if (phase === 'pending' && !record.dispatchedAt && plan?.createdAt && Date.now() - plan.createdAt > STALE_PENDING_MS) {
|
|
873
926
|
phase = 'cancelled';
|
|
874
927
|
}
|
|
928
|
+
/*
|
|
929
|
+
* [1.6.0] 终态收敛:这一轮已经结束,而计划没有完成 ⇒ 不许再显示「进行中」。
|
|
930
|
+
*
|
|
931
|
+
* `phase` 到这里为止只由「派发动作」写入(executing / paused / blocked …),
|
|
932
|
+
* 而派发结束后没有任何事件把它改回来,于是会话结束、进程空闲、面板却仍在
|
|
933
|
+
* 「进行中」组里(事故截图)。上面 `sessionTurnState` 用会话日志回答
|
|
934
|
+
* 「此刻还有没有东西在跑」,这里把答案落成独立阶段 `ended`:
|
|
935
|
+
*
|
|
936
|
+
* - done / cancelled 已经是终态,不再改写(done 改写会直接违反不变量 I8);
|
|
937
|
+
* - 其余阶段一律收敛为 `ended`,并把原阶段留在 `suspendedPhase` 里,
|
|
938
|
+
* 卡片因此仍能显示「预算用完,可续跑」「卡住了」这类二级事实,不丢信息;
|
|
939
|
+
* - 只改**下发出去的那一份**,不动 `record.phase`:用户说「继续」后同一轮
|
|
940
|
+
* 重新开始(日志里出现新的 turn/start),下一份 payload 自然回到原来的
|
|
941
|
+
* 派发状态,不需要任何反向迁移逻辑;
|
|
942
|
+
* - 实时路径与回放路径都经过本函数,因此刷新前后结论必然一致(T4 硬约束)。
|
|
943
|
+
*/
|
|
944
|
+
let suspendedPhase = null;
|
|
945
|
+
let endReason = null;
|
|
946
|
+
let endReasonDetail = null;
|
|
947
|
+
let endedAt = null;
|
|
948
|
+
if (phase !== 'done' && phase !== 'cancelled') {
|
|
949
|
+
const turn = sessionTurnState(session);
|
|
950
|
+
if (turn.state === 'ended') {
|
|
951
|
+
suspendedPhase = phase;
|
|
952
|
+
endReason = turn.reason;
|
|
953
|
+
endReasonDetail = turn.reasonDetail;
|
|
954
|
+
endedAt = turn.at;
|
|
955
|
+
phase = 'ended';
|
|
956
|
+
}
|
|
957
|
+
}
|
|
875
958
|
/*
|
|
876
959
|
* 每次真正下发前跑一遍跨字段不变量(I1–I10)。正常情况下恒为空;一旦某个
|
|
877
960
|
* 未来改动重新引入「进度与状态打架」,这里会把它连同具体字段值一起带出去,
|
|
@@ -883,6 +966,7 @@ function serialisePlan(record, session) {
|
|
|
883
966
|
planId: record.planId ?? plan?.planId ?? null,
|
|
884
967
|
title: plan?.title ?? record.title ?? '任务进度',
|
|
885
968
|
phase,
|
|
969
|
+
...(phase === 'ended' ? { suspendedPhase, endReason, endReasonDetail, endedAt } : {}),
|
|
886
970
|
...(violations.length ? { violations } : {}),
|
|
887
971
|
degraded: record.degraded === true,
|
|
888
972
|
error: record.error ? truncate(record.error, 2000) : null,
|
|
@@ -950,6 +1034,8 @@ export function reconstructFromSession(session, targetPlanId) {
|
|
|
950
1034
|
* plan and let the caller ask for a specific one.
|
|
951
1035
|
*/
|
|
952
1036
|
const finished = [];
|
|
1037
|
+
/** [1.6.0] callId → 待确认的 update 写入;只有配对的 tool/result 才能定案。 */
|
|
1038
|
+
const pendingToolCalls = new Map();
|
|
953
1039
|
const snapshot = () => ({ plan, statuses, resultPhase, resultSeen, resumeCount, cancelled });
|
|
954
1040
|
for (let seq = start; seq < session.seq; seq += 1) {
|
|
955
1041
|
let event;
|
|
@@ -1002,20 +1088,47 @@ export function reconstructFromSession(session, targetPlanId) {
|
|
|
1002
1088
|
}
|
|
1003
1089
|
}
|
|
1004
1090
|
else if (event.type === 'tool/call' && plan) {
|
|
1091
|
+
/*
|
|
1092
|
+
* [1.6.0] 只登记,不下结论。
|
|
1093
|
+
*
|
|
1094
|
+
* 回放原来一见到 `dsh_workplan_update` 的调用就 applyStatus,但调用发起不等于
|
|
1095
|
+
* 状态写入成功:宿主的 tool/result 带着 `message.isError`。会话
|
|
1096
|
+
* session-7129faea 里就有一次失败的 update 被回放记成完成,于是刷新后面板显示
|
|
1097
|
+
* 7/12,而真实只完成 6/12 —— 回放比实时“更乐观”,这是第二个数据源打架点。
|
|
1098
|
+
* 现在一律等对应的 tool/result:成功才采纳,失败或没有结果都不采纳。
|
|
1099
|
+
*/
|
|
1005
1100
|
const data = event.data;
|
|
1006
|
-
if (data?.name === 'dsh_workplan_update') {
|
|
1101
|
+
if (data?.name === 'dsh_workplan_update' && typeof data.callId === 'string') {
|
|
1007
1102
|
const args = readToolArguments(data.arguments);
|
|
1008
1103
|
if (args && typeof args.subtaskId === 'string' && typeof args.status === 'string') {
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1104
|
+
pendingToolCalls.set(data.callId, { args, planId: plan.planId });
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
else if (event.type === 'tool/result') {
|
|
1109
|
+
const message = event.data?.message;
|
|
1110
|
+
const callId = message?.role === 'tool' ? message.toolCallId : null;
|
|
1111
|
+
if (typeof callId !== 'string')
|
|
1112
|
+
continue;
|
|
1113
|
+
const pending = pendingToolCalls.get(callId);
|
|
1114
|
+
pendingToolCalls.delete(callId);
|
|
1115
|
+
if (!pending || !plan || pending.planId !== plan.planId) {
|
|
1116
|
+
// 结果晚于新计划到达:旧计划的写入不污染新计划的状态表。
|
|
1117
|
+
}
|
|
1118
|
+
else if (message.isError === true) {
|
|
1119
|
+
// 失败的更新不是完成:状态表保持原样。
|
|
1120
|
+
}
|
|
1121
|
+
else {
|
|
1122
|
+
const args = pending.args;
|
|
1123
|
+
try {
|
|
1124
|
+
statuses = applyStatus(statuses, args.subtaskId, {
|
|
1125
|
+
status: args.status,
|
|
1126
|
+
progress: typeof args.progress === 'number' ? args.progress : undefined,
|
|
1127
|
+
summary: typeof args.note === 'string' ? args.note : undefined,
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
catch {
|
|
1131
|
+
// An invalid historical call is ignored; the live cache remains authoritative.
|
|
1019
1132
|
}
|
|
1020
1133
|
}
|
|
1021
1134
|
}
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -20,21 +20,26 @@ window.__ModuleLoader__.load({
|
|
|
20
20
|
"panel": "任务进度",
|
|
21
21
|
"groupLive": "进行中",
|
|
22
22
|
"groupDone": "已完成",
|
|
23
|
+
"groupEnded": "已结束",
|
|
23
24
|
"groupLiveHint": "还没结束,正在或即将推进",
|
|
24
25
|
"groupDoneHint": "已全部完成并收尾",
|
|
26
|
+
"groupEndedHint": "这一轮已经停了,没有东西在跑;说「续跑」可继续推进",
|
|
25
27
|
"groupCollapse": "收起",
|
|
26
28
|
"groupExpand": "展开",
|
|
27
29
|
"groupNeeds": "需处理",
|
|
28
30
|
"groupLiveEmpty": "没有进行中的任务",
|
|
29
31
|
"groupDoneEmpty": "还没有完成的任务",
|
|
32
|
+
"groupEndedEmpty": "没有已结束但未完成的任务",
|
|
30
33
|
"groupSummary": "未完成 {open} / 已完成 {done}(完成率 {pct}%)",
|
|
31
34
|
"groupSummaryNoRate": "未完成 {open} / 已完成 {done}(完成率暂无数据)",
|
|
32
35
|
"groupAria": "{title}组,{plans} 个计划,共 {total} 件具体工作,已完成 {done} 件,完成率 {pct}%。其中正在做 {active} 件、卡住 {blocked} 件、还没开始 {pending} 件。",
|
|
33
36
|
"groupAriaEmpty": "{title}组,{plans} 个计划,暂无具体工作项。",
|
|
34
37
|
"groupPlansLabel": "个计划",
|
|
35
|
-
"chartGroups": "
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
+
"chartGroups": "计划数量与完成情况",
|
|
39
|
+
"chartOpen": "未结束",
|
|
40
|
+
"chartDone": "已完成",
|
|
41
|
+
"chartLive": "未完成计划的完成度(按工作项)",
|
|
42
|
+
"chartMore": "另有 {n} 个未完成计划未画入图表",
|
|
38
43
|
|
|
39
44
|
"title": "任务进度:一眼看懂",
|
|
40
45
|
"subtitle": "用大白话说明:现在要做什么、谁在做、做到哪一步、接下来做什么。",
|
|
@@ -69,7 +74,8 @@ window.__ModuleLoader__.load({
|
|
|
69
74
|
"blocked": "卡住了",
|
|
70
75
|
"paused": "预算用完,可续跑",
|
|
71
76
|
"degraded": "安排失败",
|
|
72
|
-
"cancelled": "已取消"
|
|
77
|
+
"cancelled": "已取消",
|
|
78
|
+
"ended": "已结束,可续跑"
|
|
73
79
|
},
|
|
74
80
|
"status": {
|
|
75
81
|
"pending": "还没开始",
|
|
@@ -185,6 +191,7 @@ window.__ModuleLoader__.load({
|
|
|
185
191
|
"en": {
|
|
186
192
|
"groupLive": "In progress",
|
|
187
193
|
"groupDone": "Completed",
|
|
194
|
+
"groupEnded": "Ended",
|
|
188
195
|
"groupLiveHint": "Not finished yet — running or about to run",
|
|
189
196
|
"groupDoneHint": "All work finished and wrapped up",
|
|
190
197
|
"groupCollapse": "Collapse",
|
|
@@ -192,14 +199,17 @@ window.__ModuleLoader__.load({
|
|
|
192
199
|
"groupNeeds": "needs attention",
|
|
193
200
|
"groupLiveEmpty": "No work in progress",
|
|
194
201
|
"groupDoneEmpty": "Nothing completed yet",
|
|
202
|
+
"groupEndedEmpty": "No ended-but-unfinished plans",
|
|
195
203
|
"groupSummary": "Open {open} / done {done} ({pct}% complete)",
|
|
196
204
|
"groupSummaryNoRate": "Open {open} / done {done} (completion rate unavailable)",
|
|
197
205
|
"groupAria": "{title} group: {plans} plan(s), {total} work item(s), {done} done, {pct}% complete. Right now {active} in progress, {blocked} blocked, {pending} not started.",
|
|
198
206
|
"groupAriaEmpty": "{title} group: {plans} plan(s), no work items yet.",
|
|
199
207
|
"groupPlansLabel": "plan(s)",
|
|
200
|
-
"chartGroups": "Plan count
|
|
201
|
-
"
|
|
202
|
-
"
|
|
208
|
+
"chartGroups": "Plan count and completion",
|
|
209
|
+
"chartOpen": "Open",
|
|
210
|
+
"chartDone": "Completed",
|
|
211
|
+
"chartLive": "Open plans by work item",
|
|
212
|
+
"chartMore": "{n} more open plans not drawn",
|
|
203
213
|
"panel": "Task progress",
|
|
204
214
|
"title": "Task progress at a glance",
|
|
205
215
|
"subtitle": "Plain language: what this is, who is doing it, how far along it is, and what happens next.",
|
|
@@ -234,7 +244,8 @@ window.__ModuleLoader__.load({
|
|
|
234
244
|
"blocked": "Stuck",
|
|
235
245
|
"paused": "Paused (budget spent)",
|
|
236
246
|
"degraded": "Planning failed",
|
|
237
|
-
"cancelled": "Cancelled"
|
|
247
|
+
"cancelled": "Cancelled",
|
|
248
|
+
"ended": "Ended (resumable)"
|
|
238
249
|
},
|
|
239
250
|
"status": {
|
|
240
251
|
"pending": "Not started",
|
|
@@ -405,6 +416,7 @@ window.__ModuleLoader__.load({
|
|
|
405
416
|
.dsh-wp-group::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 4px; }
|
|
406
417
|
.dsh-wp-group--live::before { background: linear-gradient(180deg, #fb923c, #f97316); }
|
|
407
418
|
.dsh-wp-group--done::before { background: linear-gradient(180deg, #38bdf8, #0284c7); }
|
|
419
|
+
.dsh-wp-group--ended::before { background: linear-gradient(180deg, #94a3b8, #64748b); }
|
|
408
420
|
/* T6 — 组头多出一行完成度摘要,底部内边距 10px → 6px,两组仍在同一屏内读完。 */
|
|
409
421
|
.dsh-wp-group-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 0 4px 6px; }
|
|
410
422
|
.dsh-wp-group-head--btn { width: 100%; background: none; border: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: 8px; }
|
|
@@ -431,6 +443,8 @@ window.__ModuleLoader__.load({
|
|
|
431
443
|
.dsh-wp-group-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
|
|
432
444
|
.dsh-wp-group--live .dsh-wp-group-dot { background: #f97316; box-shadow: 0 0 0 4px rgba(249,115,22,.16); animation: dsh-wp-pulse 1.8s ease-in-out infinite; }
|
|
433
445
|
.dsh-wp-group--done .dsh-wp-group-dot { background: #0ea5e9; box-shadow: 0 0 0 4px rgba(14,165,233,.14); }
|
|
446
|
+
/* [1.6.0] 已结束:灰点、不脉冲——"没有东西在跑"这件事不允许用动画表达。 */
|
|
447
|
+
.dsh-wp-group--ended .dsh-wp-group-dot { background: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.16); }
|
|
434
448
|
.dsh-wp-group-title { font-size: 15px; font-weight: 700; letter-spacing: .2px; color: var(--dsw-alias-label-primary, #0f172a); }
|
|
435
449
|
/* 设计稿 §6.1:橙 #f97316 / 白 ≈2.8:1、蓝 #0ea5e9 / 白 ≈2.77:1,均不足 4.5:1,
|
|
436
450
|
15px 粗体也不算法定「大字号」,所以组头一律用主文本色;品牌色只做「面」——
|
|
@@ -439,6 +453,7 @@ window.__ModuleLoader__.load({
|
|
|
439
453
|
.dsh-wp-group-count { min-width: 22px; padding: 0 7px; height: 20px; border-radius: 999px; font-size: 12px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; color: var(--dsw-alias-label-primary, #0f172a); font-variant-numeric: tabular-nums; }
|
|
440
454
|
.dsh-wp-group--live .dsh-wp-group-count { background: rgba(249,115,22,.14); box-shadow: inset 0 0 0 1px rgba(249,115,22,.34); }
|
|
441
455
|
.dsh-wp-group--done .dsh-wp-group-count { background: rgba(14,165,233,.14); box-shadow: inset 0 0 0 1px rgba(14,165,233,.34); }
|
|
456
|
+
.dsh-wp-group--ended .dsh-wp-group-count { background: rgba(148,163,184,.16); box-shadow: inset 0 0 0 1px rgba(148,163,184,.36); }
|
|
442
457
|
/* WCAG AA:错误色叠 12% 红底后,浅色 #ec1313 只有 3.70:1;与主文本色混合 20%
|
|
443
458
|
让浅色变深、深色变浅,两套主题都 ≥4.5:1(实测 5.16:1 / 5.67:1)。 */
|
|
444
459
|
.dsh-wp-group-alert { font-size: 12px; font-weight: 650; color: var(--dsw-alias-state-error-primary, #ef4444); color: color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 80%, var(--dsw-alias-label-primary, #0f172a) 20%); background: rgba(239,68,68,.12); padding: 1px 8px; border-radius: 999px; }
|
|
@@ -751,6 +766,7 @@ window.__ModuleLoader__.load({
|
|
|
751
766
|
.dsh-wp-group::before { content: ''; position: absolute; left: 0; top: 14px; bottom: 14px; width: 3px; border-radius: 0 3px 3px 0; opacity: .9; }
|
|
752
767
|
.dsh-wp-group--live::before { background: linear-gradient(180deg, #fbbf24, #ea580c); }
|
|
753
768
|
.dsh-wp-group--done::before { background: linear-gradient(180deg, #7dd3fc, #0369a1); }
|
|
769
|
+
.dsh-wp-group--ended::before { background: linear-gradient(180deg, #cbd5e1, #94a3b8); }
|
|
754
770
|
.dsh-wp-group--done { opacity: .97; }
|
|
755
771
|
.dsh-wp-group-head--btn { transition: background-color .16s ease; }
|
|
756
772
|
.dsh-wp-group-head--btn:hover { background: rgba(148,163,184,.10); }
|
|
@@ -982,12 +998,33 @@ window.__ModuleLoader__.load({
|
|
|
982
998
|
|
|
983
999
|
function phaseTone(phase: any) {
|
|
984
1000
|
if (phase === 'blocked') return 'blocked'
|
|
1001
|
+
/*
|
|
1002
|
+
* [1.6.0] `ended` = 这一轮停了,但计划没做完。它既不是成功(done),也不能
|
|
1003
|
+
* 冒充正在干活(active);用中性 idle 色,卡在哪由二级徽标 suspendedPhase 说明。
|
|
1004
|
+
*/
|
|
1005
|
+
if (phase === 'ended') return 'idle'
|
|
985
1006
|
if (phase === 'degraded' || phase === 'cancelled' || phase === 'paused') return 'warn'
|
|
986
1007
|
if (phase === 'integrating' || phase === 'done') return 'done'
|
|
987
1008
|
if (phase === 'executing' || phase === 'planning') return 'active'
|
|
988
1009
|
return 'idle'
|
|
989
1010
|
}
|
|
990
1011
|
|
|
1012
|
+
/*
|
|
1013
|
+
* [1.6.0] 「已结束」徽标要把「停在哪儿」一起说出来。
|
|
1014
|
+
*
|
|
1015
|
+
* 只说「已结束,可续跑」,用户不知道是预算用完、卡住了、还是压根没开始;
|
|
1016
|
+
* 只说原阶段(「正在做」),又会像事故截图那样暗示还在跑。两段式表达:
|
|
1017
|
+
* 终态 + 原阶段,一次说清两个事实。
|
|
1018
|
+
*/
|
|
1019
|
+
function phaseLabel(phase: any, session: any, t: any) {
|
|
1020
|
+
const base = t(`phase.${phase}`)
|
|
1021
|
+
if (phase !== 'ended') return base
|
|
1022
|
+
const suspended = String(session?.suspendedPhase ?? '')
|
|
1023
|
+
if (!suspended) return base
|
|
1024
|
+
const detail = t(`phase.${suspended}`)
|
|
1025
|
+
return detail && detail !== base ? `${base} · ${detail}` : base
|
|
1026
|
+
}
|
|
1027
|
+
|
|
991
1028
|
function allEntries(session: any) {
|
|
992
1029
|
const byId = new Map((Array.isArray(session?.statuses) ? session.statuses : []).map((item: any) => [item.subtaskId, item]))
|
|
993
1030
|
const entries: any[] = []
|
|
@@ -1863,7 +1900,7 @@ window.__ModuleLoader__.load({
|
|
|
1863
1900
|
panelClassName: 'dsh-wp-session-body',
|
|
1864
1901
|
title: session.title || t('panel'),
|
|
1865
1902
|
meta: h(PlanViz, { t, counts, now, rows: stageRows }),
|
|
1866
|
-
badge: h(Pill, { tone },
|
|
1903
|
+
badge: h(Pill, { tone }, phaseLabel(phase, session, t)),
|
|
1867
1904
|
},
|
|
1868
1905
|
session.updatedAt
|
|
1869
1906
|
? h('div', { className: 'dsh-wp-meta dsh-wp-session-updated' }, `${t('updatedAt')} ${formatTime(session.updatedAt)}`)
|
|
@@ -1967,7 +2004,7 @@ window.__ModuleLoader__.load({
|
|
|
1967
2004
|
charts.push(new lib(canvasA, {
|
|
1968
2005
|
type: 'doughnut',
|
|
1969
2006
|
data: {
|
|
1970
|
-
labels: [t('
|
|
2007
|
+
labels: [t('chartOpen'), t('chartDone')],
|
|
1971
2008
|
datasets: [{ data: [liveCount, doneCount], backgroundColor: [liveFill, doneFill], borderWidth: 0, hoverOffset: 5 }],
|
|
1972
2009
|
},
|
|
1973
2010
|
options: {
|
|
@@ -2022,16 +2059,16 @@ window.__ModuleLoader__.load({
|
|
|
2022
2059
|
// 「无 canvas → 不建图 → 不 ready → 无 canvas」的死循环(T3 实测
|
|
2023
2060
|
// 探针输出 canvasNodes:0 / fallbackBars:1)。改为并存:
|
|
2024
2061
|
// fallback 在 ready 前可见,ready 后由 CSS 隐藏,canvas 反向隐藏。
|
|
2025
|
-
h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'groups', role: 'img', 'aria-label': `${t('
|
|
2062
|
+
h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'groups', role: 'img', 'aria-label': `${t('chartOpen')} ${liveCount} / ${t('chartDone')} ${doneCount}` })),
|
|
2026
2063
|
chartState !== 'ready'
|
|
2027
2064
|
? h('div', { className: 'dsh-wp-fallback' },
|
|
2028
|
-
h('div', { className: 'dsh-wp-fallback-bar', role: 'img', 'aria-label': `${t('
|
|
2065
|
+
h('div', { className: 'dsh-wp-fallback-bar', role: 'img', 'aria-label': `${t('chartOpen')} ${liveCount} / ${t('chartDone')} ${doneCount}` },
|
|
2029
2066
|
h('span', { className: 'dsh-wp-fallback-live', style: { width: `${livePct}%` } }),
|
|
2030
2067
|
h('span', { className: 'dsh-wp-fallback-done', style: { width: `${donePct}%` } }),
|
|
2031
2068
|
),
|
|
2032
2069
|
h('div', { className: 'dsh-wp-fallback-legend' },
|
|
2033
|
-
h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--live' }), `${t('
|
|
2034
|
-
h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--done' }), `${t('
|
|
2070
|
+
h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--live' }), `${t('chartOpen')} ${liveCount}`),
|
|
2071
|
+
h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--done' }), `${t('chartDone')} ${doneCount}`),
|
|
2035
2072
|
),
|
|
2036
2073
|
)
|
|
2037
2074
|
: null,
|
|
@@ -2148,18 +2185,25 @@ window.__ModuleLoader__.load({
|
|
|
2148
2185
|
}
|
|
2149
2186
|
|
|
2150
2187
|
function SessionGroup({ group, list, hasLive, urgent, t, openSessionId, setOpenSessionId }: any) {
|
|
2188
|
+
/*
|
|
2189
|
+
* [1.6.0] 三个分组共用一套组件:live(真的在跑)、ended(这一轮停了但没做完)、
|
|
2190
|
+
* done(全部完成)。`ended` 是本次事故的修复核心——它必须是一个**独立分组**,
|
|
2191
|
+
* 而不是被塞回「进行中」:会话已经结束的事实不允许用「进行中」这三个字表达。
|
|
2192
|
+
*/
|
|
2151
2193
|
const isDone = group === 'done'
|
|
2194
|
+
const isEnded = group === 'ended'
|
|
2195
|
+
const keySuffix = isDone ? 'Done' : isEnded ? 'Ended' : 'Live'
|
|
2152
2196
|
// 设计稿 §6.4 #1/#6:已完成组在有进行中任务时默认折叠,避免历史任务抢走
|
|
2153
2197
|
// 「进行中」的视觉权重;没有进行中任务时默认展开,空的进度栏仍是空的。
|
|
2154
|
-
//
|
|
2155
|
-
const [open, setOpen] = useState(!(isDone && hasLive))
|
|
2198
|
+
// [1.6.0] 「已结束」组同理:还有东西在跑时收起来,只留组头一行结论。
|
|
2199
|
+
const [open, setOpen] = useState(!((isDone || isEnded) && hasLive))
|
|
2156
2200
|
// §7.3「不打扰」的自动展开:只在这两种情形打开一次,不做反复弹开——
|
|
2157
2201
|
// ① 进行中清零(最后一个任务完成);② 用户正展开着的卡片被移进本组。
|
|
2158
2202
|
// 依赖都是布尔值,轮询刷新不会重复触发。
|
|
2159
2203
|
const holdsOpenCard = list.some((session: any) => session.sessionId === openSessionId)
|
|
2160
2204
|
useEffect(() => { if (!hasLive) setOpen(true) }, [hasLive])
|
|
2161
2205
|
useEffect(() => { if (holdsOpenCard) setOpen(true) }, [holdsOpenCard])
|
|
2162
|
-
const collapsible = isDone && list.length > 0
|
|
2206
|
+
const collapsible = (isDone || isEnded) && list.length > 0
|
|
2163
2207
|
// 折叠只隐藏、不卸载:`hidden` 必须是布尔 true,不能传空字符串——
|
|
2164
2208
|
// React 对 `hidden` 走 DOM 布尔属性赋值,`el.hidden = ''` 会得到 false,
|
|
2165
2209
|
// 只剩 CSS 类在遮挡;那样「折叠态 DOM 保留 + 无障碍语义」就不成立。
|
|
@@ -2170,8 +2214,8 @@ window.__ModuleLoader__.load({
|
|
|
2170
2214
|
// T6:组头完成度摘要 + 零依赖进度环。数值只来自 groupTotals(list),
|
|
2171
2215
|
// 先用纯文本给出「进行中 N / 已完成 M(完成率 x%)」,不点开、不看颜色也能读出进度。
|
|
2172
2216
|
const totals = useMemo(() => groupTotals(list), [list])
|
|
2173
|
-
const groupTitle = t(
|
|
2174
|
-
const hintText = t(
|
|
2217
|
+
const groupTitle = t(`group${keySuffix}`)
|
|
2218
|
+
const hintText = t(`group${keySuffix}Hint`)
|
|
2175
2219
|
// 用户 Q3:0/0 时不再显示「未完成 0 / 已完成 0(完成率暂无数据)」——
|
|
2176
2220
|
// 环上已经是「—」,组内空态也已说明没有工作项;再重复一遍 0/0 只是噪声。
|
|
2177
2221
|
// 全部做完(100%)仍保留整句,因为那时「未完成 0」正是用户要找的结论。
|
|
@@ -2238,7 +2282,7 @@ window.__ModuleLoader__.load({
|
|
|
2238
2282
|
open: openSessionId === session.sessionId,
|
|
2239
2283
|
onToggle: (next: boolean) => setOpenSessionId(next ? session.sessionId : null),
|
|
2240
2284
|
})))
|
|
2241
|
-
: h('div', { className: 'dsh-wp-group-empty' }, t(
|
|
2285
|
+
: h('div', { className: 'dsh-wp-group-empty' }, t(`group${keySuffix}Empty`)),
|
|
2242
2286
|
)
|
|
2243
2287
|
}
|
|
2244
2288
|
|
|
@@ -2331,7 +2375,7 @@ window.__ModuleLoader__.load({
|
|
|
2331
2375
|
h('div', { className: `dsh-wp-bar-fill dsh-wp-bar-fill--${tone}`, style: { width: `${percent}%` } }),
|
|
2332
2376
|
),
|
|
2333
2377
|
h('div', { className: 'dsh-wp-bar-meta' },
|
|
2334
|
-
h('span', null,
|
|
2378
|
+
h('span', null, phaseLabel(phase, session, t)),
|
|
2335
2379
|
h('span', null, `${percent}%`),
|
|
2336
2380
|
),
|
|
2337
2381
|
)
|
|
@@ -2383,6 +2427,7 @@ window.__ModuleLoader__.load({
|
|
|
2383
2427
|
}, [activeSessions])
|
|
2384
2428
|
const groups = useMemo(() => {
|
|
2385
2429
|
const live: any[] = []
|
|
2430
|
+
const ended: any[] = []
|
|
2386
2431
|
const finished: any[] = []
|
|
2387
2432
|
const urgentPhases = ['blocked', 'degraded', 'paused']
|
|
2388
2433
|
/*
|
|
@@ -2392,9 +2437,15 @@ window.__ModuleLoader__.load({
|
|
|
2392
2437
|
* 14/14、100% 的计划带着 integrating 被塞进 live 组,组头于是出现
|
|
2393
2438
|
* 「未完成 0 / 已完成 14(完成率 100%)」却仍标着「进行中」。
|
|
2394
2439
|
* `planPhaseOf` 让分组、组头计数、卡片胶囊共用同一个结论。
|
|
2440
|
+
*
|
|
2441
|
+
* [1.6.0] 新增 ended 分组:宿主已经 `turn/end`(会话这一轮结束、没有任何
|
|
2442
|
+
* 东西在跑),而计划还没做完的,一律进「已结束」,绝不进「进行中」。
|
|
2443
|
+
* 这正是本次事故的现象:会话结束 4 小时后仍显示「进行中」。
|
|
2395
2444
|
*/
|
|
2396
2445
|
for (const session of activeSessions) {
|
|
2397
|
-
|
|
2446
|
+
const phase = planPhaseOf(session.phase, planCounts(session.stats))
|
|
2447
|
+
if (phase === 'done') finished.push(session)
|
|
2448
|
+
else if (phase === 'ended') ended.push(session)
|
|
2398
2449
|
else live.push(session)
|
|
2399
2450
|
}
|
|
2400
2451
|
/*
|
|
@@ -2411,11 +2462,19 @@ window.__ModuleLoader__.load({
|
|
|
2411
2462
|
integrating: 3,
|
|
2412
2463
|
planning: 4,
|
|
2413
2464
|
pending: 5,
|
|
2465
|
+
ended: 6,
|
|
2414
2466
|
}
|
|
2415
2467
|
const rank = (session: any) => phaseRank[planPhaseOf(session.phase, planCounts(session.stats))] ?? 5
|
|
2416
2468
|
live.sort((a: any, b: any) => (rank(a) - rank(b))
|
|
2417
2469
|
|| ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0))
|
|
2418
2470
|
|| String(a.sessionId).localeCompare(String(b.sessionId)))
|
|
2471
|
+
/*
|
|
2472
|
+
* [1.6.0] 已结束组按「最近结束在前」:`endedAt` 是宿主 turn/end 的时间戳
|
|
2473
|
+
* (后端下发),比 updatedAt 更准确地表达「什么时候停的」。
|
|
2474
|
+
*/
|
|
2475
|
+
const endedAt = (session: any) => Number(session.endedAt) || Number(session.updatedAt) || Number(session.createdAt) || 0
|
|
2476
|
+
ended.sort((a: any, b: any) => (endedAt(b) - endedAt(a))
|
|
2477
|
+
|| ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0)))
|
|
2419
2478
|
/*
|
|
2420
2479
|
* 已完成组按「最近完成在前」(§3.3 / C8):优先取子任务
|
|
2421
2480
|
* statuses[].finishedAt 的最大值(忽略 null / 非数字),缺失则退回
|
|
@@ -2438,9 +2497,20 @@ window.__ModuleLoader__.load({
|
|
|
2438
2497
|
}
|
|
2439
2498
|
finished.sort((a: any, b: any) => (finishedAt(b) - finishedAt(a))
|
|
2440
2499
|
|| ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0)))
|
|
2441
|
-
return {
|
|
2500
|
+
return {
|
|
2501
|
+
live,
|
|
2502
|
+
ended,
|
|
2503
|
+
finished,
|
|
2504
|
+
urgent: live.filter((session: any) => urgentPhases.includes(session.phase)).length,
|
|
2505
|
+
/*
|
|
2506
|
+
* [1.6.0] 「已结束」组也需要「需处理」角标:卡住的计划停了仍然是卡住的,
|
|
2507
|
+
* 判定用后端下发的 suspendedPhase(原派发阶段),而不是被收敛后的 ended。
|
|
2508
|
+
*/
|
|
2509
|
+
urgentEnded: ended.filter((session: any) => urgentPhases.includes(String(session.suspendedPhase ?? ''))).length,
|
|
2510
|
+
}
|
|
2442
2511
|
}, [activeSessions])
|
|
2443
|
-
|
|
2512
|
+
// [1.6.0] 图表口径 = 未结束计划(进行中 + 已结束未完成),与环图标签一致。
|
|
2513
|
+
const liveBars = useMemo(() => [...groups.live, ...groups.ended].slice(0, 6).map((session: any) => {
|
|
2444
2514
|
const raw = String(session.title || t('panel'))
|
|
2445
2515
|
return {
|
|
2446
2516
|
label: raw.length > 14 ? `${raw.slice(0, 14)}…` : raw,
|
|
@@ -2472,8 +2542,16 @@ window.__ModuleLoader__.load({
|
|
|
2472
2542
|
activeSessions.length === 0
|
|
2473
2543
|
? h('div', { className: 'dsh-wp-empty' }, h('div', { style: { fontWeight: 650, marginBottom: '4px' } }, t('empty')), h('div', null, t('emptyHint')))
|
|
2474
2544
|
: h('div', { className: 'dsh-wp-groups' },
|
|
2475
|
-
|
|
2476
|
-
|
|
2545
|
+
/*
|
|
2546
|
+
* [1.6.0] 三组顺序:真的在跑 → 已结束(停在半路) → 已完成。
|
|
2547
|
+
* 「已结束」夹在中间是有意为之:它是用户最需要立刻知道的那一档
|
|
2548
|
+
* (刚停、还没做完),不能滑到历史组的下面去。
|
|
2549
|
+
*/
|
|
2550
|
+
h(SessionGroup, { key: 'live', group: 'live', list: groups.live, urgent: groups.urgent, t, openSessionId, setOpenSessionId }),
|
|
2551
|
+
groups.ended.length > 0
|
|
2552
|
+
? h(SessionGroup, { key: 'ended', group: 'ended', list: groups.ended, hasLive: groups.live.length > 0, urgent: groups.urgentEnded, t, openSessionId, setOpenSessionId })
|
|
2553
|
+
: null,
|
|
2554
|
+
h(SessionGroup, { key: 'done', group: 'done', list: groups.finished, hasLive: groups.live.length > 0 || groups.ended.length > 0, urgent: 0, t, openSessionId, setOpenSessionId }),
|
|
2477
2555
|
// 用户选择 B:总览块(完成率环 + 每个计划的进度条)同样下移到两组之后,
|
|
2478
2556
|
// 让开屏第一屏直接是「进行中」组的任务卡(此前统计条 → 总览 → 说明行 →
|
|
2479
2557
|
// 任务卡,第一张卡要下滑约一屏)。顶部只保留 5 格数字统计条与一行口径说明。
|
|
@@ -2483,7 +2561,8 @@ window.__ModuleLoader__.load({
|
|
|
2483
2561
|
) : null,
|
|
2484
2562
|
// 图表放两组之后:两张卡各 ≈246px,排在组前会让首屏看不到任何一张任务卡
|
|
2485
2563
|
// (设计稿 §8 第 12 条,T8 复核时复现在 1.4.2 源码里)。
|
|
2486
|
-
|
|
2564
|
+
// [1.6.0] 「未结束」= 进行中 + 已结束但未完成:两者都还没做完,都是用户要盯的对象。
|
|
2565
|
+
h(GroupCharts, { liveCount: groups.live.length + groups.ended.length, doneCount: groups.finished.length, liveBars, hiddenBars: Math.max(0, groups.live.length + groups.ended.length - 6), t }),
|
|
2487
2566
|
),
|
|
2488
2567
|
)
|
|
2489
2568
|
}
|
|
@@ -2511,7 +2590,7 @@ window.__ModuleLoader__.load({
|
|
|
2511
2590
|
return h('div', { className: `dsh-wp-turn dsh-wp-tone-${tone}` },
|
|
2512
2591
|
h('div', { className: 'dsh-wp-turn-title' },
|
|
2513
2592
|
h('span', null, session.title || t('panel')),
|
|
2514
|
-
h(Pill, { tone },
|
|
2593
|
+
h(Pill, { tone }, phaseLabel(phase, session, t)),
|
|
2515
2594
|
),
|
|
2516
2595
|
h('div', { className: 'dsh-wp-muted', style: { marginTop: '6px' } },
|
|
2517
2596
|
`${t('helpWho')}:${workerText(session, t)}`),
|
package/src/host.ts
CHANGED
|
@@ -325,7 +325,7 @@ export function apply(ctx: any, config: WorkplanConfig = {}) {
|
|
|
325
325
|
|
|
326
326
|
export const meta = {
|
|
327
327
|
name,
|
|
328
|
-
version: '1.
|
|
328
|
+
version: '1.6.0',
|
|
329
329
|
promptOrder: PROMPT_ORDER,
|
|
330
330
|
routes: ['/dsh-workplan/plans', '/dsh-workplan/vendor/chart.umd.js'],
|
|
331
331
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -856,6 +856,58 @@ export async function dispatchPlan(ctx: any, record: any, options: any) {
|
|
|
856
856
|
return record
|
|
857
857
|
}
|
|
858
858
|
|
|
859
|
+
/*
|
|
860
|
+
* [1.6.0] 会话「这一轮到底还在不在跑」的唯一事实源。
|
|
861
|
+
*
|
|
862
|
+
* 事故(session-7129faea…):会话早已结束,面板仍把计划列在「进行中」组,
|
|
863
|
+
* 卡片写着「预算用完,可续跑」——没有任何东西在跑,状态却表示正在跑。
|
|
864
|
+
*
|
|
865
|
+
* 根因:插件只订阅 `agent/pre-step`,`turn/end`、`session/disposed` 全都收不到;
|
|
866
|
+
* 计划状态因此只由「派发结果」驱动,而派发结束后不再有任何事件把它收敛。
|
|
867
|
+
*
|
|
868
|
+
* 修复不引入第二个事件订阅(跨 scope 的事件投递受 scope 过滤,不可靠;而且
|
|
869
|
+
* 订阅只覆盖实时路径,刷新/重启后全靠回放,两条链路一旦不同源就会来回翻转)。
|
|
870
|
+
* 这里直接读会话日志本身——它既是实时事实,也是回放事实:
|
|
871
|
+
*
|
|
872
|
+
* - 从末尾向前找最近的一条「轮次边界」事件:先遇到 `turn/end` ⇒ 这一轮已结束;
|
|
873
|
+
* 先遇到 `turn/start` ⇒ 这一轮还开着;都没找到 ⇒ 未知,调用方保持原判定。
|
|
874
|
+
* - 不存在「会话已结束」这种事件:宿主只有每轮 `turn/end`。因此**不能**把
|
|
875
|
+
* `turn/end` 直接等同于「计划取消/完成」——一轮结束后可以随时续跑,这也是
|
|
876
|
+
* 卡片提供「可续跑」的原因。它只证明「此刻没有东西在跑」。
|
|
877
|
+
* - 向后扫描设上限,避免越界;超过上限就返回 unknown,绝不猜测。
|
|
878
|
+
*/
|
|
879
|
+
const TURN_BOUNDARY_SCAN = 200
|
|
880
|
+
|
|
881
|
+
export function sessionTurnState(session: any) {
|
|
882
|
+
const total = Number(session?.seq)
|
|
883
|
+
if (!Number.isFinite(total) || total <= 0 || typeof session?.eventAt !== 'function') {
|
|
884
|
+
return { state: 'unknown', reason: null, reasonDetail: null, at: null }
|
|
885
|
+
}
|
|
886
|
+
const floor = Math.max(0, Math.floor(total) - TURN_BOUNDARY_SCAN)
|
|
887
|
+
for (let seq = Math.floor(total) - 1; seq >= floor; seq -= 1) {
|
|
888
|
+
let event
|
|
889
|
+
try {
|
|
890
|
+
event = session.eventAt(seq)
|
|
891
|
+
} catch {
|
|
892
|
+
return { state: 'unknown', reason: null, reasonDetail: null, at: null }
|
|
893
|
+
}
|
|
894
|
+
if (!event) continue
|
|
895
|
+
if (event.type === 'turn/start') return { state: 'open', reason: null, reasonDetail: null, at: null }
|
|
896
|
+
if (event.type === 'turn/end') {
|
|
897
|
+
const reason = event.data?.reason ?? null
|
|
898
|
+
const detail = reason && reason.kind === 'aborted' ? (reason.reason?.kind ?? null) : null
|
|
899
|
+
const at = Number(event.time)
|
|
900
|
+
return {
|
|
901
|
+
state: 'ended',
|
|
902
|
+
reason: typeof reason?.kind === 'string' ? reason.kind : 'completed',
|
|
903
|
+
reasonDetail: typeof detail === 'string' ? detail : null,
|
|
904
|
+
at: Number.isFinite(at) ? at : null,
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
return { state: 'unknown', reason: null, reasonDetail: null, at: null }
|
|
909
|
+
}
|
|
910
|
+
|
|
859
911
|
function serialisePlan(record: any, session: any) {
|
|
860
912
|
const plan = record.plan ?? null
|
|
861
913
|
/*
|
|
@@ -888,6 +940,36 @@ function serialisePlan(record: any, session: any) {
|
|
|
888
940
|
if (phase === 'pending' && !record.dispatchedAt && plan?.createdAt && Date.now() - plan.createdAt > STALE_PENDING_MS) {
|
|
889
941
|
phase = 'cancelled'
|
|
890
942
|
}
|
|
943
|
+
/*
|
|
944
|
+
* [1.6.0] 终态收敛:这一轮已经结束,而计划没有完成 ⇒ 不许再显示「进行中」。
|
|
945
|
+
*
|
|
946
|
+
* `phase` 到这里为止只由「派发动作」写入(executing / paused / blocked …),
|
|
947
|
+
* 而派发结束后没有任何事件把它改回来,于是会话结束、进程空闲、面板却仍在
|
|
948
|
+
* 「进行中」组里(事故截图)。上面 `sessionTurnState` 用会话日志回答
|
|
949
|
+
* 「此刻还有没有东西在跑」,这里把答案落成独立阶段 `ended`:
|
|
950
|
+
*
|
|
951
|
+
* - done / cancelled 已经是终态,不再改写(done 改写会直接违反不变量 I8);
|
|
952
|
+
* - 其余阶段一律收敛为 `ended`,并把原阶段留在 `suspendedPhase` 里,
|
|
953
|
+
* 卡片因此仍能显示「预算用完,可续跑」「卡住了」这类二级事实,不丢信息;
|
|
954
|
+
* - 只改**下发出去的那一份**,不动 `record.phase`:用户说「继续」后同一轮
|
|
955
|
+
* 重新开始(日志里出现新的 turn/start),下一份 payload 自然回到原来的
|
|
956
|
+
* 派发状态,不需要任何反向迁移逻辑;
|
|
957
|
+
* - 实时路径与回放路径都经过本函数,因此刷新前后结论必然一致(T4 硬约束)。
|
|
958
|
+
*/
|
|
959
|
+
let suspendedPhase: any = null
|
|
960
|
+
let endReason: any = null
|
|
961
|
+
let endReasonDetail: any = null
|
|
962
|
+
let endedAt: any = null
|
|
963
|
+
if (phase !== 'done' && phase !== 'cancelled') {
|
|
964
|
+
const turn = sessionTurnState(session)
|
|
965
|
+
if (turn.state === 'ended') {
|
|
966
|
+
suspendedPhase = phase
|
|
967
|
+
endReason = turn.reason
|
|
968
|
+
endReasonDetail = turn.reasonDetail
|
|
969
|
+
endedAt = turn.at
|
|
970
|
+
phase = 'ended'
|
|
971
|
+
}
|
|
972
|
+
}
|
|
891
973
|
/*
|
|
892
974
|
* 每次真正下发前跑一遍跨字段不变量(I1–I10)。正常情况下恒为空;一旦某个
|
|
893
975
|
* 未来改动重新引入「进度与状态打架」,这里会把它连同具体字段值一起带出去,
|
|
@@ -899,6 +981,7 @@ function serialisePlan(record: any, session: any) {
|
|
|
899
981
|
planId: record.planId ?? plan?.planId ?? null,
|
|
900
982
|
title: plan?.title ?? record.title ?? '任务进度',
|
|
901
983
|
phase,
|
|
984
|
+
...(phase === 'ended' ? { suspendedPhase, endReason, endReasonDetail, endedAt } : {}),
|
|
902
985
|
...(violations.length ? { violations } : {}),
|
|
903
986
|
degraded: record.degraded === true,
|
|
904
987
|
error: record.error ? truncate(record.error, 2000) : null,
|
|
@@ -966,6 +1049,8 @@ export function reconstructFromSession(session: any, targetPlanId?: string) {
|
|
|
966
1049
|
* plan and let the caller ask for a specific one.
|
|
967
1050
|
*/
|
|
968
1051
|
const finished: any[] = []
|
|
1052
|
+
/** [1.6.0] callId → 待确认的 update 写入;只有配对的 tool/result 才能定案。 */
|
|
1053
|
+
const pendingToolCalls = new Map<string, any>()
|
|
969
1054
|
const snapshot = () => ({ plan, statuses, resultPhase, resultSeen, resumeCount, cancelled })
|
|
970
1055
|
for (let seq = start; seq < session.seq; seq += 1) {
|
|
971
1056
|
let event
|
|
@@ -1013,19 +1098,42 @@ export function reconstructFromSession(session: any, targetPlanId?: string) {
|
|
|
1013
1098
|
cancelled = true
|
|
1014
1099
|
}
|
|
1015
1100
|
} else if (event.type === 'tool/call' && plan) {
|
|
1101
|
+
/*
|
|
1102
|
+
* [1.6.0] 只登记,不下结论。
|
|
1103
|
+
*
|
|
1104
|
+
* 回放原来一见到 `dsh_workplan_update` 的调用就 applyStatus,但调用发起不等于
|
|
1105
|
+
* 状态写入成功:宿主的 tool/result 带着 `message.isError`。会话
|
|
1106
|
+
* session-7129faea 里就有一次失败的 update 被回放记成完成,于是刷新后面板显示
|
|
1107
|
+
* 7/12,而真实只完成 6/12 —— 回放比实时“更乐观”,这是第二个数据源打架点。
|
|
1108
|
+
* 现在一律等对应的 tool/result:成功才采纳,失败或没有结果都不采纳。
|
|
1109
|
+
*/
|
|
1016
1110
|
const data = event.data
|
|
1017
|
-
if (data?.name === 'dsh_workplan_update') {
|
|
1111
|
+
if (data?.name === 'dsh_workplan_update' && typeof data.callId === 'string') {
|
|
1018
1112
|
const args = readToolArguments(data.arguments)
|
|
1019
1113
|
if (args && typeof args.subtaskId === 'string' && typeof args.status === 'string') {
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1114
|
+
pendingToolCalls.set(data.callId, { args, planId: plan.planId })
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
} else if (event.type === 'tool/result') {
|
|
1118
|
+
const message = event.data?.message
|
|
1119
|
+
const callId = message?.role === 'tool' ? message.toolCallId : null
|
|
1120
|
+
if (typeof callId !== 'string') continue
|
|
1121
|
+
const pending = pendingToolCalls.get(callId)
|
|
1122
|
+
pendingToolCalls.delete(callId)
|
|
1123
|
+
if (!pending || !plan || pending.planId !== plan.planId) {
|
|
1124
|
+
// 结果晚于新计划到达:旧计划的写入不污染新计划的状态表。
|
|
1125
|
+
} else if (message.isError === true) {
|
|
1126
|
+
// 失败的更新不是完成:状态表保持原样。
|
|
1127
|
+
} else {
|
|
1128
|
+
const args = pending.args
|
|
1129
|
+
try {
|
|
1130
|
+
statuses = applyStatus(statuses, args.subtaskId, {
|
|
1131
|
+
status: args.status,
|
|
1132
|
+
progress: typeof args.progress === 'number' ? args.progress : undefined,
|
|
1133
|
+
summary: typeof args.note === 'string' ? args.note : undefined,
|
|
1134
|
+
})
|
|
1135
|
+
} catch {
|
|
1136
|
+
// An invalid historical call is ignored; the live cache remains authoritative.
|
|
1029
1137
|
}
|
|
1030
1138
|
}
|
|
1031
1139
|
}
|