dsh-activity-pane 0.4.0 → 0.7.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/scripts/check.mjs CHANGED
@@ -73,7 +73,9 @@ import {
73
73
  summarizeToolArguments,
74
74
  usageSummary,
75
75
  workspaceHue,
76
- resolveWorkspaceHues,
76
+ WORKSPACE_COLOR_SLOTS,
77
+ WORKSPACE_BACKGROUND_SLOTS,
78
+ resolveWorkspaceColors,
77
79
  workspaceInfoForSession,
78
80
  } from "../src/core.mjs";
79
81
  import {
@@ -81,6 +83,7 @@ import {
81
83
  bindBackdropDismiss,
82
84
  bindCardActivation,
83
85
  openSession,
86
+ scrollCardIntoView,
84
87
  shouldDismissDrawerOnActivation,
85
88
  suppressComposerAutofocus,
86
89
  } from "../src/navigation.mjs";
@@ -114,6 +117,56 @@ assert.equal(
114
117
  "sessions.open 失败时交给调用方进入 refresh/retry",
115
118
  );
116
119
 
120
+ // ---- R-01-006/AC-02 当前卡片最小滚动:只调整越界方向,不居中 ----
121
+ const viewport = { top: 10, bottom: 110 };
122
+ const makeScrollBox = (scrollTop, smooth = true) => {
123
+ const scroll = {
124
+ scrollTop,
125
+ scrollHeight: 300,
126
+ clientHeight: 100,
127
+ getBoundingClientRect: () => viewport,
128
+ };
129
+ if (smooth) {
130
+ scroll.scrollCalls = [];
131
+ scroll.scrollTo = ({ top, behavior }) => {
132
+ scroll.scrollCalls.push({ top, behavior });
133
+ scroll.scrollTop = top;
134
+ };
135
+ }
136
+ return scroll;
137
+ };
138
+ const makeCardBox = (top, bottom) => ({
139
+ getBoundingClientRect: () => ({ top, bottom }),
140
+ });
141
+ const belowScroll = makeScrollBox(0);
142
+ assert.equal(scrollCardIntoView(belowScroll, makeCardBox(120, 160)), true, "卡片底部越界时执行向下定位");
143
+ assert.equal(belowScroll.scrollTop, 50, "向下只滚动卡片底部超出的最小距离");
144
+ assert.deepEqual(belowScroll.scrollCalls, [{ top: 50, behavior: "smooth" }], "向下定位使用快速平滑滚动");
145
+ const reducedMotionScroll = makeScrollBox(0);
146
+ assert.equal(scrollCardIntoView(reducedMotionScroll, makeCardBox(120, 160), "auto"), true, "降低动效偏好时仍执行定位");
147
+ assert.deepEqual(reducedMotionScroll.scrollCalls, [{ top: 50, behavior: "auto" }], "降低动效偏好时跳过平滑过渡");
148
+ const fallbackScroll = makeScrollBox(0, false);
149
+ assert.equal(scrollCardIntoView(fallbackScroll, makeCardBox(120, 160)), true, "缺少 scrollTo 时保留直接滚动 fallback");
150
+ assert.equal(fallbackScroll.scrollTop, 50, "scrollTo fallback 仍使用最小滚动距离");
151
+ const throwingScroll = makeScrollBox(0);
152
+ throwingScroll.scrollTo = () => { throw new TypeError("unsupported options"); };
153
+ assert.equal(scrollCardIntoView(throwingScroll, makeCardBox(120, 160)), true, "scrollTo options 不受支持时回退为直接滚动");
154
+ assert.equal(throwingScroll.scrollTop, 50, "scrollTo fallback 仍完成最小滚动");
155
+ const unexpectedScroll = makeScrollBox(0);
156
+ unexpectedScroll.scrollTo = () => { throw new Error("unexpected failure"); };
157
+ assert.throws(() => scrollCardIntoView(unexpectedScroll, makeCardBox(120, 160)), /unexpected failure/, "非预期滚动异常继续暴露");
158
+ const aboveScroll = makeScrollBox(100);
159
+ assert.equal(scrollCardIntoView(aboveScroll, makeCardBox(-20, 20)), true, "卡片顶部越界时执行向上定位");
160
+ assert.equal(aboveScroll.scrollTop, 70, "向上只滚动卡片顶部超出的最小距离");
161
+ const visibleScroll = makeScrollBox(42);
162
+ assert.equal(scrollCardIntoView(visibleScroll, makeCardBox(20, 90)), false, "卡片完整可见时不滚动");
163
+ assert.equal(visibleScroll.scrollTop, 42, "卡片完整可见时保持原滚动位置");
164
+ const topBoundaryScroll = makeScrollBox(0);
165
+ assert.equal(scrollCardIntoView(topBoundaryScroll, makeCardBox(-20, 20)), false, "顶部边界不能继续向上滚动");
166
+ const bottomBoundaryScroll = makeScrollBox(200);
167
+ assert.equal(scrollCardIntoView(bottomBoundaryScroll, makeCardBox(120, 160)), false, "底部边界不能继续向下滚动");
168
+ assert.equal(scrollCardIntoView(null, makeCardBox(0, 1)), false, "缺少滚动容器时静默跳过");
169
+
117
170
  // ---- R-01-005/AC-01、R-02-003/AC-01 card 自身 click/键盘监听与卸载 ----
118
171
  const cardListeners = new Map();
119
172
  const card = {
@@ -221,7 +274,62 @@ assert.equal(escapeCssString("a\fb"), "a\\c b", "换页按 CSS 字符串码位
221
274
  assert.equal(escapeCssString("a\0b"), "a�b", "NUL 归一为替换字符");
222
275
  // R-01-012/AC-01
223
276
  assert.equal(isSubagentRow({ parentId: "ghost" }, {}), false, "父级不在列表时按主会话处理(仍允许 models 读取)");
224
- assert.equal(isSubagentRow({ parentId: "p" }, { p: { id: "p" } }), true, "直属子代理判定命中时跳过 models 读取");
277
+ assert.equal(isSubagentRow({ parentId: "p" }, { p: { id: "p" } }), false, "普通 fork 的 parentId 不足以判定为子代理");
278
+ assert.equal(isSubagentRow({ parentId: "p", origin: "subagent" }, { p: { id: "p" } }), true, "origin 为 subagent 且父级有效时命中子代理判定");
279
+ assert.equal(isSubagentRow({ origin: "subagent" }, { p: { id: "p" } }), false, "缺少有效 parentId 时不判定为子代理");
280
+ // R-01-003/AC-01、R-01-010/AC-01:普通 fork 虽有 parentId,仍按独立主会话处理。
281
+ const forkWorkspace = [{ title: "Fork 工作区", path: "/srv/fork", sessionIds: ["fork-root", "fork-child"] }];
282
+ const forkActivity = {
283
+ ids: ["fork-root", "fork-child"],
284
+ byId: {
285
+ "fork-root": { id: "fork-root", displayTitle: "来源会话", running: false, completed: false, updatedAt: 1_000 },
286
+ "fork-child": { id: "fork-child", displayTitle: "分支会话", running: true, completed: false, parentId: "fork-root" },
287
+ },
288
+ current: null,
289
+ };
290
+ assert.equal(isSubagentRow(forkActivity.byId["fork-child"], forkActivity.byId), false, "普通 fork 的 parentId 不改变主会话身份");
291
+ assert.deepEqual([...activeSessionIds(forkActivity.byId)], ["fork-child"], "活动 fork 不把来源会话算作活动后代母会话");
292
+ assert.deepEqual(
293
+ buildEntries(forkActivity, forkWorkspace).map((entry) => [entry.id, entry.kind, entry.depth, entry.parentId, entry.workspaceTitle]),
294
+ [["fork-child", "running", 0, null, "Fork 工作区"]],
295
+ "活动 fork 保持独立主会话条目与工作区身份");
296
+ const forkRecent = {
297
+ ...forkActivity,
298
+ byId: {
299
+ "fork-root": { ...forkActivity.byId["fork-root"], updatedAt: 1_000 },
300
+ "fork-child": { ...forkActivity.byId["fork-child"], running: false, updatedAt: 1_500 },
301
+ },
302
+ };
303
+ assert.deepEqual(
304
+ buildRecent(forkRecent, forkWorkspace, 2_000).map((entry) => [entry.id, entry.workspaceTitle]),
305
+ [["fork-child", "Fork 工作区"], ["fork-root", "Fork 工作区"]],
306
+ "结束 fork 可进入最近历史且保留工作区身份");
307
+ const trueSubagentActivity = {
308
+ ...forkActivity,
309
+ byId: { ...forkActivity.byId, "fork-child": { ...forkActivity.byId["fork-child"], origin: "subagent" } },
310
+ };
311
+ assert.deepEqual(
312
+ buildEntries(trueSubagentActivity, forkWorkspace).map((entry) => [entry.id, entry.kind, entry.depth, entry.parentId]),
313
+ [["fork-root", "running", 0, null], ["fork-child", "subagent", 1, "fork-root"]],
314
+ "origin 为 subagent 的真实子代理仍保持嵌套与母会话委托呈现");
315
+ const subagentUnderFork = {
316
+ ids: ["source", "fork", "nested-subagent"],
317
+ byId: {
318
+ source: { id: "source", displayTitle: "来源会话", running: false },
319
+ fork: { id: "fork", displayTitle: "独立分支", parentId: "source", running: false },
320
+ "nested-subagent": { id: "nested-subagent", displayTitle: "分支中的子代理", parentId: "fork", origin: "subagent", running: true },
321
+ },
322
+ current: null,
323
+ };
324
+ assert.deepEqual([...activeSessionIds(subagentUnderFork.byId)].sort(), ["fork", "nested-subagent"], "分支下真实子代理的活动祖先不越过独立 fork 来源");
325
+ assert.deepEqual(
326
+ buildEntries(subagentUnderFork, [{ title: "Fork 工作区", path: "/srv/fork", sessionIds: ["source", "fork", "nested-subagent"] }]).map((entry) => [entry.id, entry.kind, entry.depth, entry.parentId]),
327
+ [["fork", "running", 0, null], ["nested-subagent", "subagent", 1, "fork"]],
328
+ "分支下真实子代理只嵌套到 fork,不把来源会话变成母会话");
329
+ assert.deepEqual(
330
+ buildEntries(subagentUnderFork, [], {}, null, null, ["source"]).map((entry) => entry.id),
331
+ ["fork", "nested-subagent"],
332
+ "归档 fork 来源不影响独立 fork 及其真实子代理子树");
225
333
  // models/history 加载决策行为链:首读 → 在途不重发 → 失败置空后可见期内不热重试 → 离开可见清理后重回可重试
226
334
  const loadDetail = {};
227
335
  assert.deepEqual(
@@ -530,56 +638,86 @@ const hueSpread = new Set();
530
638
  for (let i = 0; i < 50; i += 1) hueSpread.add(workspaceHue(`/home/user/proj/ws-${i}`));
531
639
  assert.ok(hueSpread.size >= 40, `50 个长前缀身份分散到 ${hueSpread.size} 个不同色相(≥40,R-01-003/AC-09)`);
532
640
 
533
- // ---- R-01-003/AC-12 OKLCH 七色感知锚点 + 步进 3 跨色区槽位消解 ----
534
- const workspaceHueAnchors = [55, 100, 145, 190, 235, 280, 325];
641
+ // ---- R-01-003/AC-12 OKLCH 十二前景槽、三档背景变体、主题距离与复合容量 ----
642
+ const workspacePrimaryHues = [55, 100, 145, 190, 235, 280, 325];
643
+ const workspacePaletteHues = WORKSPACE_COLOR_SLOTS.map((slot) => slot.hue);
535
644
  const realWorkspaceCluster = [
536
645
  "/home/cailei/ops",
537
646
  "/home/cailei/proj/docsim",
538
647
  "/home/cailei/proj/dsh-activity-pane",
539
648
  "/home/cailei/proj/dsh-control-center",
540
649
  ];
541
- const resolvedCluster = resolveWorkspaceHues(realWorkspaceCluster);
650
+ const resolvedCluster = resolveWorkspaceColors(realWorkspaceCluster);
542
651
  assert.deepEqual(
543
- [...resolvedCluster.entries()],
652
+ [...resolvedCluster.entries()].map(([key, color]) => [key, color.foreground.hue]),
544
653
  [
545
654
  ["/home/cailei/ops", 280],
546
655
  ["/home/cailei/proj/docsim", 55],
547
656
  ["/home/cailei/proj/dsh-activity-pane", 190],
548
657
  ["/home/cailei/proj/dsh-control-center", 100],
549
658
  ],
550
- "真实撞槽子集以 +3 探测确定性拆分到蓝紫/橙/青/黄绿明显色区(R-01-003/AC-12)",
551
- );
552
- assert.deepEqual(
553
- [...resolveWorkspaceHues([...realWorkspaceCluster].reverse(), "", " ", realWorkspaceCluster[0]).entries()],
554
- [...resolvedCluster.entries()],
555
- "输入顺序、重复项与空白身份不影响消解映射(R-01-003/AC-08、AC-12)",
556
- );
557
- assert.deepEqual([...resolveWorkspaceHues(null).entries()], [], "无身份集合返回空映射(R-01-003/AC-12)");
558
- const sevenHues = [...resolveWorkspaceHues(Array.from({ length: 7 }, (_, index) => `/home/user/proj/seven-${index}`)).values()];
559
- assert.deepEqual([...sevenHues].sort((a, b) => a - b), workspaceHueAnchors, "七个工作区恰占满七个避红 OKLCH 感知锚点(R-01-003/AC-12)");
560
- const oklabHueDistance = (hueA, hueB, chroma) => {
561
- const a = hueA * Math.PI / 180;
562
- const b = hueB * Math.PI / 180;
563
- return Math.hypot(chroma * Math.cos(a) - chroma * Math.cos(b), chroma * Math.sin(a) - chroma * Math.sin(b));
659
+ "真实撞槽子集保持 +3 探测拆分到蓝紫/橙/青/黄绿明显色区(R-01-003/AC-12)",
660
+ );
661
+ assert.deepEqual(
662
+ [...resolveWorkspaceColors([...realWorkspaceCluster].reverse(), "", " ", realWorkspaceCluster[0]).entries()].map(([key, color]) => [key, color.foreground.slot, color.background.slot]),
663
+ [...resolvedCluster.entries()].map(([key, color]) => [key, color.foreground.slot, color.background.slot]),
664
+ "输入顺序、重复项与空白身份不影响前景/背景复合槽位映射(R-01-003/AC-08、AC-12)",
665
+ );
666
+ assert.deepEqual([...resolveWorkspaceColors(null).entries()], [], "无身份集合返回空映射(R-01-003/AC-12)");
667
+ const sevenColors = [...resolveWorkspaceColors(Array.from({ length: 7 }, (_, index) => `/home/user/proj/seven-${index}`)).values()];
668
+ assert.deepEqual([...sevenColors].map((color) => color.foreground.hue).sort((a, b) => a - b), workspacePrimaryHues, "七个工作区仍恰占满七个主色相槽位(R-01-003/AC-12)");
669
+ assert.ok([...sevenColors].every((color) => color.foreground.slot < workspacePrimaryHues.length), "七个工作区不提前使用补充槽位(R-01-003/AC-12)");
670
+ const oklabPoint = (color, theme) => {
671
+ const hue = color.hue * Math.PI / 180;
672
+ const lightness = theme === "dark" ? color.darkL : color.lightL;
673
+ const chroma = theme === "dark" ? color.darkC : color.lightC;
674
+ return [lightness, chroma * Math.cos(hue), chroma * Math.sin(hue)];
564
675
  };
565
- for (let i = 0; i < sevenHues.length; i += 1)
566
- for (let j = i + 1; j < sevenHues.length; j += 1) {
567
- assert.ok(oklabHueDistance(sevenHues[i], sevenHues[j], 0.16) >= 0.11, "深色主题七锚点任意两色 OKLab 距离至少 0.11(R-01-003/AC-12)");
568
- assert.ok(oklabHueDistance(sevenHues[i], sevenHues[j], 0.15) >= 0.11, "浅色主题七锚点任意两色 OKLab 距离至少 0.11(R-01-003/AC-12)");
569
- }
570
- const crowdedHues = resolveWorkspaceHues(Array.from({ length: 20 }, (_, index) => `/home/user/proj/crowded-${index}`));
571
- assert.equal(crowdedHues.size, 20, "超容量集合仍为每个身份返回色相并有限终止(R-01-003/AC-12)");
572
- assert.ok([...crowdedHues.values()].every((hue) => workspaceHueAnchors.includes(hue)), "超容量时仍只使用七个避红 OKLCH 感知锚点(R-01-003/AC-12)");
573
- const anchorUses = workspaceHueAnchors.map((anchor) => [...crowdedHues.values()].filter((hue) => hue === anchor).length);
574
- assert.ok(Math.max(...anchorUses) - Math.min(...anchorUses) <= 1, "超容量时七锚点复用计数差不超过 1(R-01-003/AC-12)");
676
+ const oklabDistance = (colorA, colorB, theme) => {
677
+ const a = oklabPoint(colorA, theme);
678
+ const b = oklabPoint(colorB, theme);
679
+ return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
680
+ };
681
+ const twelveColors = [...resolveWorkspaceColors(Array.from({ length: 12 }, (_, index) => `/home/user/proj/twelve-${index}`)).values()];
682
+ assert.deepEqual(
683
+ [...twelveColors].map((color) => color.foreground.slot).sort((a, b) => a - b),
684
+ Array.from({ length: 12 }, (_, index) => index),
685
+ "十二个工作区恰好使用十二个唯一前景颜色槽位(R-01-003/AC-12)",
686
+ );
687
+ assert.equal(new Set(workspacePaletteHues).size, workspacePaletteHues.length, "十二槽 hue 不重复(R-01-003/AC-12)");
688
+ assert.ok(workspacePaletteHues.every((hue) => hue > 30 && hue < 330), "十二槽 hue 均避开红色警戒环段(R-01-003/AC-09、AC-12)");
689
+ assert.deepEqual(
690
+ WORKSPACE_BACKGROUND_SLOTS.map((slot) => slot.slot),
691
+ [0, 1, 2],
692
+ "每个前景槽位提供三个背景变体槽(R-01-003/AC-12)",
693
+ );
694
+ assert.equal(new Set(WORKSPACE_BACKGROUND_SLOTS.map((slot) => slot.dark.l)).size, 3, "深色背景变体具有三档独立明度(R-01-003/AC-10、AC-11)");
695
+ assert.equal(new Set(WORKSPACE_BACKGROUND_SLOTS.map((slot) => slot.light.l)).size, 3, "浅色背景变体具有三档独立明度(R-01-003/AC-10、AC-11)");
696
+ for (const theme of ["dark", "light"])
697
+ for (let i = 0; i < twelveColors.length; i += 1)
698
+ for (let j = i + 1; j < twelveColors.length; j += 1)
699
+ assert.ok(oklabDistance(twelveColors[i].foreground, twelveColors[j].foreground, theme) >= 0.11, `${theme} 主题十二槽任意两前景色 OKLab 距离至少 0.11(R-01-003/AC-12)`);
700
+ const thirtySixColors = [...resolveWorkspaceColors(Array.from({ length: 36 }, (_, index) => `/home/user/proj/thirty-six-${index}`)).values()];
701
+ const pairKeys = thirtySixColors.map((color) => `${color.foreground.slot}/${color.background.slot}`);
702
+ assert.equal(new Set(pairKeys).size, 36, "三十六个工作区恰好使用三十六个唯一前景/背景复合槽位(R-01-003/AC-12)");
703
+ for (const foregroundSlot of WORKSPACE_COLOR_SLOTS.map((slot) => slot.slot)) {
704
+ const backgrounds = thirtySixColors.filter((color) => color.foreground.slot === foregroundSlot).map((color) => color.background.slot);
705
+ assert.deepEqual(backgrounds.sort((a, b) => a - b), [0, 1, 2], `前景槽 ${foregroundSlot} 先使用三个背景变体(R-01-003/AC-12)`);
706
+ }
707
+ const crowdedColors = resolveWorkspaceColors(Array.from({ length: 40 }, (_, index) => `/home/user/proj/crowded-${index}`));
708
+ assert.equal(crowdedColors.size, 40, "超容量集合仍为每个身份返回复合颜色槽位并有限终止(R-01-003/AC-12)");
709
+ assert.ok([...crowdedColors.values()].every((color) => color.foreground.slot >= 0 && color.foreground.slot < WORKSPACE_COLOR_SLOTS.length), "超容量时仍只使用十二个前景颜色槽位(R-01-003/AC-12)");
710
+ assert.ok([...crowdedColors.values()].every((color) => color.background.slot >= 0 && color.background.slot < WORKSPACE_BACKGROUND_SLOTS.length), "超容量时仍只使用三个背景变体槽位(R-01-003/AC-12)");
711
+ const pairUses = [...new Set([...crowdedColors.values()].map((color) => `${color.foreground.slot}/${color.background.slot}`))].map((pair) => [...crowdedColors.values()].filter((color) => `${color.foreground.slot}/${color.background.slot}` === pair).length);
712
+ assert.ok(Math.max(...pairUses) - Math.min(...pairUses) <= 1, "超容量时三十六个复合槽位复用计数差不超过 1(R-01-003/AC-12)");
575
713
 
576
714
  // ---- R-01-001/AC-01 活动卡片逐条显示 | R-01-003/AC-01 子代理嵌套 | R-01-003/AC-02 子代理结束即消失 | R-01-006/AC-01 当前会话 ----
577
715
  const snapshot = {
578
716
  ids: ["sA", "sA-c1", "sA-c2", "sB", "sX"],
579
717
  byId: {
580
718
  sA: { id: "sA", displayTitle: "主A", running: true, completed: false },
581
- "sA-c1": { id: "sA-c1", displayTitle: "子1", running: true, parentId: "sA" },
582
- "sA-c2": { id: "sA-c2", displayTitle: "子2", running: false, completed: true, parentId: "sA" },
719
+ "sA-c1": { id: "sA-c1", displayTitle: "子1", running: true, parentId: "sA", origin: "subagent" },
720
+ "sA-c2": { id: "sA-c2", displayTitle: "子2", running: false, completed: true, parentId: "sA", origin: "subagent" },
583
721
  sB: { id: "sB", displayTitle: "主B", running: false, completed: true },
584
722
  sX: { id: "sX", displayTitle: "主X", running: false, completed: false },
585
723
  },
@@ -685,8 +823,8 @@ const inheritedActivity = {
685
823
  ids: ["root", "parent", "child"],
686
824
  byId: {
687
825
  root: { id: "root", displayTitle: "根母会话", running: false, completed: false, updatedAt: 1900 },
688
- parent: { id: "parent", displayTitle: "中间母会话", running: false, completed: false, parentId: "root", updatedAt: 1900 },
689
- child: { id: "child", displayTitle: "活动子会话", running: true, parentId: "parent" },
826
+ parent: { id: "parent", displayTitle: "中间母会话", running: false, completed: false, parentId: "root", origin: "subagent", updatedAt: 1900 },
827
+ child: { id: "child", displayTitle: "活动子会话", running: true, parentId: "parent", origin: "subagent" },
690
828
  },
691
829
  current: null,
692
830
  };
@@ -716,7 +854,7 @@ const delegCompleted = {
716
854
  ids: ["root", "child"],
717
855
  byId: {
718
856
  root: { id: "root", displayTitle: "母会话", running: false, updatedAt: 1900 },
719
- child: { id: "child", displayTitle: "活动子会话", running: true, parentId: "root" },
857
+ child: { id: "child", displayTitle: "活动子会话", running: true, parentId: "root", origin: "subagent" },
720
858
  },
721
859
  current: null,
722
860
  };
@@ -897,6 +1035,11 @@ assert.deepEqual(
897
1035
  );
898
1036
  assert.equal(fmtElapsedMs(47_000), "47s", "时长短格式");
899
1037
  assert.equal(fmtElapsedMs(193_000), "3m13s", "时长分秒格式");
1038
+ // R-01-009/AC-05 超过 1 小时按时分秒显示
1039
+ assert.equal(fmtElapsedMs(3_723_000), "1h2m3s", "时长时分秒格式");
1040
+ assert.equal(fmtElapsedMs(9_772_000), "2h42m52s", "多小时格式");
1041
+ assert.equal(fmtElapsedMs(3_600_000), "1h0m0s", "整小时保留零段");
1042
+ assert.equal(fmtElapsedMs(3_599_500), "1h0m0s", "秒数四舍五入进位到小时");
900
1043
  // R-01-009/AC-03
901
1044
  assert.equal(fmtElapsedMs(NaN), "", "NaN 时长归一为空");
902
1045
  assert.equal(fmtElapsedMs(Infinity), "", "Infinity 时长归一为空");
@@ -2474,7 +2617,7 @@ assert.equal(completionReminder(holdBase, { lastTurnEnd: 1000, ackedAt: 1000 },
2474
2617
  assert.equal(completionReminder(holdBase, { lastTurnEnd: 1000, ackedAt: 2000 }, false), false, "ackedAt 晚于 lastTurnEnd 不成立");
2475
2618
  assert.equal(completionReminder(holdBase, null, false), false, "无完成登记不成立(升级不回溯补发提醒)");
2476
2619
  assert.equal(completionReminder(holdBase, { lastTurnEnd: 0, ackedAt: null }, false), false, "lastTurnEnd 非法不作数");
2477
- assert.equal(completionReminder({ id: "m-c1", parentId: "m", displayTitle: "子S" }, { lastTurnEnd: 1000, ackedAt: null }, true), false, "子代理不产生完成提醒");
2620
+ assert.equal(completionReminder({ id: "m-c1", parentId: "m", origin: "subagent", displayTitle: "子S" }, { lastTurnEnd: 1000, ackedAt: null }, true), false, "子代理不产生完成提醒");
2478
2621
  // 打开/切换当前会话不解除(AC-05):完成提醒成立与 current 无关。
2479
2622
  // R-01-002/AC-05 打开或切换当前会话不解除完成提醒:判定与 current 无关(C-030)。
2480
2623
  const holdSnap = { ids: ["sB"], byId: { sB: holdBase }, current: "sA" };
@@ -2512,7 +2655,7 @@ assert.deepEqual(
2512
2655
  // 新回合隐式更替:lastTurnEnd 前移后旧确认游标不再覆盖新回合(仍未确认则对新回合成立)。
2513
2656
  assert.equal(completionReminder(holdBase, { lastTurnEnd: 2000, ackedAt: 1500 }, false), true, "新回合完成后提醒针对新回合重新成立");
2514
2657
  // 委托周期抑制:后代活动期间完成提醒不生效(呈现不依赖宿主 completed)。
2515
- const delegDoneMix = { ids: ["root", "root-c1"], byId: { root: holdBase, "root-c1": { id: "root-c1", displayTitle: "子S", parentId: "root", running: true } }, current: null };
2658
+ const delegDoneMix = { ids: ["root", "root-c1"], byId: { root: holdBase, "root-c1": { id: "root-c1", displayTitle: "子S", parentId: "root", origin: "subagent", running: true } }, current: null };
2516
2659
  assert.deepEqual(
2517
2660
  buildEntries(delegDoneMix, [], {}, acks(1000), new Set(["root"])).map((e) => [e.id, e.pendingText ?? null]),
2518
2661
  [["root", null], ["root-c1", null]],
@@ -2534,7 +2677,7 @@ assert.equal(errorReminder({ id: "sB", displayTitle: "旧B" }, { lastTurnEndKind
2534
2677
  assert.equal(errorReminder({ id: "sB", displayTitle: "旧B" }, { lastTurnEndKind: "completed" }, false), false, "正常回合不成立");
2535
2678
  assert.equal(errorReminder({ id: "sB", displayTitle: "旧B" }, { lastTurnEndKind: "error", ackedAt: 99999 }, false), true, "错误提醒不消费 ack 游标(ackedAt 不影响成立)");
2536
2679
  assert.equal(errorReminder({ id: "sB", displayTitle: "旧B" }, null, false), false, "无登记不成立(升级不回溯补发错误提醒)");
2537
- assert.equal(errorReminder({ id: "m-c1", parentId: "m", displayTitle: "子S" }, { lastTurnEndKind: "error" }, true), false, "子代理不产生错误提醒");
2680
+ assert.equal(errorReminder({ id: "m-c1", parentId: "m", origin: "subagent", displayTitle: "子S" }, { lastTurnEndKind: "error" }, true), false, "子代理不产生错误提醒");
2538
2681
  const errEntries = buildEntries(holdSnap, [], {}, errAcks("error", "The engine is currently overloaded, please try again later"));
2539
2682
  assert.deepEqual(
2540
2683
  errEntries.map((e) => [e.id, e.kind, e.pendingText ?? null, e.waitClass, e.noteText]),
@@ -2586,7 +2729,7 @@ assert.deepEqual(
2586
2729
  const clearedErrAcked = buildEntries(holdSnap, [], {}, errAcks("completed", null, 2000));
2587
2730
  assert.deepEqual(clearedErrAcked.map((e) => [e.id, e.kind]), [], "新回合正常结束且已确认后完全退出活动区(错误提醒无确认按钮语义)");
2588
2731
  // 委托周期抑制:后代活动期间错误提醒不生效。
2589
- const delegErrMix = { ids: ["root", "root-c1"], byId: { root: holdBase, "root-c1": { id: "root-c1", displayTitle: "子S", parentId: "root", running: true } }, current: null };
2732
+ const delegErrMix = { ids: ["root", "root-c1"], byId: { root: holdBase, "root-c1": { id: "root-c1", displayTitle: "子S", parentId: "root", origin: "subagent", running: true } }, current: null };
2590
2733
  const delegErrAcks = new Map([["root", { lastTurnEnd: 1000, lastTurnEndKind: "error", lastTurnEndError: "boom", ackedAt: null }]]);
2591
2734
  assert.deepEqual(
2592
2735
  buildEntries(delegErrMix, [], {}, delegErrAcks, new Set(["root"])).map((e) => e.id),
@@ -2628,7 +2771,7 @@ const recentSubSnap = {
2628
2771
  ids: ["m", "m-c1"],
2629
2772
  byId: {
2630
2773
  m: { id: "m", displayTitle: "主M", running: false, completed: false, updatedAt: NOW - 1_000 },
2631
- "m-c1": { id: "m-c1", displayTitle: "子S", running: false, completed: false, parentId: "m", updatedAt: NOW - 500 },
2774
+ "m-c1": { id: "m-c1", displayTitle: "子S", running: false, completed: false, parentId: "m", origin: "subagent", updatedAt: NOW - 500 },
2632
2775
  },
2633
2776
  current: null,
2634
2777
  };
@@ -2677,6 +2820,67 @@ assert.deepEqual(
2677
2820
  "归档集同样接受 Set 形态",
2678
2821
  );
2679
2822
 
2823
+ // R-01-010/AC-01:归档集合同时驱动活动区过滤;未确认完成提醒也不得在归档后滞留。
2824
+ const activeArchivedSnap = {
2825
+ ids: ["sKeep", "sGone"],
2826
+ byId: {
2827
+ sKeep: { id: "sKeep", displayTitle: "保留K", running: true },
2828
+ sGone: { id: "sGone", displayTitle: "归档G", running: false },
2829
+ },
2830
+ current: null,
2831
+ };
2832
+ assert.deepEqual(
2833
+ buildEntries(activeArchivedSnap, [], {}, new Map([["sGone", { lastTurnEnd: NOW, ackedAt: null }]]), null, ["sGone"]).map((e) => e.id),
2834
+ ["sKeep"],
2835
+ "归档会话从活动区消失,不因未确认完成提醒继续滞留",
2836
+ );
2837
+ const archivedStatusSnap = {
2838
+ ids: ["sRunning", "sDone", "sError", "sDelegating"],
2839
+ byId: {
2840
+ sRunning: { id: "sRunning", displayTitle: "运行中归档", running: true },
2841
+ sDone: { id: "sDone", displayTitle: "完成归档", running: false },
2842
+ sError: { id: "sError", displayTitle: "错误归档", running: false },
2843
+ sDelegating: { id: "sDelegating", displayTitle: "委托归档", running: false },
2844
+ },
2845
+ current: null,
2846
+ };
2847
+ assert.deepEqual(
2848
+ buildEntries(
2849
+ archivedStatusSnap,
2850
+ [],
2851
+ {},
2852
+ new Map([
2853
+ ["sDone", { lastTurnEnd: NOW, ackedAt: null }],
2854
+ ["sError", { lastTurnEnd: NOW, lastTurnEndKind: "error", lastTurnEndError: "boom", ackedAt: null }],
2855
+ ]),
2856
+ new Set(["sDelegating"]),
2857
+ new Set(["sRunning", "sDone", "sError", "sDelegating"]),
2858
+ ).map((e) => e.id),
2859
+ [],
2860
+ "Set 归档集合同时过滤运行、完成提醒、错误提醒与委托周期中的会话",
2861
+ );
2862
+ const archivedLineageSnap = {
2863
+ ids: ["root", "root-child", "root-grandchild", "keep-root", "keep-child"],
2864
+ byId: {
2865
+ root: { id: "root", displayTitle: "归档母会话", running: false },
2866
+ "root-child": { id: "root-child", displayTitle: "归档子代理", parentId: "root", origin: "subagent", running: true },
2867
+ "root-grandchild": { id: "root-grandchild", displayTitle: "归档孙代理", parentId: "root-child", origin: "subagent", running: true },
2868
+ "keep-root": { id: "keep-root", displayTitle: "保留母会话", running: false },
2869
+ "keep-child": { id: "keep-child", displayTitle: "被归档子代理", parentId: "keep-root", origin: "subagent", running: true },
2870
+ },
2871
+ current: null,
2872
+ };
2873
+ assert.deepEqual(
2874
+ buildEntries(archivedLineageSnap, [], {}, null, null, ["root"]).map((e) => e.id),
2875
+ ["keep-root", "keep-child"],
2876
+ "归档母会话时其子会话与更深层后代均不产出活动条目",
2877
+ );
2878
+ assert.deepEqual(
2879
+ buildEntries(archivedLineageSnap, [], {}, null, null, ["keep-child"]).map((e) => e.id),
2880
+ ["root", "root-child", "root-grandchild"],
2881
+ "归档子会话后不因残留后代活动把未归档母会话留在活动区",
2882
+ );
2883
+
2680
2884
  // ---- R-01-014/AC-01 列表在途显示加载指示而非空态 ----
2681
2885
  assert.equal(listLoadState(null), "loading", "快照缺失视为列表在途");
2682
2886
  assert.equal(listLoadState({ phase: "pending" }), "loading", "phase 为 pending 视为列表在途");
@@ -2810,6 +3014,16 @@ assert.ok(
2810
3014
  clientSource.includes("const sig = JSON.stringify([listState, cardSignature(visibleEntries), pulseSurface, recentTimeSignature]);"),
2811
3015
  "列表 phase 与历史时间文案必须参与结构化渲染签名,空列表不得冻结在加载/失败状态(T-087)",
2812
3016
  );
3017
+ assert.ok(
3018
+ clientSource.includes("scroll?.querySelector?.(`.${LIST_CLASS} .${CARD_CLASS}[data-current]`)") &&
3019
+ !clientSource.includes("scroll?.querySelector?.(`.${CARD_CLASS}[data-current]`)"),
3020
+ "当前卡片自动滚动仅定位活动列表,完成卡迁入历史后不追随历史区(R-01-006/AC-02)",
3021
+ );
3022
+ assert.ok(
3023
+ clientSource.includes("source.contains(focusedElement)") &&
3024
+ clientSource.includes("cardsById.get(focusAfterMigrationId)?.el.focus()"),
3025
+ "活动卡迁移时将已有键盘焦点交给仍在活动区的相邻卡片(R-01-005/AC-01)",
3026
+ );
2813
3027
  // R-01-013/AC-02 回归:卡片标题必须随快照更新——单卡渲染异常不得冻结其余卡片
2814
3028
  // (此前渲染签名先于卡片循环提交且无异常隔离,故障卡及其后全部卡片永久滞留旧标题,
2815
3029
  // 历史卡因此停在首条消息形态的 fallback 标题,与左侧栏脱节)。
@@ -2961,7 +3175,7 @@ assert.ok(
2961
3175
  assert.ok(
2962
3176
  clientSource.includes("const INDENT_PX = 16;") &&
2963
3177
  bundle.includes("Math.round(parent.left + indentPx / 2 + 1)") &&
2964
- bundle.includes("renderCardIntoList(activeList, entry, cardsById, index, 1, hueByWorkspace)"),
3178
+ bundle.includes("renderCardIntoList(activeList, entry, cardsById, index, 1, colorByWorkspace)"),
2965
3179
  "几何耦合钉住:INDENT_PX=16、轨道 left 由母会话卡片左缘测量推导(+半槽+1px border,取整后与横线起笔相接)与活动区卡片 offset=1(轨道层为首子节点),改任一必须同步",
2966
3180
  );
2967
3181
  // R-01-003/AC-05
@@ -3443,20 +3657,27 @@ assert.ok(bundle.includes('workspace.append(workspaceIcon, makeEl("span", "dap-w
3443
3657
  assert.ok(bundle.includes("if (workspaceText !== null) restoreTextField(workspaceText, entry.workspaceTitle)"), "热装旧骨架无文本段时容空跳过,不中断渲染(R-01-003/AC-06 健壮性)");
3444
3658
 
3445
3659
  // R-01-003/AC-08、AC-09、AC-10、AC-11、AC-12
3446
- // 工作区徽标按身份派生基色、经同屏跨色区槽位消解后着色:渲染层写入 --dap-workspace-hue,
3447
- // CSS 以 OKLCH 调色板色直接呈现文字,底色/描边在 OKLCH 空间同色相混合;胶囊几何与字号不变。
3448
- assert.ok(bundle.includes("resolveWorkspaceHues(visibleEntries.map((entry) => entry.workspaceKey))"), "渲染层按同帧可见身份集合消解色相(R-01-003/AC-12)");
3449
- assert.ok(bundle.includes("hueByWorkspace.get(entry.workspaceKey)"), "每张卡使用集合消解后的工作区色相(R-01-003/AC-08、AC-12)");
3450
- assert.ok(bundle.includes('style.setProperty("--dap-workspace-hue"'), "渲染层把消解后色相写入徽标 --dap-workspace-hue(R-01-003/AC-08、AC-12)");
3451
- assert.ok(bundle.includes('style.removeProperty("--dap-workspace-hue")'), "徽标隐藏时移除色相变量,不留陈旧着色(R-01-003/AC-08)");
3452
- assert.ok(bundle.includes("--dap-workspace-color: oklch(0.78 0.16 var(--dap-workspace-hue, 235))"), "深色主题文字使用 OKLCH 高明度中高彩度调色板色(R-01-003/AC-11)");
3660
+ // 工作区徽标按身份派生基色、经同屏前景/背景复合槽位消解后着色:渲染层写入
3661
+ // 前景 hue/L/C 与背景变体主题参数,CSS 以 OKLCH 前景色呈现文字、独立背景源色混合底色/描边;胶囊几何与字号不变。
3662
+ assert.ok(bundle.includes("resolveWorkspaceColors(visibleEntries.map((entry) => entry.workspaceKey))"), "渲染层按同帧可见身份集合消解十二槽颜色(R-01-003/AC-12)");
3663
+ assert.ok(bundle.includes("colorByWorkspace.get(entry.workspaceKey)"), "每张卡使用集合消解后的工作区颜色槽位(R-01-003/AC-08、AC-12)");
3664
+ assert.ok(bundle.includes('entry.workspaceTitle !== "" && foreground !== undefined && background !== undefined'), "缺少工作区身份或背景变体时隐藏徽标而不使用默认颜色(R-01-003/AC-08)");
3665
+ assert.ok(bundle.includes("style.setProperty(property, next)"), "渲染层把槽位颜色变量写入徽标(R-01-003/AC-08、AC-12)");
3666
+ assert.ok(bundle.includes('"--dap-workspace-dark-l": foreground?.darkL'), "渲染层把前景深色主题 L 写入徽标(R-01-003/AC-10、AC-11)");
3667
+ assert.ok(bundle.includes('"--dap-workspace-light-l": foreground?.lightL'), "渲染层把前景浅色主题 L 写入徽标(R-01-003/AC-10、AC-11)");
3668
+ assert.ok(bundle.includes('"--dap-workspace-bg-dark-l": background?.dark?.l'), "渲染层把背景变体深色主题 L 写入徽标(R-01-003/AC-10、AC-11)");
3669
+ assert.ok(bundle.includes('"--dap-workspace-bg-light-l": background?.light?.l'), "渲染层把背景变体浅色主题 L 写入徽标(R-01-003/AC-10、AC-11)");
3670
+ assert.ok(bundle.includes("for (const property of Object.keys(colorVariables)) workspaceLabel.style.removeProperty(property)"), "徽标隐藏时移除全部前景/背景颜色变量,不留陈旧着色(R-01-003/AC-08)");
3671
+ assert.ok(bundle.includes("var(--dap-workspace-dark-l, 0.78)"), "深色主题使用槽位 OKLCH 明度变量(R-01-003/AC-11)");
3672
+ assert.ok(bundle.includes("var(--dap-workspace-dark-c, 0.16)"), "深色主题使用槽位 OKLCH 彩度变量(R-01-003/AC-11)");
3453
3673
  assert.ok(bundle.includes("color: var(--dap-workspace-color)"), "徽标文字直接使用调色板色、不混 currentColor(R-01-003/AC-11)");
3454
- assert.ok(bundle.includes("color-mix(in oklch, var(--dap-workspace-color) 14%, transparent)"), "深色主题底色在 OKLCH 空间同色相铺底(R-01-003/AC-11)");
3455
- assert.ok(bundle.includes("color-mix(in oklch, var(--dap-workspace-color) 34%, transparent)"), "深色主题描边在 OKLCH 空间同色相混合(R-01-003/AC-11)");
3674
+ assert.ok(bundle.includes("color-mix(in oklch, var(--dap-workspace-background-color) var(--dap-workspace-bg-dark-mix, 31%), transparent)"), "深色主题底色使用加强的独立背景变体混合(R-01-003/AC-10、AC-11)");
3675
+ assert.ok(bundle.includes("color-mix(in oklch, var(--dap-workspace-color) var(--dap-workspace-bg-dark-border-mix, 52%), transparent)"), "深色主题描边使用高对比前景色混合(R-01-003/AC-10、AC-11)");
3456
3676
  assert.ok(bundle.includes("body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {"), "浅色主题单独校准徽标配色(R-01-003/AC-10、AC-11)");
3457
- assert.ok(bundle.includes("--dap-workspace-color: oklch(0.48 0.15 var(--dap-workspace-hue, 235))"), "浅色主题文字使用 OKLCH 低明度中高彩度调色板色(R-01-003/AC-11)");
3458
- assert.ok(bundle.includes("color-mix(in oklch, var(--dap-workspace-color) 10%, transparent)"), "浅色主题底色更轻(R-01-003/AC-11)");
3459
- assert.ok(bundle.includes("color-mix(in oklch, var(--dap-workspace-color) 28%, transparent)"), "浅色主题描边(R-01-003/AC-11)");
3677
+ assert.ok(bundle.includes("var(--dap-workspace-light-l, 0.48)"), "浅色主题使用槽位 OKLCH 明度变量(R-01-003/AC-11)");
3678
+ assert.ok(bundle.includes("var(--dap-workspace-light-c, 0.15)"), "浅色主题使用槽位 OKLCH 彩度变量(R-01-003/AC-11)");
3679
+ assert.ok(bundle.includes("color-mix(in oklch, var(--dap-workspace-background-color) var(--dap-workspace-bg-light-mix, 27%), transparent)"), "浅色主题底色使用加强的独立背景变体混合(R-01-003/AC-10、AC-11)");
3680
+ assert.ok(bundle.includes("color-mix(in oklch, var(--dap-workspace-color) var(--dap-workspace-bg-light-border-mix, 42%), transparent)"), "浅色主题描边使用高对比前景色混合(R-01-003/AC-10、AC-11)");
3460
3681
  assert.ok(!bundle.includes("color-mix(in srgb, var(--dap-workspace-color) 92%, currentColor)"), "工作区文字不得再以 currentColor 冲淡调色板色(R-01-003/AC-11)");
3461
3682
 
3462
3683
  // R-01-010/AC-01、R-01-010/AC-05