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/client.js CHANGED
@@ -19,21 +19,26 @@ window.__ModuleLoader__.load({
19
19
  "panel": "任务进度",
20
20
  "groupLive": "进行中",
21
21
  "groupDone": "已完成",
22
+ "groupEnded": "已结束",
22
23
  "groupLiveHint": "还没结束,正在或即将推进",
23
24
  "groupDoneHint": "已全部完成并收尾",
25
+ "groupEndedHint": "这一轮已经停了,没有东西在跑;说「续跑」可继续推进",
24
26
  "groupCollapse": "收起",
25
27
  "groupExpand": "展开",
26
28
  "groupNeeds": "需处理",
27
29
  "groupLiveEmpty": "没有进行中的任务",
28
30
  "groupDoneEmpty": "还没有完成的任务",
31
+ "groupEndedEmpty": "没有已结束但未完成的任务",
29
32
  "groupSummary": "未完成 {open} / 已完成 {done}(完成率 {pct}%)",
30
33
  "groupSummaryNoRate": "未完成 {open} / 已完成 {done}(完成率暂无数据)",
31
34
  "groupAria": "{title}组,{plans} 个计划,共 {total} 件具体工作,已完成 {done} 件,完成率 {pct}%。其中正在做 {active} 件、卡住 {blocked} 件、还没开始 {pending} 件。",
32
35
  "groupAriaEmpty": "{title}组,{plans} 个计划,暂无具体工作项。",
33
36
  "groupPlansLabel": "个计划",
34
- "chartGroups": "两类计划数量占比",
35
- "chartLive": "进行中计划完成度(按工作项)",
36
- "chartMore": "另有 {n} 个进行中计划未画入图表",
37
+ "chartGroups": "计划数量与完成情况",
38
+ "chartOpen": "未结束",
39
+ "chartDone": "已完成",
40
+ "chartLive": "未完成计划的完成度(按工作项)",
41
+ "chartMore": "另有 {n} 个未完成计划未画入图表",
37
42
  "title": "任务进度:一眼看懂",
38
43
  "subtitle": "用大白话说明:现在要做什么、谁在做、做到哪一步、接下来做什么。",
39
44
  "helpTitle": "先看这三句",
@@ -43,6 +48,9 @@ window.__ModuleLoader__.load({
43
48
  "helpNobody": "还没开始",
44
49
  "helpDone": "都做完了",
45
50
  "helpNextDone": "没有下一步,这件事已完成",
51
+ "helpWaitActive": "等这一步做完,再继续下一项",
52
+ "helpNoPlan": "还没有安排任何工作",
53
+ "nowRemaining": "还剩 {n} 项",
46
54
  "helpHint": "负责团队 = 谁来做;具体工作 = 要完成的一件事;卡住了 = 需要人处理。",
47
55
  "handleBlocked": "先处理",
48
56
  "resumeReady": "对主 Agent 说「续跑这个计划」,会从受阻处继续(已完成的不重跑)",
@@ -64,7 +72,8 @@ window.__ModuleLoader__.load({
64
72
  "blocked": "卡住了",
65
73
  "paused": "预算用完,可续跑",
66
74
  "degraded": "安排失败",
67
- "cancelled": "已取消"
75
+ "cancelled": "已取消",
76
+ "ended": "已结束,可续跑"
68
77
  },
69
78
  "status": {
70
79
  "pending": "还没开始",
@@ -180,6 +189,7 @@ window.__ModuleLoader__.load({
180
189
  "en": {
181
190
  "groupLive": "In progress",
182
191
  "groupDone": "Completed",
192
+ "groupEnded": "Ended",
183
193
  "groupLiveHint": "Not finished yet — running or about to run",
184
194
  "groupDoneHint": "All work finished and wrapped up",
185
195
  "groupCollapse": "Collapse",
@@ -187,14 +197,17 @@ window.__ModuleLoader__.load({
187
197
  "groupNeeds": "needs attention",
188
198
  "groupLiveEmpty": "No work in progress",
189
199
  "groupDoneEmpty": "Nothing completed yet",
200
+ "groupEndedEmpty": "No ended-but-unfinished plans",
190
201
  "groupSummary": "Open {open} / done {done} ({pct}% complete)",
191
202
  "groupSummaryNoRate": "Open {open} / done {done} (completion rate unavailable)",
192
203
  "groupAria": "{title} group: {plans} plan(s), {total} work item(s), {done} done, {pct}% complete. Right now {active} in progress, {blocked} blocked, {pending} not started.",
193
204
  "groupAriaEmpty": "{title} group: {plans} plan(s), no work items yet.",
194
205
  "groupPlansLabel": "plan(s)",
195
- "chartGroups": "Plan count by group",
196
- "chartLive": "In-progress plans by work item",
197
- "chartMore": "{n} more in-progress plans not drawn",
206
+ "chartGroups": "Plan count and completion",
207
+ "chartOpen": "Open",
208
+ "chartDone": "Completed",
209
+ "chartLive": "Open plans by work item",
210
+ "chartMore": "{n} more open plans not drawn",
198
211
  "panel": "Task progress",
199
212
  "title": "Task progress at a glance",
200
213
  "subtitle": "Plain language: what this is, who is doing it, how far along it is, and what happens next.",
@@ -205,6 +218,9 @@ window.__ModuleLoader__.load({
205
218
  "helpNobody": "Not started yet",
206
219
  "helpDone": "Everything is done",
207
220
  "helpNextDone": "Nothing next; this task is complete",
221
+ "helpWaitActive": "Wait for this step to finish, then continue",
222
+ "helpNoPlan": "No work has been scheduled yet",
223
+ "nowRemaining": "{n} item(s) left",
208
224
  "helpHint": "Team = who does it; work item = one thing to finish; stuck = a person needs to help.",
209
225
  "handleBlocked": "Fix first",
210
226
  "resumeReady": "Tell the main agent to resume this plan; it continues from the blocked step without re-running finished work",
@@ -226,7 +242,8 @@ window.__ModuleLoader__.load({
226
242
  "blocked": "Stuck",
227
243
  "paused": "Paused (budget spent)",
228
244
  "degraded": "Planning failed",
229
- "cancelled": "Cancelled"
245
+ "cancelled": "Cancelled",
246
+ "ended": "Ended (resumable)"
230
247
  },
231
248
  "status": {
232
249
  "pending": "Not started",
@@ -396,6 +413,7 @@ window.__ModuleLoader__.load({
396
413
  .dsh-wp-group::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 4px; }
397
414
  .dsh-wp-group--live::before { background: linear-gradient(180deg, #fb923c, #f97316); }
398
415
  .dsh-wp-group--done::before { background: linear-gradient(180deg, #38bdf8, #0284c7); }
416
+ .dsh-wp-group--ended::before { background: linear-gradient(180deg, #94a3b8, #64748b); }
399
417
  /* T6 — 组头多出一行完成度摘要,底部内边距 10px → 6px,两组仍在同一屏内读完。 */
400
418
  .dsh-wp-group-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 0 4px 6px; }
401
419
  .dsh-wp-group-head--btn { width: 100%; background: none; border: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: 8px; }
@@ -422,6 +440,8 @@ window.__ModuleLoader__.load({
422
440
  .dsh-wp-group-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
423
441
  .dsh-wp-group--live .dsh-wp-group-dot { background: #f97316; box-shadow: 0 0 0 4px rgba(249,115,22,.16); animation: dsh-wp-pulse 1.8s ease-in-out infinite; }
424
442
  .dsh-wp-group--done .dsh-wp-group-dot { background: #0ea5e9; box-shadow: 0 0 0 4px rgba(14,165,233,.14); }
443
+ /* [1.6.0] 已结束:灰点、不脉冲——"没有东西在跑"这件事不允许用动画表达。 */
444
+ .dsh-wp-group--ended .dsh-wp-group-dot { background: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.16); }
425
445
  .dsh-wp-group-title { font-size: 15px; font-weight: 700; letter-spacing: .2px; color: var(--dsw-alias-label-primary, #0f172a); }
426
446
  /* 设计稿 §6.1:橙 #f97316 / 白 ≈2.8:1、蓝 #0ea5e9 / 白 ≈2.77:1,均不足 4.5:1,
427
447
  15px 粗体也不算法定「大字号」,所以组头一律用主文本色;品牌色只做「面」——
@@ -430,6 +450,7 @@ window.__ModuleLoader__.load({
430
450
  .dsh-wp-group-count { min-width: 22px; padding: 0 7px; height: 20px; border-radius: 999px; font-size: 12px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; color: var(--dsw-alias-label-primary, #0f172a); font-variant-numeric: tabular-nums; }
431
451
  .dsh-wp-group--live .dsh-wp-group-count { background: rgba(249,115,22,.14); box-shadow: inset 0 0 0 1px rgba(249,115,22,.34); }
432
452
  .dsh-wp-group--done .dsh-wp-group-count { background: rgba(14,165,233,.14); box-shadow: inset 0 0 0 1px rgba(14,165,233,.34); }
453
+ .dsh-wp-group--ended .dsh-wp-group-count { background: rgba(148,163,184,.16); box-shadow: inset 0 0 0 1px rgba(148,163,184,.36); }
433
454
  /* WCAG AA:错误色叠 12% 红底后,浅色 #ec1313 只有 3.70:1;与主文本色混合 20%
434
455
  让浅色变深、深色变浅,两套主题都 ≥4.5:1(实测 5.16:1 / 5.67:1)。 */
435
456
  .dsh-wp-group-alert { font-size: 12px; font-weight: 650; color: var(--dsw-alias-state-error-primary, #ef4444); color: color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 80%, var(--dsw-alias-label-primary, #0f172a) 20%); background: rgba(239,68,68,.12); padding: 1px 8px; border-radius: 999px; }
@@ -742,6 +763,7 @@ window.__ModuleLoader__.load({
742
763
  .dsh-wp-group::before { content: ''; position: absolute; left: 0; top: 14px; bottom: 14px; width: 3px; border-radius: 0 3px 3px 0; opacity: .9; }
743
764
  .dsh-wp-group--live::before { background: linear-gradient(180deg, #fbbf24, #ea580c); }
744
765
  .dsh-wp-group--done::before { background: linear-gradient(180deg, #7dd3fc, #0369a1); }
766
+ .dsh-wp-group--ended::before { background: linear-gradient(180deg, #cbd5e1, #94a3b8); }
745
767
  .dsh-wp-group--done { opacity: .97; }
746
768
  .dsh-wp-group-head--btn { transition: background-color .16s ease; }
747
769
  .dsh-wp-group-head--btn:hover { background: rgba(148,163,184,.10); }
@@ -974,6 +996,12 @@ window.__ModuleLoader__.load({
974
996
  function phaseTone(phase) {
975
997
  if (phase === 'blocked')
976
998
  return 'blocked';
999
+ /*
1000
+ * [1.6.0] `ended` = 这一轮停了,但计划没做完。它既不是成功(done),也不能
1001
+ * 冒充正在干活(active);用中性 idle 色,卡在哪由二级徽标 suspendedPhase 说明。
1002
+ */
1003
+ if (phase === 'ended')
1004
+ return 'idle';
977
1005
  if (phase === 'degraded' || phase === 'cancelled' || phase === 'paused')
978
1006
  return 'warn';
979
1007
  if (phase === 'integrating' || phase === 'done')
@@ -982,6 +1010,23 @@ window.__ModuleLoader__.load({
982
1010
  return 'active';
983
1011
  return 'idle';
984
1012
  }
1013
+ /*
1014
+ * [1.6.0] 「已结束」徽标要把「停在哪儿」一起说出来。
1015
+ *
1016
+ * 只说「已结束,可续跑」,用户不知道是预算用完、卡住了、还是压根没开始;
1017
+ * 只说原阶段(「正在做」),又会像事故截图那样暗示还在跑。两段式表达:
1018
+ * 终态 + 原阶段,一次说清两个事实。
1019
+ */
1020
+ function phaseLabel(phase, session, t) {
1021
+ const base = t(`phase.${phase}`);
1022
+ if (phase !== 'ended')
1023
+ return base;
1024
+ const suspended = String(session?.suspendedPhase ?? '');
1025
+ if (!suspended)
1026
+ return base;
1027
+ const detail = t(`phase.${suspended}`);
1028
+ return detail && detail !== base ? `${base} · ${detail}` : base;
1029
+ }
985
1030
  function allEntries(session) {
986
1031
  const byId = new Map((Array.isArray(session?.statuses) ? session.statuses : []).map((item) => [item.subtaskId, item]));
987
1032
  const entries = [];
@@ -1026,14 +1071,21 @@ window.__ModuleLoader__.load({
1026
1071
  return text;
1027
1072
  }
1028
1073
  function workerText(session, t) {
1029
- const active = findEntry(session, 'in_progress');
1030
- if (active) {
1031
- return `${deptName(t, active.subtask.department)} · ${active.subtask.title}`;
1032
- }
1074
+ /*
1075
+ * [1.5.2] 与 `planNow` 同序:卡住优先。
1076
+ *
1077
+ * 旧顺序是「正在做」优先,而卡片右上角的「现在」是「卡住了」优先,
1078
+ * 于是同一屏一边说「现在卡住了 T1」、一边说「正在做 T2」(T5 C12)。
1079
+ * 卡住需要人介入,两个位置都应先报它。
1080
+ */
1033
1081
  const blocked = findEntry(session, 'blocked');
1034
1082
  if (blocked) {
1035
1083
  return `${deptName(t, blocked.subtask.department)} · ${blocked.subtask.title}(${t('status.blocked')})`;
1036
1084
  }
1085
+ const active = findEntry(session, 'in_progress');
1086
+ if (active) {
1087
+ return `${deptName(t, active.subtask.department)} · ${active.subtask.title}`;
1088
+ }
1037
1089
  const stats = session.stats || {};
1038
1090
  if ((stats.total ?? 0) > 0 && (stats.done ?? 0) >= (stats.total ?? 0))
1039
1091
  return t('helpDone');
@@ -1060,13 +1112,26 @@ window.__ModuleLoader__.load({
1060
1112
  const blocked = findEntry(session, 'blocked');
1061
1113
  if (blocked)
1062
1114
  return `${t('handleBlocked')}:${deptName(t, blocked.subtask.department)} · ${blocked.subtask.title}${resumeHint(session, t)}`;
1115
+ /*
1116
+ * [1.5.2] 「没有下一步」只允许出现在真正全部完成之后。
1117
+ *
1118
+ * 旧实现找不到 pending 就直接 return「没有下一步,这件事已完成」,于是
1119
+ * · 单任务计划刚进入 in_progress(环 0%)→ 同一张卡宣布「已完成」(T5 C2);
1120
+ * · 降级计划 0/0 → 一边「安排失败」一边「已完成」(T5 C11)。
1121
+ * 现在先按计数判终态,再区分「等当前这一步」「根本没有计划」。
1122
+ */
1123
+ const counts = planCounts(session.stats || {});
1124
+ if (counts.total > 0 && counts.done === counts.total)
1125
+ return t('helpDone');
1126
+ if (counts.total === 0)
1127
+ return t('helpNoPlan');
1063
1128
  const next = findEntry(session, 'pending') || allEntries(session).find((entry) => !entry.item);
1064
1129
  if (next)
1065
1130
  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))
1068
- return t('helpDone');
1069
- return t('helpNextDone');
1131
+ const active = findEntry(session, 'in_progress');
1132
+ if (active)
1133
+ return `${t('helpWaitActive')}:${deptName(t, active.subtask.department)} · ${active.subtask.title}`;
1134
+ return t('helpWaitActive');
1070
1135
  }
1071
1136
  function progressText(item, t) {
1072
1137
  const status = item?.status || 'pending';
@@ -1254,15 +1319,19 @@ window.__ModuleLoader__.load({
1254
1319
  const done = items.filter(({ item }) => item?.status === 'done').length;
1255
1320
  const blocked = items.filter(({ item }) => item?.status === 'blocked').length;
1256
1321
  const inProgress = items.some(({ item }) => item?.status === 'in_progress');
1257
- const tone = blocked > 0 ? 'blocked' : done === items.length ? 'done' : inProgress ? 'active' : 'idle';
1322
+ // [1.5.2] 与 `planStageRows` 同一条规则:有 done 就是 active,不再只在
1323
+ // 有 in_progress 时才算「正在做」。旧规则让同一阶段在概览行显示「正在做」、
1324
+ // 在阶段卡显示「还没开始」(T5 C9)。
1325
+ const tone = blocked > 0 ? 'blocked' : items.length > 0 && done === items.length ? 'done' : inProgress || done > 0 ? 'active' : 'idle';
1258
1326
  // Same tone/counts as before; only the presentation moved into the
1259
1327
  // collapsed summary row (done/total + state pill), so the numbers keep one
1260
1328
  // source: this step's own items from `statuses`.
1261
1329
  const stateText = blocked > 0
1262
1330
  ? `${blocked} ${t('stats.blocked')}`
1263
- : done === items.length
1331
+ : items.length > 0 && done === items.length
1264
1332
  ? t('helpDone')
1265
- : inProgress ? t('phase.executing') : t('phase.pending');
1333
+ : inProgress ? t('phase.executing')
1334
+ : done > 0 ? fill(t('nowRemaining'), { n: items.length - done }) : t('phase.pending');
1266
1335
  return h('section', { className: `dsh-wp-stage dsh-wp-tone-${tone}` }, h(Collapse, {
1267
1336
  className: 'dsh-wp-stage-collapse',
1268
1337
  panelClassName: 'dsh-wp-stage-body',
@@ -1290,6 +1359,17 @@ window.__ModuleLoader__.load({
1290
1359
  return 0;
1291
1360
  return Math.max(0, Math.round(number));
1292
1361
  }
1362
+ /*
1363
+ * [1.5.2] 计划级百分比的唯一算式。
1364
+ *
1365
+ * 1.5.1 及以前,同一页面上至少有三处各自 `Math.round(done / total * 100)`:
1366
+ * 卡片环(planCounts)、组头环(groupTotals)、按任务进度条(TaskBars / 总览柱状图)。
1367
+ * 三份实现口径相同只是巧合,任何一处单独改动都会让「同一张卡两个百分比」重现。
1368
+ * 现在只有这一个函数,其它地方一律调用它。
1369
+ */
1370
+ function planPercent(done, total) {
1371
+ return total > 0 ? Math.round((done / total) * 100) : null;
1372
+ }
1293
1373
  // 与 groupTotals 同一套口径:先夹到 [0,total],再让「正在做/还没开始」
1294
1374
  // 吃掉剩余量,四段之和恒等于总数(构成条不会溢出或出现负宽度)。
1295
1375
  function planCounts(stats) {
@@ -1299,8 +1379,46 @@ window.__ModuleLoader__.load({
1299
1379
  const blocked = Math.min(planCount(source.blocked), total - done);
1300
1380
  const active = Math.min(planCount(source.inProgress), total - done - blocked);
1301
1381
  const pending = Math.max(0, total - done - blocked - active);
1302
- const percent = total > 0 ? Math.round((done / total) * 100) : null;
1303
- return { total, done, active, blocked, pending, percent };
1382
+ return { total, done, active, blocked, pending, percent: planPercent(done, total) };
1383
+ }
1384
+ /*
1385
+ * [1.5.2] 完成语义的第二道保险:状态文案必须服从计数事实。
1386
+ *
1387
+ * 后端已保证「工作项全部 done ⇒ phase=done」(`reconcilePhase`)。这里再兜一层,
1388
+ * 让任何历史载荷、旧缓存或第三方构造的 payload 都不可能渲染出「100% + 正在
1389
+ * 收尾」:计数齐全且全部完成时一律按「已完成」呈现;反过来,phase 说 done
1390
+ * 但计数里还有没做完的项时,按真实计数降级,避免「已完成下面挂着没做完的活」。
1391
+ *
1392
+ * 计数未知(total=0,例如已取消或尚未拆解)时不动原始 phase——此时没有事实
1393
+ * 可以覆盖它,硬改会把「进行中/已完成」的分组依据一起抹掉。
1394
+ * 「integrating」只在计数证明全部完成时升级为 done,否则保留原语义(回放旧会话)。
1395
+ */
1396
+ function planPhaseOf(rawPhase, counts) {
1397
+ const phase = String(rawPhase ?? 'pending');
1398
+ if (counts.total > 0) {
1399
+ if (counts.done === counts.total)
1400
+ return 'done';
1401
+ if (phase === 'done') {
1402
+ if (counts.blocked > 0)
1403
+ return 'blocked';
1404
+ if (counts.active > 0)
1405
+ return 'executing';
1406
+ return 'pending';
1407
+ }
1408
+ }
1409
+ return phase;
1410
+ }
1411
+ /*
1412
+ * [1.5.2] 加权总进度的展示口径:未全部完成时封顶 99。
1413
+ *
1414
+ * 服务端 `deriveAggregates` 已做同样封顶;这里再算一次是为了让旧载荷/手工
1415
+ * fixture 也守同一条不变量——「总体进度 100%」绝不能与「还有 N 项没完成」同屏。
1416
+ */
1417
+ function planWeightedPercent(stats, counts) {
1418
+ const weighted = clampPct(stats?.progress ?? 0);
1419
+ if (counts.total > 0 && counts.done === counts.total)
1420
+ return 100;
1421
+ return Math.min(99, weighted);
1304
1422
  }
1305
1423
  function planStatusMap(statuses) {
1306
1424
  const byId = new Map();
@@ -1384,6 +1502,16 @@ window.__ModuleLoader__.load({
1384
1502
  }
1385
1503
  if (counts.total > 0 && counts.done === counts.total)
1386
1504
  return { state: 'done', text: t('helpDone') };
1505
+ /*
1506
+ * [1.5.2] 部分完成不再落到「还没开始」。
1507
+ *
1508
+ * `done>0` 但没有 blocked、也没有 in_progress 时(预算把剩余项重置成
1509
+ * pending,或剩余项还没轮到),旧代码直接返回 `stats.pending`「还没开始」,
1510
+ * 与同一张卡上的「1/2 做好了」和 50% 直接冲突(T5 C5)。
1511
+ */
1512
+ if (counts.done > 0) {
1513
+ return { state: 'active', text: fill(t('nowRemaining'), { n: counts.total - counts.done }) };
1514
+ }
1387
1515
  return { state: 'pending', text: t('stats.pending') };
1388
1516
  }
1389
1517
  function PlanRing({ percent }) {
@@ -1456,7 +1584,9 @@ window.__ModuleLoader__.load({
1456
1584
  if (rows.length < 1 || rows.length > 12)
1457
1585
  return null;
1458
1586
  return h('div', { className: 'dsh-wp-stepper', 'aria-hidden': 'true', 'data-stages': String(rows.length) }, rows.map((row) => {
1459
- const reach = row.total > 0 ? ((row.done + row.active + row.blocked) / row.total) * 100 : 0;
1587
+ // [1.5.2] 只按 done 计。旧式把 blocked/active 也算「触达」,于是环 50%
1588
+ // 时阶段条已经全满(T5 C7)——同一张卡两个「完成度」。
1589
+ const reach = row.total > 0 ? (row.done / row.total) * 100 : 0;
1460
1590
  const width = row.tone === 'pending' ? 0 : reach;
1461
1591
  return h('span', {
1462
1592
  key: row.id,
@@ -1473,11 +1603,20 @@ window.__ModuleLoader__.load({
1473
1603
  }));
1474
1604
  }
1475
1605
  function PlanViz({ t, counts, now, rows }) {
1606
+ /*
1607
+ * [1.5.2] data-state 也是「完成语义」的一个读者。
1608
+ *
1609
+ * 旧实现只看 percent:0% 的 pending 卡拿到 data-state="active",
1610
+ * 而 100% 的卡即使 phase 说 integrating 也是 "done"(T5 C13/C1)。
1611
+ * 现在与胶囊、分组、环共用计数:全 done 才 done,0 且有未完成才 idle。
1612
+ */
1476
1613
  const state = counts.percent === null
1477
1614
  ? 'idle'
1478
1615
  : counts.blocked > 0
1479
1616
  ? 'blocked'
1480
- : counts.percent >= 100 ? 'done' : 'active';
1617
+ : counts.total > 0 && counts.done === counts.total
1618
+ ? 'done'
1619
+ : counts.done > 0 || counts.active > 0 ? 'active' : 'idle';
1481
1620
  return h('div', {
1482
1621
  className: 'dsh-wp-planviz',
1483
1622
  'data-percent': counts.percent === null ? 'none' : String(counts.percent),
@@ -1496,7 +1635,7 @@ window.__ModuleLoader__.load({
1496
1635
  className: 'dsh-wp-planoverview',
1497
1636
  'data-plan-total': String(counts.total),
1498
1637
  'data-plan-done': String(counts.done),
1499
- }, h('div', { className: 'dsh-wp-planoverview-head' }, h('span', { className: 'dsh-wp-planoverview-title' }, t('vizStageTitle')), h('span', null, `${counts.done}/${counts.total} ${t('stats.done')}`)), h('div', { className: 'dsh-wp-planoverview-stats' }, h('span', null, `${t('vizWeighted')} ${clampPct(stats?.progress ?? 0)}%`), h('span', null, `${counts.active} ${t('stats.inProgress')} · ${counts.pending} ${t('stats.pending')}`), counts.blocked > 0 ? h('span', null, `${counts.blocked} ${t('stats.blocked')}`) : null), rows.length
1638
+ }, h('div', { className: 'dsh-wp-planoverview-head' }, h('span', { className: 'dsh-wp-planoverview-title' }, t('vizStageTitle')), h('span', null, `${counts.done}/${counts.total} ${t('stats.done')}`)), h('div', { className: 'dsh-wp-planoverview-stats' }, h('span', null, `${t('vizWeighted')} ${planWeightedPercent(stats, counts)}%`), h('span', null, `${counts.active} ${t('stats.inProgress')} · ${counts.pending} ${t('stats.pending')}`), counts.blocked > 0 ? h('span', null, `${counts.blocked} ${t('stats.blocked')}`) : null), rows.length
1500
1639
  ? h('div', { className: 'dsh-wp-planrows' }, rows.map((row) => {
1501
1640
  const width = row.total > 0 ? (row.done / row.total) * 100 : 0;
1502
1641
  return h('div', {
@@ -1514,13 +1653,18 @@ window.__ModuleLoader__.load({
1514
1653
  }
1515
1654
  function SessionCard({ session, t, open, onToggle }) {
1516
1655
  const domId = `dsh-wp-session-${session.sessionId}`;
1517
- const phase = session.phase || 'pending';
1518
1656
  const stats = session.stats || {};
1519
- const tone = phaseTone(phase);
1520
- const departments = Array.isArray(session.departments) ? session.departments : [];
1521
1657
  // 1.5.0:折叠卡摘要=图表(环 + 百分比 + 四段构成条 + 图例 + 阶段步进条);
1522
1658
  // 数字仍只来自 session.stats,与旧头部的口径完全一致,不引入第二套统计。
1523
1659
  const counts = planCounts(stats);
1660
+ /*
1661
+ * [1.5.2] 卡片的色调与胶囊文案也走统一对账:只要计数显示全部完成,
1662
+ * 胶囊就必须是「已完成」,视觉档位也必须是 done。截图里那张卡之所以
1663
+ * 「100% + 正在收尾 + 留在进行中组」,就是因为这三个读者各读各的字段。
1664
+ */
1665
+ const phase = planPhaseOf(session.phase || 'pending', counts);
1666
+ const tone = phaseTone(phase);
1667
+ const departments = Array.isArray(session.departments) ? session.departments : [];
1524
1668
  const statuses = Array.isArray(session.statuses) ? session.statuses : [];
1525
1669
  const stageRows = planStageRows(session.stages, statuses);
1526
1670
  const now = planNow(t, counts, planTaskEntries(session.stages, statuses));
@@ -1532,7 +1676,7 @@ window.__ModuleLoader__.load({
1532
1676
  panelClassName: 'dsh-wp-session-body',
1533
1677
  title: session.title || t('panel'),
1534
1678
  meta: h(PlanViz, { t, counts, now, rows: stageRows }),
1535
- badge: h(Pill, { tone }, t(`phase.${phase}`)),
1679
+ badge: h(Pill, { tone }, phaseLabel(phase, session, t)),
1536
1680
  }, session.updatedAt
1537
1681
  ? h('div', { className: 'dsh-wp-meta dsh-wp-session-updated' }, `${t('updatedAt')} ${formatTime(session.updatedAt)}`)
1538
1682
  : null, open === true ? h(PlanOverview, { t, stats, counts, rows: stageRows }) : null, session.degraded ? h('div', { className: 'dsh-wp-banner dsh-wp-banner--degraded' }, h('strong', null, t('degraded')), h('div', null, plainError(session.error || '')), h('div', { className: 'dsh-wp-muted' }, t('degradedHint'))) : null, h(Guide, { session, t }), departments.length ? h('div', { className: 'dsh-wp-section' }, h('div', { className: 'dsh-wp-section-title' }, t('who')), h('div', { className: 'dsh-wp-departments' }, departments.map((dept) => h(Pill, { key: dept.id, tone: dept.blocked > 0 ? 'blocked' : dept.done === dept.total ? 'done' : 'active' }, `${deptName(t, dept.id)} ${dept.done}/${dept.total} ${t('stats.done')}`)))) : null, h('div', { className: 'dsh-wp-section' }, h('div', { className: 'dsh-wp-section-title' }, t('steps')), (session.stages || []).map((stage, index) => h(StageCard, {
@@ -1617,7 +1761,7 @@ window.__ModuleLoader__.load({
1617
1761
  charts.push(new lib(canvasA, {
1618
1762
  type: 'doughnut',
1619
1763
  data: {
1620
- labels: [t('groupLive'), t('groupDone')],
1764
+ labels: [t('chartOpen'), t('chartDone')],
1621
1765
  datasets: [{ data: [liveCount, doneCount], backgroundColor: [liveFill, doneFill], borderWidth: 0, hoverOffset: 5 }],
1622
1766
  },
1623
1767
  options: {
@@ -1678,8 +1822,8 @@ window.__ModuleLoader__.load({
1678
1822
  // 「无 canvas → 不建图 → 不 ready → 无 canvas」的死循环(T3 实测
1679
1823
  // 探针输出 canvasNodes:0 / fallbackBars:1)。改为并存:
1680
1824
  // fallback 在 ready 前可见,ready 后由 CSS 隐藏,canvas 反向隐藏。
1681
- h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'groups', role: 'img', 'aria-label': `${t('groupLive')} ${liveCount} / ${t('groupDone')} ${doneCount}` })), chartState !== 'ready'
1682
- ? h('div', { className: 'dsh-wp-fallback' }, h('div', { className: 'dsh-wp-fallback-bar', role: 'img', 'aria-label': `${t('groupLive')} ${liveCount} / ${t('groupDone')} ${doneCount}` }, h('span', { className: 'dsh-wp-fallback-live', style: { width: `${livePct}%` } }), h('span', { className: 'dsh-wp-fallback-done', style: { width: `${donePct}%` } })), h('div', { className: 'dsh-wp-fallback-legend' }, h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--live' }), `${t('groupLive')} ${liveCount}`), h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--done' }), `${t('groupDone')} ${doneCount}`)))
1825
+ h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'groups', role: 'img', 'aria-label': `${t('chartOpen')} ${liveCount} / ${t('chartDone')} ${doneCount}` })), chartState !== 'ready'
1826
+ ? h('div', { className: 'dsh-wp-fallback' }, h('div', { className: 'dsh-wp-fallback-bar', role: 'img', 'aria-label': `${t('chartOpen')} ${liveCount} / ${t('chartDone')} ${doneCount}` }, h('span', { className: 'dsh-wp-fallback-live', style: { width: `${livePct}%` } }), h('span', { className: 'dsh-wp-fallback-done', style: { width: `${donePct}%` } })), h('div', { className: 'dsh-wp-fallback-legend' }, h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--live' }), `${t('chartOpen')} ${liveCount}`), h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--done' }), `${t('chartDone')} ${doneCount}`)))
1683
1827
  : null), (liveBars || []).length
1684
1828
  ? h('div', { className: 'dsh-wp-chart-card' }, h('div', { className: 'dsh-wp-chart-title' }, t('chartLive')), h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'live', role: 'img', 'aria-label': t('chartLive') })), hiddenBars > 0 ? h('div', { className: 'dsh-wp-chart-more' }, fill(t('chartMore'), { n: hiddenBars })) : null)
1685
1829
  : null);
@@ -1729,7 +1873,7 @@ window.__ModuleLoader__.load({
1729
1873
  total,
1730
1874
  done,
1731
1875
  open: total - done,
1732
- percent: total > 0 ? Math.round((done / total) * 100) : null,
1876
+ percent: planPercent(done, total),
1733
1877
  active: Math.max(0, active),
1734
1878
  pending: Math.max(0, pending),
1735
1879
  blocked: Math.max(0, blocked),
@@ -1778,11 +1922,18 @@ window.__ModuleLoader__.load({
1778
1922
  : null), h('span', { className: 'dsh-wp-group-ring-label' }, value === null ? '—' : `${value}%`));
1779
1923
  }
1780
1924
  function SessionGroup({ group, list, hasLive, urgent, t, openSessionId, setOpenSessionId }) {
1925
+ /*
1926
+ * [1.6.0] 三个分组共用一套组件:live(真的在跑)、ended(这一轮停了但没做完)、
1927
+ * done(全部完成)。`ended` 是本次事故的修复核心——它必须是一个**独立分组**,
1928
+ * 而不是被塞回「进行中」:会话已经结束的事实不允许用「进行中」这三个字表达。
1929
+ */
1781
1930
  const isDone = group === 'done';
1931
+ const isEnded = group === 'ended';
1932
+ const keySuffix = isDone ? 'Done' : isEnded ? 'Ended' : 'Live';
1782
1933
  // 设计稿 §6.4 #1/#6:已完成组在有进行中任务时默认折叠,避免历史任务抢走
1783
1934
  // 「进行中」的视觉权重;没有进行中任务时默认展开,空的进度栏仍是空的。
1784
- // 折叠态由本组件自己持有(不给 users 存偏好),刷新后回到默认,符合 §7.3「不打扰」。
1785
- const [open, setOpen] = useState(!(isDone && hasLive));
1935
+ // [1.6.0] 「已结束」组同理:还有东西在跑时收起来,只留组头一行结论。
1936
+ const [open, setOpen] = useState(!((isDone || isEnded) && hasLive));
1786
1937
  // §7.3「不打扰」的自动展开:只在这两种情形打开一次,不做反复弹开——
1787
1938
  // ① 进行中清零(最后一个任务完成);② 用户正展开着的卡片被移进本组。
1788
1939
  // 依赖都是布尔值,轮询刷新不会重复触发。
@@ -1791,7 +1942,7 @@ window.__ModuleLoader__.load({
1791
1942
  setOpen(true); }, [hasLive]);
1792
1943
  useEffect(() => { if (holdsOpenCard)
1793
1944
  setOpen(true); }, [holdsOpenCard]);
1794
- const collapsible = isDone && list.length > 0;
1945
+ const collapsible = (isDone || isEnded) && list.length > 0;
1795
1946
  // 折叠只隐藏、不卸载:`hidden` 必须是布尔 true,不能传空字符串——
1796
1947
  // React 对 `hidden` 走 DOM 布尔属性赋值,`el.hidden = ''` 会得到 false,
1797
1948
  // 只剩 CSS 类在遮挡;那样「折叠态 DOM 保留 + 无障碍语义」就不成立。
@@ -1802,8 +1953,8 @@ window.__ModuleLoader__.load({
1802
1953
  // T6:组头完成度摘要 + 零依赖进度环。数值只来自 groupTotals(list),
1803
1954
  // 先用纯文本给出「进行中 N / 已完成 M(完成率 x%)」,不点开、不看颜色也能读出进度。
1804
1955
  const totals = useMemo(() => groupTotals(list), [list]);
1805
- const groupTitle = t(isDone ? 'groupDone' : 'groupLive');
1806
- const hintText = t(isDone ? 'groupDoneHint' : 'groupLiveHint');
1956
+ const groupTitle = t(`group${keySuffix}`);
1957
+ const hintText = t(`group${keySuffix}Hint`);
1807
1958
  // 用户 Q3:0/0 时不再显示「未完成 0 / 已完成 0(完成率暂无数据)」——
1808
1959
  // 环上已经是「—」,组内空态也已说明没有工作项;再重复一遍 0/0 只是噪声。
1809
1960
  // 全部做完(100%)仍保留整句,因为那时「未完成 0」正是用户要找的结论。
@@ -1868,7 +2019,7 @@ window.__ModuleLoader__.load({
1868
2019
  open: openSessionId === session.sessionId,
1869
2020
  onToggle: (next) => setOpenSessionId(next ? session.sessionId : null),
1870
2021
  })))
1871
- : h('div', { className: 'dsh-wp-group-empty' }, t(isDone ? 'groupDoneEmpty' : 'groupLiveEmpty')));
2022
+ : h('div', { className: 'dsh-wp-group-empty' }, t(`group${keySuffix}Empty`)));
1872
2023
  }
1873
2024
  function Donut({ totals, t }) {
1874
2025
  const total = Math.max(0, Number(totals?.total) || 0);
@@ -1919,10 +2070,11 @@ window.__ModuleLoader__.load({
1919
2070
  function TaskBars({ sessions, t, onSelect }) {
1920
2071
  return h('section', { className: 'dsh-wp-chart-card dsh-wp-task-bars' }, h('div', { className: 'dsh-wp-section-title' }, t('taskChart')), (Array.isArray(sessions) ? sessions : []).map((session) => {
1921
2072
  const stats = session.stats || {};
1922
- const total = Math.max(0, Number(stats.total) || 0);
1923
- const done = Math.max(0, Number(stats.done) || 0);
1924
- const percent = total > 0 ? Math.round((done / total) * 100) : 0;
1925
- const tone = phaseTone(session.phase || 'pending');
2073
+ const counts = planCounts(stats);
2074
+ // 同一套计数与对账:柱状图的百分比、文案、色调都不得自成一套。
2075
+ const percent = counts.percent ?? 0;
2076
+ const phase = planPhaseOf(session.phase || 'pending', counts);
2077
+ const tone = phaseTone(phase);
1926
2078
  return h('button', {
1927
2079
  key: session.sessionId,
1928
2080
  type: 'button',
@@ -1930,7 +2082,7 @@ window.__ModuleLoader__.load({
1930
2082
  'aria-label': `${t('jumpToTask')} ${session.title || t('panel')}`,
1931
2083
  onClick: () => { if (typeof onSelect === 'function')
1932
2084
  onSelect(session.sessionId); },
1933
- }, h('div', { className: 'dsh-wp-bar-head' }, h('span', { className: 'dsh-wp-bar-title' }, session.title || t('panel')), h('span', { className: 'dsh-wp-bar-value' }, `${done}/${total}`)), h('div', { className: 'dsh-wp-bar-track' }, h('div', { className: `dsh-wp-bar-fill dsh-wp-bar-fill--${tone}`, style: { width: `${percent}%` } })), h('div', { className: 'dsh-wp-bar-meta' }, h('span', null, t(`phase.${session.phase || 'pending'}`)), h('span', null, `${percent}%`)));
2085
+ }, h('div', { className: 'dsh-wp-bar-head' }, h('span', { className: 'dsh-wp-bar-title' }, session.title || t('panel')), h('span', { className: 'dsh-wp-bar-value' }, `${counts.done}/${counts.total}`)), h('div', { className: 'dsh-wp-bar-track' }, h('div', { className: `dsh-wp-bar-fill dsh-wp-bar-fill--${tone}`, style: { width: `${percent}%` } })), h('div', { className: 'dsh-wp-bar-meta' }, h('span', null, phaseLabel(phase, session, t)), h('span', null, `${percent}%`)));
1934
2086
  }));
1935
2087
  }
1936
2088
  function Dashboard({ t }) {
@@ -1978,11 +2130,27 @@ window.__ModuleLoader__.load({
1978
2130
  }, [activeSessions]);
1979
2131
  const groups = useMemo(() => {
1980
2132
  const live = [];
2133
+ const ended = [];
1981
2134
  const finished = [];
1982
2135
  const urgentPhases = ['blocked', 'degraded', 'paused'];
2136
+ /*
2137
+ * [1.5.2] 分组也按对账后的 phase,而不是 payload 里的原始 phase。
2138
+ *
2139
+ * 截图那张卡之所以停在「进行中」组,就是因为这里只读 `session.phase`:
2140
+ * 14/14、100% 的计划带着 integrating 被塞进 live 组,组头于是出现
2141
+ * 「未完成 0 / 已完成 14(完成率 100%)」却仍标着「进行中」。
2142
+ * `planPhaseOf` 让分组、组头计数、卡片胶囊共用同一个结论。
2143
+ *
2144
+ * [1.6.0] 新增 ended 分组:宿主已经 `turn/end`(会话这一轮结束、没有任何
2145
+ * 东西在跑),而计划还没做完的,一律进「已结束」,绝不进「进行中」。
2146
+ * 这正是本次事故的现象:会话结束 4 小时后仍显示「进行中」。
2147
+ */
1983
2148
  for (const session of activeSessions) {
1984
- if (session.phase === 'done')
2149
+ const phase = planPhaseOf(session.phase, planCounts(session.stats));
2150
+ if (phase === 'done')
1985
2151
  finished.push(session);
2152
+ else if (phase === 'ended')
2153
+ ended.push(session);
1986
2154
  else
1987
2155
  live.push(session);
1988
2156
  }
@@ -2000,11 +2168,19 @@ window.__ModuleLoader__.load({
2000
2168
  integrating: 3,
2001
2169
  planning: 4,
2002
2170
  pending: 5,
2171
+ ended: 6,
2003
2172
  };
2004
- const rank = (session) => phaseRank[session.phase] ?? 5;
2173
+ const rank = (session) => phaseRank[planPhaseOf(session.phase, planCounts(session.stats))] ?? 5;
2005
2174
  live.sort((a, b) => (rank(a) - rank(b))
2006
2175
  || ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0))
2007
2176
  || String(a.sessionId).localeCompare(String(b.sessionId)));
2177
+ /*
2178
+ * [1.6.0] 已结束组按「最近结束在前」:`endedAt` 是宿主 turn/end 的时间戳
2179
+ * (后端下发),比 updatedAt 更准确地表达「什么时候停的」。
2180
+ */
2181
+ const endedAt = (session) => Number(session.endedAt) || Number(session.updatedAt) || Number(session.createdAt) || 0;
2182
+ ended.sort((a, b) => (endedAt(b) - endedAt(a))
2183
+ || ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0)));
2008
2184
  /*
2009
2185
  * 已完成组按「最近完成在前」(§3.3 / C8):优先取子任务
2010
2186
  * statuses[].finishedAt 的最大值(忽略 null / 非数字),缺失则退回
@@ -2030,27 +2206,46 @@ window.__ModuleLoader__.load({
2030
2206
  };
2031
2207
  finished.sort((a, b) => (finishedAt(b) - finishedAt(a))
2032
2208
  || ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0)));
2033
- return { live, finished, urgent: live.filter((session) => urgentPhases.includes(session.phase)).length };
2209
+ return {
2210
+ live,
2211
+ ended,
2212
+ finished,
2213
+ urgent: live.filter((session) => urgentPhases.includes(session.phase)).length,
2214
+ /*
2215
+ * [1.6.0] 「已结束」组也需要「需处理」角标:卡住的计划停了仍然是卡住的,
2216
+ * 判定用后端下发的 suspendedPhase(原派发阶段),而不是被收敛后的 ended。
2217
+ */
2218
+ urgentEnded: ended.filter((session) => urgentPhases.includes(String(session.suspendedPhase ?? ''))).length,
2219
+ };
2034
2220
  }, [activeSessions]);
2035
- const liveBars = useMemo(() => groups.live.slice(0, 6).map((session) => {
2036
- const doneCount = session.stats?.done ?? 0;
2037
- const totalCount = session.stats?.total ?? 0;
2221
+ // [1.6.0] 图表口径 = 未结束计划(进行中 + 已结束未完成),与环图标签一致。
2222
+ const liveBars = useMemo(() => [...groups.live, ...groups.ended].slice(0, 6).map((session) => {
2038
2223
  const raw = String(session.title || t('panel'));
2039
2224
  return {
2040
2225
  label: raw.length > 14 ? `${raw.slice(0, 14)}…` : raw,
2041
- value: totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0,
2226
+ // [1.5.2] 与卡片/组头同一个百分比函数,杜绝第二套口径。
2227
+ value: planPercent(planCount(session.stats?.done), planCount(session.stats?.total)) ?? 0,
2042
2228
  };
2043
2229
  }), [groups, t]);
2044
2230
  return h('div', { className: 'dsh-wp' }, h('header', { className: 'dsh-wp-header' }, h('div', null, h('h1', { className: 'dsh-wp-title' }, t('title')), h('p', { className: 'dsh-wp-subtitle' }, t('subtitle')), h('div', { className: 'dsh-wp-meta' }, updatedAt ? h('span', null, `${t('updatedAt')} ${formatTime(updatedAt)}`) : null, h('span', null, `${t('scopeAll')}:${activeSessions.length} ${t('sessionCount')},${t('taskBreakdown')} ${totals.total} ${t('subtaskCount')}`))), h('button', { type: 'button', className: 'dsh-wp-button', onClick: reload, disabled: loading }, t('refresh'))), error ? h(ErrorBanner, { message: error, onRetry: reload, t }) : null, activeSessions.length > 0 ? h('div', { className: 'dsh-wp-statbar', role: 'list' }, h('div', { className: 'dsh-wp-stat dsh-wp-stat--tasks', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, activeSessions.length), h('div', { className: 'dsh-wp-stat-label' }, t('sessionCount'))), h('div', { className: 'dsh-wp-stat', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.total), h('div', { className: 'dsh-wp-stat-label' }, t('workItems'))), h('div', { className: 'dsh-wp-stat dsh-wp-stat--done', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.done), h('div', { className: 'dsh-wp-stat-label' }, t('stats.done'))), h('div', { className: 'dsh-wp-stat dsh-wp-stat--active', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.inProgress), h('div', { className: 'dsh-wp-stat-label' }, t('stats.inProgress'))), h('div', { className: 'dsh-wp-stat dsh-wp-stat--blocked', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.blocked), h('div', { className: 'dsh-wp-stat-label' }, t('stats.blocked')))) : null, activeSessions.length > 0 ? h('div', { className: 'dsh-wp-scope-note' }, t('allPlansHint')) : null, activeSessions.length === 0
2045
2231
  ? h('div', { className: 'dsh-wp-empty' }, h('div', { style: { fontWeight: 650, marginBottom: '4px' } }, t('empty')), h('div', null, t('emptyHint')))
2046
- : h('div', { className: 'dsh-wp-groups' }, h(SessionGroup, { group: 'live', list: groups.live, urgent: groups.urgent, t, openSessionId, setOpenSessionId }), h(SessionGroup, { group: 'done', list: groups.finished, hasLive: groups.live.length > 0, urgent: 0, t, openSessionId, setOpenSessionId }),
2232
+ : h('div', { className: 'dsh-wp-groups' },
2233
+ /*
2234
+ * [1.6.0] 三组顺序:真的在跑 → 已结束(停在半路) → 已完成。
2235
+ * 「已结束」夹在中间是有意为之:它是用户最需要立刻知道的那一档
2236
+ * (刚停、还没做完),不能滑到历史组的下面去。
2237
+ */
2238
+ h(SessionGroup, { key: 'live', group: 'live', list: groups.live, urgent: groups.urgent, t, openSessionId, setOpenSessionId }), groups.ended.length > 0
2239
+ ? h(SessionGroup, { key: 'ended', group: 'ended', list: groups.ended, hasLive: groups.live.length > 0, urgent: groups.urgentEnded, t, openSessionId, setOpenSessionId })
2240
+ : null, h(SessionGroup, { key: 'done', group: 'done', list: groups.finished, hasLive: groups.live.length > 0 || groups.ended.length > 0, urgent: 0, t, openSessionId, setOpenSessionId }),
2047
2241
  // 用户选择 B:总览块(完成率环 + 每个计划的进度条)同样下移到两组之后,
2048
2242
  // 让开屏第一屏直接是「进行中」组的任务卡(此前统计条 → 总览 → 说明行 →
2049
2243
  // 任务卡,第一张卡要下滑约一屏)。顶部只保留 5 格数字统计条与一行口径说明。
2050
2244
  activeSessions.length > 0 ? h('div', { className: 'dsh-wp-overview' }, h(Donut, { totals, t }), h(TaskBars, { sessions: activeSessions, t, onSelect: selectSession })) : null,
2051
2245
  // 图表放两组之后:两张卡各 ≈246px,排在组前会让首屏看不到任何一张任务卡
2052
2246
  // (设计稿 §8 第 12 条,T8 复核时复现在 1.4.2 源码里)。
2053
- h(GroupCharts, { liveCount: groups.live.length, doneCount: groups.finished.length, liveBars, hiddenBars: Math.max(0, groups.live.length - 6), t })));
2247
+ // [1.6.0] 「未结束」= 进行中 + 已结束但未完成:两者都还没做完,都是用户要盯的对象。
2248
+ h(GroupCharts, { liveCount: groups.live.length + groups.ended.length, doneCount: groups.finished.length, liveBars, hiddenBars: Math.max(0, groups.live.length + groups.ended.length - 6), t })));
2054
2249
  }
2055
2250
  function TurnCard({ sessionId, turn, t }) {
2056
2251
  const { sessions } = usePlans(sessionId);
@@ -2066,9 +2261,16 @@ window.__ModuleLoader__.load({
2066
2261
  if (currentTurn > createdTurn && !active)
2067
2262
  return null;
2068
2263
  }
2264
+ /*
2265
+ * [1.5.2] 会话内联卡(TurnCard)也走同一套对账。
2266
+ *
2267
+ * 面板卡片修好了、内联卡却仍按原始 phase 渲染的话,同一张计划会在
2268
+ * 「侧栏已完成 / 对话里正在收尾」之间再次自相矛盾。
2269
+ */
2069
2270
  const stats = session.stats || {};
2070
- const tone = phaseTone(session.phase || 'pending');
2071
- return h('div', { className: `dsh-wp-turn dsh-wp-tone-${tone}` }, h('div', { className: 'dsh-wp-turn-title' }, h('span', null, session.title || t('panel')), h(Pill, { tone }, t(`phase.${session.phase || 'pending'}`))), h('div', { className: 'dsh-wp-muted', style: { marginTop: '6px' } }, `${t('helpWho')}:${workerText(session, t)}`), h('div', { className: 'dsh-wp-muted', style: { marginTop: '2px' } }, `${t('helpNext')}:${nextText(session, t)}`), h('div', { className: 'dsh-wp-progress-meta' }, h('span', null, `${stats.done ?? 0}/${stats.total ?? 0} ${t('stats.done')} · ${stats.blocked ?? 0} ${t('stats.blocked')}`), h('span', { className: 'dsh-wp-muted' }, t('viewHint'))));
2271
+ const phase = planPhaseOf(session.phase || 'pending', planCounts(stats));
2272
+ const tone = phaseTone(phase);
2273
+ return h('div', { className: `dsh-wp-turn dsh-wp-tone-${tone}` }, h('div', { className: 'dsh-wp-turn-title' }, h('span', null, session.title || t('panel')), h(Pill, { tone }, phaseLabel(phase, session, t))), h('div', { className: 'dsh-wp-muted', style: { marginTop: '6px' } }, `${t('helpWho')}:${workerText(session, t)}`), h('div', { className: 'dsh-wp-muted', style: { marginTop: '2px' } }, `${t('helpNext')}:${nextText(session, t)}`), h('div', { className: 'dsh-wp-progress-meta' }, h('span', null, `${stats.done ?? 0}/${stats.total ?? 0} ${t('stats.done')} · ${stats.blocked ?? 0} ${t('stats.blocked')}`), h('span', { className: 'dsh-wp-muted' }, t('viewHint'))));
2072
2274
  }
2073
2275
  function contentText(block) {
2074
2276
  if (!block || !Array.isArray(block.content))