dsh-workflow-map 0.3.4 → 0.3.5

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 CHANGED
@@ -3,6 +3,45 @@
3
3
  `dsh-workflow-map` 的所有重要变更记录在此。
4
4
  格式遵循 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),版本号遵循 [SemVer](https://semver.org/)。
5
5
 
6
+ ## [0.3.5] - 2026-10-31
7
+
8
+ ### Added
9
+
10
+ - **README 截图区改为按市场评分规则书写的六图画廊**:主界面(深色/浅色)、运行中的单元、分步详情、
11
+ 热注入、设置面板。图片落在 `docs/screenshots/`,相对路径引用,图注含「预览/截图/主界面」等关键词,
12
+ 使插件市场的卡片与安装对话框能自动取到(该市场只认 README 里位于 GitHub 四域名的图片,
13
+ 并按语义与尺寸打分取前 6 张;带 badge/logo 的一律排除)。
14
+ - **标题栏增加手动刷新按钮(`↻`)**。点一次立即重新读取当前会话的视图,不等自动轮询的下一个周期;
15
+ 读取成功后按钮短暂变为「已刷新」,随后恢复图标。它走的是同一条 `GET /dsh-workflow/state` 与同一个
16
+ `If-None-Match` 校验器,不引入第二条数据通道。自动轮询正常工作时无需使用,仅在自动刷新受阻时兜底。
17
+
18
+ ### Fixed
19
+
20
+ - **返回即结束:节点的「已完成」不再由正文是否为空推断**。`nodeView` 发布布尔 `result`(折叠层的 `settled` 标记),
21
+ 工具返回空正文(例如无输出的 `pwsh`)也算已返回。此前浏览器半只按 `resultHead !== ""` 判断,
22
+ 空输出的调用因此永远停在「正在调用」状态。同时 `STATE_VERSION` 升到 5,旧检查点按契约丢弃重建。
23
+ - **结果不是活动**。`foldToolResult` 曾把刚返回的节点重新设为 `active`,面板因此对已经返回的调用声称「正在调用」;
24
+ 现在只有当更新的调用打开了另一个节点时才保留高亮,否则交还给步骤。
25
+ - **未收到 `turn/end` 的回合不再让整张图无限流动**。`view.running` 是回合标志,中断/崩溃/压缩后的会话会把它永久留在 `true`;
26
+ 浏览器半现在以时钟为第二证人:最后一个已折叠事件早于空闲窗口(15 s)即判定为空闲,无时钟(离线预览)时仍信任标志。
27
+ - **重开的行不再继承上一轮的 100 %**。`foldTodos` 把 `completed` 行改回 `in_progress` 时保留了 `percent` 与 `maxPercent`,
28
+ 新行一出现就钉在 100 %,进度条不再移动。现在重开(以及降级为 `pending`)会清空百分比与高水位,`startedAt` 重新计时。
29
+ - **运行中的步骤不再显示 100 %**,且**自报的最新值就是显示值**。`percentFor` 的模型分支此前取「自报」与「高水位」的较大值并允许 100,
30
+ 既让未完成的步骤声称 100 %,也让后续自报被历史高水位压住。高水位仍记录在 `step.maxPercent`,只是不再参与显示。
31
+ - **自动轮询不再因一次慢请求而永久停止**。每个周期开始时会中止上一个未完成的请求,被中止的请求返回
32
+ `status: -1`;旧代码在这里直接 `return`,不重新排程。只要有一次请求慢于轮询间隔(网络抖动、宿主繁忙),
33
+ 循环就此结束,地图停在最后一帧——表现为「地图不会自己更新」。现在中止只让位给已在飞行中的替代请求,
34
+ 两者都结束后照常排程;该规则抽成纯函数 `wfPollContinues()` 并纳入自测。
35
+ - **已结束的 PAE 计划不再改写地图**。`<cwd>/.pae/<sessionId>/orchestrator.json` 一旦写入就长期保留,
36
+ `phase` 为 `completed` / `aborted` 时仍会被当作当前计划读取,用它自己的 `statuses`、`anchorSeqs` 与步骤标题
37
+ 覆盖本轮待办表刚建出的行,于是面板显示的是上一轮的计划。现在计划段新增布尔 `terminal`,为真时仍发布
38
+ (详情页要用它的 `stepReports`),但不再修改任何行的标题与状态;浏览器半的状态判定同样据此让位。
39
+
40
+ ### Changed
41
+
42
+ - 自测口径:离线 10 套合计 1570 条(`selftest-fold` 217、`selftest-host` 54、`selftest-orchestrator` 155、其余同前),
43
+ 浏览器量测 10 套合计 323 条。各套件的输出以退出码与逐条结果为准,本文件不复述结论。
44
+
6
45
  ## [0.3.4] - 2026-10-30
7
46
 
8
47
  ### Changed
@@ -49,7 +88,7 @@
49
88
 
50
89
  ### Tests
51
90
 
52
- - **离线自测 10 套 / 1533 条断言**全部通过,退出码全 0(0.3.3 为 1515 条)。本轮新增 13 条:`selftest-fold`
91
+ - **离线自测 10 套 / 1533 条**(0.3.3 为 1515 条)。本轮新增 13 条:`selftest-fold`
53
92
  的 `(r)` 用例 8 条(待办表改回 `pending` 必须降级、改写表后的 `pending` 行不得顶替运行步、
54
93
  `in_progress` 行对应的记录即使原来是 `pending` 也要转为运行、未命名空行被丢弃、回合结束仍保留表中
55
94
  `in_progress` 的步、非运行行不出数字……),`selftest-panel` 的浅色主题针脚 5 条(抬升变量、深色覆盖、
@@ -57,7 +96,7 @@
57
96
  - **真实会话日志**:`check-real-injects` 折叠 147 个会话、识别出 19 条热注入记录、failures 0;
58
97
  `audit-real` 折叠 148 个会话、629 个步骤,约束 ①(分步必须存在于待办表)与 ②(同一时刻只有一个
59
98
  running 步)各 0 违规;`done` 步显示 <100% 的数量为 **0**,非运行步带数字的数量为 **0**(修前 74 条)。
60
- - **浏览器量测 10 套 / 323 条断言**全部通过,退出码全 0(0.3.3 为 308 条):
99
+ - **浏览器量测 10 套 / 323 条**(0.3.3 为 308 条):
61
100
  `panel 21 · timing 20 · inject 43 · dock 14 · flow 20 · effects 28 · branches 15 · detail 50 · settings 56 · main 56`。
62
101
  `main` 从 41 增至 56 的 15 条是本轮新增的主题对比检查:浅色帧的页面底色确实是宿主真值 `#fff`、
63
102
  两种主题下 `--wf-lift` 各自生效、单元卡边线 alpha 与抬升渐变俱在、14 px 颗星徽标靠自身颜色边线可见
@@ -86,7 +125,7 @@
86
125
 
87
126
  - `tools/selftest-fold.mjs` 新增 8 条断言:`(o1)`/`(o2)` 编号清单换表后序号连续、标题不重名、
88
127
  行不继承邻居标题;`(p)` 计划确认点只落在该落的步骤、`kind` 为 `plan`、重复应用不重复、已完成步骤不带标记。
89
- - 离线自测 **10 套 / 1515 条断言**全部通过(0.3.2 为 1507 条)。
128
+ - 离线自测 **10 套 / 1515 条**(0.3.2 为 1507 条)。
90
129
 
91
130
 
92
131
  ## [0.3.1] - 2026-10-28
@@ -111,7 +150,7 @@
111
150
 
112
151
  ### Tests
113
152
 
114
- - 离线自测 **10 套 / 1507 条断言**、浏览器量测 **10 套 / 308 条断言**,全部通过。
153
+ - 离线自测 **10 套 / 1507 条**、浏览器量测 **10 套 / 308 条**。
115
154
 
116
155
  ### Notes
117
156
 
package/README.en.md CHANGED
@@ -25,16 +25,28 @@ Three entry points share one data source and one set of components: the sidebar
25
25
  The panel folds session events into a step sequence, showing each step's status, progress, tool calls
26
26
  and file operations, and can inject prompts into the next step of a running session.
27
27
 
28
- ![Workflow map: one main flow, type interfaces on both sides, details on the right page](docs/screenshot-detail.png)
28
+ ![Workflow map main screen preview (dark theme): serial main flow, type interfaces on both sides, details on the right page](docs/screenshots/01-main-dark.png)
29
29
 
30
- *Left: the selected sidebar entry. Centre: the serial main flow (one unit card per step, round interfaces
31
- on both sides for tool/file types; the running unit is highlighted with a flowing frame). Right: the page
32
- opened for a step (type list, step detail). Bottom: the persistent injection bar.*
30
+ *Main screen (dark): the selected sidebar entry on the left, the serial main flow in the centre (one unit
31
+ card per step, round interfaces on both sides for tool/file types, the running unit highlighted with a
32
+ flowing frame), the step detail page on the right, and the persistent injection bar at the bottom.*
33
+
34
+ ### Screenshots (full preview)
35
+
36
+ | | |
37
+ |---|---|
38
+ | ![Main screen full preview (light theme)](docs/screenshots/02-main-light.png) | ![Running step preview: flowing gradient frame](docs/screenshots/03-running-step.png) |
39
+ | **Main screen · light theme** — same layout, colours follow the host theme | **Running unit** — the flowing frame marks "calling right now" |
40
+ | ![Step detail preview: type list and call records on the right page](docs/screenshots/04-step-detail.png) | ![Hot inject preview: queue a prompt for the next step](docs/screenshots/05-hot-inject.png) |
41
+ | **Step detail** — type list, call arguments and result summary for that step | **Hot inject** — queue a prompt for the next step, revocable before it is claimed |
42
+ | ![Settings panel preview: palette, motion, detail depth and export](docs/screenshots/06-settings.png) | |
43
+
44
+ All six are offscreen renders of the interface and contain no real session content.
33
45
 
34
46
  ![license](https://img.shields.io/badge/license-MIT-blue)
35
47
  ![platform](https://img.shields.io/badge/platform-DeepSeek%20Harness-brightgreen)
36
48
  ![node](https://img.shields.io/badge/node-%E2%89%A520-informational)
37
- ![version](https://img.shields.io/badge/version-0.3.4-success)
49
+ ![version](https://img.shields.io/badge/version-0.3.5-success)
38
50
 
39
51
  ---
40
52
 
@@ -112,18 +124,21 @@ Each step's percentage is resolved by the following **priority** (`percentFor` i
112
124
  | Priority | Source | Rule | Cap |
113
125
  |---|---|---|---|
114
126
  | 1 | Todo table `todos` | `round(completed / total * 100)`; when a self-report exists, this measured value is still what is returned | 99 |
115
- | 2 | Model self-report `model` | Only when the todo table has no countable rows: the **larger** of the `stepProgress/report` percentage and the self-report high-water mark | 100 |
127
+ | 2 | Model self-report `model` | Only when the todo table has no countable rows: the most recent `stepProgress/report` percentage | 99 |
116
128
  | 3 | Time baseline `baseline` | `elapsed / median(finished-step durations in this session) * 100` | 90 |
117
129
  | 4 | Plan ordinal `baseline:steps` | Cold start with `current step / total steps * 100` when no durations exist | 90 |
118
130
  | - | `none` | None of the four is available | No number shown |
119
131
 
120
132
  Constraint: an unfinished step never shows 100 %; a finished (done or failed) step is pinned to 100 %.
121
133
 
122
- The choice follows verifiability: the todo table is an objective count, a model self-report is not. So
123
- whenever the todo table has completed rows, the percentage is that measured ratio (both branches of that
124
- `percentFor` expression yield `measured`); the self-report is a fallback only when no row can be measured.
125
- The high-water record `step.maxPercent` is still kept and never rolled back, but it no longer takes part in
126
- computing the displayed value.
134
+ The choice follows verifiability: the todo table is a count of rows, a model self-report has no per-row
135
+ basis. So whenever the todo table has completed rows, the percentage is that ratio (both branches of
136
+ `percentFor` return `measured`); the self-report is a fallback only when no row can be counted, and the
137
+ **most recent** report is used so that a later report can move the value. The high-water record
138
+ `step.maxPercent` is kept and never rolled back, and does not take part in the displayed value.
139
+ When `foldTodos` takes a finished row back to `in_progress`, or demotes it to `pending`, it clears
140
+ `percent` and `maxPercent` and resets `startedAt`: a finished record's number belongs to that closure,
141
+ not to the row that reopens it.
127
142
 
128
143
  A second line under the rail reads `estimated N minutes ago`, i.e. `view.asOf - step.lastEstimateAt`.
129
144
  It describes how **stale** the number is, distinguishing "being updated" from "unchanged for a long time".
@@ -279,8 +294,14 @@ so time-based eviction would empty historical steps.
279
294
  | Click a call row | Expand the full arguments, result and error in place |
280
295
  | `Esc` | Close the right page, not the panel |
281
296
  | Gear | Open settings: palette, motion, detail depth, node limit |
297
+ | `↻` | Read the current session's view again now; the button briefly shows "Refreshed" |
282
298
  | Copy control | Copy path / command / snapshot, with a receipt that restores after 1.5 s |
283
299
 
300
+ The view is polled: every 0.8 s while the page is visible, every 3 s while it is hidden, as a conditional
301
+ request (`If-None-Match`) that the host answers with `304` and no body when nothing changed. `↻` runs one
302
+ cycle of that same request, for the case where automatic polling is blocked; it neither changes the cadence
303
+ nor adds a second data path.
304
+
284
305
  Settings:
285
306
 
286
307
  | Item | Values | Effect |
@@ -320,7 +341,7 @@ steps, their status, the percentage source, the node list and the report artifac
320
341
  # after editing src/client/*.js, rebuild the browser half
321
342
  node tools/build-client.mjs
322
343
 
323
- # offline self-tests (10 suites, 1533 assertions)
344
+ # offline self-tests (10 suites)
324
345
  node tools/selftest-fold.mjs # fold semantics
325
346
  node tools/selftest-host.mjs # registration, schema, projection wiring
326
347
  node tools/selftest-orchestrator.mjs # plan file reading and merging
@@ -337,7 +358,7 @@ node tools/check-real-injects.mjs # fold historical logs in bulk
337
358
  node tools/audit-real.mjs # four-constraint audit
338
359
  ```
339
360
 
340
- Browser measurements (needs a Chromium; the scripts auto-detect Chrome / Edge; 323 assertions, light/dark theme contrast included):
361
+ Browser measurements (needs a Chromium; the scripts auto-detect Chrome / Edge; light/dark theme contrast included):
341
362
 
342
363
  ```bash
343
364
  pwsh -File tools/measure-panel.ps1
@@ -359,7 +380,7 @@ pwsh -File tools/measure-main.ps1
359
380
 
360
381
  ## 10. Key constants
361
382
 
362
- Every threshold is defined once and asserted by the tests, so this table cannot drift from the code:
383
+ Every threshold is defined once and read by the self-tests, so this table and the code share one source:
363
384
 
364
385
  | Constant | Value | Location | Meaning |
365
386
  |---|---|---|---|
@@ -376,7 +397,23 @@ Every threshold is defined once and asserted by the tests, so this table cannot
376
397
 
377
398
  ---
378
399
 
379
- ## 11. Known issues
400
+ ## 11. Plan file and todo table precedence
401
+
402
+ The map's steps come from two sources, in a fixed order:
403
+
404
+ | Priority | Source | Effect |
405
+ |---|---|---|
406
+ | 1 | Todo table `todo/write` | This turn's step titles and statuses, the session's newest statement |
407
+ | 2 | Plan file `.pae/<sessionId>/orchestrator.json` | While `phase` is `planning` / `scheduled` / `executing` / `paused`, its step titles, `statuses` and `anchorSeqs` apply |
408
+ | - | The same file at `completed` / `aborted` | The plan is over: its steps and reports are still published for the detail page, but it no longer changes any row's title or status |
409
+
410
+ The plan file is written once and never deleted. Read without that distinction, a previous turn's plan
411
+ overrides the rows the current todo table has just created, and the panel disagrees with the conversation.
412
+ The view's plan section therefore carries a boolean `terminal`, and both halves (host and browser) obey it.
413
+
414
+ ---
415
+
416
+ ## 12. Known issues
380
417
 
381
418
  Both defects recorded in 0.3.0 are closed:
382
419
 
@@ -390,7 +427,7 @@ that is how activation works, not a defect.
390
427
 
391
428
  ---
392
429
 
393
- ## 12. Layout
430
+ ## 13. Layout
394
431
 
395
432
  ```
396
433
  dsh-workflow-map/
@@ -410,7 +447,7 @@ dsh-workflow-map/
410
447
 
411
448
  ---
412
449
 
413
- ## 13. FAQ
450
+ ## 14. FAQ
414
451
 
415
452
  **Can it show several sessions at once?**
416
453
  The panel shows the current session only. Switching sessions switches the content; nothing is merged or tiled.
@@ -441,13 +478,13 @@ the panel to `none`.
441
478
 
442
479
  ---
443
480
 
444
- ## 14. License
481
+ ## 15. License
445
482
 
446
483
  MIT. Commercial use, modification and redistribution are permitted; keep the copyright notice.
447
484
 
448
485
  ---
449
486
 
450
- ## 15. Reporting an issue
487
+ ## 16. Reporting an issue
451
488
 
452
489
  The following helps locate a problem:
453
490
 
package/README.md CHANGED
@@ -24,16 +24,27 @@ DeepSeek Harness 插件:把当前会话渲染为一张可交互的分步工作
24
24
  会话标签栏(`conversation.view`)。面板将会话事件折叠为分步序列,展示每步的状态、进度、
25
25
  工具调用与文件操作,并支持在会话运行中向其下一步注入提示词。
26
26
 
27
- ![工作流地图:单一主流程 + 两侧类型接口 + 右页详情](docs/screenshot-detail.png)
27
+ ![工作流地图主界面预览(深色主题):串行主流程 + 两侧类型接口 + 右页详情](docs/screenshots/01-main-dark.png)
28
28
 
29
- *左:侧边栏入口选中态;中:串行主流程(每步一张单元卡,两侧圆形接口为工具/文件类型,
30
- 运行中的单元高亮并带流动外框);右:点开分步后的右页(类型清单、分步详情),
31
- 底部为常驻注入条。*
29
+ *主界面(深色):左为侧边栏入口选中态,中为串行主流程(每步一张单元卡,两侧圆形接口为工具/文件
30
+ 类型,运行中的单元高亮并带流动外框),右为分步详情页,底部为常驻注入条。*
31
+
32
+ ### 界面截图(完整预览)
33
+
34
+ | | |
35
+ |---|---|
36
+ | ![主界面完整预览(浅色主题)](docs/screenshots/02-main-light.png) | ![运行中的步骤预览:流动渐变外框](docs/screenshots/03-running-step.png) |
37
+ | **主界面 · 浅色主题** —— 与深色同一布局,配色随宿主主题切换 | **运行中的单元** —— 流动渐变外框标出「此刻在调用」 |
38
+ | ![分步详情预览:右页的类型清单与调用记录](docs/screenshots/04-step-detail.png) | ![热注入界面预览:把提示词排入下一步队列](docs/screenshots/05-hot-inject.png) |
39
+ | **分步详情** —— 右页列出该步的类型清单、调用参数与结果摘要 | **热注入** —— 提示词排入下一步执行队列,被取用前可撤回 |
40
+ | ![设置面板预览:配色、动效、详情深度与导出](docs/screenshots/06-settings.png) | |
41
+
42
+ 以上六张为离屏渲染的界面预览,不含任何真实会话内容。
32
43
 
33
44
  ![license](https://img.shields.io/badge/license-MIT-blue)
34
45
  ![platform](https://img.shields.io/badge/platform-DeepSeek%20Harness-brightgreen)
35
46
  ![node](https://img.shields.io/badge/node-%E2%89%A520-informational)
36
- ![version](https://img.shields.io/badge/version-0.3.4-success)
47
+ ![version](https://img.shields.io/badge/version-0.3.5-success)
37
48
 
38
49
  ---
39
50
 
@@ -106,15 +117,18 @@ body 超 64 KiB,`503` 宿主未暴露相应能力。
106
117
  | 优先级 | 来源 | 计算规则 | 上限 |
107
118
  |---|---|---|---|
108
119
  | 1 | 待办表实测 `todos` | `round(已完成项 / 总项 × 100)`;自报存在时也只取该实测值 | 99 |
109
- | 2 | 模型自报 `model` | 待办表无可计行时,取 `stepProgress/report` 百分比与自报高水位中的较 **大** 者 | 100 |
120
+ | 2 | 模型自报 `model` | 待办表无可计行时,取最近一次 `stepProgress/report` 的百分比 | 99 |
110
121
  | 3 | 时间基线 `baseline` | `elapsed / median(本会话已完步耗时) × 100` | 90 |
111
122
  | 4 | 计划步序 `baseline:steps` | 无历史耗时时,用 `当前步序 / 总步数 × 100` 冷启动 | 90 |
112
123
  | — | `none` | 四个来源均不可用 | 不显示数字 |
113
124
 
114
125
  约束:未结束的步不会显示 100%;已结束(完成或失败)的步固定为 100%。
115
- 选用规则由可验证性决定:待办表是逐行计数得到的实测值,模型自报无此行级依据。因此只要待办表有已完成行,
116
- 百分比就取该实测比值(`percentFor` 中两个分支值均为 `measured`),自报只能作为无实测行时的备选;
117
- 高水位记录 `step.maxPercent` 仍保留且不回退,只是不再参与显示值计算。
126
+
127
+ 选用规则依据可核验性:待办表是逐行计数得到的量值,模型自报没有对应的行级依据。因此只要待办表存在已完成行,
128
+ 百分比取该计数比值(`percentFor` 的两个分支均返回 `measured`),自报仅在无行可计数时作为备选;且取
129
+ **最近一次**自报,使后续报告能够改变显示值。高水位记录 `step.maxPercent` 保留且不回退,不参与显示值计算。
130
+ `foldTodos` 将已结束行改回 `in_progress`、或降级为 `pending` 时,清空 `percent` 与 `maxPercent` 并重置
131
+ `startedAt`:已结束记录的数字属于那一次结束,不属于重新打开它的行。
118
132
 
119
133
  进度条下方另有一行元信息:`N 分钟前估计`,即 `view.asOf − step.lastEstimateAt`。
120
134
  该值描述数字的**陈旧程度**,用于区分「正在更新」与「长时间未更新」。字段缺失时整行不渲染。
@@ -264,8 +278,13 @@ curl -i "http://127.0.0.1:<GUI端口>/dsh-workflow/state?sessionId=<会话ID>"
264
278
  | 点调用行 | 就地展开参数全文、结果与错误 |
265
279
  | `Esc` | 关闭右页,不关闭面板 |
266
280
  | 齿轮 | 打开设置:配色、动效、详情深度、节点上限 |
281
+ | `↻` | 立即重新读取当前会话的视图;读取成功后按钮短暂显示「已刷新」 |
267
282
  | 复制控件 | 复制路径 / 命令 / 快照,1.5 秒后回执并复原 |
268
283
 
284
+ 视图由轮询获得:页面可见时每 0.8 秒一次,隐藏时每 3 秒一次,条件请求(`If-None-Match`),
285
+ 未变化时宿主返回 `304` 且无响应体。`↻` 只是触发同一请求的一个周期,用于自动轮询受阻时手动取一次,
286
+ 不改变轮询节奏,也不构成第二条数据通道。
287
+
269
288
  设置项:
270
289
 
271
290
  | 项 | 取值 | 作用 |
@@ -303,7 +322,7 @@ curl -i "http://127.0.0.1:<GUI端口>/dsh-workflow/state?sessionId=<会话ID>"
303
322
  # 修改 src/client/*.js 后必须重新生成浏览器半
304
323
  node tools/build-client.mjs
305
324
 
306
- # 离线自测(10 套,共 1533 条断言)
325
+ # 离线自测(10 套)
307
326
  node tools/selftest-fold.mjs # 折叠语义
308
327
  node tools/selftest-host.mjs # 注册、schema、投影接线
309
328
  node tools/selftest-orchestrator.mjs # 计划文件读取与合并
@@ -320,7 +339,7 @@ node tools/check-real-injects.mjs # 批量折叠历史日志
320
339
  node tools/audit-real.mjs # 四项约束审计
321
340
  ```
322
341
 
323
- 浏览器量测(需一份 Chromium,脚本自动探测 Chrome / Edge,共 323 条断言,含浅色/深色主题对比检查):
342
+ 浏览器量测(需一份 Chromium,脚本自动探测 Chrome / Edge;含浅色/深色主题对比检查):
324
343
 
325
344
  ```bash
326
345
  pwsh -File tools/measure-panel.ps1
@@ -342,7 +361,7 @@ pwsh -File tools/measure-main.ps1
342
361
 
343
362
  ## 10. 关键常量
344
363
 
345
- 所有阈值集中定义,并由测试直接断言,因此文档取值与实现取值不会脱节:
364
+ 所有阈值集中定义,并由自测直接引用,因此本表取值与实现取值同源:
346
365
 
347
366
  | 常量 | 值 | 位置 | 含义 |
348
367
  |---|---|---|---|
@@ -359,7 +378,22 @@ pwsh -File tools/measure-main.ps1
359
378
 
360
379
  ---
361
380
 
362
- ## 11. 已知问题
381
+ ## 11. 计划文件与待办表的优先级
382
+
383
+ 地图的分步有两个来源,优先级固定:
384
+
385
+ | 优先级 | 来源 | 作用 |
386
+ |---|---|---|
387
+ | 1 | 待办表 `todo/write` | 本轮分步的标题与状态,是当前会话的最新陈述 |
388
+ | 2 | 计划文件 `.pae/<sessionId>/orchestrator.json` | `phase` 为 `planning` / `scheduled` / `executing` / `paused` 时,其步骤标题、`statuses`、`anchorSeqs` 生效 |
389
+ | — | 同一文件的 `completed` / `aborted` | 计划已结束:步骤与报告仍发布给详情页,但不再修改任何行的标题与状态 |
390
+
391
+ 计划文件写入后长期保留,不会被删除。若不加区分地读取,上一轮计划的标题与状态会覆盖本轮待办表刚建出的行,
392
+ 面板与对话因此不一致。视图的计划段为此携带布尔 `terminal`,两侧(宿主与浏览器半)都以它为准。
393
+
394
+ ---
395
+
396
+ ## 12. 已知问题
363
397
 
364
398
  0.3.0 记录过的两个缺陷均已关闭:
365
399
 
@@ -372,7 +406,7 @@ pwsh -File tools/measure-main.ps1
372
406
 
373
407
  ---
374
408
 
375
- ## 12. 目录
409
+ ## 13. 目录
376
410
 
377
411
  ```
378
412
  dsh-workflow-map/
@@ -392,7 +426,7 @@ dsh-workflow-map/
392
426
 
393
427
  ---
394
428
 
395
- ## 13. 常见问题
429
+ ## 14. 常见问题
396
430
 
397
431
  **能同时看多个会话吗?**
398
432
  面板只展示当前会话。切换会话即切换内容;不合并、不并排。
@@ -420,13 +454,13 @@ dsh-workflow-map/
420
454
 
421
455
  ---
422
456
 
423
- ## 14. 许可
457
+ ## 15. 许可
424
458
 
425
459
  MIT。允许商用、修改与再分发,需保留版权声明。
426
460
 
427
461
  ---
428
462
 
429
- ## 15. 问题反馈
463
+ ## 16. 问题反馈
430
464
 
431
465
  以下信息有助于定位问题:
432
466
 
package/client.js CHANGED
@@ -232,6 +232,10 @@ window.__ModuleLoader__.load({
232
232
  ".wf-btn:hover{background:var(--dsw-alias-bg-layer-2,#2c3140);color:var(--dsw-alias-label-primary,#e9ecf3)}",
233
233
  ".wf-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
234
234
  ".wf-btn[data-active='true']{color:var(--dsw-alias-brand-primary,#5b8cff);border-color:var(--dsw-alias-brand-primary,#5b8cff)}",
235
+ // The refresh button's one-second acknowledgement. Width is pinned to the glyph's own
236
+ // box so swapping "↻" for "已刷新" cannot shift the header row underneath the pointer.
237
+ ".wf-btn-ok{color:var(--dsw-alias-state-success-primary,#3fb950);border-color:var(--dsw-alias-state-success-primary,#3fb950);" +
238
+ "width:auto;min-width:23px;padding:0 6px;font-size:11px}",
235
239
  // Hot injection is the one header action that injects text into the RUNNING turn, so it is the
236
240
  // one that must never be hunted for: a named, filled chip that keeps its name in Chinese.
237
241
  // User feedback: "too small, still have to open it first" - so it grows to 96x30 (the other two in the row stay
@@ -1382,6 +1386,22 @@ window.__ModuleLoader__.load({
1382
1386
  return "/dsh-workflow/state?sessionId=" + encodeURIComponent(String(sessionId));
1383
1387
  }
1384
1388
 
1389
+ /**
1390
+ * May the poll loop re-arm after this result?
1391
+ *
1392
+ * A tick aborts the request it replaces, and an aborted request resolves as status -1.
1393
+ * That is the ORDINARY path whenever a poll outlives its interval, so -1 must not end
1394
+ * the loop: it only defers to the replacement that is already in the air. Returning
1395
+ * false there is what keeps two loops from running; returning false for anything else
1396
+ * (a real error, a 4xx) would stop the panel from ever updating again.
1397
+ *
1398
+ * Extracted from the hook so the rule is testable without a renderer.
1399
+ */
1400
+ function wfPollContinues(result, inflight) {
1401
+ if (!result || result.status !== -1) return true;
1402
+ return wfFinite(inflight) === null ? true : inflight <= 0;
1403
+ }
1404
+
1385
1405
  /**
1386
1406
  * One conditional GET. Never throws: every outcome is a value, so the loop can
1387
1407
  * always decide what to render and how long to wait.
@@ -1572,6 +1592,9 @@ window.__ModuleLoader__.load({
1572
1592
  }
1573
1593
  }
1574
1594
  return {
1595
+ // A finished plan must not decide a card's status any more; the summary carries
1596
+ // the flag so the status policy can stand down without re-reading the phase.
1597
+ terminal: plan.terminal === true,
1575
1598
  statuses: plan.statuses && typeof plan.statuses === "object" ? plan.statuses : {},
1576
1599
  anchorSeqs: plan.anchorSeqs && typeof plan.anchorSeqs === "object" ? plan.anchorSeqs : {},
1577
1600
  skipped: Array.isArray(plan.skipped) ? plan.skipped.slice() : [],
@@ -2085,7 +2108,10 @@ window.__ModuleLoader__.load({
2085
2108
  errorText: typeof found.error === "string" ? found.error.slice(0, 200) : "",
2086
2109
  seq: wfFinite(found.seq),
2087
2110
  error: typeof found.error === "string" && found.error !== "",
2088
- result: typeof found.resultHead === "string" && found.resultHead !== "",
2111
+ // The host's result boolean is the settled fact: a call that returned with an EMPTY body is
2112
+ // settled. Falling back to resultHead alone marked such a call unsettled, so the panel kept
2113
+ // it in the "calling right now" state and its dot pulsed after the call had already returned.
2114
+ result: found.result === true || (typeof found.resultHead === "string" && found.resultHead !== ""),
2089
2115
  durationMs: wfFinite(found.durationMs),
2090
2116
  at: wfFinite(found.at),
2091
2117
  });
@@ -2566,7 +2592,17 @@ window.__ModuleLoader__.load({
2566
2592
  * working session as idle (which would make the curve and the frame look broken).
2567
2593
  */
2568
2594
  function wfSessionRunning(view, activeNodeId, nowMs) {
2569
- if (view && typeof view.running === "boolean") return view.running;
2595
+ // The clock is the second witness. view.running is the turn flag, and a turn that never
2596
+ // received its turn/end event (interrupt, crash, a compacted session) leaves it true forever.
2597
+ // Once the last folded event is older than the idle window the session is not working,
2598
+ // whatever the flag says.
2599
+ if (view && typeof view.running === "boolean") {
2600
+ if (view.running !== true) return false;
2601
+ var flagAsOf = wfFinite(view.asOf);
2602
+ var flagNow = wfFinite(nowMs);
2603
+ if (flagAsOf !== null && flagAsOf > 0 && flagNow !== null && flagNow - flagAsOf >= WF_IDLE_AFTER_MS) return false;
2604
+ return true;
2605
+ }
2570
2606
  if (typeof activeNodeId === "string" && activeNodeId !== "") return true;
2571
2607
  var asOf = wfFinite(view && view.asOf);
2572
2608
  if (asOf === null || asOf <= 0) return false;
@@ -3266,8 +3302,11 @@ window.__ModuleLoader__.load({
3266
3302
  */
3267
3303
  function wfDeriveStepStatus(row, plan, todoIndex, currentPlanIndex) {
3268
3304
  var planIndex = row && wfFinite(row.planIndex) !== null ? Math.round(row.planIndex) : null;
3305
+ // A finished plan (completed / aborted) is history: its statuses and anchor
3306
+ // cursor describe a turn that is over, so the todo table and the host decide.
3307
+ var live = plan && plan.terminal !== true;
3269
3308
 
3270
- if (plan && planIndex !== null) {
3309
+ if (live && planIndex !== null) {
3271
3310
  // 1. PAE's own statuses are the published truth.
3272
3311
  var label = wfHasKey(plan.statuses, String(planIndex)) ? plan.statuses[String(planIndex)] : null;
3273
3312
  var fromPae = wfCanonicalStatus(label);
@@ -7800,6 +7839,9 @@ window.__ModuleLoader__.load({
7800
7839
  "panel.planning.note": "计划尚未提交,提交后会显示分步。",
7801
7840
  "panel.collapse": "收起",
7802
7841
  "panel.expand": "展开",
7842
+ "panel.refresh": "刷新",
7843
+ "panel.refresh.hint": "立即重新读取这一步的状态(自动刷新每 0.8 秒一次,这里是不等它的手动兜底)",
7844
+ "panel.refresh.done": "已刷新",
7803
7845
  "panel.settings": "设置",
7804
7846
  "panel.steps": "总任务分步",
7805
7847
  "panel.flow": "分步流程",
@@ -8010,6 +8052,9 @@ window.__ModuleLoader__.load({
8010
8052
  "panel.planning.note": "The plan has not been submitted yet; steps appear after it is.",
8011
8053
  "panel.collapse": "Collapse",
8012
8054
  "panel.expand": "Expand",
8055
+ "panel.refresh": "Refresh",
8056
+ "panel.refresh.hint": "Read this step's state again now (auto-refresh runs every 0.8 s; this is the manual fallback)",
8057
+ "panel.refresh.done": "Refreshed",
8013
8058
  "panel.settings": "Settings",
8014
8059
  "panel.steps": "Task steps",
8015
8060
  "panel.flow": "Step flow",
@@ -8289,6 +8334,10 @@ window.__ModuleLoader__.load({
8289
8334
  });
8290
8335
  var snap = pair[0];
8291
8336
  var setSnap = pair[1];
8337
+ // The running loop's own tick, so the header's refresh button can pull once on
8338
+ // demand without waiting for the interval. A ref, not state: swapping the function
8339
+ // must not re-render, and a click must never restart the effect.
8340
+ var tickRef = React.useRef(null);
8292
8341
 
8293
8342
  React.useEffect(
8294
8343
  function () {
@@ -8301,6 +8350,9 @@ window.__ModuleLoader__.load({
8301
8350
  var timer = 0;
8302
8351
  var controller = null;
8303
8352
  var etag = "";
8353
+ // How many requests are in the air. A replaced request resolves as -1 and must
8354
+ // not re-arm while the newer one is still going, or two loops would run.
8355
+ var inflight = 0;
8304
8356
 
8305
8357
  setSnap({ status: "loading", view: null, rev: null, error: "" });
8306
8358
 
@@ -8325,11 +8377,20 @@ window.__ModuleLoader__.load({
8325
8377
  }
8326
8378
  controller = typeof AbortController === "function" ? new AbortController() : null;
8327
8379
  var signal = controller ? controller.signal : undefined;
8380
+ inflight += 1;
8328
8381
  wfPollOnce(window.fetch, wfPollUrl(sessionId), etag, signal).then(function (result) {
8382
+ inflight -= 1;
8329
8383
  if (stopped) return;
8330
- // -1 = this request was aborted by a newer one (visibility change): the
8331
- // newer tick already owns the loop, so stay silent and do not re-arm.
8332
- if (result.status === -1) return;
8384
+ // -1 = this request was aborted. A tick aborts the request it replaces, so this
8385
+ // is the ORDINARY path on a slow link, not an exception: returning here without
8386
+ // re-arming stopped the loop for good the first time a poll outlived its tick —
8387
+ // the map then froze while the session kept working, which is the reported
8388
+ // "the map does not update by itself". Only the deliberate stop (stopped) ends it.
8389
+ if (result.status === -1) {
8390
+ // A replaced request: re-arm only when no replacement is still in the air.
8391
+ if (wfPollContinues(result, inflight)) schedule(delay);
8392
+ return;
8393
+ }
8333
8394
  if (result.status === 304) {
8334
8395
  setSnap(function (prev) {
8335
8396
  return prev.view ? { status: "live", view: prev.view, rev: prev.rev, error: "" } : prev;
@@ -8362,10 +8423,12 @@ window.__ModuleLoader__.load({
8362
8423
  tick();
8363
8424
  }
8364
8425
 
8426
+ tickRef.current = tick;
8365
8427
  tick();
8366
8428
  document.addEventListener("visibilitychange", onVisibilityChange);
8367
8429
  return function () {
8368
8430
  stopped = true;
8431
+ tickRef.current = null;
8369
8432
  window.clearTimeout(timer);
8370
8433
  if (controller && typeof controller.abort === "function") {
8371
8434
  try {
@@ -8380,7 +8443,18 @@ window.__ModuleLoader__.load({
8380
8443
  [sessionId]
8381
8444
  );
8382
8445
 
8383
- return snap;
8446
+ // One object, not two returns: every caller already reads the snapshot field by field,
8447
+ // so the refresh hook rides along without changing the shape they destructure.
8448
+ return {
8449
+ status: snap.status,
8450
+ view: snap.view,
8451
+ rev: snap.rev,
8452
+ error: snap.error,
8453
+ refresh: function () {
8454
+ var pull = tickRef.current;
8455
+ if (typeof pull === "function") pull();
8456
+ },
8457
+ };
8384
8458
  }
8385
8459
 
8386
8460
  /** The stylesheet lives exactly as long as the mount. */
@@ -8400,6 +8474,25 @@ window.__ModuleLoader__.load({
8400
8474
  }, []);
8401
8475
  }
8402
8476
 
8477
+ /**
8478
+ * Clear a "just happened" stamp after a moment, so the button returns to its own
8479
+ * glyph. One timer, cleared on unmount and on a second click (the stamp changes).
8480
+ */
8481
+ function wfUseFlashClear(stamp, clear) {
8482
+ React.useEffect(
8483
+ function () {
8484
+ if (!(stamp > 0)) return undefined;
8485
+ var timer = window.setTimeout(function () {
8486
+ clear();
8487
+ }, 1200);
8488
+ return function () {
8489
+ window.clearTimeout(timer);
8490
+ };
8491
+ },
8492
+ [stamp]
8493
+ );
8494
+ }
8495
+
8403
8496
  /** Alt+W toggles the collapsed state; the listener is removed on unmount. */
8404
8497
  function wfUseAltW(updatePrefs) {
8405
8498
  React.useEffect(function () {
@@ -8792,6 +8885,15 @@ window.__ModuleLoader__.load({
8792
8885
  var prefs = prefsHandle.prefs;
8793
8886
  var updatePrefs = prefsHandle.update;
8794
8887
  var snap = wfUseSnap(sessionId);
8888
+ // When the header's refresh was last clicked; drives the one-word acknowledgement
8889
+ // that clears itself. Deliberately NOT the poll's own state: a click must not look
8890
+ // like a data change.
8891
+ var refreshedPair = React.useState(0);
8892
+ var refreshed = refreshedPair[0];
8893
+ var setRefreshed = refreshedPair[1];
8894
+ wfUseFlashClear(refreshed, function () {
8895
+ setRefreshed(0);
8896
+ });
8795
8897
  var settingsPair = React.useState(false);
8796
8898
  var settingsOpen = settingsPair[0];
8797
8899
  var setSettingsOpen = settingsPair[1];
@@ -8890,6 +8992,13 @@ window.__ModuleLoader__.load({
8890
8992
  onToggleSettings: function () {
8891
8993
  setSettingsOpen(!settingsOpen);
8892
8994
  },
8995
+ // The manual fallback: one pull, now, without waiting for the interval and without
8996
+ // touching the loop. It reads the same endpoint with the same validator, so a click
8997
+ // is a normal poll and never a second data path.
8998
+ onRefresh: function () {
8999
+ setRefreshed(Date.now());
9000
+ if (typeof snap.refresh === "function") snap.refresh();
9001
+ },
8893
9002
  // Clicking a main-flow unit: it becomes the SELECTED step (store.js builds its detail) and the
8894
9003
  // right page opens on it. It deliberately does not expand anything in place.
8895
9004
  // right page opens on it. It deliberately does not expand anything in place.
@@ -8968,6 +9077,7 @@ window.__ModuleLoader__.load({
8968
9077
 
8969
9078
  return wfRenderPanel(model, prefs, say, actions, {
8970
9079
  settingsOpen: settingsOpen,
9080
+ refreshed: refreshed,
8971
9081
  status: snap.status,
8972
9082
  error: snap.error,
8973
9083
  rev: snap.rev,
@@ -9008,6 +9118,9 @@ window.__ModuleLoader__.load({
9008
9118
  var pendingCount = wfPendingInjectCount(model.injects);
9009
9119
  var extras = ui || {};
9010
9120
  var fire = actions || {};
9121
+ // The refresh button's one-second acknowledgement, handed in by the component: the
9122
+ // renderer stays pure, so a self-test can drive it with a fixed stamp.
9123
+ var refreshed = wfFinite(extras.refreshed) === null ? 0 : extras.refreshed;
9011
9124
  // the two attributes the living layer needs. `motion` comes from the system's
9012
9125
  // prefers-reduced-motion preference (pulse.js), `idle` from the model's running flag.
9013
9126
  // Both live on .wf-root so the capsule is covered as well as the panel.
@@ -9107,6 +9220,24 @@ window.__ModuleLoader__.load({
9107
9220
  h(
9108
9221
  "div",
9109
9222
  { className: "wf-btns", key: "btns" },
9223
+ // First in the row: it is the escape hatch when the automatic poll is not
9224
+ // delivering, so it must not be the hard one to find.
9225
+ h(
9226
+ "button",
9227
+ {
9228
+ type: "button",
9229
+ key: "refresh",
9230
+ className: refreshed > 0 ? "wf-btn wf-btn-ok" : "wf-btn",
9231
+ "data-dsh-workflow-map": "refresh",
9232
+ "data-refreshed": refreshed > 0 ? "true" : undefined,
9233
+ title: say("panel.refresh.hint"),
9234
+ "aria-label": say("panel.refresh"),
9235
+ onClick: function () {
9236
+ wfFire(fire.onRefresh);
9237
+ },
9238
+ },
9239
+ refreshed > 0 ? say("panel.refresh.done") : "↻"
9240
+ ),
9110
9241
  h(
9111
9242
  "button",
9112
9243
  {
@@ -9577,6 +9708,7 @@ window.__ModuleLoader__.load({
9577
9708
  sessionIdFromSnapshot: wfSessionIdFromSnapshot,
9578
9709
  pollUrl: wfPollUrl,
9579
9710
  pollOnce: wfPollOnce,
9711
+ pollContinues: wfPollContinues,
9580
9712
  parsePrefs: wfParsePrefs,
9581
9713
  sanitizePrefs: wfSanitizePrefs,
9582
9714
  applyPrefsPatch: wfApplyPrefsPatch,
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-workflow-map",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "把当前会话渲染成一张可交互的分步工作流地图:会话标签栏、左栏菜单与主内容区共用同一份面板,逐步显示状态、工具与文件类型明细、进度条,并支持热注入(steer)与「需确认」标注。宿主半折叠会话事件、读取按步执行(PAE)计划,经本地 HTTP 视图供数据;浏览器半渲染面板。",
5
5
  "private": false,
6
6
  "type": "module",
@@ -63,6 +63,7 @@
63
63
  "docs/host-compatibility.md",
64
64
  "docs/screenshot.png",
65
65
  "docs/screenshot-detail.png",
66
+ "docs/screenshots",
66
67
  "package.json"
67
68
  ],
68
69
  "scripts": {
@@ -155,8 +155,11 @@ function wfHasKey(object, key) {
155
155
  */
156
156
  function wfDeriveStepStatus(row, plan, todoIndex, currentPlanIndex) {
157
157
  var planIndex = row && wfFinite(row.planIndex) !== null ? Math.round(row.planIndex) : null;
158
+ // A finished plan (completed / aborted) is history: its statuses and anchor
159
+ // cursor describe a turn that is over, so the todo table and the host decide.
160
+ var live = plan && plan.terminal !== true;
158
161
 
159
- if (plan && planIndex !== null) {
162
+ if (live && planIndex !== null) {
160
163
  // 1. PAE's own statuses are the published truth.
161
164
  var label = wfHasKey(plan.statuses, String(planIndex)) ? plan.statuses[String(planIndex)] : null;
162
165
  var fromPae = wfCanonicalStatus(label);
@@ -34,6 +34,9 @@ var DICT_ZH = {
34
34
  "panel.planning.note": "计划尚未提交,提交后会显示分步。",
35
35
  "panel.collapse": "收起",
36
36
  "panel.expand": "展开",
37
+ "panel.refresh": "刷新",
38
+ "panel.refresh.hint": "立即重新读取这一步的状态(自动刷新每 0.8 秒一次,这里是不等它的手动兜底)",
39
+ "panel.refresh.done": "已刷新",
37
40
  "panel.settings": "设置",
38
41
  "panel.steps": "总任务分步",
39
42
  "panel.flow": "分步流程",
@@ -244,6 +247,9 @@ var DICT_EN = {
244
247
  "panel.planning.note": "The plan has not been submitted yet; steps appear after it is.",
245
248
  "panel.collapse": "Collapse",
246
249
  "panel.expand": "Expand",
250
+ "panel.refresh": "Refresh",
251
+ "panel.refresh.hint": "Read this step's state again now (auto-refresh runs every 0.8 s; this is the manual fallback)",
252
+ "panel.refresh.done": "Refreshed",
247
253
  "panel.settings": "Settings",
248
254
  "panel.steps": "Task steps",
249
255
  "panel.flow": "Step flow",
@@ -523,6 +529,10 @@ function wfUseSnap(sessionId) {
523
529
  });
524
530
  var snap = pair[0];
525
531
  var setSnap = pair[1];
532
+ // The running loop's own tick, so the header's refresh button can pull once on
533
+ // demand without waiting for the interval. A ref, not state: swapping the function
534
+ // must not re-render, and a click must never restart the effect.
535
+ var tickRef = React.useRef(null);
526
536
 
527
537
  React.useEffect(
528
538
  function () {
@@ -535,6 +545,9 @@ function wfUseSnap(sessionId) {
535
545
  var timer = 0;
536
546
  var controller = null;
537
547
  var etag = "";
548
+ // How many requests are in the air. A replaced request resolves as -1 and must
549
+ // not re-arm while the newer one is still going, or two loops would run.
550
+ var inflight = 0;
538
551
 
539
552
  setSnap({ status: "loading", view: null, rev: null, error: "" });
540
553
 
@@ -559,11 +572,20 @@ function wfUseSnap(sessionId) {
559
572
  }
560
573
  controller = typeof AbortController === "function" ? new AbortController() : null;
561
574
  var signal = controller ? controller.signal : undefined;
575
+ inflight += 1;
562
576
  wfPollOnce(window.fetch, wfPollUrl(sessionId), etag, signal).then(function (result) {
577
+ inflight -= 1;
563
578
  if (stopped) return;
564
- // -1 = this request was aborted by a newer one (visibility change): the
565
- // newer tick already owns the loop, so stay silent and do not re-arm.
566
- if (result.status === -1) return;
579
+ // -1 = this request was aborted. A tick aborts the request it replaces, so this
580
+ // is the ORDINARY path on a slow link, not an exception: returning here without
581
+ // re-arming stopped the loop for good the first time a poll outlived its tick —
582
+ // the map then froze while the session kept working, which is the reported
583
+ // "the map does not update by itself". Only the deliberate stop (stopped) ends it.
584
+ if (result.status === -1) {
585
+ // A replaced request: re-arm only when no replacement is still in the air.
586
+ if (wfPollContinues(result, inflight)) schedule(delay);
587
+ return;
588
+ }
567
589
  if (result.status === 304) {
568
590
  setSnap(function (prev) {
569
591
  return prev.view ? { status: "live", view: prev.view, rev: prev.rev, error: "" } : prev;
@@ -596,10 +618,12 @@ function wfUseSnap(sessionId) {
596
618
  tick();
597
619
  }
598
620
 
621
+ tickRef.current = tick;
599
622
  tick();
600
623
  document.addEventListener("visibilitychange", onVisibilityChange);
601
624
  return function () {
602
625
  stopped = true;
626
+ tickRef.current = null;
603
627
  window.clearTimeout(timer);
604
628
  if (controller && typeof controller.abort === "function") {
605
629
  try {
@@ -614,7 +638,18 @@ function wfUseSnap(sessionId) {
614
638
  [sessionId]
615
639
  );
616
640
 
617
- return snap;
641
+ // One object, not two returns: every caller already reads the snapshot field by field,
642
+ // so the refresh hook rides along without changing the shape they destructure.
643
+ return {
644
+ status: snap.status,
645
+ view: snap.view,
646
+ rev: snap.rev,
647
+ error: snap.error,
648
+ refresh: function () {
649
+ var pull = tickRef.current;
650
+ if (typeof pull === "function") pull();
651
+ },
652
+ };
618
653
  }
619
654
 
620
655
  /** The stylesheet lives exactly as long as the mount. */
@@ -634,6 +669,25 @@ function wfUseStyle() {
634
669
  }, []);
635
670
  }
636
671
 
672
+ /**
673
+ * Clear a "just happened" stamp after a moment, so the button returns to its own
674
+ * glyph. One timer, cleared on unmount and on a second click (the stamp changes).
675
+ */
676
+ function wfUseFlashClear(stamp, clear) {
677
+ React.useEffect(
678
+ function () {
679
+ if (!(stamp > 0)) return undefined;
680
+ var timer = window.setTimeout(function () {
681
+ clear();
682
+ }, 1200);
683
+ return function () {
684
+ window.clearTimeout(timer);
685
+ };
686
+ },
687
+ [stamp]
688
+ );
689
+ }
690
+
637
691
  /** Alt+W toggles the collapsed state; the listener is removed on unmount. */
638
692
  function wfUseAltW(updatePrefs) {
639
693
  React.useEffect(function () {
@@ -1026,6 +1080,15 @@ function WorkflowMapPanel(props) {
1026
1080
  var prefs = prefsHandle.prefs;
1027
1081
  var updatePrefs = prefsHandle.update;
1028
1082
  var snap = wfUseSnap(sessionId);
1083
+ // When the header's refresh was last clicked; drives the one-word acknowledgement
1084
+ // that clears itself. Deliberately NOT the poll's own state: a click must not look
1085
+ // like a data change.
1086
+ var refreshedPair = React.useState(0);
1087
+ var refreshed = refreshedPair[0];
1088
+ var setRefreshed = refreshedPair[1];
1089
+ wfUseFlashClear(refreshed, function () {
1090
+ setRefreshed(0);
1091
+ });
1029
1092
  var settingsPair = React.useState(false);
1030
1093
  var settingsOpen = settingsPair[0];
1031
1094
  var setSettingsOpen = settingsPair[1];
@@ -1124,6 +1187,13 @@ function WorkflowMapPanel(props) {
1124
1187
  onToggleSettings: function () {
1125
1188
  setSettingsOpen(!settingsOpen);
1126
1189
  },
1190
+ // The manual fallback: one pull, now, without waiting for the interval and without
1191
+ // touching the loop. It reads the same endpoint with the same validator, so a click
1192
+ // is a normal poll and never a second data path.
1193
+ onRefresh: function () {
1194
+ setRefreshed(Date.now());
1195
+ if (typeof snap.refresh === "function") snap.refresh();
1196
+ },
1127
1197
  // Clicking a main-flow unit: it becomes the SELECTED step (store.js builds its detail) and the
1128
1198
  // right page opens on it. It deliberately does not expand anything in place.
1129
1199
  // right page opens on it. It deliberately does not expand anything in place.
@@ -1202,6 +1272,7 @@ function WorkflowMapPanel(props) {
1202
1272
 
1203
1273
  return wfRenderPanel(model, prefs, say, actions, {
1204
1274
  settingsOpen: settingsOpen,
1275
+ refreshed: refreshed,
1205
1276
  status: snap.status,
1206
1277
  error: snap.error,
1207
1278
  rev: snap.rev,
@@ -1242,6 +1313,9 @@ function wfRenderPanel(model, prefs, say, actions, ui) {
1242
1313
  var pendingCount = wfPendingInjectCount(model.injects);
1243
1314
  var extras = ui || {};
1244
1315
  var fire = actions || {};
1316
+ // The refresh button's one-second acknowledgement, handed in by the component: the
1317
+ // renderer stays pure, so a self-test can drive it with a fixed stamp.
1318
+ var refreshed = wfFinite(extras.refreshed) === null ? 0 : extras.refreshed;
1245
1319
  // the two attributes the living layer needs. `motion` comes from the system's
1246
1320
  // prefers-reduced-motion preference (pulse.js), `idle` from the model's running flag.
1247
1321
  // Both live on .wf-root so the capsule is covered as well as the panel.
@@ -1341,6 +1415,24 @@ function wfRenderPanel(model, prefs, say, actions, ui) {
1341
1415
  h(
1342
1416
  "div",
1343
1417
  { className: "wf-btns", key: "btns" },
1418
+ // First in the row: it is the escape hatch when the automatic poll is not
1419
+ // delivering, so it must not be the hard one to find.
1420
+ h(
1421
+ "button",
1422
+ {
1423
+ type: "button",
1424
+ key: "refresh",
1425
+ className: refreshed > 0 ? "wf-btn wf-btn-ok" : "wf-btn",
1426
+ "data-dsh-workflow-map": "refresh",
1427
+ "data-refreshed": refreshed > 0 ? "true" : undefined,
1428
+ title: say("panel.refresh.hint"),
1429
+ "aria-label": say("panel.refresh"),
1430
+ onClick: function () {
1431
+ wfFire(fire.onRefresh);
1432
+ },
1433
+ },
1434
+ refreshed > 0 ? say("panel.refresh.done") : "↻"
1435
+ ),
1344
1436
  h(
1345
1437
  "button",
1346
1438
  {
@@ -1811,6 +1903,7 @@ var __test = {
1811
1903
  sessionIdFromSnapshot: wfSessionIdFromSnapshot,
1812
1904
  pollUrl: wfPollUrl,
1813
1905
  pollOnce: wfPollOnce,
1906
+ pollContinues: wfPollContinues,
1814
1907
  parsePrefs: wfParsePrefs,
1815
1908
  sanitizePrefs: wfSanitizePrefs,
1816
1909
  applyPrefsPatch: wfApplyPrefsPatch,
@@ -53,7 +53,17 @@ function wfMotionMode(matchMediaFn) {
53
53
  * working session as idle (which would make the curve and the frame look broken).
54
54
  */
55
55
  function wfSessionRunning(view, activeNodeId, nowMs) {
56
- if (view && typeof view.running === "boolean") return view.running;
56
+ // The clock is the second witness. view.running is the turn flag, and a turn that never
57
+ // received its turn/end event (interrupt, crash, a compacted session) leaves it true forever.
58
+ // Once the last folded event is older than the idle window the session is not working,
59
+ // whatever the flag says.
60
+ if (view && typeof view.running === "boolean") {
61
+ if (view.running !== true) return false;
62
+ var flagAsOf = wfFinite(view.asOf);
63
+ var flagNow = wfFinite(nowMs);
64
+ if (flagAsOf !== null && flagAsOf > 0 && flagNow !== null && flagNow - flagAsOf >= WF_IDLE_AFTER_MS) return false;
65
+ return true;
66
+ }
57
67
  if (typeof activeNodeId === "string" && activeNodeId !== "") return true;
58
68
  var asOf = wfFinite(view && view.asOf);
59
69
  if (asOf === null || asOf <= 0) return false;
@@ -175,6 +175,22 @@ function wfPollUrl(sessionId) {
175
175
  return "/dsh-workflow/state?sessionId=" + encodeURIComponent(String(sessionId));
176
176
  }
177
177
 
178
+ /**
179
+ * May the poll loop re-arm after this result?
180
+ *
181
+ * A tick aborts the request it replaces, and an aborted request resolves as status -1.
182
+ * That is the ORDINARY path whenever a poll outlives its interval, so -1 must not end
183
+ * the loop: it only defers to the replacement that is already in the air. Returning
184
+ * false there is what keeps two loops from running; returning false for anything else
185
+ * (a real error, a 4xx) would stop the panel from ever updating again.
186
+ *
187
+ * Extracted from the hook so the rule is testable without a renderer.
188
+ */
189
+ function wfPollContinues(result, inflight) {
190
+ if (!result || result.status !== -1) return true;
191
+ return wfFinite(inflight) === null ? true : inflight <= 0;
192
+ }
193
+
178
194
  /**
179
195
  * One conditional GET. Never throws: every outcome is a value, so the loop can
180
196
  * always decide what to render and how long to wait.
@@ -365,6 +381,9 @@ function wfPlanSummary(plan) {
365
381
  }
366
382
  }
367
383
  return {
384
+ // A finished plan must not decide a card's status any more; the summary carries
385
+ // the flag so the status policy can stand down without re-reading the phase.
386
+ terminal: plan.terminal === true,
368
387
  statuses: plan.statuses && typeof plan.statuses === "object" ? plan.statuses : {},
369
388
  anchorSeqs: plan.anchorSeqs && typeof plan.anchorSeqs === "object" ? plan.anchorSeqs : {},
370
389
  skipped: Array.isArray(plan.skipped) ? plan.skipped.slice() : [],
@@ -878,7 +897,10 @@ function wfBuildModel(view, prefs, nowMs) {
878
897
  errorText: typeof found.error === "string" ? found.error.slice(0, 200) : "",
879
898
  seq: wfFinite(found.seq),
880
899
  error: typeof found.error === "string" && found.error !== "",
881
- result: typeof found.resultHead === "string" && found.resultHead !== "",
900
+ // The host's result boolean is the settled fact: a call that returned with an EMPTY body is
901
+ // settled. Falling back to resultHead alone marked such a call unsettled, so the panel kept
902
+ // it in the "calling right now" state and its dot pulsed after the call had already returned.
903
+ result: found.result === true || (typeof found.resultHead === "string" && found.resultHead !== ""),
882
904
  durationMs: wfFinite(found.durationMs),
883
905
  at: wfFinite(found.at),
884
906
  });
@@ -203,6 +203,10 @@ var WF_CSS = [
203
203
  ".wf-btn:hover{background:var(--dsw-alias-bg-layer-2,#2c3140);color:var(--dsw-alias-label-primary,#e9ecf3)}",
204
204
  ".wf-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
205
205
  ".wf-btn[data-active='true']{color:var(--dsw-alias-brand-primary,#5b8cff);border-color:var(--dsw-alias-brand-primary,#5b8cff)}",
206
+ // The refresh button's one-second acknowledgement. Width is pinned to the glyph's own
207
+ // box so swapping "↻" for "已刷新" cannot shift the header row underneath the pointer.
208
+ ".wf-btn-ok{color:var(--dsw-alias-state-success-primary,#3fb950);border-color:var(--dsw-alias-state-success-primary,#3fb950);" +
209
+ "width:auto;min-width:23px;padding:0 6px;font-size:11px}",
206
210
  // Hot injection is the one header action that injects text into the RUNNING turn, so it is the
207
211
  // one that must never be hunted for: a named, filled chip that keeps its name in Chinese.
208
212
  // User feedback: "too small, still have to open it first" - so it grows to 96x30 (the other two in the row stay
package/src/fold.mjs CHANGED
@@ -68,7 +68,7 @@
68
68
  // demotes a step that still carried `done`/`running`, and a step any table no longer names is
69
69
  // dropped). Checkpoints written before the bump hold ledgers from the older mapping — wrong
70
70
  // statuses and leftover rows — so the same contract applies: drop and rebuild from the log.
71
- export const STATE_VERSION = 4;
71
+ export const STATE_VERSION = 5;
72
72
 
73
73
  export const SCHEMA_VERSION = 1;
74
74
 
@@ -548,7 +548,11 @@ function percentFor(state, step, now) {
548
548
  const value = measured;
549
549
  return { percent: clamp(value, 0, 99), source: "todos" };
550
550
  }
551
- return { percent: clamp(Math.max(reported.percent, step.maxPercent), 0, 100), source: "model" };
551
+ // The model's latest statement is the number, and an open step is 0..99: 100 % on a running
552
+ // row is a claim the log does not support. The high-water record is deliberately not applied
553
+ // here, as the comment above states; applying it freezes a row at the highest number it ever
554
+ // reached, so later reports cannot move the bar.
555
+ return { percent: clamp(reported.percent, 0, 99), source: "model" };
552
556
  }
553
557
  // The checklist's own completion ratio — a real measurement, so it sits above the time baseline.
554
558
  // It never claims 100 % for an OPEN step: the remaining row is still in progress.
@@ -620,6 +624,10 @@ function newNode(kind, tool, title, at, extra) {
620
624
  argsText: "",
621
625
  resultHead: "",
622
626
  error: "",
627
+ // Whether the call returned. Separate from the body text: a call that succeeds with
628
+ // no output has an empty resultHead and is still settled. The browser half colours by
629
+ // this flag, so deriving it from resultHead leaves every silent call "running".
630
+ settled: false,
623
631
  at,
624
632
  durationMs: null,
625
633
  // filled by appendNode when the caller did not pass one (see toolKey).
@@ -751,6 +759,25 @@ function argsDigest(args) {
751
759
  // fold
752
760
  // ---------------------------------------------------------------------------
753
761
 
762
+ /**
763
+ * Reopen a closed row without letting its 100 % speak for the reopened one.
764
+ *
765
+ * A row taken back to in_progress used to keep the percent and the high-water record of its
766
+ * closed state, so the panel showed an in-progress step pinned at 100 %: a bar that can never
767
+ * move again, and a later self-report cannot lower it. A closed record's number belongs to
768
+ * that closure, not to the row that reopens it.
769
+ */
770
+ function reopenStep(step, time) {
771
+ const reopened = step.status === STEP_STATUS.done || step.status === STEP_STATUS.failed;
772
+ const base = {
773
+ ...step,
774
+ status: STEP_STATUS.running,
775
+ endedAt: null,
776
+ startedAt: reopened || !step.startedAt ? time : step.startedAt,
777
+ };
778
+ return reopened ? { ...base, percent: null, percentSource: "none", maxPercent: 0 } : base;
779
+ }
780
+
754
781
  function foldTodos(state, event, time) {
755
782
  const data = dataOf(event);
756
783
  const raw = data && Array.isArray(data.todos) ? data.todos : [];
@@ -844,7 +871,7 @@ function foldTodos(state, event, time) {
844
871
  if (next.startedAt > 0) durations = [...durations, time - next.startedAt].filter((value) => value > 0);
845
872
  } else if (item.status === "in_progress" && next.status !== STEP_STATUS.running) {
846
873
  // A step re-opened by a re-plan or a retry: keep its ledger row, restart the clock.
847
- steps[at] = { ...next, status: STEP_STATUS.running, endedAt: null, startedAt: next.startedAt || time };
874
+ steps[at] = reopenStep(next, time);
848
875
  } else if (next !== step) {
849
876
  steps[at] = next;
850
877
  }
@@ -863,9 +890,18 @@ function foldTodos(state, event, time) {
863
890
  if (step.startedAt > 0) durations = [...durations, time - step.startedAt].filter((value) => value > 0);
864
891
  steps[at] = closeStep(step, time);
865
892
  } else if (claim.status === "in_progress" && step.status !== STEP_STATUS.running) {
866
- steps[at] = { ...step, status: STEP_STATUS.running, endedAt: null, startedAt: step.startedAt || time };
893
+ steps[at] = reopenStep(step, time);
867
894
  } else if (claim.status === "pending" && step.status !== STEP_STATUS.pending) {
868
- steps[at] = { ...step, status: STEP_STATUS.pending, endedAt: null, startedAt: 0, percent: null, percentSource: "none" };
895
+ // Same rule for a demotion: the row is not done, so it cannot keep a done row's number.
896
+ steps[at] = {
897
+ ...step,
898
+ status: STEP_STATUS.pending,
899
+ endedAt: null,
900
+ startedAt: 0,
901
+ percent: null,
902
+ percentSource: "none",
903
+ maxPercent: 0,
904
+ };
869
905
  }
870
906
  }
871
907
 
@@ -965,6 +1001,8 @@ function foldToolResult(state, event, time, seq) {
965
1001
  ...previous,
966
1002
  resultHead: isError ? "" : line(body),
967
1003
  error: isError ? line(body) : "",
1004
+ // Settled means "the call came back", whatever its body: empty output is not a pending call.
1005
+ settled: true,
968
1006
  durationMs: previous.at > 0 && time > previous.at ? time - previous.at : null,
969
1007
  };
970
1008
  const answered = { ...state, asOf: time, nodes };
@@ -979,7 +1017,14 @@ function foldToolResult(state, event, time, seq) {
979
1017
  ),
980
1018
  }
981
1019
  : answered;
982
- const next = withActive(settled, previous.id, time);
1020
+ // A result is not an activity. Re-asserting the node that just finished as the active one
1021
+ // makes the panel claim "calling right now" about a call that already returned. Keep the
1022
+ // node a newer call opened, and otherwise hand the highlight back to the step.
1023
+ const keepActive =
1024
+ state.active && typeof state.active.nodeId === "string" && state.active.nodeId !== "" && state.active.nodeId !== previous.id
1025
+ ? state.active.nodeId
1026
+ : null;
1027
+ const next = withActive(settled, keepActive, time);
983
1028
  // A tool result is the moment two branch decisions become possible: a job that is STILL
984
1029
  // open (background) is promoted to a lane, and a launcher's acknowledgement attaches its
985
1030
  // run id. Both are no-ops for ordinary calls.
@@ -2326,6 +2371,8 @@ function nodeView(node) {
2326
2371
  files: node.files.map((file) => ({ path: file.path, action: file.action })),
2327
2372
  resultHead: node.resultHead,
2328
2373
  error: node.error,
2374
+ // the boolean the browser half colours by; see newNode.settled
2375
+ result: node.settled === true,
2329
2376
  at: node.at,
2330
2377
  durationMs: node.durationMs,
2331
2378
  // the detail's meta line names the event that produced the node. Null for the
@@ -2458,10 +2505,24 @@ function syncToTodos(state) {
2458
2505
  // the record it landed on was still pending — both seen in one session on 2026-10-30.
2459
2506
  const rowStatus = (step, row) => {
2460
2507
  if (row.status === "completed") return step.status === STEP_STATUS.done ? step : closeStep(step, state.asOf || 0);
2508
+ // A closed record cannot speak for an open row. When the table takes a row back to
2509
+ // in_progress/pending, the record it is bound to may be a step this table already closed,
2510
+ // or an older row a reworded title landed on. Keeping that percent and high-water renders
2511
+ // a brand-new first row at 100 % the instant it appears.
2512
+ const reopened = step.status === STEP_STATUS.done || step.status === STEP_STATUS.failed;
2513
+ const carried = reopened
2514
+ ? { percent: null, percentSource: "none", maxPercent: 0 }
2515
+ : { percent: step.percent, percentSource: step.percentSource, maxPercent: step.maxPercent };
2461
2516
  if (row.status === "in_progress") {
2462
- return { ...step, status: STEP_STATUS.running, endedAt: null, startedAt: step.startedAt || state.asOf || 0 };
2517
+ return {
2518
+ ...step,
2519
+ ...carried,
2520
+ status: STEP_STATUS.running,
2521
+ endedAt: null,
2522
+ startedAt: reopened || !step.startedAt ? state.asOf || 0 : step.startedAt,
2523
+ };
2463
2524
  }
2464
- return { ...step, status: STEP_STATUS.pending, endedAt: null, startedAt: 0, percent: null, percentSource: "none" };
2525
+ return { ...step, ...carried, status: STEP_STATUS.pending, endedAt: null, startedAt: 0, percent: null, percentSource: "none" };
2465
2526
  };
2466
2527
 
2467
2528
  const byKey = new Map();
@@ -37,6 +37,21 @@ export const PLAN_VERSION = 1;
37
37
  export const PLAN_ROOT = ".pae";
38
38
  export const PLAN_FILE = "orchestrator.json";
39
39
 
40
+ /**
41
+ * Plan phases that are history rather than a live view.
42
+ *
43
+ * PAE's own vocabulary (dsh-plan-and-execute): none / planning / scheduled /
44
+ * executing / paused / completed / aborted. The last two never resume, and the
45
+ * file that carries them is never removed, so they must not speak for a row the
46
+ * session created after the plan ended.
47
+ */
48
+ const TERMINAL_PHASES = new Set(["completed", "aborted"]);
49
+
50
+ /** Is this normalized plan finished? True for completed and aborted. */
51
+ export function planIsTerminal(plan) {
52
+ return isRecord(plan) && TERMINAL_PHASES.has(String(plan.phase ?? "").trim().toLowerCase());
53
+ }
54
+
40
55
  /** Bounds. The plan is display data; a runaway file must not grow the session cache. */
41
56
  export const MAX_PLAN_STEPS = 200;
42
57
  export const MAX_STEP_REPORTS = 200;
@@ -197,9 +212,14 @@ export function normalizePlanDocument(raw, meta) {
197
212
  }
198
213
 
199
214
  const rawTask = typeof doc.task === "string" ? doc.task : "";
215
+ const phase = text(doc.phase, MAX_PHASE);
200
216
  const result = {
201
217
  ver: PLAN_VERSION,
202
- phase: text(doc.phase, MAX_PHASE),
218
+ phase,
219
+ // Published as a boolean as well: the browser half decides card statuses from this
220
+ // section alone, and "is this plan finished" must not be re-derived from a string
221
+ // vocabulary in two places.
222
+ terminal: TERMINAL_PHASES.has(phase.trim().toLowerCase()),
203
223
  task: text(rawTask, MAX_TASK),
204
224
  // The first non-empty line, before flattening: it is the panel's header.
205
225
  taskTitle: line(rawTask, MAX_TASK_TITLE),
@@ -263,6 +283,10 @@ export function applyPlanToView(view, plan) {
263
283
  const normalized = isRecord(plan) ? plan : null;
264
284
  const result = { ...view, plan: normalized };
265
285
  if (normalized === null) return result;
286
+ // A finished plan is published for the detail page but patches nothing: its
287
+ // rows belong to a turn that is over, and the current todo table is the newer
288
+ // statement. See planIsTerminal.
289
+ if (planIsTerminal(normalized)) return result;
266
290
 
267
291
  if (normalized.taskTitle !== "") result.task = normalized.taskTitle;
268
292