dsh-workplan 1.5.1 → 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/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
- "chartLive": "进行中计划完成度(按工作项)",
37
- "chartMore": "另有 {n} 个进行中计划未画入图表",
38
+ "chartGroups": "计划数量与完成情况",
39
+ "chartOpen": "未结束",
40
+ "chartDone": "已完成",
41
+ "chartLive": "未完成计划的完成度(按工作项)",
42
+ "chartMore": "另有 {n} 个未完成计划未画入图表",
38
43
 
39
44
  "title": "任务进度:一眼看懂",
40
45
  "subtitle": "用大白话说明:现在要做什么、谁在做、做到哪一步、接下来做什么。",
@@ -45,6 +50,9 @@ window.__ModuleLoader__.load({
45
50
  "helpNobody": "还没开始",
46
51
  "helpDone": "都做完了",
47
52
  "helpNextDone": "没有下一步,这件事已完成",
53
+ "helpWaitActive": "等这一步做完,再继续下一项",
54
+ "helpNoPlan": "还没有安排任何工作",
55
+ "nowRemaining": "还剩 {n} 项",
48
56
  "helpHint": "负责团队 = 谁来做;具体工作 = 要完成的一件事;卡住了 = 需要人处理。",
49
57
  "handleBlocked": "先处理",
50
58
  "resumeReady": "对主 Agent 说「续跑这个计划」,会从受阻处继续(已完成的不重跑)",
@@ -66,7 +74,8 @@ window.__ModuleLoader__.load({
66
74
  "blocked": "卡住了",
67
75
  "paused": "预算用完,可续跑",
68
76
  "degraded": "安排失败",
69
- "cancelled": "已取消"
77
+ "cancelled": "已取消",
78
+ "ended": "已结束,可续跑"
70
79
  },
71
80
  "status": {
72
81
  "pending": "还没开始",
@@ -182,6 +191,7 @@ window.__ModuleLoader__.load({
182
191
  "en": {
183
192
  "groupLive": "In progress",
184
193
  "groupDone": "Completed",
194
+ "groupEnded": "Ended",
185
195
  "groupLiveHint": "Not finished yet — running or about to run",
186
196
  "groupDoneHint": "All work finished and wrapped up",
187
197
  "groupCollapse": "Collapse",
@@ -189,14 +199,17 @@ window.__ModuleLoader__.load({
189
199
  "groupNeeds": "needs attention",
190
200
  "groupLiveEmpty": "No work in progress",
191
201
  "groupDoneEmpty": "Nothing completed yet",
202
+ "groupEndedEmpty": "No ended-but-unfinished plans",
192
203
  "groupSummary": "Open {open} / done {done} ({pct}% complete)",
193
204
  "groupSummaryNoRate": "Open {open} / done {done} (completion rate unavailable)",
194
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.",
195
206
  "groupAriaEmpty": "{title} group: {plans} plan(s), no work items yet.",
196
207
  "groupPlansLabel": "plan(s)",
197
- "chartGroups": "Plan count by group",
198
- "chartLive": "In-progress plans by work item",
199
- "chartMore": "{n} more in-progress plans not drawn",
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",
200
213
  "panel": "Task progress",
201
214
  "title": "Task progress at a glance",
202
215
  "subtitle": "Plain language: what this is, who is doing it, how far along it is, and what happens next.",
@@ -207,6 +220,9 @@ window.__ModuleLoader__.load({
207
220
  "helpNobody": "Not started yet",
208
221
  "helpDone": "Everything is done",
209
222
  "helpNextDone": "Nothing next; this task is complete",
223
+ "helpWaitActive": "Wait for this step to finish, then continue",
224
+ "helpNoPlan": "No work has been scheduled yet",
225
+ "nowRemaining": "{n} item(s) left",
210
226
  "helpHint": "Team = who does it; work item = one thing to finish; stuck = a person needs to help.",
211
227
  "handleBlocked": "Fix first",
212
228
  "resumeReady": "Tell the main agent to resume this plan; it continues from the blocked step without re-running finished work",
@@ -228,7 +244,8 @@ window.__ModuleLoader__.load({
228
244
  "blocked": "Stuck",
229
245
  "paused": "Paused (budget spent)",
230
246
  "degraded": "Planning failed",
231
- "cancelled": "Cancelled"
247
+ "cancelled": "Cancelled",
248
+ "ended": "Ended (resumable)"
232
249
  },
233
250
  "status": {
234
251
  "pending": "Not started",
@@ -399,6 +416,7 @@ window.__ModuleLoader__.load({
399
416
  .dsh-wp-group::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 4px; }
400
417
  .dsh-wp-group--live::before { background: linear-gradient(180deg, #fb923c, #f97316); }
401
418
  .dsh-wp-group--done::before { background: linear-gradient(180deg, #38bdf8, #0284c7); }
419
+ .dsh-wp-group--ended::before { background: linear-gradient(180deg, #94a3b8, #64748b); }
402
420
  /* T6 — 组头多出一行完成度摘要,底部内边距 10px → 6px,两组仍在同一屏内读完。 */
403
421
  .dsh-wp-group-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 0 4px 6px; }
404
422
  .dsh-wp-group-head--btn { width: 100%; background: none; border: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: 8px; }
@@ -425,6 +443,8 @@ window.__ModuleLoader__.load({
425
443
  .dsh-wp-group-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
426
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; }
427
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); }
428
448
  .dsh-wp-group-title { font-size: 15px; font-weight: 700; letter-spacing: .2px; color: var(--dsw-alias-label-primary, #0f172a); }
429
449
  /* 设计稿 §6.1:橙 #f97316 / 白 ≈2.8:1、蓝 #0ea5e9 / 白 ≈2.77:1,均不足 4.5:1,
430
450
  15px 粗体也不算法定「大字号」,所以组头一律用主文本色;品牌色只做「面」——
@@ -433,6 +453,7 @@ window.__ModuleLoader__.load({
433
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; }
434
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); }
435
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); }
436
457
  /* WCAG AA:错误色叠 12% 红底后,浅色 #ec1313 只有 3.70:1;与主文本色混合 20%
437
458
  让浅色变深、深色变浅,两套主题都 ≥4.5:1(实测 5.16:1 / 5.67:1)。 */
438
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; }
@@ -745,6 +766,7 @@ window.__ModuleLoader__.load({
745
766
  .dsh-wp-group::before { content: ''; position: absolute; left: 0; top: 14px; bottom: 14px; width: 3px; border-radius: 0 3px 3px 0; opacity: .9; }
746
767
  .dsh-wp-group--live::before { background: linear-gradient(180deg, #fbbf24, #ea580c); }
747
768
  .dsh-wp-group--done::before { background: linear-gradient(180deg, #7dd3fc, #0369a1); }
769
+ .dsh-wp-group--ended::before { background: linear-gradient(180deg, #cbd5e1, #94a3b8); }
748
770
  .dsh-wp-group--done { opacity: .97; }
749
771
  .dsh-wp-group-head--btn { transition: background-color .16s ease; }
750
772
  .dsh-wp-group-head--btn:hover { background: rgba(148,163,184,.10); }
@@ -976,12 +998,33 @@ window.__ModuleLoader__.load({
976
998
 
977
999
  function phaseTone(phase: any) {
978
1000
  if (phase === 'blocked') return 'blocked'
1001
+ /*
1002
+ * [1.6.0] `ended` = 这一轮停了,但计划没做完。它既不是成功(done),也不能
1003
+ * 冒充正在干活(active);用中性 idle 色,卡在哪由二级徽标 suspendedPhase 说明。
1004
+ */
1005
+ if (phase === 'ended') return 'idle'
979
1006
  if (phase === 'degraded' || phase === 'cancelled' || phase === 'paused') return 'warn'
980
1007
  if (phase === 'integrating' || phase === 'done') return 'done'
981
1008
  if (phase === 'executing' || phase === 'planning') return 'active'
982
1009
  return 'idle'
983
1010
  }
984
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
+
985
1028
  function allEntries(session: any) {
986
1029
  const byId = new Map((Array.isArray(session?.statuses) ? session.statuses : []).map((item: any) => [item.subtaskId, item]))
987
1030
  const entries: any[] = []
@@ -1028,14 +1071,21 @@ window.__ModuleLoader__.load({
1028
1071
  }
1029
1072
 
1030
1073
  function workerText(session: any, t: any) {
1031
- const active = findEntry(session, 'in_progress')
1032
- if (active) {
1033
- return `${deptName(t, active.subtask.department)} · ${active.subtask.title}`
1034
- }
1074
+ /*
1075
+ * [1.5.2] 与 `planNow` 同序:卡住优先。
1076
+ *
1077
+ * 旧顺序是「正在做」优先,而卡片右上角的「现在」是「卡住了」优先,
1078
+ * 于是同一屏一边说「现在卡住了 T1」、一边说「正在做 T2」(T5 C12)。
1079
+ * 卡住需要人介入,两个位置都应先报它。
1080
+ */
1035
1081
  const blocked = findEntry(session, 'blocked')
1036
1082
  if (blocked) {
1037
1083
  return `${deptName(t, blocked.subtask.department)} · ${blocked.subtask.title}(${t('status.blocked')})`
1038
1084
  }
1085
+ const active = findEntry(session, 'in_progress')
1086
+ if (active) {
1087
+ return `${deptName(t, active.subtask.department)} · ${active.subtask.title}`
1088
+ }
1039
1089
  const stats = session.stats || {}
1040
1090
  if ((stats.total ?? 0) > 0 && (stats.done ?? 0) >= (stats.total ?? 0)) return t('helpDone')
1041
1091
  return t('helpNobody')
@@ -1061,11 +1111,22 @@ window.__ModuleLoader__.load({
1061
1111
  function nextText(session: any, t: any) {
1062
1112
  const blocked = findEntry(session, 'blocked')
1063
1113
  if (blocked) return `${t('handleBlocked')}:${deptName(t, blocked.subtask.department)} · ${blocked.subtask.title}${resumeHint(session, t)}`
1114
+ /*
1115
+ * [1.5.2] 「没有下一步」只允许出现在真正全部完成之后。
1116
+ *
1117
+ * 旧实现找不到 pending 就直接 return「没有下一步,这件事已完成」,于是
1118
+ * · 单任务计划刚进入 in_progress(环 0%)→ 同一张卡宣布「已完成」(T5 C2);
1119
+ * · 降级计划 0/0 → 一边「安排失败」一边「已完成」(T5 C11)。
1120
+ * 现在先按计数判终态,再区分「等当前这一步」「根本没有计划」。
1121
+ */
1122
+ const counts = planCounts(session.stats || {})
1123
+ if (counts.total > 0 && counts.done === counts.total) return t('helpDone')
1124
+ if (counts.total === 0) return t('helpNoPlan')
1064
1125
  const next = findEntry(session, 'pending') || allEntries(session).find((entry: any) => !entry.item)
1065
1126
  if (next) return `${deptName(t, next.subtask.department)} · ${next.subtask.title}`
1066
- const stats = session.stats || {}
1067
- if ((stats.total ?? 0) > 0 && (stats.done ?? 0) >= (stats.total ?? 0)) return t('helpDone')
1068
- return t('helpNextDone')
1127
+ const active = findEntry(session, 'in_progress')
1128
+ if (active) return `${t('helpWaitActive')}:${deptName(t, active.subtask.department)} · ${active.subtask.title}`
1129
+ return t('helpWaitActive')
1069
1130
  }
1070
1131
 
1071
1132
  function progressText(item: any, t: any) {
@@ -1339,15 +1400,19 @@ window.__ModuleLoader__.load({
1339
1400
  const done = items.filter(({ item }: any) => item?.status === 'done').length
1340
1401
  const blocked = items.filter(({ item }: any) => item?.status === 'blocked').length
1341
1402
  const inProgress = items.some(({ item }: any) => item?.status === 'in_progress')
1342
- const tone = blocked > 0 ? 'blocked' : done === items.length ? 'done' : inProgress ? 'active' : 'idle'
1403
+ // [1.5.2] 与 `planStageRows` 同一条规则:有 done 就是 active,不再只在
1404
+ // 有 in_progress 时才算「正在做」。旧规则让同一阶段在概览行显示「正在做」、
1405
+ // 在阶段卡显示「还没开始」(T5 C9)。
1406
+ const tone = blocked > 0 ? 'blocked' : items.length > 0 && done === items.length ? 'done' : inProgress || done > 0 ? 'active' : 'idle'
1343
1407
  // Same tone/counts as before; only the presentation moved into the
1344
1408
  // collapsed summary row (done/total + state pill), so the numbers keep one
1345
1409
  // source: this step's own items from `statuses`.
1346
1410
  const stateText = blocked > 0
1347
1411
  ? `${blocked} ${t('stats.blocked')}`
1348
- : done === items.length
1412
+ : items.length > 0 && done === items.length
1349
1413
  ? t('helpDone')
1350
- : inProgress ? t('phase.executing') : t('phase.pending')
1414
+ : inProgress ? t('phase.executing')
1415
+ : done > 0 ? fill(t('nowRemaining'), { n: items.length - done }) : t('phase.pending')
1351
1416
  return h('section', { className: `dsh-wp-stage dsh-wp-tone-${tone}` },
1352
1417
  h(Collapse, {
1353
1418
  className: 'dsh-wp-stage-collapse',
@@ -1463,6 +1528,18 @@ window.__ModuleLoader__.load({
1463
1528
  return Math.max(0, Math.round(number))
1464
1529
  }
1465
1530
 
1531
+ /*
1532
+ * [1.5.2] 计划级百分比的唯一算式。
1533
+ *
1534
+ * 1.5.1 及以前,同一页面上至少有三处各自 `Math.round(done / total * 100)`:
1535
+ * 卡片环(planCounts)、组头环(groupTotals)、按任务进度条(TaskBars / 总览柱状图)。
1536
+ * 三份实现口径相同只是巧合,任何一处单独改动都会让「同一张卡两个百分比」重现。
1537
+ * 现在只有这一个函数,其它地方一律调用它。
1538
+ */
1539
+ function planPercent(done: number, total: number): number | null {
1540
+ return total > 0 ? Math.round((done / total) * 100) : null
1541
+ }
1542
+
1466
1543
  // 与 groupTotals 同一套口径:先夹到 [0,total],再让「正在做/还没开始」
1467
1544
  // 吃掉剩余量,四段之和恒等于总数(构成条不会溢出或出现负宽度)。
1468
1545
  function planCounts(stats: any): PlanCounts {
@@ -1472,8 +1549,44 @@ window.__ModuleLoader__.load({
1472
1549
  const blocked = Math.min(planCount(source.blocked), total - done)
1473
1550
  const active = Math.min(planCount(source.inProgress), total - done - blocked)
1474
1551
  const pending = Math.max(0, total - done - blocked - active)
1475
- const percent = total > 0 ? Math.round((done / total) * 100) : null
1476
- return { total, done, active, blocked, pending, percent }
1552
+ return { total, done, active, blocked, pending, percent: planPercent(done, total) }
1553
+ }
1554
+
1555
+ /*
1556
+ * [1.5.2] 完成语义的第二道保险:状态文案必须服从计数事实。
1557
+ *
1558
+ * 后端已保证「工作项全部 done ⇒ phase=done」(`reconcilePhase`)。这里再兜一层,
1559
+ * 让任何历史载荷、旧缓存或第三方构造的 payload 都不可能渲染出「100% + 正在
1560
+ * 收尾」:计数齐全且全部完成时一律按「已完成」呈现;反过来,phase 说 done
1561
+ * 但计数里还有没做完的项时,按真实计数降级,避免「已完成下面挂着没做完的活」。
1562
+ *
1563
+ * 计数未知(total=0,例如已取消或尚未拆解)时不动原始 phase——此时没有事实
1564
+ * 可以覆盖它,硬改会把「进行中/已完成」的分组依据一起抹掉。
1565
+ * 「integrating」只在计数证明全部完成时升级为 done,否则保留原语义(回放旧会话)。
1566
+ */
1567
+ function planPhaseOf(rawPhase: any, counts: PlanCounts): string {
1568
+ const phase = String(rawPhase ?? 'pending')
1569
+ if (counts.total > 0) {
1570
+ if (counts.done === counts.total) return 'done'
1571
+ if (phase === 'done') {
1572
+ if (counts.blocked > 0) return 'blocked'
1573
+ if (counts.active > 0) return 'executing'
1574
+ return 'pending'
1575
+ }
1576
+ }
1577
+ return phase
1578
+ }
1579
+
1580
+ /*
1581
+ * [1.5.2] 加权总进度的展示口径:未全部完成时封顶 99。
1582
+ *
1583
+ * 服务端 `deriveAggregates` 已做同样封顶;这里再算一次是为了让旧载荷/手工
1584
+ * fixture 也守同一条不变量——「总体进度 100%」绝不能与「还有 N 项没完成」同屏。
1585
+ */
1586
+ function planWeightedPercent(stats: any, counts: PlanCounts): number {
1587
+ const weighted = clampPct(stats?.progress ?? 0)
1588
+ if (counts.total > 0 && counts.done === counts.total) return 100
1589
+ return Math.min(99, weighted)
1477
1590
  }
1478
1591
 
1479
1592
  function planStatusMap(statuses: any): Map<string, any> {
@@ -1555,6 +1668,16 @@ window.__ModuleLoader__.load({
1555
1668
  return { state: 'active', text: withTitle(t('stats.inProgress'), active?.title) }
1556
1669
  }
1557
1670
  if (counts.total > 0 && counts.done === counts.total) return { state: 'done', text: t('helpDone') }
1671
+ /*
1672
+ * [1.5.2] 部分完成不再落到「还没开始」。
1673
+ *
1674
+ * `done>0` 但没有 blocked、也没有 in_progress 时(预算把剩余项重置成
1675
+ * pending,或剩余项还没轮到),旧代码直接返回 `stats.pending`「还没开始」,
1676
+ * 与同一张卡上的「1/2 做好了」和 50% 直接冲突(T5 C5)。
1677
+ */
1678
+ if (counts.done > 0) {
1679
+ return { state: 'active', text: fill(t('nowRemaining'), { n: counts.total - counts.done }) }
1680
+ }
1558
1681
  return { state: 'pending', text: t('stats.pending') }
1559
1682
  }
1560
1683
 
@@ -1643,7 +1766,9 @@ window.__ModuleLoader__.load({
1643
1766
  if (rows.length < 1 || rows.length > 12) return null
1644
1767
  return h('div', { className: 'dsh-wp-stepper', 'aria-hidden': 'true', 'data-stages': String(rows.length) },
1645
1768
  rows.map((row) => {
1646
- const reach = row.total > 0 ? ((row.done + row.active + row.blocked) / row.total) * 100 : 0
1769
+ // [1.5.2] 只按 done 计。旧式把 blocked/active 也算「触达」,于是环 50%
1770
+ // 时阶段条已经全满(T5 C7)——同一张卡两个「完成度」。
1771
+ const reach = row.total > 0 ? (row.done / row.total) * 100 : 0
1647
1772
  const width = row.tone === 'pending' ? 0 : reach
1648
1773
  return h('span', {
1649
1774
  key: row.id,
@@ -1664,11 +1789,20 @@ window.__ModuleLoader__.load({
1664
1789
  }
1665
1790
 
1666
1791
  function PlanViz({ t, counts, now, rows }: { t: (key: string) => string; counts: PlanCounts; now: PlanNow; rows: PlanStageRow[] }) {
1792
+ /*
1793
+ * [1.5.2] data-state 也是「完成语义」的一个读者。
1794
+ *
1795
+ * 旧实现只看 percent:0% 的 pending 卡拿到 data-state="active",
1796
+ * 而 100% 的卡即使 phase 说 integrating 也是 "done"(T5 C13/C1)。
1797
+ * 现在与胶囊、分组、环共用计数:全 done 才 done,0 且有未完成才 idle。
1798
+ */
1667
1799
  const state = counts.percent === null
1668
1800
  ? 'idle'
1669
1801
  : counts.blocked > 0
1670
1802
  ? 'blocked'
1671
- : counts.percent >= 100 ? 'done' : 'active'
1803
+ : counts.total > 0 && counts.done === counts.total
1804
+ ? 'done'
1805
+ : counts.done > 0 || counts.active > 0 ? 'active' : 'idle'
1672
1806
  return h('div', {
1673
1807
  className: 'dsh-wp-planviz',
1674
1808
  'data-percent': counts.percent === null ? 'none' : String(counts.percent),
@@ -1708,7 +1842,7 @@ window.__ModuleLoader__.load({
1708
1842
  h('span', null, `${counts.done}/${counts.total} ${t('stats.done')}`),
1709
1843
  ),
1710
1844
  h('div', { className: 'dsh-wp-planoverview-stats' },
1711
- h('span', null, `${t('vizWeighted')} ${clampPct(stats?.progress ?? 0)}%`),
1845
+ h('span', null, `${t('vizWeighted')} ${planWeightedPercent(stats, counts)}%`),
1712
1846
  h('span', null, `${counts.active} ${t('stats.inProgress')} · ${counts.pending} ${t('stats.pending')}`),
1713
1847
  counts.blocked > 0 ? h('span', null, `${counts.blocked} ${t('stats.blocked')}`) : null,
1714
1848
  ),
@@ -1742,13 +1876,18 @@ window.__ModuleLoader__.load({
1742
1876
 
1743
1877
  function SessionCard({ session, t, open, onToggle }: any) {
1744
1878
  const domId = `dsh-wp-session-${session.sessionId}`
1745
- const phase = session.phase || 'pending'
1746
1879
  const stats = session.stats || {}
1747
- const tone = phaseTone(phase)
1748
- const departments = Array.isArray(session.departments) ? session.departments : []
1749
1880
  // 1.5.0:折叠卡摘要=图表(环 + 百分比 + 四段构成条 + 图例 + 阶段步进条);
1750
1881
  // 数字仍只来自 session.stats,与旧头部的口径完全一致,不引入第二套统计。
1751
1882
  const counts = planCounts(stats)
1883
+ /*
1884
+ * [1.5.2] 卡片的色调与胶囊文案也走统一对账:只要计数显示全部完成,
1885
+ * 胶囊就必须是「已完成」,视觉档位也必须是 done。截图里那张卡之所以
1886
+ * 「100% + 正在收尾 + 留在进行中组」,就是因为这三个读者各读各的字段。
1887
+ */
1888
+ const phase = planPhaseOf(session.phase || 'pending', counts)
1889
+ const tone = phaseTone(phase)
1890
+ const departments = Array.isArray(session.departments) ? session.departments : []
1752
1891
  const statuses = Array.isArray(session.statuses) ? session.statuses : []
1753
1892
  const stageRows = planStageRows(session.stages, statuses)
1754
1893
  const now = planNow(t, counts, planTaskEntries(session.stages, statuses))
@@ -1761,7 +1900,7 @@ window.__ModuleLoader__.load({
1761
1900
  panelClassName: 'dsh-wp-session-body',
1762
1901
  title: session.title || t('panel'),
1763
1902
  meta: h(PlanViz, { t, counts, now, rows: stageRows }),
1764
- badge: h(Pill, { tone }, t(`phase.${phase}`)),
1903
+ badge: h(Pill, { tone }, phaseLabel(phase, session, t)),
1765
1904
  },
1766
1905
  session.updatedAt
1767
1906
  ? h('div', { className: 'dsh-wp-meta dsh-wp-session-updated' }, `${t('updatedAt')} ${formatTime(session.updatedAt)}`)
@@ -1865,7 +2004,7 @@ window.__ModuleLoader__.load({
1865
2004
  charts.push(new lib(canvasA, {
1866
2005
  type: 'doughnut',
1867
2006
  data: {
1868
- labels: [t('groupLive'), t('groupDone')],
2007
+ labels: [t('chartOpen'), t('chartDone')],
1869
2008
  datasets: [{ data: [liveCount, doneCount], backgroundColor: [liveFill, doneFill], borderWidth: 0, hoverOffset: 5 }],
1870
2009
  },
1871
2010
  options: {
@@ -1920,16 +2059,16 @@ window.__ModuleLoader__.load({
1920
2059
  // 「无 canvas → 不建图 → 不 ready → 无 canvas」的死循环(T3 实测
1921
2060
  // 探针输出 canvasNodes:0 / fallbackBars:1)。改为并存:
1922
2061
  // fallback 在 ready 前可见,ready 后由 CSS 隐藏,canvas 反向隐藏。
1923
- h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'groups', role: 'img', 'aria-label': `${t('groupLive')} ${liveCount} / ${t('groupDone')} ${doneCount}` })),
2062
+ h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'groups', role: 'img', 'aria-label': `${t('chartOpen')} ${liveCount} / ${t('chartDone')} ${doneCount}` })),
1924
2063
  chartState !== 'ready'
1925
2064
  ? h('div', { className: 'dsh-wp-fallback' },
1926
- h('div', { className: 'dsh-wp-fallback-bar', role: 'img', 'aria-label': `${t('groupLive')} ${liveCount} / ${t('groupDone')} ${doneCount}` },
2065
+ h('div', { className: 'dsh-wp-fallback-bar', role: 'img', 'aria-label': `${t('chartOpen')} ${liveCount} / ${t('chartDone')} ${doneCount}` },
1927
2066
  h('span', { className: 'dsh-wp-fallback-live', style: { width: `${livePct}%` } }),
1928
2067
  h('span', { className: 'dsh-wp-fallback-done', style: { width: `${donePct}%` } }),
1929
2068
  ),
1930
2069
  h('div', { className: 'dsh-wp-fallback-legend' },
1931
- h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--live' }), `${t('groupLive')} ${liveCount}`),
1932
- h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--done' }), `${t('groupDone')} ${doneCount}`),
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}`),
1933
2072
  ),
1934
2073
  )
1935
2074
  : null,
@@ -1989,7 +2128,7 @@ window.__ModuleLoader__.load({
1989
2128
  total,
1990
2129
  done,
1991
2130
  open: total - done,
1992
- percent: total > 0 ? Math.round((done / total) * 100) : null,
2131
+ percent: planPercent(done, total),
1993
2132
  active: Math.max(0, active),
1994
2133
  pending: Math.max(0, pending),
1995
2134
  blocked: Math.max(0, blocked),
@@ -2046,18 +2185,25 @@ window.__ModuleLoader__.load({
2046
2185
  }
2047
2186
 
2048
2187
  function SessionGroup({ group, list, hasLive, urgent, t, openSessionId, setOpenSessionId }: any) {
2188
+ /*
2189
+ * [1.6.0] 三个分组共用一套组件:live(真的在跑)、ended(这一轮停了但没做完)、
2190
+ * done(全部完成)。`ended` 是本次事故的修复核心——它必须是一个**独立分组**,
2191
+ * 而不是被塞回「进行中」:会话已经结束的事实不允许用「进行中」这三个字表达。
2192
+ */
2049
2193
  const isDone = group === 'done'
2194
+ const isEnded = group === 'ended'
2195
+ const keySuffix = isDone ? 'Done' : isEnded ? 'Ended' : 'Live'
2050
2196
  // 设计稿 §6.4 #1/#6:已完成组在有进行中任务时默认折叠,避免历史任务抢走
2051
2197
  // 「进行中」的视觉权重;没有进行中任务时默认展开,空的进度栏仍是空的。
2052
- // 折叠态由本组件自己持有(不给 users 存偏好),刷新后回到默认,符合 §7.3「不打扰」。
2053
- const [open, setOpen] = useState(!(isDone && hasLive))
2198
+ // [1.6.0] 「已结束」组同理:还有东西在跑时收起来,只留组头一行结论。
2199
+ const [open, setOpen] = useState(!((isDone || isEnded) && hasLive))
2054
2200
  // §7.3「不打扰」的自动展开:只在这两种情形打开一次,不做反复弹开——
2055
2201
  // ① 进行中清零(最后一个任务完成);② 用户正展开着的卡片被移进本组。
2056
2202
  // 依赖都是布尔值,轮询刷新不会重复触发。
2057
2203
  const holdsOpenCard = list.some((session: any) => session.sessionId === openSessionId)
2058
2204
  useEffect(() => { if (!hasLive) setOpen(true) }, [hasLive])
2059
2205
  useEffect(() => { if (holdsOpenCard) setOpen(true) }, [holdsOpenCard])
2060
- const collapsible = isDone && list.length > 0
2206
+ const collapsible = (isDone || isEnded) && list.length > 0
2061
2207
  // 折叠只隐藏、不卸载:`hidden` 必须是布尔 true,不能传空字符串——
2062
2208
  // React 对 `hidden` 走 DOM 布尔属性赋值,`el.hidden = ''` 会得到 false,
2063
2209
  // 只剩 CSS 类在遮挡;那样「折叠态 DOM 保留 + 无障碍语义」就不成立。
@@ -2068,8 +2214,8 @@ window.__ModuleLoader__.load({
2068
2214
  // T6:组头完成度摘要 + 零依赖进度环。数值只来自 groupTotals(list),
2069
2215
  // 先用纯文本给出「进行中 N / 已完成 M(完成率 x%)」,不点开、不看颜色也能读出进度。
2070
2216
  const totals = useMemo(() => groupTotals(list), [list])
2071
- const groupTitle = t(isDone ? 'groupDone' : 'groupLive')
2072
- const hintText = t(isDone ? 'groupDoneHint' : 'groupLiveHint')
2217
+ const groupTitle = t(`group${keySuffix}`)
2218
+ const hintText = t(`group${keySuffix}Hint`)
2073
2219
  // 用户 Q3:0/0 时不再显示「未完成 0 / 已完成 0(完成率暂无数据)」——
2074
2220
  // 环上已经是「—」,组内空态也已说明没有工作项;再重复一遍 0/0 只是噪声。
2075
2221
  // 全部做完(100%)仍保留整句,因为那时「未完成 0」正是用户要找的结论。
@@ -2136,7 +2282,7 @@ window.__ModuleLoader__.load({
2136
2282
  open: openSessionId === session.sessionId,
2137
2283
  onToggle: (next: boolean) => setOpenSessionId(next ? session.sessionId : null),
2138
2284
  })))
2139
- : h('div', { className: 'dsh-wp-group-empty' }, t(isDone ? 'groupDoneEmpty' : 'groupLiveEmpty')),
2285
+ : h('div', { className: 'dsh-wp-group-empty' }, t(`group${keySuffix}Empty`)),
2140
2286
  )
2141
2287
  }
2142
2288
 
@@ -2209,10 +2355,11 @@ window.__ModuleLoader__.load({
2209
2355
  h('div', { className: 'dsh-wp-section-title' }, t('taskChart')),
2210
2356
  (Array.isArray(sessions) ? sessions : []).map((session: any) => {
2211
2357
  const stats = session.stats || {}
2212
- const total = Math.max(0, Number(stats.total) || 0)
2213
- const done = Math.max(0, Number(stats.done) || 0)
2214
- const percent = total > 0 ? Math.round((done / total) * 100) : 0
2215
- const tone = phaseTone(session.phase || 'pending')
2358
+ const counts = planCounts(stats)
2359
+ // 同一套计数与对账:柱状图的百分比、文案、色调都不得自成一套。
2360
+ const percent = counts.percent ?? 0
2361
+ const phase = planPhaseOf(session.phase || 'pending', counts)
2362
+ const tone = phaseTone(phase)
2216
2363
  return h('button', {
2217
2364
  key: session.sessionId,
2218
2365
  type: 'button',
@@ -2222,13 +2369,13 @@ window.__ModuleLoader__.load({
2222
2369
  },
2223
2370
  h('div', { className: 'dsh-wp-bar-head' },
2224
2371
  h('span', { className: 'dsh-wp-bar-title' }, session.title || t('panel')),
2225
- h('span', { className: 'dsh-wp-bar-value' }, `${done}/${total}`),
2372
+ h('span', { className: 'dsh-wp-bar-value' }, `${counts.done}/${counts.total}`),
2226
2373
  ),
2227
2374
  h('div', { className: 'dsh-wp-bar-track' },
2228
2375
  h('div', { className: `dsh-wp-bar-fill dsh-wp-bar-fill--${tone}`, style: { width: `${percent}%` } }),
2229
2376
  ),
2230
2377
  h('div', { className: 'dsh-wp-bar-meta' },
2231
- h('span', null, t(`phase.${session.phase || 'pending'}`)),
2378
+ h('span', null, phaseLabel(phase, session, t)),
2232
2379
  h('span', null, `${percent}%`),
2233
2380
  ),
2234
2381
  )
@@ -2280,10 +2427,25 @@ window.__ModuleLoader__.load({
2280
2427
  }, [activeSessions])
2281
2428
  const groups = useMemo(() => {
2282
2429
  const live: any[] = []
2430
+ const ended: any[] = []
2283
2431
  const finished: any[] = []
2284
2432
  const urgentPhases = ['blocked', 'degraded', 'paused']
2433
+ /*
2434
+ * [1.5.2] 分组也按对账后的 phase,而不是 payload 里的原始 phase。
2435
+ *
2436
+ * 截图那张卡之所以停在「进行中」组,就是因为这里只读 `session.phase`:
2437
+ * 14/14、100% 的计划带着 integrating 被塞进 live 组,组头于是出现
2438
+ * 「未完成 0 / 已完成 14(完成率 100%)」却仍标着「进行中」。
2439
+ * `planPhaseOf` 让分组、组头计数、卡片胶囊共用同一个结论。
2440
+ *
2441
+ * [1.6.0] 新增 ended 分组:宿主已经 `turn/end`(会话这一轮结束、没有任何
2442
+ * 东西在跑),而计划还没做完的,一律进「已结束」,绝不进「进行中」。
2443
+ * 这正是本次事故的现象:会话结束 4 小时后仍显示「进行中」。
2444
+ */
2285
2445
  for (const session of activeSessions) {
2286
- if (session.phase === 'done') finished.push(session)
2446
+ const phase = planPhaseOf(session.phase, planCounts(session.stats))
2447
+ if (phase === 'done') finished.push(session)
2448
+ else if (phase === 'ended') ended.push(session)
2287
2449
  else live.push(session)
2288
2450
  }
2289
2451
  /*
@@ -2300,11 +2462,19 @@ window.__ModuleLoader__.load({
2300
2462
  integrating: 3,
2301
2463
  planning: 4,
2302
2464
  pending: 5,
2465
+ ended: 6,
2303
2466
  }
2304
- const rank = (session: any) => phaseRank[session.phase] ?? 5
2467
+ const rank = (session: any) => phaseRank[planPhaseOf(session.phase, planCounts(session.stats))] ?? 5
2305
2468
  live.sort((a: any, b: any) => (rank(a) - rank(b))
2306
2469
  || ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0))
2307
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)))
2308
2478
  /*
2309
2479
  * 已完成组按「最近完成在前」(§3.3 / C8):优先取子任务
2310
2480
  * statuses[].finishedAt 的最大值(忽略 null / 非数字),缺失则退回
@@ -2327,15 +2497,25 @@ window.__ModuleLoader__.load({
2327
2497
  }
2328
2498
  finished.sort((a: any, b: any) => (finishedAt(b) - finishedAt(a))
2329
2499
  || ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0)))
2330
- return { live, finished, urgent: live.filter((session: any) => urgentPhases.includes(session.phase)).length }
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
+ }
2331
2511
  }, [activeSessions])
2332
- const liveBars = useMemo(() => groups.live.slice(0, 6).map((session: any) => {
2333
- const doneCount = session.stats?.done ?? 0
2334
- const totalCount = session.stats?.total ?? 0
2512
+ // [1.6.0] 图表口径 = 未结束计划(进行中 + 已结束未完成),与环图标签一致。
2513
+ const liveBars = useMemo(() => [...groups.live, ...groups.ended].slice(0, 6).map((session: any) => {
2335
2514
  const raw = String(session.title || t('panel'))
2336
2515
  return {
2337
2516
  label: raw.length > 14 ? `${raw.slice(0, 14)}…` : raw,
2338
- value: totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0,
2517
+ // [1.5.2] 与卡片/组头同一个百分比函数,杜绝第二套口径。
2518
+ value: planPercent(planCount(session.stats?.done), planCount(session.stats?.total)) ?? 0,
2339
2519
  }
2340
2520
  }), [groups, t])
2341
2521
  return h('div', { className: 'dsh-wp' },
@@ -2362,8 +2542,16 @@ window.__ModuleLoader__.load({
2362
2542
  activeSessions.length === 0
2363
2543
  ? h('div', { className: 'dsh-wp-empty' }, h('div', { style: { fontWeight: 650, marginBottom: '4px' } }, t('empty')), h('div', null, t('emptyHint')))
2364
2544
  : h('div', { className: 'dsh-wp-groups' },
2365
- h(SessionGroup, { group: 'live', list: groups.live, urgent: groups.urgent, t, openSessionId, setOpenSessionId }),
2366
- h(SessionGroup, { group: 'done', list: groups.finished, hasLive: groups.live.length > 0, urgent: 0, t, openSessionId, setOpenSessionId }),
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 }),
2367
2555
  // 用户选择 B:总览块(完成率环 + 每个计划的进度条)同样下移到两组之后,
2368
2556
  // 让开屏第一屏直接是「进行中」组的任务卡(此前统计条 → 总览 → 说明行 →
2369
2557
  // 任务卡,第一张卡要下滑约一屏)。顶部只保留 5 格数字统计条与一行口径说明。
@@ -2373,7 +2561,8 @@ window.__ModuleLoader__.load({
2373
2561
  ) : null,
2374
2562
  // 图表放两组之后:两张卡各 ≈246px,排在组前会让首屏看不到任何一张任务卡
2375
2563
  // (设计稿 §8 第 12 条,T8 复核时复现在 1.4.2 源码里)。
2376
- h(GroupCharts, { liveCount: groups.live.length, doneCount: groups.finished.length, liveBars, hiddenBars: Math.max(0, groups.live.length - 6), t }),
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 }),
2377
2566
  ),
2378
2567
  )
2379
2568
  }
@@ -2389,12 +2578,19 @@ window.__ModuleLoader__.load({
2389
2578
  const active = ['planning', 'executing', 'blocked'].includes(session.phase)
2390
2579
  if (currentTurn > createdTurn && !active) return null
2391
2580
  }
2581
+ /*
2582
+ * [1.5.2] 会话内联卡(TurnCard)也走同一套对账。
2583
+ *
2584
+ * 面板卡片修好了、内联卡却仍按原始 phase 渲染的话,同一张计划会在
2585
+ * 「侧栏已完成 / 对话里正在收尾」之间再次自相矛盾。
2586
+ */
2392
2587
  const stats = session.stats || {}
2393
- const tone = phaseTone(session.phase || 'pending')
2588
+ const phase = planPhaseOf(session.phase || 'pending', planCounts(stats))
2589
+ const tone = phaseTone(phase)
2394
2590
  return h('div', { className: `dsh-wp-turn dsh-wp-tone-${tone}` },
2395
2591
  h('div', { className: 'dsh-wp-turn-title' },
2396
2592
  h('span', null, session.title || t('panel')),
2397
- h(Pill, { tone }, t(`phase.${session.phase || 'pending'}`)),
2593
+ h(Pill, { tone }, phaseLabel(phase, session, t)),
2398
2594
  ),
2399
2595
  h('div', { className: 'dsh-wp-muted', style: { marginTop: '6px' } },
2400
2596
  `${t('helpWho')}:${workerText(session, t)}`),