nx-kv 0.21.0 → 0.23.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +57 -40
  2. package/package.json +1 -1
  3. package/src/core/workflow/project.js +9 -5
  4. package/src/modules/localtask/move-prims.js +4 -1
  5. package/src/modules/localtask/store-io.js +13 -0
  6. package/src/modules/localtask/task-lifecycle.js +5 -1
  7. package/src/modules/localtask/task-ops.js +5 -0
  8. package/src/modules/localtask/task-remove.js +6 -1
  9. package/src/web/frontend/components/md/ack-rail.jsx +263 -0
  10. package/src/web/frontend/modules/board/ack-nodes.js +89 -0
  11. package/src/web/frontend/modules/board/ack-rail-pos.js +50 -0
  12. package/src/web/frontend/modules/md/style.css +12 -0
  13. package/src/web/frontend/page/dir/task.jsx +24 -0
  14. package/src/web/public/assets/{Introduction-BSDhb2Sj.js → Introduction-BFPD7dEs.js} +1 -1
  15. package/src/web/public/assets/{cloud-Sfi1OGsF.js → cloud-CcKgVB1k.js} +1 -1
  16. package/src/web/public/assets/{dag-BnXCpGph.js → dag-Cn4d2qQ8.js} +1 -1
  17. package/src/web/public/assets/{dir-DG-o4u5h.js → dir-TPbJjoUI.js} +1 -1
  18. package/src/web/public/assets/{group-CO1dZAIi.js → group-BVbsnISZ.js} +1 -1
  19. package/src/web/public/assets/{index-Cg0WaaAi.css → index-CfxLBdFE.css} +1 -1
  20. package/src/web/public/assets/{index-BSQ-y9mw.js → index-Dlx5BdmV.js} +2 -2
  21. package/src/web/public/assets/{md-history-DwI1pcCg.js → md-history-ji4xpx2J.js} +1 -1
  22. package/src/web/public/assets/{member-picker-BgVyjr7m.js → member-picker-BH-XaSGJ.js} +1 -1
  23. package/src/web/public/assets/{overview-CdGfl5aQ.js → overview-3P0pC6De.js} +1 -1
  24. package/src/web/public/assets/{progress-CxIXeuOg.js → progress-CbJJg5bl.js} +1 -1
  25. package/src/web/public/assets/{repos-DiYt0y77.js → repos-BvJ_kmUv.js} +1 -1
  26. package/src/web/public/assets/{route-crumb-BEAGbZBS.js → route-crumb-BGrg1YqO.js} +1 -1
  27. package/src/web/public/assets/{settings-BIDVlTR_.js → settings-DCgK_btM.js} +1 -1
  28. package/src/web/public/assets/task-5ohM-kDI.js +5 -0
  29. package/src/web/public/assets/{template-slots-B7SKP2-A.js → template-slots-jmHCtXit.js} +1 -1
  30. package/src/web/public/assets/{templates-BOJCv21y.js → templates-DuxGMEgE.js} +1 -1
  31. package/src/web/public/assets/{upload-ChrVGtCq.js → upload-Cc8eUwzX.js} +1 -1
  32. package/src/web/public/index.html +2 -2
  33. package/src/web/public/assets/task-5c2dyDzF.js +0 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
4
4
 
5
+ ## [0.23.0] - 2026-11-05 定位轨可拖动
6
+
7
+ ### Added
8
+
9
+ - **任务页 ack 定位轨支持拖动(任务 #55)**:按住汉堡钮把整条轨拖到正文区内任意位置,
10
+ 双击回默认钉位;落点存 localStorage,重开面板还在。单击(位移 <4px)仍是展开/收起,
11
+ 拖动不会误触;拖到容器边缘自动钳回界内。
12
+ - 实现注意一:钉位态的轨盒子被 `top/bottom` 双锚拉满(`bottom:44px` → 高 760),
13
+ 钳制必须用**临时解除底锚量出的自然高**(同帧同步量取再恢复,子元素本来就堆在
14
+ 顶部,无视觉跳动)——直接用拉伸盒高会把第一次拖动错钳到容器顶部。
15
+ - 实现注意二:落点存盘用**拖动会话里的最终落点**(`pointerup` 时 React 状态可能
16
+ 还未提交,读 state 会存回拖动前的旧位置,等于白拖)。
17
+ - 落点纯逻辑(钳制 + 存取)独立成 `modules/board/ack-rail-pos.js`,node 单测
18
+ `tests/unit/ack-rail-pos.test.mjs` 6 条;端到端 Playwright 探针(隔离 store +
19
+ 临时端口 7879 + 本机 Chrome)11 条断言全过:跟手、越界钳制、拖完即持久化、
20
+ 拖后 click 不误触、展开态拖动、重开还原、双击回默认位、零 console error。
21
+
22
+ ## [0.22.0] - 2026-11-05 任务页 ack 定位轨
23
+
24
+ ### Added
25
+
26
+ - **任务页 ack 定位轨(任务 #48)**:反复 ack 反馈的任务(正文一轮一轮变长,#34 有 5 轮、
27
+ 正文 13903 字符)在右侧多一条定位轨——**一轮一个节点**,点一下直接跳到那一轮的
28
+ 「上一轮完成总结」。复用简介页那套定位轨的视觉(`.doc-rail`),但定位方式不同:
29
+ 按**块序号**跳,不按标题锚点跳。
30
+ - 为什么不用标题锚点:实时预览态逐块渲染,每块各有一套标题 id 生成器,于是同名标题在
31
+ DOM 里拿到的是**重复的同一个 id**(两个「## 上一轮完成总结」都是同一个 id)——
32
+ 按 id 跳永远落在第一轮。块序号(`data-i`)才唯一。
33
+ - 阅读态(整篇一次性渲染)没有块元素,改按「第 N 个 ack 标题」定位——该态标题 id 互不相同。
34
+ - 轮次从**正文**抽(每轮一个「## 上一轮完成总结」段):正文即历史,改完正文轨立刻跟着变。
35
+ - 只有一个节点时不显示(没有可跳的多轮,挂了也是噪音)。
36
+
5
37
  ## [0.21.0] - 2026-11-05 队列与编排的职责单一化 + store 损坏防护
6
38
 
7
39
  ### Fixed
@@ -42,51 +74,23 @@
42
74
  - 闸门徽章升级:显示被挡后续数(`闸门×N`,悬停列出 #id)。
43
75
  - 组任务行尾新增「⋮」行菜单(编排依赖 / 闸门开关),待领取行悬停可见。
44
76
 
77
+ - `localtask config --suffix-list "一条,二条"` / `--ack-list "一条,二条"`:**整体替换**多条
78
+ 提示词列表(逗号分隔;HTTP body 传数组)。此前 action 只暴露单条 `--suffix`/`--ack`,
79
+ 面板的多条保存无处可传(#41 实测发现并修)。
80
+
45
81
  ### Changed
46
82
 
47
83
  - **设置面板观感重做(任务 #41 重开反馈「ui 太丑了」→ 二轮「更丑了」)**:交互语义不变,只收拾
48
84
  密度与层级——「提示词 / 模板」栏的竖排 tab 侧栏改成顶部一排药丸、内容限宽;提示词条目行去描边、
49
85
  编号不用圆圈、**收起标题洗掉原始模板标记**(不再直接甩 `{{#goal}}〔…〕` 给人看);操作条分隔线分组;
50
86
  项目级设置从大卡片改成紧凑行(项目多时一屏 8 个而不是 2 个),「持续 get」关着时只留一枚行内小开关;
51
- 「提示词 / 模板」栏不再套卡(盒中盒显脏)。:交互语义不变,只收拾
52
- 密度与层级——「提示词 / 模板」栏的竖排 tab 侧栏改成顶部一排药丸、内容限宽;
53
- 提示词条目行去描边、编号不用圆圈;操作条分隔线分组;项目级设置从大卡片改成
54
- 紧凑行(项目多时一屏 8 个而不是 2 个),「持续 get」关着时只留一枚行内小开关。
55
-
56
- ### Changed
87
+ 「提示词 / 模板」栏不再套卡(盒中盒显脏)。
57
88
 
58
89
  - **面包屑移到 maincontent 面板内顶部(任务 #44)**:路径条不再占窗口顶部通栏
59
90
  (那是侧栏的地盘),改成挂在**当前主面板**顶部的一枚小药丸——目录任务表 /
60
91
  任务详情页 / 项目组 / 全部目录总览 / 云端总览 / 仓库管理 / 任务模板 各面板
61
92
  根部都有一条;MD 工作台里则直接放进顶部工具栏(`mdw-top`)内、标题左侧;
62
- 「← 返回」随路径条一起搬进面板(按来处返回的行为不变)。:路径条不再占窗口顶部通栏
63
- (那是侧栏的地盘),改成挂在**当前主面板**顶部的一枚小药丸——目录任务表 /
64
- 任务详情页 / 项目组 / 全部目录总览 / 云端总览 / 仓库管理 / 任务模板 各面板
65
- 根部都有一条;「← 返回」随路径条一起搬进面板(按来处返回的行为不变)。
66
-
67
- ### Fixed
68
-
69
- - **改归属项目即搬家(任务 #43「提交任务后发现归属项目填错了,重新修改提交无效」)**:
70
- 此前「归属决定落点」只在提交那一刻生效——提交后发现归属填错,改正文里的 project 再保存,
71
- 正文改了队列没动:任务还挂在旧项目队列里,旧项目的 agent 照旧领走,新项目的 agent 永远
72
- 看不见,组面板/目录页也都显示旧归属。现在 `localtask update --text` / `localtask reopen --text`
73
- / 面板「保存并同步 / 重做」走与派任务同一套落点判定:effective frontmatter.project 解析到
74
- 哪个登记项目,任务就迁到哪个队列;介绍引用(projectIntro/projectDesc)随归属刷新。
75
- 约束如实说明:已领取的任务不能改归属(要改先 reopen);带队列内依赖/闸门的任务搬运会
76
- 跨队列断链,先解除再改(明确报错,不静默);搬家后任务拿到**新队列的 id**(面板页签自动
77
- 跟到新号,CLI 输出标注迁移);指向它的跨队列引用(`topicKey:id`)改写到新地址。
78
- 另自愈历史脏数据:归属字段与所在队列对不上(上一轮只改字段没搬队列的残留)时,
79
- 保存即搬到归属队列;已领取/带队列内依赖的脏数据不搬也不拦普通编辑。
80
-
81
- - **顶部面包屑真正可见 + 补返回键(任务 #42)**:面包屑此前**渲染了却看不见**——
82
- FlexLayout 的根是绝对定位(inset:0),铺满工作区把 26px 的路径条盖在下面
83
- (侧栏面板压在上面,路径条点不到也看不见)。现在工作区有面包屑时布局整体下移一条;
84
- 侧栏也不再出页签条(#33「无页签化」的残留,页签条同样压在路径条上)。
85
- 面包屑条左侧新增「← 返回」:按**来处**返回(会话内路由历史栈)——
86
- 从项目组点任务(任务住在 dir 队列里,详情页落在 dir 作用域)时,返回会回到**组**
87
- 而不是掉进目录;没有上一级时不画返回键。
88
-
89
- ### Changed
93
+ 「← 返回」随路径条一起搬进面板(按来处返回的行为不变)。
90
94
 
91
95
  - **可视化编排面板(任务 #40 验收反馈重开)**:目录与项目组**共用**一块编排面板,
92
96
  从任务面板**头部右侧的绿色超链**进入(开在右侧主内容区):
@@ -107,13 +111,27 @@
107
111
  - **项目级「持续 get」一句话配置**:选模板(内置默认 + 全局回调提示词条目)→ 改一句话
108
112
  (`%s` = 任务编号,存取时与 `{{task.id}}` 互转)→ 添加;不再手写整段模板。
109
113
 
110
- ### Added
114
+ ### Fixed
111
115
 
112
- - `localtask config --suffix-list "一条,二条"` / `--ack-list "一条,二条"`:**整体替换**多条
113
- 提示词列表(逗号分隔;HTTP body 传数组)。此前 action 只暴露单条 `--suffix`/`--ack`,
114
- 面板的多条保存无处可传(#41 实测发现并修)。
116
+ - **改归属项目即搬家(任务 #43「提交任务后发现归属项目填错了,重新修改提交无效」)**:
117
+ 此前「归属决定落点」只在提交那一刻生效——提交后发现归属填错,改正文里的 project 再保存,
118
+ 正文改了队列没动:任务还挂在旧项目队列里,旧项目的 agent 照旧领走,新项目的 agent 永远
119
+ 看不见,组面板/目录页也都显示旧归属。现在 `localtask update --text` / `localtask reopen --text`
120
+ / 面板「保存并同步 / 重做」走与派任务同一套落点判定:effective frontmatter.project 解析到
121
+ 哪个登记项目,任务就迁到哪个队列;介绍引用(projectIntro/projectDesc)随归属刷新。
122
+ 约束如实说明:已领取的任务不能改归属(要改先 reopen);带队列内依赖/闸门的任务搬运会
123
+ 跨队列断链,先解除再改(明确报错,不静默);搬家后任务拿到**新队列的 id**(面板页签自动
124
+ 跟到新号,CLI 输出标注迁移);指向它的跨队列引用(`topicKey:id`)改写到新地址。
125
+ 另自愈历史脏数据:归属字段与所在队列对不上(上一轮只改字段没搬队列的残留)时,
126
+ 保存即搬到归属队列;已领取/带队列内依赖的脏数据不搬也不拦普通编辑。
115
127
 
116
- ### Fixed
128
+ - **顶部面包屑真正可见 + 补返回键(任务 #42)**:面包屑此前**渲染了却看不见**——
129
+ FlexLayout 的根是绝对定位(inset:0),铺满工作区把 26px 的路径条盖在下面
130
+ (侧栏面板压在上面,路径条点不到也看不见)。现在工作区有面包屑时布局整体下移一条;
131
+ 侧栏也不再出页签条(#33「无页签化」的残留,页签条同样压在路径条上)。
132
+ 面包屑条左侧新增「← 返回」:按**来处**返回(会话内路由历史栈)——
133
+ 从项目组点任务(任务住在 dir 队列里,详情页落在 dir 作用域)时,返回会回到**组**
134
+ 而不是掉进目录;没有上一级时不画返回键。
117
135
 
118
136
  - **首次打开设置面板爆栈**(任务 #41 实测发现,#33 遗留):`openPanel` 按区域分发时
119
137
  FLOAT → `openFloatingPanel` → `openPanel`(未带 area)→ 又判成 FLOAT → 无限递归
@@ -575,7 +593,6 @@
575
593
  - **实时预览块级查找**:Ctrl/⌘F 在预览态可用,n/m 计数 + 命中块高亮 + 滚动定位
576
594
  - **无障碍**:编辑区 aria-label 补齐、状态栏小字对比度提到 WCAG AA
577
595
 
578
-
579
596
  - **仓库获得全局唯一 key,导入导出改以 key 为身份(v3)+ 全屏导入导出面板**:
580
597
  - **key**:归一化远程地址派生(`git@c:o/r.git`、`https://c/o/r`、
581
598
  `ssh://git@ssh.github.com:443/o/r.git`、带端口、带 `.git` 都是同一个
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "nx-kv — KV 清单管理中枢(远程)+ 本机本地任务队列(localtask):serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 提交 / 领取 / 阻塞接收任务",
5
5
  "type": "module",
6
6
  "bin": {
@@ -64,16 +64,20 @@ export function applySnapshotToTopic(topic, snap) {
64
64
  syncTaskFromNode(task, node);
65
65
  }
66
66
 
67
- // 边 → blockBy(显式依赖);闸门边由 blocksFollowing 表达,不写进每条 blockBy
67
+ // 边 → blockBy(显式依赖);闸门边由 blocksFollowing 表达,不写进每条 blockBy。
68
+ // 本地依赖取「现有 ∪ 快照边」的**并集**而不是按边重建:快照可能滞后于外部任务行写
69
+ // (引擎在途写),按边重建会把外部刚设好的 blockBy 洗掉。依赖是「只能多、不能凭空少」
70
+ // 的保守口径——解除依赖一律走外部 clear 先落盘,再由 syncTaskDagUpdate 拆边,
71
+ // 不存在「只删引擎边、不删任务行」的路径,所以并集不会让解除失效。
68
72
  const gateIds = new Set(
69
73
  (snap.nodes || []).filter((n) => isGateNode(n)).map((n) => String(n.id)),
70
74
  );
71
75
 
72
76
  for (const task of tasks) {
73
77
  if (task.deletedAt || task.migratedTo) continue;
74
- const keepForeign = normalizeBlockBy(task.blockBy, task.id).filter(
75
- (d) => parseDepRef(d)?.kind === 'foreign',
76
- );
78
+ const keep = normalizeBlockBy(task.blockBy, task.id);
79
+ const keepForeign = keep.filter((d) => parseDepRef(d)?.kind === 'foreign');
80
+ const keepLocal = keep.filter((d) => parseDepRef(d)?.kind === 'local');
77
81
  const incoming = (snap.edges || []).filter((e) => String(e.to) === String(task.id));
78
82
  const localDeps = [];
79
83
  for (const e of incoming) {
@@ -82,7 +86,7 @@ export function applySnapshotToTopic(topic, snap) {
82
86
  const n = Number(e.from);
83
87
  if (Number.isFinite(n) && n > 0) localDeps.push(n);
84
88
  }
85
- task.blockBy = normalizeBlockBy([...localDeps, ...keepForeign], task.id);
89
+ task.blockBy = normalizeBlockBy([...keepLocal, ...localDeps, ...keepForeign], task.id);
86
90
  }
87
91
 
88
92
  topic.workflowRevision = Number(snap.revision) || 0;
@@ -2,7 +2,7 @@
2
2
  // 迁出 tombstone(目标端写成功后才调用)、撤销迁出、云端回插待领取首位。
3
3
  import { badInput } from '../../core/errors.js';
4
4
  import { normalizeCwd, resolveTaskKey } from './paths.js';
5
- import { loadStore, saveStore, stampNow, normalizeText, topicOf, nextTaskId } from './store-io.js';
5
+ import { loadStore, saveStore, stampNow, normalizeText, topicOf, nextTaskId, bumpWorkflowRevision } from './store-io.js';
6
6
  import { normalizeTask, isPending } from './task-view.js';
7
7
  // ─── 迁移原语(task move 用;store 封装不外泄)─────────────────────
8
8
  //
@@ -43,6 +43,7 @@ export async function markTaskMigrated({ cwd: rawCwd, key: rawKey, id, migratedT
43
43
  }
44
44
  raw.migratedTo = { side: 'cloud', topic: String(migratedTo.topic || ''), targetId: Number(migratedTo.targetId) || 0, at: String(migratedTo.at || stampNow()) };
45
45
  t.updatedAt = stampNow();
46
+ bumpWorkflowRevision(t);
46
47
  await saveStore(store);
47
48
  return { status: 'ok', key, cwd: t.cwd || '', name: t.name, task: normalizeTask(raw) };
48
49
  }
@@ -70,6 +71,7 @@ export async function clearTaskMigrated({ cwd: rawCwd, key: rawKey, id, toTop =
70
71
  t.tasks.unshift(raw);
71
72
  }
72
73
  t.updatedAt = stampNow();
74
+ bumpWorkflowRevision(t);
73
75
  await saveStore(store);
74
76
  return { status: 'ok', restored: true, key, cwd: t.cwd || '', name: t.name, task: normalizeTask(raw) };
75
77
  }
@@ -90,6 +92,7 @@ export async function insertTaskAtTop({ cwd: rawCwd, key: rawKey, text, from = '
90
92
  if (firstPending >= 0) t.tasks.splice(firstPending, 0, task);
91
93
  else t.tasks.unshift(task);
92
94
  t.updatedAt = stampNow();
95
+ bumpWorkflowRevision(t);
93
96
  await saveStore(store);
94
97
  return { status: 'ok', key, cwd: t.cwd || cwd, name: t.name, task };
95
98
  }
@@ -248,6 +248,19 @@ export function nextTaskId(tasks) {
248
248
  }
249
249
  return max + 1;
250
250
  }
251
+
252
+ // 外部写(不经引擎 Mutator)落盘前把主题的 workflowRevision 顶一格。
253
+ // 为什么必须:引擎侧落盘有 revision CAS(saveAtomic),但这条 CAS 只对「引擎自己的写」
254
+ // 成立——外部改任务行(设 blockBy / 领取 / 完成 / 删除)不动 revision,引擎**在途**的
255
+ // 过期快照(scheduler 定时器 / close 没等到的 runOnce)落盘时 CAS 照样通过,
256
+ // applySnapshotToTopic 会把过期边**重建**回全队列 blockBy,把刚写进去的依赖洗掉
257
+ // (现场:CI 上 localtask-attribution「历史脏数据但带本地依赖」偶发搬家,就是依赖被
258
+ // 在途写洗空后守卫放行)。顶一格让过期快照必然 CAS 冲突 → fail-open 丢弃;
259
+ // 引擎写全是任务行的可重投影,丢了无副作用,下一次 sync 会按新任务行重新投影。
260
+ export function bumpWorkflowRevision(topic) {
261
+ if (!topic) return;
262
+ topic.workflowRevision = (Number(topic.workflowRevision) || 0) + 1;
263
+ }
251
264
  // "YYYY-MM-DD HH:mm:ss"(本地)→ epoch ms;无法解析返回 NaN
252
265
  export function parseStampMs(s) {
253
266
  const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/.exec(String(s || ''));
@@ -6,7 +6,7 @@ import { formatDuration } from '../../core/instruction-template.js';
6
6
  import { preserveFrontmatter } from '../../shared/frontmatter.js';
7
7
  import { findFirstReadyPending } from './dag.js';
8
8
  import { isGlobalKey, resolveTaskKey } from './paths.js';
9
- import { loadStore, saveStore, stampNow, normalizeSummary, normalizeText } from './store-io.js';
9
+ import { loadStore, saveStore, stampNow, normalizeSummary, normalizeText, bumpWorkflowRevision } from './store-io.js';
10
10
  import { normalizeTask, foreignResolver, isPending, relocateInStore } from './task-view.js';
11
11
  import { markConsumed } from './waiters.js';
12
12
  // 把一条任务重新开放(用户的**修改反馈** → 重新进入执行循环)。
@@ -51,6 +51,8 @@ export async function reopenTask({ cwd: rawCwd, key: rawKey, id, text, moveTo }
51
51
  }
52
52
  }
53
53
  host.updatedAt = stampNow();
54
+ bumpWorkflowRevision(host);
55
+ if (host !== t) bumpWorkflowRevision(t);
54
56
  await saveStore(store);
55
57
  return {
56
58
  status: 'ok',
@@ -81,6 +83,7 @@ export async function consumeFirstPending(key, roundSummary, by) {
81
83
  byName: by && by.name,
82
84
  });
83
85
  t.updatedAt = stampNow();
86
+ bumpWorkflowRevision(t);
84
87
  await saveStore(store);
85
88
  return normalizeTask(task);
86
89
  }
@@ -145,6 +148,7 @@ export async function completeTask({ cwd: rawCwd, key: rawKey, id, summary: rawS
145
148
  task.completedAt = stampNow();
146
149
  task.roundSummary = summary;
147
150
  t.updatedAt = stampNow();
151
+ bumpWorkflowRevision(t);
148
152
  await saveStore(store);
149
153
  // 单一写路径:完成 → Mutator 标 success + 调度一轮(fail-open)
150
154
  try {
@@ -14,6 +14,7 @@ import {
14
14
  normalizeText,
15
15
  topicOf,
16
16
  nextTaskId,
17
+ bumpWorkflowRevision,
17
18
  } from './store-io.js';
18
19
  import { GLOBAL_KEY, GLOBAL_NAME, GLOBAL_CWD, isGlobalKey, normalizeCwd, resolveTaskKey, topicKeyFor } from './paths.js';
19
20
  import { normalizeTask, foreignResolver, queueRelationsOf, relocateInStore } from './task-view.js';
@@ -131,6 +132,7 @@ export async function addTask({
131
132
  }
132
133
  }
133
134
 
135
+ bumpWorkflowRevision(t);
134
136
  await saveStore(store); // 先落盘再唤醒:wait 返回时数据必然一致
135
137
  if (wake) wake(normalizeTask(task));
136
138
 
@@ -273,6 +275,9 @@ export async function updateTask({
273
275
  }
274
276
  host.updatedAt = stampNow();
275
277
  if (host !== t) t.updatedAt = stampNow();
278
+ // 搬家动了两个队列,源/目标都要顶 revision(引擎在途快照对两个都可能过期)
279
+ bumpWorkflowRevision(host);
280
+ if (host !== t) bumpWorkflowRevision(t);
276
281
  await saveStore(store);
277
282
  if (hasDagInput) {
278
283
  try {
@@ -2,7 +2,7 @@
2
2
  // 与 purge 彻底摘除两套词汇;reorder 只在 pending 子序列内换位。
3
3
  import { badInput } from '../../core/errors.js';
4
4
  import { normalizeCwd, resolveTaskKey } from './paths.js';
5
- import { loadStore, saveStore, stampNow } from './store-io.js';
5
+ import { loadStore, saveStore, stampNow, bumpWorkflowRevision } from './store-io.js';
6
6
  import { normalizeTask, topicView } from './task-view.js';
7
7
  export async function removeTask({ cwd: rawCwd, key: rawKey, id, purge = false } = {}) {
8
8
  const key = resolveTaskKey({ key: rawKey, cwd: rawCwd });
@@ -22,6 +22,7 @@ export async function removeTask({ cwd: rawCwd, key: rawKey, id, purge = false }
22
22
  // 彻底清除:把行真的摘出去(不可恢复)。软删除区里的"清干净"用这个。
23
23
  t.tasks.splice(index, 1);
24
24
  t.updatedAt = stampNow();
25
+ bumpWorkflowRevision(t);
25
26
  await saveStore(store);
26
27
  return { status: 'ok', purged: true, key, cwd: t.cwd || cwd, name: t.name, removed: normalizeTask(task) };
27
28
  }
@@ -32,6 +33,7 @@ export async function removeTask({ cwd: rawCwd, key: rawKey, id, purge = false }
32
33
  task.deletedAt = stampNow();
33
34
  task.trashIndex = index; // 恢复时放回原位
34
35
  t.updatedAt = stampNow();
36
+ bumpWorkflowRevision(t);
35
37
  await saveStore(store);
36
38
  return { status: 'ok', purged: false, key, cwd: t.cwd || cwd, name: t.name, removed: normalizeTask(task) };
37
39
  }
@@ -77,6 +79,7 @@ export async function removeTasks({ cwd: rawCwd, key: rawKey, ids, purge = false
77
79
  }
78
80
  if (removed.length) {
79
81
  t.updatedAt = stampNow();
82
+ bumpWorkflowRevision(t);
80
83
  await saveStore(store);
81
84
  }
82
85
  return {
@@ -114,6 +117,7 @@ export async function restoreTask({ cwd: rawCwd, key: rawKey, id } = {}) {
114
117
  task.deletedAt = '';
115
118
  delete task.trashIndex;
116
119
  t.updatedAt = stampNow();
120
+ bumpWorkflowRevision(t);
117
121
  await saveStore(store);
118
122
  return { status: 'ok', restored: true, key, cwd: t.cwd || cwd, name: t.name, task: normalizeTask(task) };
119
123
  }
@@ -161,6 +165,7 @@ export async function reorderTasks({ cwd: rawCwd, key: rawKey, id, beforeId, aft
161
165
  const ti = t.tasks.indexOf(target); // 删除 drag 后目标下标自动修正
162
166
  t.tasks.splice(hasBefore ? ti : ti + 1, 0, drag);
163
167
  t.updatedAt = stampNow();
168
+ bumpWorkflowRevision(t);
164
169
 
165
170
  await saveStore(store);
166
171
  return { status: 'ok', key, cwd: t.cwd || '', name: t.name, topic: topicView(t) };
@@ -0,0 +1,263 @@
1
+ // 任务页的「ack 定位轨」——反复 ack 反馈的长任务页里,一键跳到某一轮。
2
+ //
3
+ // 复用的是简介页那套定位轨(components/Introduction/rail.jsx 的 .doc-rail 视觉),
4
+ // 但**定位方式不同**:简介页按标题锚点跳,本轨按**块序号**跳。
5
+ // 原因见 modules/board/ack-nodes.js 的注释——实时预览态逐块渲染,同名标题在 DOM 里
6
+ // 拿到的是重复的同一个 id,按 id 跳永远落在第一轮;块序号(data-i)才唯一。
7
+ //
8
+ // 两种渲染态都要能用:
9
+ // - 实时预览(.mdw-lp):块有 data-i,按序号直接取
10
+ // - 阅读态(.mdw-preview):整篇一次性渲染,没有块元素,按「第 N 个 ack 标题」取
11
+ //
12
+ // 轨可拖动(任务 #55):汉堡钮是拖把——按住拖整条轨(Pointer Events,>4px 算拖、
13
+ // 否则算点击),双击回默认钉位;落点经 modules/board/ack-rail-pos.js 钳制并
14
+ // 存 localStorage,重开面板还在。拖动语义只在任务页这层加,简介页轨不受影响。
15
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
+ import { ackNodeTitle } from '../../modules/board/ack-nodes.js';
17
+ import { clampRailPos, loadRailPos, saveRailPos } from '../../modules/board/ack-rail-pos.js';
18
+
19
+ // 位移阈值:超过才算拖动(否则 pointerup 后的 click 照常展开/收起)
20
+ const DRAG_THRESHOLD = 4;
21
+
22
+ /**
23
+ * 在滚动容器里按块序号找到目标块。
24
+ *
25
+ * 两种渲染态的 DOM 形状不同:
26
+ * - 实时预览(.mdw-lp):每块一个 .mdw-lp-block,带 data-i → 直接取
27
+ * - 阅读态(.mdw-preview):整篇一次性渲染成一段 HTML,**没有块元素**;
28
+ * 此时改用「第 N 个 ack 标题元素」定位(同名标题在阅读态是整篇共享 usedIds,
29
+ * id 各不相同,所以能按序号取到)
30
+ *
31
+ * @param {Element|null} root 滚动容器
32
+ * @param {number} idx 块序号
33
+ * @param {number} ackOrdinal 该轮是第几个 ack(0 基;阅读态按标题序号取)
34
+ * @returns {Element|null}
35
+ */
36
+ function blockAt(root, idx, ackOrdinal) {
37
+ if (!root || idx < 0) return null;
38
+ // 实时预览:每块带 data-i
39
+ const live = root.querySelector(`.mdw-lp-block[data-i="${idx}"]`);
40
+ if (live) return live;
41
+ // 阅读态:整篇渲染 → 按「第 N 个 ack 标题」取
42
+ const heads = [...root.querySelectorAll('h2')].filter(
43
+ (h) => (h.textContent || '').trim() === '上一轮完成总结'
44
+ );
45
+ if (heads[ackOrdinal]) return heads[ackOrdinal];
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * @param {{nodes: {round:number, blockIndex:number, feedbackBlockIndex:number, ackText:string}[],
51
+ * scrollRoot: Element|null,
52
+ * onJump?: (node:object)=>void}} props
53
+ */
54
+ export default function AckRail({ nodes = [], scrollRoot = null, onJump = null }) {
55
+ const [open, setOpen] = useState(false);
56
+ const [activeRound, setActiveRound] = useState(0);
57
+ // 拖动落点:null = 默认钉位(CSS);有值 = inline left/top 自由落点
58
+ const [pos, setPos] = useState(loadRailPos);
59
+ const [dragging, setDragging] = useState(false);
60
+ const railRef = useRef(null);
61
+ const dragRef = useRef(null); // 拖动会话:起点、容器/轨尺寸、是否已过阈值、最终落点
62
+ const draggedRef = useRef(false); // 刚结束的是拖动 → 吃掉紧随的 click,别误触展开
63
+
64
+ // 持久化落点钳回当前容器内(视口可能比上次拖动时小)
65
+ useEffect(() => {
66
+ const el = railRef.current;
67
+ const parent = el && el.parentElement;
68
+ if (!el || !parent) return undefined;
69
+ const fix = () => {
70
+ // 经 setPos updater 读最新落点(不另设 ref);钳制是纯函数,重复调用幂等
71
+ setPos((cur) => {
72
+ if (!cur) return cur;
73
+ const pr = parent.getBoundingClientRect();
74
+ const rr = el.getBoundingClientRect();
75
+ const fixed = clampRailPos({
76
+ x: cur.x, y: cur.y, parentW: pr.width, parentH: pr.height, railW: rr.width, railH: rr.height,
77
+ });
78
+ return fixed && (fixed.x !== cur.x || fixed.y !== cur.y) ? fixed : cur;
79
+ });
80
+ };
81
+ fix();
82
+ // 窗口缩放时也别出界
83
+ window.addEventListener('resize', fix, { passive: true });
84
+ return () => window.removeEventListener('resize', fix);
85
+ }, []);
86
+
87
+ // ─── 拖动(汉堡钮 = 拖把)────────────────────────────────────────
88
+ const beginDrag = useCallback((e) => {
89
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
90
+ const el = railRef.current;
91
+ const parent = el && el.parentElement;
92
+ if (!el || !parent) return;
93
+ // 钉位态盒子被 top/bottom 双锚拉满(bottom:44 → 高 760),实测高 ≠ 拖后自由态
94
+ // 自然高——拿它钳制会把上限错钳到容器顶部(第一次拖只能落在 y≤132)。
95
+ // 临时解除底锚量一次自然高再恢复:同帧同步、子元素本来就从顶部堆叠,无视觉跳动。
96
+ const prevBottom = el.style.bottom;
97
+ el.style.bottom = 'auto';
98
+ const rr = el.getBoundingClientRect();
99
+ if (prevBottom) el.style.bottom = prevBottom;
100
+ else el.style.bottom = '';
101
+ const pr = parent.getBoundingClientRect();
102
+ dragRef.current = {
103
+ pointerId: e.pointerId,
104
+ startX: e.clientX,
105
+ startY: e.clientY,
106
+ startLeft: rr.left - pr.left, // 轨在容器内的当前左/上(含钉位态)——位移在此基础上叠加
107
+ startTop: rr.top - pr.top,
108
+ parentW: pr.width,
109
+ parentH: pr.height,
110
+ railW: rr.width,
111
+ railH: rr.height, // 自然高(已解除底锚量取)
112
+ moved: false,
113
+ };
114
+ try {
115
+ e.currentTarget.setPointerCapture(e.pointerId);
116
+ } catch {
117
+ /* 老浏览器不支持捕获时照常工作(指针没离开按钮的话) */
118
+ }
119
+ }, []);
120
+
121
+ const onDragMove = useCallback((e) => {
122
+ const d = dragRef.current;
123
+ if (!d || e.pointerId !== d.pointerId) return;
124
+ const dx = e.clientX - d.startX;
125
+ const dy = e.clientY - d.startY;
126
+ if (!d.moved) {
127
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
128
+ d.moved = true;
129
+ draggedRef.current = true;
130
+ setDragging(true);
131
+ document.body.style.userSelect = 'none'; // 拖动别顺带选中正文
132
+ }
133
+ // 抓握点跟随手指:轨在容器内的落点 = 指针按下时的轨位置 + 指针位移
134
+ //(startLeft 按下时实测,grabDX 的偏移天然已含在内,不需要再修正)
135
+ const fixed = clampRailPos({
136
+ x: d.startLeft + dx,
137
+ y: d.startTop + dy,
138
+ parentW: d.parentW,
139
+ parentH: d.parentH,
140
+ railW: d.railW,
141
+ railH: d.railH,
142
+ });
143
+ if (fixed) {
144
+ d.last = fixed; // 最终落点记在会话里——pointerup 直接取,不等 React 提交
145
+ setPos(fixed);
146
+ }
147
+ }, []);
148
+
149
+ const endDrag = useCallback((e) => {
150
+ const d = dragRef.current;
151
+ if (!d || e.pointerId !== d.pointerId) return;
152
+ dragRef.current = null;
153
+ if (!d.moved) return; // 没过阈值 = 点击,交给 onClick 展开/收起
154
+ setDragging(false);
155
+ document.body.style.userSelect = '';
156
+ saveRailPos(d.last); // 拖完落点才存(拖动过程不写盘;存会话里的最终落点,不读可能未提交的 state)
157
+ }, []);
158
+
159
+ const resetPos = useCallback(() => {
160
+ setPos(null);
161
+ saveRailPos(null); // 清记录 = 回 CSS 默认钉位
162
+ }, []);
163
+
164
+ const items = useMemo(() => (nodes || []).map((n) => ({ ...n, title: ackNodeTitle(n) })), [nodes]);
165
+
166
+ // 当前视口里最近的一个 ack 段 → 高亮对应节点
167
+ useEffect(() => {
168
+ const root = scrollRoot;
169
+ if (!root || !items.length) return undefined;
170
+ const sync = () => {
171
+ const rootRect = root.getBoundingClientRect();
172
+ let cur = 0;
173
+ for (const it of items) {
174
+ const el = blockAt(root, it.blockIndex, it.round - 1);
175
+ if (!el) continue;
176
+ const r = el.getBoundingClientRect();
177
+ if (r.top - rootRect.top <= 40) cur = it.round;
178
+ }
179
+ setActiveRound(cur);
180
+ };
181
+ sync();
182
+ root.addEventListener('scroll', sync, { passive: true });
183
+ return () => root.removeEventListener('scroll', sync);
184
+ }, [scrollRoot, items]);
185
+
186
+ const jump = useCallback(
187
+ (node) => {
188
+ const root = scrollRoot;
189
+ const el = root ? blockAt(root, node.blockIndex, node.round - 1) : null;
190
+ if (el) {
191
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
192
+ setActiveRound(node.round);
193
+ }
194
+ onJump?.(node);
195
+ },
196
+ [scrollRoot, onJump]
197
+ );
198
+
199
+ if (!items.length) return null;
200
+
201
+ return (
202
+ <aside
203
+ ref={railRef}
204
+ className={'doc-rail ack-rail' + (open ? ' open' : '') + (dragging ? ' dragging' : '')}
205
+ style={pos ? { left: pos.x, top: pos.y, right: 'auto', bottom: 'auto' } : undefined}
206
+ aria-label="验收轮次"
207
+ >
208
+ <button
209
+ type="button"
210
+ className="doc-rail-menu"
211
+ title={open ? '收起轮次(双击回默认位)' : '展开轮次 · 按住拖动 · 双击回默认位'}
212
+ aria-expanded={open}
213
+ onClick={() => {
214
+ if (draggedRef.current) {
215
+ draggedRef.current = false; // 这下 click 是拖动结束带出来的,别当点击
216
+ return;
217
+ }
218
+ setOpen((v) => !v);
219
+ }}
220
+ onPointerDown={beginDrag}
221
+ onPointerMove={onDragMove}
222
+ onPointerUp={endDrag}
223
+ onPointerCancel={endDrag}
224
+ onDoubleClick={resetPos}
225
+ >
226
+ <span /><span /><span />
227
+ </button>
228
+
229
+ <div className="doc-rail-ticks" role="navigation" aria-label="验收轮次">
230
+ {items.map((it) => (
231
+ <button
232
+ key={it.round}
233
+ type="button"
234
+ className={'doc-rail-tick' + (activeRound === it.round ? ' on' : '')}
235
+ style={{ '--tw': '18px' }}
236
+ title={it.title}
237
+ aria-current={activeRound === it.round ? 'true' : undefined}
238
+ onClick={() => jump(it)}
239
+ />
240
+ ))}
241
+ </div>
242
+
243
+ {open && (
244
+ <div className="doc-rail-panel">
245
+ <div className="doc-rail-sec">
246
+ <div className="doc-rail-sec-lab">验收轮次</div>
247
+ {items.map((it) => (
248
+ <button
249
+ key={it.round}
250
+ type="button"
251
+ className={'doc-rail-row' + (activeRound === it.round ? ' on' : '')}
252
+ title={it.ackText}
253
+ onClick={() => jump(it)}
254
+ >
255
+ 第 {it.round} 轮
256
+ </button>
257
+ ))}
258
+ </div>
259
+ </div>
260
+ )}
261
+ </aside>
262
+ );
263
+ }