dsh-activity-pane 0.9.0 → 0.10.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.
@@ -130,6 +130,26 @@ function clampPaneWidth(raw) {
130
130
  return Math.min(PANE_WIDTH_MAX, Math.max(PANE_WIDTH_MIN, Math.round(value)));
131
131
  }
132
132
 
133
+ /**
134
+ * 把任意输入(localStorage 字符串等)归一为合法卡片显示档位:
135
+ * 仅 'full'/'medium'/'compact' 为合法档位,其余(含缺失/非法值)回退默认中间档(R-01-021/AC-06)。
136
+ */
137
+ function normalizeDensity(raw) {
138
+ return raw === "compact" || raw === "medium" || raw === "full" ? raw : "medium";
139
+ }
140
+
141
+ /** 显示档位的循环次序:完整 → 中间 → 紧凑 → 完整(R-01-021/AC-01)。 */
142
+ const DENSITY_ORDER = ["full", "medium", "compact"];
143
+
144
+ /**
145
+ * 返回循环切换后的下一显示档位:完整 → 中间 → 紧凑 → 完整;
146
+ * 输入先经 normalizeDensity 归一,非法值视作中间档(R-01-021/AC-01)。
147
+ */
148
+ function nextDensity(value) {
149
+ const index = DENSITY_ORDER.indexOf(normalizeDensity(value));
150
+ return DENSITY_ORDER[(index + 1) % DENSITY_ORDER.length];
151
+ }
152
+
133
153
  function isRecord(value) {
134
154
  return value !== null && typeof value === "object" && !Array.isArray(value);
135
155
  }
@@ -1293,25 +1313,25 @@ function awaitBadgeStats(entries) {
1293
1313
 
1294
1314
  /** 数量标识呈现态(R-01-014/AC-06):列表在途(loading)时不冒充计数——归一为
1295
1315
  * loading 呈现(加载指示 + 加载中 aria 文案,不等待、不脉冲);否则归一为 count
1296
- * 呈现(n/m 文本 + 计数 aria 文案)。awaiting 表达「存在等待行动」——底色经
1316
+ * 呈现(n/m 文本 + 计数 aria 文案 + 悬停 tips 文案)。分子为正在运行的主会话数
1317
+ * (running = total − waiting)——随会话逐一完成递减至 0,与等待行动数互补
1318
+ * (R-01-001/AC-04、AC-05);awaiting 表达「存在等待行动」——底色经
1297
1319
  * awaitBadgeTone(错误 > 阻塞 > 完成,红/金/绿)与脉冲门控同一信号:任一等待行动
1298
1320
  * (阻塞等待、完成提醒或错误提醒)即脉冲(R-01-002/AC-06,C-037、C-043)。
1299
1321
  * blocked 入参只用于 aria 文案的计数说明,不再驱动门控。错误轴不算在途,维持计数呈现。 */
1300
1322
  function countBadgeState(listState, waiting, total, blocked = 0) {
1301
- if (listState === "loading") return { mode: "loading", text: "", ariaText: "活动会话计数加载中", awaiting: false };
1323
+ if (listState === "loading") return { mode: "loading", text: "", ariaText: "活动会话计数加载中", tip: "", awaiting: false };
1302
1324
  const awaiting = waiting > 0;
1303
1325
  const hasBlocked = blocked > 0;
1304
1326
  const doneCount = waiting - (hasBlocked ? blocked : 0);
1327
+ const running = total - waiting;
1305
1328
  return {
1306
1329
  mode: "count",
1307
- text: `${waiting}/${total}`,
1308
- ariaText: hasBlocked
1309
- ? doneCount > 0
1310
- ? `${total} 个活动会话,${blocked} 个等待你答复,${doneCount} 个已完成`
1311
- : `${total} 个活动会话,${blocked} 个等待你答复`
1312
- : awaiting
1313
- ? `${total} 个活动会话,${waiting} 个已完成`
1314
- : `${total} 个活动会话`,
1330
+ text: `${running}/${total}`,
1331
+ ariaText: `${total} 个活动会话,${running} 个正在运行`
1332
+ + (hasBlocked ? `,${blocked} 个等待你答复` : "")
1333
+ + (doneCount > 0 ? `,${doneCount} 个已完成` : ""),
1334
+ tip: `运行中的会话 ${running} / 总活动会话 ${total}`,
1315
1335
  awaiting,
1316
1336
  };
1317
1337
  }
@@ -1386,16 +1406,11 @@ function workspaceInfoForSession(sessionId, workspaceItems, byId = {}) {
1386
1406
  }
1387
1407
 
1388
1408
  /**
1389
- * 工作区徽标基色色相(R-01-003/AC-08、AC-09):以工作区身份为唯一输入的纯函数——
1390
- * djb2 哈希经雪崩终混(异或右移 + 乘法,把高位熵折入低位,消除 djb2 低位分布
1391
- * 聚集;每步 >>> 0 保持无符号,异或结果可能带符号位)后在避开红色警戒区的
1392
- * 色相弧 [30°,320°] 上均匀取色(30 + hash % 291),输出 [30,320] 整数。
1393
- * 同一身份恒得同一基色色相,与工作区列表顺序、会话状态及持久化存储无关,页面
1394
- * 刷新后不变;空身份返回 null。
1409
+ * 身份字符串 → 32 位无符号哈希:djb2 经雪崩终混(见 C-029 决策记录)。
1410
+ * 异或右移 + 乘法把高位熵折入低位,消除 djb2 低位分布聚集;每步 >>> 0 保持
1411
+ * 无符号,异或结果可能带符号位。
1395
1412
  */
1396
- function workspaceHue(key) {
1397
- const text = cleanText(key);
1398
- if (!text) return null;
1413
+ function identityHash(text) {
1399
1414
  let hash = 5381;
1400
1415
  for (let i = 0; i < text.length; i += 1)
1401
1416
  hash = ((hash << 5) + hash + text.charCodeAt(i)) >>> 0;
@@ -1405,8 +1420,24 @@ function workspaceHue(key) {
1405
1420
  // 乘法的乘积(最大约 5×10^18)超出 double 精确整数上限 2^53,低 32 位
1406
1421
  // 会丢失精度。
1407
1422
  hash = Math.imul(hash, 0x45d9f3b) >>> 0;
1408
- hash = (hash ^ (hash >>> 16)) >>> 0;
1409
- return 30 + (hash % 291);
1423
+ return (hash ^ (hash >>> 16)) >>> 0;
1424
+ }
1425
+
1426
+ // 避红基色色相弧 [30°,320°] 的起点与取值数(30 + hash % 291);背景变体
1427
+ // 以同一弧宽的商派生,共享常量避免两处口径漂移(R-01-003/AC-09、C-077)。
1428
+ const WORKSPACE_HUE_ARC_START = 30;
1429
+ const WORKSPACE_HUE_ARC_SIZE = 291;
1430
+
1431
+ /**
1432
+ * 工作区徽标基色色相(R-01-003/AC-08、AC-09):以工作区身份为唯一输入的纯函数,
1433
+ * 在避开红色警戒区的色相弧 [30°,320°] 上均匀取色,输出 [30,320] 整数;哈希
1434
+ * 机制见 {@link identityHash}。同一身份恒得同一基色色相,与工作区列表顺序、
1435
+ * 会话状态及持久化存储无关,页面刷新后不变;空身份返回 null。
1436
+ */
1437
+ function workspaceHue(key) {
1438
+ const text = cleanText(key);
1439
+ if (!text) return null;
1440
+ return WORKSPACE_HUE_ARC_START + (identityHash(text) % WORKSPACE_HUE_ARC_SIZE);
1410
1441
  }
1411
1442
 
1412
1443
  const WORKSPACE_COLOR_SLOTS = Object.freeze([
@@ -1442,90 +1473,46 @@ const WORKSPACE_BACKGROUND_SLOTS = Object.freeze([
1442
1473
  },
1443
1474
  ].map(Object.freeze));
1444
1475
 
1445
- const WORKSPACE_PRIMARY_SLOT_COUNT = 7;
1446
- const WORKSPACE_SECONDARY_SLOT_COUNT = WORKSPACE_COLOR_SLOTS.length - WORKSPACE_PRIMARY_SLOT_COUNT;
1447
-
1448
- function workspaceSlotProbe(start, count, step) {
1449
- return Array.from({ length: count }, (_, offset) => (start + offset * step) % count);
1476
+ /** 环形色相距离(度,0–180)。 */
1477
+ function hueDistance(a, b) {
1478
+ const d = Math.abs(a - b) % 360;
1479
+ return Math.min(d, 360 - d);
1450
1480
  }
1451
1481
 
1452
1482
  /**
1453
- * 同屏工作区复合颜色槽位消解(R-01-003/AC-08、AC-12):前七个身份沿原七色
1454
- * OKLCH 主槽位分配,主槽位占满后再使用五个受控明度/色相补充槽位;每个前景槽位
1455
- * 内优先使用未用的三个背景变体;超过三十六个复合身份后按槽位使用次数均衡复用。
1483
+ * 同屏工作区复合颜色槽位消解(R-01-003/AC-08、AC-12):逐身份纯映射——每个
1484
+ * 身份独立地以其 32 位雪崩哈希派生前景与背景槽位,身份之间互不影响,创建、
1485
+ * 移除或变更其它工作区不改变既有工作区的颜色(C-077);前景取基色色相在 12 个
1486
+ * 前景槽位中环形距离最近者(平局取低槽位),背景变体取 floor(hash / 弧宽) % 3。
1487
+ * 均匀哈希使不同身份的前景碰撞概率保持在约 1/12、复合碰撞约 1/36 的最小水平。
1456
1488
  */
1457
1489
  function resolveWorkspaceColors(keys) {
1458
1490
  const identities = [...new Set((Array.isArray(keys) ? keys : []).map(cleanText).filter(Boolean))].sort();
1459
- const uses = WORKSPACE_COLOR_SLOTS.map(() => 0);
1460
- const backgroundUses = WORKSPACE_COLOR_SLOTS.map(() => WORKSPACE_BACKGROUND_SLOTS.map(() => 0));
1461
1491
  const resolved = new Map();
1462
1492
  for (const identity of identities) {
1463
- const baseHue = workspaceHue(identity);
1464
- const primaryOrder = workspaceSlotProbe(
1465
- (baseHue - 30) % WORKSPACE_PRIMARY_SLOT_COUNT,
1466
- WORKSPACE_PRIMARY_SLOT_COUNT,
1467
- 3,
1468
- );
1469
- const secondaryOrder = workspaceSlotProbe(
1470
- (baseHue - 30) % WORKSPACE_SECONDARY_SLOT_COUNT,
1471
- WORKSPACE_SECONDARY_SLOT_COUNT,
1472
- 2,
1473
- ).map((index) => WORKSPACE_PRIMARY_SLOT_COUNT + index);
1474
- const order = [...primaryOrder, ...secondaryOrder];
1475
- const backgroundOrder = workspaceSlotProbe(
1476
- (baseHue - 30) % WORKSPACE_BACKGROUND_SLOTS.length,
1477
- WORKSPACE_BACKGROUND_SLOTS.length,
1478
- 2,
1479
- );
1480
- const availableForegroundOrder = order.filter((index) => backgroundOrder.some((backgroundSlot) => backgroundUses[index][backgroundSlot] === 0));
1481
- const foregroundOrder = availableForegroundOrder.length > 0 ? availableForegroundOrder : order;
1482
- let foregroundSlot = foregroundOrder.find((index) => uses[index] === 0);
1483
- if (foregroundSlot === undefined) {
1484
- foregroundSlot = foregroundOrder.reduce(
1485
- (best, index) => (uses[index] < uses[best] ? index : best),
1486
- foregroundOrder[0],
1487
- );
1493
+ const hash = identityHash(identity);
1494
+ const baseHue = WORKSPACE_HUE_ARC_START + (hash % WORKSPACE_HUE_ARC_SIZE);
1495
+ let foregroundSlot = 0;
1496
+ let nearest = hueDistance(baseHue, WORKSPACE_COLOR_SLOTS[0].hue);
1497
+ for (let slot = 1; slot < WORKSPACE_COLOR_SLOTS.length; slot += 1) {
1498
+ const distance = hueDistance(baseHue, WORKSPACE_COLOR_SLOTS[slot].hue);
1499
+ if (distance < nearest) {
1500
+ nearest = distance;
1501
+ foregroundSlot = slot;
1502
+ }
1488
1503
  }
1489
- uses[foregroundSlot] += 1;
1490
- const backgroundUsesForForeground = backgroundUses[foregroundSlot];
1491
- const backgroundSlot = backgroundOrder.reduce(
1492
- (best, index) => (backgroundUsesForForeground[index] < backgroundUsesForForeground[best] ? index : best),
1493
- backgroundOrder[0],
1494
- );
1495
- backgroundUsesForForeground[backgroundSlot] += 1;
1496
1504
  resolved.set(identity, {
1497
1505
  foreground: WORKSPACE_COLOR_SLOTS[foregroundSlot],
1498
- background: WORKSPACE_BACKGROUND_SLOTS[backgroundSlot],
1506
+ background: WORKSPACE_BACKGROUND_SLOTS[Math.floor(hash / WORKSPACE_HUE_ARC_SIZE) % WORKSPACE_BACKGROUND_SLOTS.length],
1499
1507
  });
1500
1508
  }
1501
1509
  return resolved;
1502
1510
  }
1503
1511
 
1504
-
1505
- /** 主会话按左侧工作区顺序排序的权重;不在任何 workspace 的排在最后保持 lineage 顺序。 */
1506
- function workspaceRank(workspaceItems) {
1507
- const wsIndex = new Map();
1508
- const posIndex = new Map();
1509
- for (const workspace of workspaceItems ?? []) {
1510
- if (!isRecord(workspace)) continue;
1511
- const sessionIds = Array.isArray(workspace.sessionIds)
1512
- ? workspace.sessionIds
1513
- : [];
1514
- sessionIds.forEach((sid, p) => {
1515
- const key = String(sid);
1516
- if (!wsIndex.has(key)) {
1517
- wsIndex.set(key, wsIndex.size);
1518
- posIndex.set(key, p);
1519
- }
1520
- });
1521
- }
1522
- return (id) => {
1523
- const key = String(id);
1524
- return {
1525
- ws: wsIndex.get(key) ?? Number.MAX_SAFE_INTEGER,
1526
- pos: posIndex.get(key) ?? Number.MAX_SAFE_INTEGER,
1527
- };
1528
- };
1512
+ /** 主会话活动区排序用的最后用户指令时刻:宿主列表时间,缺失/非法视为最旧。 */
1513
+ function instructionTime(row) {
1514
+ const time = Number(row?.updatedAt);
1515
+ return Number.isFinite(time) ? time : -1;
1529
1516
  }
1530
1517
 
1531
1518
  /** 子代理的展示标题:优先目录 label,其次 displayTitle,兜底 "子任务"。 */
@@ -1588,7 +1575,6 @@ function buildEntries(snapshot, workspaceItems, detailsById = {}, completions =
1588
1575
  }
1589
1576
  return false;
1590
1577
  };
1591
- const rank = workspaceRank(workspaceItems ?? []);
1592
1578
  const descendantIds = descendantActiveIds(byId, isArchived);
1593
1579
  // 第一遍:层级关系 + 显示判定(show = 自身活动 || 委托周期 || 完成提醒,单点实现避免漂移)。
1594
1580
  const rootIds = [];
@@ -1621,12 +1607,25 @@ function buildEntries(snapshot, workspaceItems, detailsById = {}, completions =
1621
1607
  meta.set(id, { row, running, pending, isSub, show, done, err, descendantActive, delegating, depth: 0 });
1622
1608
  }
1623
1609
 
1624
- // 主会话按 workspace 顺序排序;未归入任何工作区的主会话保持在 lineage 中靠后。
1610
+ // 主会话分两组排序(R-01-001/AC-07):运行中主会话置顶,组内按最后一次用户指令
1611
+ // 时间(宿主列表时间)从新到旧;等待/完成组(阻塞等待、完成提醒、错误提醒)排后,
1612
+ // 组内按进入该状态的时刻(最近一次回合结束登记时刻,缺失回落宿主列表时间)从新到旧。
1613
+ // 两组相同时间均回落宿主列表出现顺序。工作区顺序不参与排序,仅承载卡片徽标与名称。
1614
+ const isRunningEntry = (id) => {
1615
+ const m = meta.get(id);
1616
+ return m !== undefined && !m.pending && (m.running || m.delegating);
1617
+ };
1618
+ const sortTime = (id) => {
1619
+ if (isRunningEntry(id)) return instructionTime(byId[id]);
1620
+ const record = completionFor(id, completions);
1621
+ const end = isRecord(record) ? Number(record.lastTurnEnd) : NaN;
1622
+ return Number.isFinite(end) ? end : instructionTime(byId[id]);
1623
+ };
1625
1624
  rootIds.sort((a, b) => {
1626
- const ra = rank(a);
1627
- const rb = rank(b);
1628
- if (ra.ws !== rb.ws) return ra.ws - rb.ws;
1629
- if (ra.pos !== rb.pos) return ra.pos - rb.pos;
1625
+ const byGroup = Number(isRunningEntry(b)) - Number(isRunningEntry(a));
1626
+ if (byGroup !== 0) return byGroup;
1627
+ const byTime = sortTime(b) - sortTime(a);
1628
+ if (byTime !== 0) return byTime;
1630
1629
  return ids.indexOf(a) - ids.indexOf(b);
1631
1630
  });
1632
1631
 
@@ -2614,6 +2613,10 @@ const STYLE_ID = "dsh-activity-pane-style";
2614
2613
  const INSTANCE_KEY = "__dshActivityPaneCleanup";
2615
2614
  /** 拖拽调宽的 localStorage 持久化键(R-01-015/AC-04)。 */
2616
2615
  const WIDTH_STORAGE_KEY = "dsh-activity-pane:width";
2616
+ /** 卡片紧凑显示的 localStorage 持久化键(R-01-021/AC-06)。 */
2617
+ const DENSITY_STORAGE_KEY = "dsh-activity-pane:density";
2618
+ /** 三档显示的可访问名称用中文标签,键为档位值(R-01-021/AC-01)。 */
2619
+ const DENSITY_LABELS = { full: "完整", medium: "中间", compact: "紧凑" };
2617
2620
  const COLLAPSED_WIDTH = 34;
2618
2621
  /** 宿主侧完成确认 API 前缀(C-030):acks 快照 / SSE 推送 / ack 写回,同源受信。 */
2619
2622
  const PANE_API_BASE = "/dsh-activity-pane/api";
@@ -2624,6 +2627,10 @@ const INDENT_PX = 16;
2624
2627
  const MOBILE_BREAKPOINT = "767px";
2625
2628
  /** 运行卡时钟:只要存在运行中会话,就以该周期刷新时长显示。 */
2626
2629
  const CLOCK_MS = 1000;
2630
+ /** 渲染与流式派生的最小合并间隔:事件密集(流式输出)时把渲染/派生频率硬顶在
2631
+ * 10Hz——事件率随宿主流式 chunk 数增长,显示粒度(秒级时长、块级时间线)无感,
2632
+ * 而渲染与 O(日志窗口) 派生不再随刷新率(移动端 120Hz)与事件率线性放大(T-127)。 */
2633
+ const SYNC_MIN_INTERVAL_MS = 100;
2627
2634
  /** 历史卡相对时间刷新周期;无需每秒重绘整列。 */
2628
2635
  const RECENT_TIME_REFRESH_MS = 60_000;
2629
2636
  /** 冷数据读取并发池上限:慢网下避免几十张卡片的 models/history 一次性挤占通道。 */
@@ -2653,25 +2660,27 @@ const CSS = `
2653
2660
  [data-dsh-activity-pane] .dap-header {
2654
2661
  display: flex;
2655
2662
  align-items: center;
2656
- gap: 8px;
2657
- padding: 10px 12px;
2658
2663
  font-size: 12px;
2659
2664
  font-weight: 700;
2660
2665
  letter-spacing: 0.02em;
2661
2666
  }
2662
- /* 标题行整体即收起控件,两端断点一致(R-01-011/AC-03、R-01-008/AC-02):指针与悬停/聚焦反馈。 */
2663
- [data-dsh-activity-pane] .dap-header { cursor: pointer; }
2664
- [data-dsh-activity-pane] .dap-header:hover,
2665
- [data-dsh-activity-pane] .dap-header:focus-visible {
2666
- background: color-mix(in srgb, currentColor 8%, transparent);
2667
+ /* 标题行拆为两部分:左侧标题区(flex:1 占满剩余宽度)整体即收起控件,悬停/聚焦
2668
+ 高亮只覆盖标题区(R-01-011/AC-03、AC-07、R-01-008/AC-02);右侧工具区独立,
2669
+ 不参与标题区的悬停高亮与折叠激活。 */
2670
+ [data-dsh-activity-pane] .dap-titlebar {
2671
+ flex: 1;
2672
+ min-width: 0;
2673
+ display: flex;
2674
+ align-items: center;
2675
+ gap: 8px;
2676
+ padding: 10px 8px 10px 12px;
2677
+ cursor: pointer;
2667
2678
  }
2668
- [data-dsh-activity-pane] .dap-header:focus-visible { outline: none; }
2669
- /* 方向符号:标题行的一部分而非独立按钮,两端断点一致呈现(R-01-011/AC-03、R-01-008/AC-02)。 */
2670
- [data-dsh-activity-pane] .dap-collapse-hint {
2671
- margin-left: auto;
2672
- color: color-mix(in srgb, currentColor 45%, transparent);
2673
- font-size: 13px;
2679
+ [data-dsh-activity-pane] .dap-titlebar:hover,
2680
+ [data-dsh-activity-pane] .dap-titlebar:focus-visible {
2681
+ background: color-mix(in srgb, currentColor 8%, transparent);
2674
2682
  }
2683
+ [data-dsh-activity-pane] .dap-titlebar:focus-visible { outline: none; }
2675
2684
  [data-dsh-activity-pane] .dap-count {
2676
2685
  flex: none;
2677
2686
  font-size: 10px;
@@ -2742,13 +2751,13 @@ const CSS = `
2742
2751
  scrollbar-color: var(--dsh-scrollbar-thumb, color-mix(in srgb, currentColor 25%, transparent)) transparent;
2743
2752
  }
2744
2753
  }
2745
- /* 「回到顶部」悬浮图标按钮(R-01-018):窗格内右下角圆形按钮,纯图标无文字、不透明底色;
2746
- 默认 hidden,scrollTop 超阈值时由滚动监听揭隐。基类 display:flex 会压过 UA 的
2754
+ /* 「回到顶部」(R-01-018)悬浮图标按钮:右缘对齐、28px 圆形、不透明底色。
2755
+ 默认 hidden,scrollTop 超阈值时由滚动监听揭隐;基类 display:flex 会压过 UA 的
2747
2756
  [hidden] 规则,故显式补 [hidden] 隐藏。 */
2748
2757
  [data-dsh-activity-pane] .dap-top {
2749
2758
  position: absolute;
2750
- bottom: 12px;
2751
2759
  right: 12px;
2760
+ bottom: 12px;
2752
2761
  z-index: 6;
2753
2762
  display: flex;
2754
2763
  align-items: center;
@@ -2767,6 +2776,109 @@ const CSS = `
2767
2776
  [data-dsh-activity-pane] .dap-top:focus-visible {
2768
2777
  background: #262932;
2769
2778
  }
2779
+ /* 标题行右侧工具按钮区(R-01-021/AC-05):固定于行尾、常显档位切换按钮,未来新
2780
+ 工具按钮统一加入此区;宽度不随悬停态变化,收起方向图标的显隐不挤动本区按钮;
2781
+ 左缘 padding 12px 与标题区保持充分间隔。收起方向图标(R-01-011/AC-07)是标题区
2782
+ 的行尾提示:常态宽度为 0 不占位,鼠标悬停或键盘聚焦标题区时在标题区最右端即时
2783
+ 显现(无过渡动画),离开即隐藏。 */
2784
+ [data-dsh-activity-pane] .dap-tools {
2785
+ display: flex;
2786
+ align-items: center;
2787
+ gap: 4px;
2788
+ padding: 10px 12px;
2789
+ }
2790
+ [data-dsh-activity-pane] .dap-density {
2791
+ display: flex;
2792
+ align-items: center;
2793
+ justify-content: center;
2794
+ width: 22px;
2795
+ height: 22px;
2796
+ padding: 0;
2797
+ border: 1px solid rgba(255, 255, 255, 0.14);
2798
+ border-radius: 999px;
2799
+ background: #1d1f25;
2800
+ color: inherit;
2801
+ cursor: pointer;
2802
+ }
2803
+ [data-dsh-activity-pane] .dap-density:hover,
2804
+ [data-dsh-activity-pane] .dap-density:focus-visible {
2805
+ background: #262932;
2806
+ }
2807
+ /* 仓库入口无描边(T-139):视觉强度弱于带描边的档位切换按钮,仅以不透明底色圆形呈现。 */
2808
+ [data-dsh-activity-pane] .dap-repo {
2809
+ display: flex;
2810
+ align-items: center;
2811
+ justify-content: center;
2812
+ width: 22px;
2813
+ height: 22px;
2814
+ border-radius: 999px;
2815
+ background: #1d1f25;
2816
+ color: inherit;
2817
+ cursor: pointer;
2818
+ text-decoration: none;
2819
+ }
2820
+ [data-dsh-activity-pane] .dap-repo:hover,
2821
+ [data-dsh-activity-pane] .dap-repo:focus-visible {
2822
+ background: #262932;
2823
+ }
2824
+ [data-dsh-activity-pane] .dap-collapse-hint {
2825
+ margin-left: auto;
2826
+ display: flex;
2827
+ align-items: center;
2828
+ justify-content: center;
2829
+ width: 0;
2830
+ height: 22px;
2831
+ opacity: 0;
2832
+ overflow: hidden;
2833
+ }
2834
+ [data-dsh-activity-pane] .dap-titlebar:hover .dap-collapse-hint,
2835
+ [data-dsh-activity-pane] .dap-titlebar:focus-visible .dap-collapse-hint {
2836
+ width: 22px;
2837
+ opacity: 1;
2838
+ }
2839
+ /* 卡片显示档位(R-01-021):中间档保留标题行、工作区徽标行、等待末行与最近卡
2840
+ 消息预览行、经渲染层 lastOnly 单行渲染时间线(仅最新一行,AC-08);完成提醒卡
2841
+ 末行在中间档收合为单行(见下方 data-wait="done" 作用域规则);紧凑档在
2842
+ 中间档基础上再隐藏工作区徽标行、时间线末行、等待末行与消息预览行,仅保留
2843
+ 标题行(AC-02)——激活跳转逻辑不感知档位,渲染签名含显示档位分量(档位切换
2844
+ 经 queueSync 触发一轮重渲染)(R-01-021/AC-04)。 */
2845
+ [data-dsh-activity-pane][data-density="medium"] .dap-card :is(
2846
+ .dap-progress,
2847
+ .dap-token-stats
2848
+ ) {
2849
+ display: none;
2850
+ }
2851
+ [data-dsh-activity-pane][data-density="compact"] .dap-card :is(
2852
+ .dap-card-head,
2853
+ .dap-trace,
2854
+ .dap-subtrace,
2855
+ .dap-progress,
2856
+ .dap-token-stats,
2857
+ .dap-foot,
2858
+ .dap-history-line,
2859
+ .dap-note
2860
+ ) {
2861
+ display: none;
2862
+ }
2863
+ /* 中间档完成提醒卡末行收合为单行(R-01-021/AC-08,东家反馈):末行两行内容
2864
+ (「已完成」胶囊行 +「继续对话,或移入历史」与「移入历史」按钮行)冗余,收合为
2865
+ 单行——「已完成」胶囊居左、按钮 margin-left:auto 居右、正文不再显示;两段包裹层
2866
+ 以 display: contents 释放为同行 flex 项。选择器以卡片根 data-wait="done" 作用域,
2867
+ 阻塞/错误提醒卡与完整呈现档的两行结构不受影响,紧凑档仍整体隐藏末行。 */
2868
+ [data-dsh-activity-pane][data-density="medium"] .dap-card[data-kind="awaiting"][data-wait="done"] .dap-foot {
2869
+ flex-direction: row;
2870
+ align-items: center;
2871
+ gap: 6px;
2872
+ }
2873
+ [data-dsh-activity-pane][data-density="medium"] .dap-card[data-kind="awaiting"][data-wait="done"] :is(.dap-await-head, .dap-note-row) {
2874
+ display: contents;
2875
+ }
2876
+ [data-dsh-activity-pane][data-density="medium"] .dap-card[data-kind="awaiting"][data-wait="done"] .dap-note {
2877
+ display: none;
2878
+ }
2879
+ [data-dsh-activity-pane][data-density="medium"] .dap-card[data-kind="awaiting"][data-wait="done"] .dap-confirm {
2880
+ margin-left: auto;
2881
+ }
2770
2882
  [data-dsh-activity-pane] .dap-list {
2771
2883
  display: flex;
2772
2884
  flex-direction: column;
@@ -3328,17 +3440,30 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
3328
3440
  [data-dsh-activity-pane] .dap-fill {
3329
3441
  position: absolute; inset: 0 auto 0 0; width: 0%;
3330
3442
  border-radius: 6px;
3331
- background: repeating-linear-gradient(90deg, #58c98f 0 10px, #3fbf86 10px 20px);
3332
- background-size: 200% 100%;
3443
+ overflow: hidden;
3333
3444
  box-shadow: 0 0 7px rgba(88, 201, 143, 0.5);
3334
3445
  transition: width 0.45s cubic-bezier(0.22, 1, 0.36, 1);
3335
3446
  /* 进度条仅存于运行卡骨架:会话运行全程持续向右滚动条纹,作为活动标志
3336
3447
  (对齐 answer-pet 的 ap-stripes,R-01-009/AC-08)。 */
3448
+ /* 条带载体 ::after 与滚动动画分离(T-128):background-position 不可合成,
3449
+ 每帧重绘;改由伪元素 transform 平移承载滚动,fill 只保留 width 过渡与
3450
+ 裁切,滚动帧全程合成器驱动。 */
3451
+ }
3452
+ [data-dsh-activity-pane] .dap-fill::after {
3453
+ content: "";
3454
+ position: absolute;
3455
+ inset: 0 auto 0 0;
3456
+ /* 覆盖 fill 宽度 + 一次位移量:平移全程右缘不落后于 fill 右缘,无缝循环。 */
3457
+ width: calc(100% + 40px);
3458
+ /* 周期 20px(色带 10px)与原 background-position 实现一致——px 色标不受
3459
+ background-size 拉伸(T-128 双轴实测);translateX(-40px) 恰为 2 个周期,
3460
+ 无缝且速度 40px/0.8s 与原实现一致。 */
3461
+ background: repeating-linear-gradient(90deg, #58c98f 0 10px, #3fbf86 10px 20px);
3337
3462
  animation: dap-stripes 0.8s linear infinite;
3338
3463
  }
3339
3464
  @keyframes dap-stripes {
3340
- from { background-position: 0 0; }
3341
- to { background-position: 40px 0; }
3465
+ from { transform: translateX(-40px); }
3466
+ to { transform: translateX(0); }
3342
3467
  }
3343
3468
  @media (prefers-reduced-motion: reduce) {
3344
3469
  /* answer-pet 保留状态脉冲/进度条纹;仅关闭宽度过渡,避免状态反馈消失。 */
@@ -3448,6 +3573,11 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
3448
3573
  touch-action: none;
3449
3574
  }
3450
3575
  [data-dsh-activity-pane][data-open="true"] { transform: translateX(0); }
3576
+ /* 屏外休眠(T-127):抽屉关闭(含初始未开,data-open 缺省视同关闭)时子树整体
3577
+ 跳过渲染——无限动画与样式失效不再产生渲染开销;布局状态保留,scrollTop 不归零
3578
+ (区别于 display:none,T-027);打开瞬间恢复渲染,滑入过渡不变。开关与遮罩挂
3579
+ body,不落本规则,浮动开关徽标脉冲(R-01-002/AC-06、AC-07)照常。 */
3580
+ [data-dsh-activity-pane]:not([data-open="true"]) { content-visibility: hidden; }
3451
3581
  .dap-backdrop[data-drawer-open] { display: block; }
3452
3582
  .dap-toggle { display: flex; }
3453
3583
  .dap-toggle[data-drawer-open] { display: none; }
@@ -3541,13 +3671,23 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-track {
3541
3671
  body:not([data-ds-dark-theme]) .dap-toggle {
3542
3672
  background: var(--dsw-alias-button-floating-fill, rgba(255, 255, 255, 0.94));
3543
3673
  }
3544
- /* 「回到顶部」图标按钮浅色覆盖:不透明层-2 底色与外壳描边别名(R-01-018/AC-05)。 */
3545
- body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top {
3674
+ /* 「回到顶部」与标题行工具区档位/仓库入口按钮的浅色覆盖:不透明层-2 底色与外壳描边别名
3675
+ (R-01-018/AC-05、R-01-021/AC-05、R-01-022/AC-01);仓库入口无描边(T-139),只并入底色组。 */
3676
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top,
3677
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-density,
3678
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-repo {
3546
3679
  background: var(--dsw-alias-bg-layer-2, #ffffff);
3680
+ }
3681
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top,
3682
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-density {
3547
3683
  border-color: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
3548
3684
  }
3549
3685
  body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top:hover,
3550
- body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top:focus-visible {
3686
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top:focus-visible,
3687
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-density:hover,
3688
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-density:focus-visible,
3689
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-repo:hover,
3690
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-repo:focus-visible {
3551
3691
  background: var(--dsw-alias-bg-layer-3, #eceef1);
3552
3692
  }
3553
3693
  `;
@@ -3623,6 +3763,22 @@ function writeStoredPaneWidth(width) {
3623
3763
  } catch {}
3624
3764
  }
3625
3765
 
3766
+ /** 读取持久化卡片显示档位:缺失/非法值经 normalizeDensity 归一为默认中间档;
3767
+ * localStorage 不可用(隐私模式)静默回退中间档(R-01-021/AC-06)。 */
3768
+ function readStoredDensity() {
3769
+ try {
3770
+ return normalizeDensity(window.localStorage.getItem(DENSITY_STORAGE_KEY));
3771
+ } catch {
3772
+ return "medium";
3773
+ }
3774
+ }
3775
+ /** 切换时持久化卡片显示档位;localStorage 不可用时静默跳过(R-01-021/AC-06)。 */
3776
+ function writeStoredDensity(value) {
3777
+ try {
3778
+ window.localStorage.setItem(DENSITY_STORAGE_KEY, value);
3779
+ } catch {}
3780
+ }
3781
+
3626
3782
  function apply(ctx) {
3627
3783
  const previousCleanup = document[INSTANCE_KEY] ?? globalThis[INSTANCE_KEY];
3628
3784
  if (typeof previousCleanup === "function") previousCleanup();
@@ -3640,6 +3796,10 @@ function apply(ctx) {
3640
3796
  let clockTimer = null;
3641
3797
  let recentTimeTimer = null;
3642
3798
  let syncScheduled = false;
3799
+ /** 渲染节流状态(T-127):lastSyncAt 取 0 保证首轮立即渲染;syncThrottleTimer 为
3800
+ * 节流窗口尾的在途 timer,交付或卸载时清空。 */
3801
+ let lastSyncAt = 0;
3802
+ let syncThrottleTimer = null;
3643
3803
  let lastSig = "";
3644
3804
  /** 等待条目 id/类别队列签名:变化时统一重启数量胶囊与等待卡末行动画对相(R-01-002/AC-07、AC-08)。 */
3645
3805
  let pulseSignature = "";
@@ -3674,6 +3834,12 @@ function apply(ctx) {
3674
3834
  let collapsed = false;
3675
3835
  /** 当前桌面列宽:启动时从 localStorage 恢复,拖拽实时更新,重挂载后保留(R-01-015)。 */
3676
3836
  let paneWidth = readStoredPaneWidth();
3837
+ /** 卡片显示档位(full/medium/compact):启动时从 localStorage 恢复,切换实时更新,
3838
+ * 重挂载后保留(R-01-021/AC-06)。 */
3839
+ let densityLevel = readStoredDensity();
3840
+ /** 待执行的锚定补偿:档位切换时登记当前选中卡片顶部的视口相对位置,本轮渲染
3841
+ * 提交后量测该卡新位置并补偿 scrollTop(R-01-021/AC-01)。 */
3842
+ let pendingDensityAnchor = null;
3677
3843
  /** 用户最近一次激活的卡片 id;打开重试链被更新的激活意图取代即取消。 */
3678
3844
  let lastActivatedId = null;
3679
3845
  /** 最近一次已处理的当前卡片;同一卡片的运行时重绘不反复打断用户手动滚动。 */
@@ -3841,13 +4007,23 @@ function apply(ctx) {
3841
4007
  if (changed) notifyLayoutChange();
3842
4008
  }
3843
4009
 
4010
+ function deliverSync() {
4011
+ syncScheduled = false;
4012
+ syncThrottleTimer = null;
4013
+ if (disposed) return;
4014
+ lastSyncAt = Date.now();
4015
+ render();
4016
+ }
4017
+
4018
+ /** 渲染入口合帧 + 节流(T-127):距上次渲染落点不足 SYNC_MIN_INTERVAL_MS 时,
4019
+ * 本轮请求合并到窗口尾由 timer 交付(在途请求只此一个);达到间隔走原 rAF
4020
+ * 合帧立即渲染。空闲期无事件即无排队,不引入常驻唤醒。 */
3844
4021
  function queueSync() {
3845
4022
  if (disposed || syncScheduled) return;
3846
4023
  syncScheduled = true;
3847
- schedule(() => {
3848
- syncScheduled = false;
3849
- if (!disposed) render();
3850
- });
4024
+ const wait = SYNC_MIN_INTERVAL_MS - (Date.now() - lastSyncAt);
4025
+ if (wait > 0) syncThrottleTimer = setTimeout(deliverSync, wait);
4026
+ else schedule(deliverSync);
3851
4027
  }
3852
4028
 
3853
4029
  // ---- 完成确认通道(R-01-002/AC-10~AC-12、R-01-010/AC-06,C-030) ----
@@ -4037,6 +4213,36 @@ function apply(ctx) {
4037
4213
  window.addEventListener("pageshow", onBusyPageShow);
4038
4214
  connectBusyStream();
4039
4215
 
4216
+ // ---- 回到前台渲染管线自愈(R-01-001/AC-03 回归修复) ----
4217
+ // iOS 后台挂起会丢弃在途 setTimeout:queueSync 的节流窗口尾与流式派生窗口若在
4218
+ // 挂起前排队,恢复后永不交付——syncScheduled/logDeriveTimer 永久滞留,此后一切
4219
+ // 更新(store 订阅推送、SSE 快照、时钟 tick)都被 queueSync 早退吞掉,窗格停留
4220
+ // 在挂起前状态(会话已完成仍显示运行中)。回前台时无条件清掉两类在途 timer 并
4221
+ // 立即交付一轮渲染(rAF 路径挂起安全),与 acks/busy 通道回前台重建同模式。
4222
+ function resumeRenderPipeline() {
4223
+ if (disposed) return;
4224
+ if (syncThrottleTimer !== null) {
4225
+ clearTimeout(syncThrottleTimer);
4226
+ syncThrottleTimer = null;
4227
+ }
4228
+ syncScheduled = false;
4229
+ for (const detail of sessionDetailsById.values()) {
4230
+ if (detail.logDeriveTimer && detail.logDeriveFlush) {
4231
+ clearTimeout(detail.logDeriveTimer);
4232
+ detail.logDeriveFlush();
4233
+ }
4234
+ }
4235
+ schedule(deliverSync);
4236
+ }
4237
+ const onSyncVisibilityResume = () => {
4238
+ if (document.visibilityState === "visible") resumeRenderPipeline();
4239
+ };
4240
+ const onSyncPageShow = (event) => {
4241
+ if (event?.persisted === true) resumeRenderPipeline();
4242
+ };
4243
+ document.addEventListener("visibilitychange", onSyncVisibilityResume);
4244
+ window.addEventListener("pageshow", onSyncPageShow);
4245
+
4040
4246
  function remoteValue(response) {
4041
4247
  if (response?.ok === true) return response.value;
4042
4248
  throw response?.error ?? new Error("remote request failed");
@@ -4175,7 +4381,7 @@ function apply(ctx) {
4175
4381
  });
4176
4382
  }
4177
4383
  }
4178
- captureSessionLog(id, { subagent, cwd: byId[id]?.cwd ?? "" });
4384
+ captureSessionLog(id, { cwd: byId[id]?.cwd ?? "" });
4179
4385
  // 长会话深读兜底(R-01-013/AC-03):最近卡预览/子代理溯源不在尾页日志窗口内
4180
4386
  // 且宿主标记 hasMore 时,按 beforeSeq 向前回溯翻页(默认无页数上限)。
4181
4387
  const windowEntries = Array.isArray(detail.log?.entries) ? detail.log.entries : [];
@@ -4251,7 +4457,7 @@ function apply(ctx) {
4251
4457
  /** 绑定会话并水合事件源(dsh 0.1.5 起会话内容经 eventSource 流式下发:打开即收
4252
4458
  * 完整日志窗口 + 实时尾,原生 Conversation 同源)。冷会话补一次 open(),日志窗口
4253
4459
  * 快照引用变化即重派生详情(时间线/预览/模型),窗口由宿主按消息对齐分页。 */
4254
- function captureSessionLog(id, { subagent, cwd }) {
4460
+ function captureSessionLog(id, { cwd } = {}) {
4255
4461
  const detail = sessionDetailsById.get(id) ?? {};
4256
4462
  sessionDetailsById.set(id, detail);
4257
4463
  let session = null;
@@ -4272,10 +4478,7 @@ function apply(ctx) {
4272
4478
  session.eventSource.subscribe(() => {
4273
4479
  if (disposed) return;
4274
4480
  const listSnap = getSnapshot(sessions, "list");
4275
- captureSessionLog(id, {
4276
- subagent: isSubagentRow(listSnap?.byId?.[id], listSnap ?? {}),
4277
- cwd: listSnap?.byId?.[id]?.cwd ?? "",
4278
- });
4481
+ captureSessionLog(id, { cwd: listSnap?.byId?.[id]?.cwd ?? "" });
4279
4482
  queueSync();
4280
4483
  }),
4281
4484
  );
@@ -4301,8 +4504,30 @@ function apply(ctx) {
4301
4504
  const log = session.eventSource?.getSnapshot?.() ?? null;
4302
4505
  if (log === detail.log) return;
4303
4506
  detail.log = log;
4304
- const entries = Array.isArray(log?.entries) ? log.entries : [];
4305
- applyLogEvents(id, detail, entries, { subagent, cwd });
4507
+ // 流式派生合并(T-127):事件到达只更新引用并标脏,applyLogEvents O(日志窗口)
4508
+ // 全量折叠/预览/模型提取合并进 SYNC_MIN_INTERVAL_MS 窗口执行——事件率与派生成本
4509
+ // 解耦,消化时读到的即最新窗口。subagent/cwd 在回调内经 list 快照现取(timer 在途
4510
+ // 期间行属性可能突变,建窗入参不代表消化时刻;与 logSourceSubs 回调同模式),
4511
+ // cwd 在快照不可得时回退建窗入参(subagent 现取即权威,无建窗回退)。
4512
+ // 深翻路径为一次性同步调用,不经本窗口。交付体同时挂在 detail.logDeriveFlush
4513
+ // 上:回前台自愈据此补交付被挂起丢弃的窗口(resumeRenderPipeline)。
4514
+ if (!detail.logDeriveTimer && typeof setTimeout === "function") {
4515
+ const flushDerive = () => {
4516
+ detail.logDeriveTimer = null;
4517
+ detail.logDeriveFlush = null;
4518
+ if (disposed) return;
4519
+ const listSnap = getSnapshot(sessions, "list");
4520
+ const entries = Array.isArray(detail.log?.entries) ? detail.log.entries : [];
4521
+ applyLogEvents(id, detail, entries, {
4522
+ subagent: isSubagentRow(listSnap?.byId?.[id], listSnap?.byId ?? {}),
4523
+ cwd: listSnap?.byId?.[id]?.cwd ?? cwd,
4524
+ });
4525
+ queueSync();
4526
+ };
4527
+ detail.logDeriveFlush = flushDerive;
4528
+ detail.logDeriveTimer = setTimeout(flushDerive, SYNC_MIN_INTERVAL_MS);
4529
+ }
4530
+ queueSync();
4306
4531
  }
4307
4532
 
4308
4533
  /** 部署级模型目录一次性读取(R-01-012/AC-01):dsh 0.1.5 起 per-session models
@@ -4380,7 +4605,7 @@ function apply(ctx) {
4380
4605
  return true;
4381
4606
  }
4382
4607
  function bindPaneControls(pane) {
4383
- const header = pane.querySelector(".dap-header");
4608
+ const header = pane.querySelector(".dap-titlebar");
4384
4609
  const rail = pane.querySelector(".dap-rail");
4385
4610
  const resize = pane.querySelector(".dap-resize");
4386
4611
  const scroll = pane.querySelector(".dap-scroll");
@@ -4482,6 +4707,37 @@ function apply(ctx) {
4482
4707
  const onTopClick = () => {
4483
4708
  scroll?.scrollTo({ top: 0, behavior: prefersReducedMotion() ? "auto" : "smooth" });
4484
4709
  };
4710
+ // 显示档位循环切换(R-01-021/AC-01):完整→中间→紧凑→完整,形态写窗格根属性
4711
+ // 驱动纯 CSS 呈现,持久化于 localStorage(AC-06),会话状态变化不触碰已选档位(AC-07)。
4712
+ const densityBtn = pane.querySelector(".dap-density");
4713
+ const applyDensity = () => {
4714
+ pane.setAttribute("data-density", densityLevel);
4715
+ if (densityBtn !== null) {
4716
+ const next = nextDensity(densityLevel);
4717
+ densityBtn.setAttribute("aria-label", `切换为${DENSITY_LABELS[next]}显示`);
4718
+ densityBtn.title = `${DENSITY_LABELS[next]}显示`;
4719
+ }
4720
+ };
4721
+ const onDensityClick = () => {
4722
+ // 滚动锚定(R-01-021/AC-01):记录切换前当前选中卡片顶部相对滚动视口的
4723
+ // 位置,档位翻转后把 scrollTop 补偿回该相对位置,使卡片顶部在屏幕上不动。
4724
+ const scrollEl = scroll ?? pane.querySelector(".dap-scroll");
4725
+ const currentCard = scrollEl?.querySelector(".dap-card[data-current]") ?? null;
4726
+ const viewportTop = scrollEl?.getBoundingClientRect().top ?? 0;
4727
+ const anchorTop = currentCard ? currentCard.getBoundingClientRect().top - viewportTop : null;
4728
+ densityLevel = nextDensity(densityLevel);
4729
+ writeStoredDensity(densityLevel);
4730
+ applyDensity();
4731
+ // 档位已纳入渲染签名:触发一轮同步让时间线按新档位以 lastOnly 重建;
4732
+ // 锚定补偿在本轮渲染提交后执行,此时量测才含新行高
4733
+ //(R-01-021/AC-01、AC-08)。
4734
+ pendingDensityAnchor = currentCard && anchorTop !== null ? anchorTop : null;
4735
+ queueSync();
4736
+ };
4737
+ // 档位按钮位于标题行右侧的工具区(标题区折叠控件的兄弟节点),激活天然不会
4738
+ // 冒泡为标题区折叠,无需额外阻断。
4739
+ densityBtn?.addEventListener("click", onDensityClick);
4740
+ applyDensity();
4485
4741
  header?.addEventListener("click", onHeaderActivate);
4486
4742
  header?.addEventListener("keydown", onHeaderKeydown);
4487
4743
  rail?.addEventListener("click", onRailClick);
@@ -4502,6 +4758,7 @@ function apply(ctx) {
4502
4758
  recentMore?.removeEventListener("click", onRecentMoreClick);
4503
4759
  if (scrollHideTimer !== null) clearTimeout(scrollHideTimer);
4504
4760
  topBtn?.removeEventListener("click", onTopClick);
4761
+ densityBtn?.removeEventListener("click", onDensityClick);
4505
4762
  resize?.removeEventListener("pointerdown", onResizeDown);
4506
4763
  };
4507
4764
  }
@@ -4521,10 +4778,16 @@ function apply(ctx) {
4521
4778
  pane.className = PANE_CLASS;
4522
4779
  center.insertBefore(pane, seat);
4523
4780
  pane.innerHTML = `
4524
- <div class="dap-header" role="button" tabindex="0" aria-expanded="true" aria-label="收起活动会话窗格" title="收起">
4525
- <span>活动会话</span>
4526
- <span class="dap-count" role="status" aria-live="polite"></span>
4527
- <span class="dap-collapse-hint" aria-hidden="true">«</span>
4781
+ <div class="dap-header">
4782
+ <span class="dap-titlebar" role="button" tabindex="0" aria-expanded="true" aria-label="收起活动会话窗格" title="收起">
4783
+ <span>活动会话</span>
4784
+ <span class="dap-count" role="status" aria-live="polite"></span>
4785
+ <span class="dap-collapse-hint" aria-hidden="true"></span>
4786
+ </span>
4787
+ <span class="dap-tools">
4788
+ <a class="dap-repo" href="https://github.com/ccll/dsh-activity-pane" target="_blank" rel="noreferrer noopener" aria-label="报告问题" title="报告问题"></a>
4789
+ <button class="dap-density" type="button" aria-label="切换为紧凑显示" title="紧凑显示"></button>
4790
+ </span>
4528
4791
  </div>
4529
4792
  <div class="dap-scroll">
4530
4793
  <div class="dap-list" tabindex="-1"><div class="dap-tracks" aria-hidden="true"></div></div>
@@ -4543,6 +4806,13 @@ function apply(ctx) {
4543
4806
  pane.style.setProperty("--dap-width", `${paneWidth}px`);
4544
4807
  // 「回到顶部」按钮为纯图标呈现(R-01-018/AC-05):骨架无文字,图标在创建时注入。
4545
4808
  pane.querySelector(".dap-top").append(createTopIcon());
4809
+ // 紧凑显示切换按钮常显于标题行右侧工具区(R-01-021/AC-05);切换时以 data-density
4810
+ // 驱动纯 CSS 呈现,骨架重建后由 bindPaneControls 的 applyDensity 恢复形态。
4811
+ pane.querySelector(".dap-density").append(createDensityIcon());
4812
+ // 仓库入口常显于标题行右侧工具区(R-01-022/AC-01);图标在创建时注入。
4813
+ pane.querySelector(".dap-repo").append(createRepoIcon());
4814
+ // 收起方向图标为标题行悬停/聚焦的可见性提示(R-01-011/AC-07),图标在创建时注入。
4815
+ pane.querySelector(".dap-collapse-hint").append(createCollapseIcon());
4546
4816
  }
4547
4817
  if (pane !== boundPane) {
4548
4818
  unbindPaneControls?.();
@@ -4715,6 +4985,50 @@ function apply(ctx) {
4715
4985
  });
4716
4986
  }
4717
4987
 
4988
+ /** 紧凑显示切换按钮的「多行收拢为单行」密度图标:上下两个指向中线的箭头夹一条
4989
+ * 标题行横线,与「回到顶部」的单向箭头、标题行的收起方向图标均可区分
4990
+ * (canonical 图标集无现成字形,14 盒 stroke 风格与 createTopIcon 一致,R-01-021/AC-05)。 */
4991
+ function createDensityIcon() {
4992
+ return createInlineIcon({
4993
+ viewBox: "0 0 14 14",
4994
+ width: 14,
4995
+ height: 14,
4996
+ parts: [
4997
+ { attrs: { d: "m3.5 4.5 3.5-3.5 3.5 3.5", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" } },
4998
+ { attrs: { d: "M3 7h8", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" } },
4999
+ { attrs: { d: "m3.5 9.5 3.5 3.5 3.5-3.5", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" } },
5000
+ ],
5001
+ });
5002
+ }
5003
+
5004
+ /** 标题行的「收起」方向图标(R-01-011/AC-07):左侧竖杠 + 向左箭头指向竖杠,
5005
+ * 表达窗格收缩到左侧;随 .dap-collapse-hint 在标题行悬停/聚焦时显现。 */
5006
+ function createCollapseIcon() {
5007
+ return createInlineIcon({
5008
+ viewBox: "0 0 14 14",
5009
+ width: 14,
5010
+ height: 14,
5011
+ parts: [
5012
+ { attrs: { d: "M3 2.5v9", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" } },
5013
+ { attrs: { d: "M11.5 7H5", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" } },
5014
+ { attrs: { d: "M8.25 3.75 5 7l3.25 3.25", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" } },
5015
+ ],
5016
+ });
5017
+ }
5018
+
5019
+ /** 标题行工具区的仓库入口图标(R-01-022/AC-01):GitHub Octicon `mark-github` 字形
5020
+ * (MIT 许可,Primer Octicons),与工具区既有图标同用 14px 字形盒。 */
5021
+ function createRepoIcon() {
5022
+ return createInlineIcon({
5023
+ viewBox: "0 0 16 16",
5024
+ width: 14,
5025
+ height: 14,
5026
+ parts: [
5027
+ { attrs: { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.28.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z", fill: "currentColor" } },
5028
+ ],
5029
+ });
5030
+ }
5031
+
4718
5032
  function createUserIcon() {
4719
5033
  return createInlineIcon({
4720
5034
  viewBox: "0 0 16 16",
@@ -5217,7 +5531,7 @@ function apply(ctx) {
5217
5531
  if (entry.kind === "running") {
5218
5532
  renderProgressRow(el, entry.progress);
5219
5533
  const traceContainer = el.querySelector(".dap-trace");
5220
- if (traceContainer !== null) renderTimelineArea(traceContainer, entry);
5534
+ if (traceContainer !== null) renderTimelineArea(traceContainer, entry, { lastOnly: densityLevel === "medium" });
5221
5535
  renderTokenStats(el, entry);
5222
5536
  return;
5223
5537
  }
@@ -5261,7 +5575,7 @@ function apply(ctx) {
5261
5575
 
5262
5576
  if (entry.kind === "awaiting") {
5263
5577
  const traceContainer = el.querySelector(".dap-trace");
5264
- if (traceContainer !== null) renderTimelineArea(traceContainer, entry);
5578
+ if (traceContainer !== null) renderTimelineArea(traceContainer, entry, { lastOnly: densityLevel === "medium" });
5265
5579
  removeAwaitingHeadDuration(el);
5266
5580
  renderTokenStats(el, entry);
5267
5581
  const confirm = el.querySelector(".dap-confirm");
@@ -5326,7 +5640,7 @@ function apply(ctx) {
5326
5640
  // dsh 0.1.5 起快照不再携带会话内容:时间线经 eventSource 日志窗口
5327
5641
  // 就地重派生(R-01-009)。
5328
5642
  const listSnap = getSnapshot(sessions, "list");
5329
- captureSessionLog(id, { subagent: isSubagentRow(listSnap?.byId?.[id], listSnap ?? {}), cwd: listSnap?.byId?.[id]?.cwd ?? "" });
5643
+ captureSessionLog(id, { cwd: listSnap?.byId?.[id]?.cwd ?? "" });
5330
5644
  queueSync();
5331
5645
  });
5332
5646
  } catch {
@@ -5417,15 +5731,18 @@ function apply(ctx) {
5417
5731
  }
5418
5732
 
5419
5733
  /** 数量标识内容写入(R-01-014/AC-06):加载态显示活动指示——已是指示则不重写,
5420
- * 避免每轮 replaceChildren 重启动画抖动;计数态恢复文本写入,textContent 赋值自动摘除指示。 */
5734
+ * 避免每轮 replaceChildren 重启动画抖动;计数态恢复文本写入,textContent 赋值自动摘除指示。
5735
+ * 计数态同时写入悬停 tips(R-01-001/AC-08):说明分子/分母口径;加载态无计数可解释,摘除。 */
5421
5736
  function setCountBadgeContent(el, badge) {
5422
5737
  if (badge.mode === "loading") {
5423
5738
  const spinner = el.firstElementChild;
5424
5739
  if (!(el.childNodes.length === 1 && spinner !== null && spinner.classList.contains("dap-spinner")))
5425
5740
  el.replaceChildren(makeEl("span", "dap-spinner"));
5426
- } else if (el.textContent !== badge.text) {
5741
+ if (el.getAttribute("title") !== null) el.removeAttribute("title");
5742
+ } else {
5427
5743
  // 值未变不写文本节点:aria-live 下相同赋值也会触发替换与重复播报。
5428
- el.textContent = badge.text;
5744
+ if (el.textContent !== badge.text) el.textContent = badge.text;
5745
+ if (el.getAttribute("title") !== badge.tip) el.setAttribute("title", badge.tip);
5429
5746
  }
5430
5747
  }
5431
5748
 
@@ -6133,7 +6450,7 @@ function apply(ctx) {
6133
6450
  // 进入渲染,以便与当前可见等待卡末行重新对相(R-01-002/AC-07)。
6134
6451
  // 历史卡的相对活动时间随分钟级时钟变化,纳入签名后只在文案实际变化时重绘。
6135
6452
  const recentTimeSignature = recent.map((entry) => fmtRecentTime(entry.activityAt));
6136
- const sig = JSON.stringify([listState, cardSignature(visibleEntries), pulseSurface, recentTimeSignature]);
6453
+ const sig = JSON.stringify([listState, cardSignature(visibleEntries), pulseSurface, recentTimeSignature, densityLevel]);
6137
6454
  if (sig === lastSig) return;
6138
6455
  const colorByWorkspace = resolveWorkspaceColors(visibleEntries.map((entry) => entry.workspaceKey));
6139
6456
  // 跨区迁移(双向,R-01-010/AC-07):DOM 写入前量取旧卡矩形并克隆 ghost。
@@ -6213,7 +6530,7 @@ function apply(ctx) {
6213
6530
  ensureCurrentCardVisible(pane.querySelector(".dap-scroll"), snapshot?.current ?? null);
6214
6531
  if (focusAfterMigrationId !== null) cardsById.get(focusAfterMigrationId)?.el.focus();
6215
6532
  // 区域已有条目但列表仍在途时,在区头部显示行内加载指示(R-01-014/AC-01)。
6216
- const headerEl = pane.querySelector(".dap-header");
6533
+ const headerEl = pane.querySelector(".dap-titlebar");
6217
6534
  const recentHeadEl = recentSection?.querySelector(".dap-recent-head") ?? null;
6218
6535
  for (const [head, hasItems] of [[headerEl, active.length > 0], [recentHeadEl, recent.length > 0]]) {
6219
6536
  if (head === null) continue;
@@ -6225,11 +6542,12 @@ function apply(ctx) {
6225
6542
  }
6226
6543
  }
6227
6544
 
6228
- // 计数与折叠:n/m 只统计主会话——分子为等待行动数、分母为其加运行中主会话之和
6545
+ // 计数与折叠:n/m 只统计主会话——分子为运行中数、分母为其加等待行动主会话之和
6229
6546
  // (R-01-001/AC-04、AC-05);空态同样显示 0/0(AC-06)。列表在途时不冒充计数,
6230
- // 三处数量标识显示加载指示(R-01-014/AC-06)。脉冲由 data-awaiting 承载:
6231
- // 任一等待行动(阻塞等待、完成提醒或错误提醒)即脉冲(R-01-002/AC-06,C-037);
6232
- // 底色经 data-tone 跟随等待构成——错误 > 阻塞 > 完成 优先级取红/金/绿(C-040、C-043)。
6547
+ // 三处数量标识显示加载指示(R-01-014/AC-06)。悬停 tips 说明分子/分母口径(AC-08)。
6548
+ // 脉冲由 data-awaiting 承载:任一等待行动(阻塞等待、完成提醒或错误提醒)即脉冲
6549
+ // (R-01-002/AC-06,C-037);底色经 data-tone 跟随等待构成——错误 > 阻塞 > 完成
6550
+ // 优先级取红/金/绿(C-040、C-043)。
6233
6551
  const count = pane.querySelector(".dap-count");
6234
6552
  const railCount = pane.querySelector(".dap-rail-count");
6235
6553
  const { waiting, blocked, total } = awaitBadgeStats(active);
@@ -6280,6 +6598,21 @@ function apply(ctx) {
6280
6598
  prevRenderedActiveIds = new Set(active.map((entry) => String(entry.id)));
6281
6599
  prevRenderedRecentIds = new Set(recent.map((entry) => String(entry.id)));
6282
6600
  }
6601
+ // 锚定补偿(R-01-021/AC-01):档位切换的渲染落地后量测当前选中卡的新位置并
6602
+ // 补偿 scrollTop,使当前卡顶部回到切换前相对视口的位置;补偿量超滚动边界时
6603
+ // 由浏览器钳制(以当前卡不滚出可视范围为准)。
6604
+ if (pendingDensityAnchor !== null) {
6605
+ const scrollEl = pane.querySelector(".dap-scroll");
6606
+ // 补偿按当前 DOM 的 data-current 卡实时定位:跨区迁移等渲染可能重建卡片
6607
+ // 元素,不持有旧引用(量测恒为 attached 节点)。
6608
+ const currentCard = scrollEl?.querySelector(".dap-card[data-current]") ?? null;
6609
+ if (scrollEl !== null && currentCard !== null) {
6610
+ const viewportTop = scrollEl.getBoundingClientRect().top;
6611
+ const shiftedTop = currentCard.getBoundingClientRect().top - viewportTop;
6612
+ scrollEl.scrollTop += shiftedTop - pendingDensityAnchor;
6613
+ }
6614
+ pendingDensityAnchor = null;
6615
+ }
6283
6616
  }
6284
6617
 
6285
6618
  // ---- 打开会话(让 sessions.open 自己校验列表,失败时 refresh + 重试) ----
@@ -6476,12 +6809,19 @@ function apply(ctx) {
6476
6809
  window.removeEventListener("pageshow", onBusyPageShow);
6477
6810
  document.removeEventListener("visibilitychange", onVisibilityResume);
6478
6811
  window.removeEventListener("pageshow", onPageShow);
6812
+ document.removeEventListener("visibilitychange", onSyncVisibilityResume);
6813
+ window.removeEventListener("pageshow", onSyncPageShow);
6479
6814
  completeAcksById.clear();
6480
6815
  busyById.clear();
6481
6816
  busyRequestedIds.clear();
6482
6817
  busyRetryAtById.clear();
6483
6818
  if (clockTimer !== null) clearInterval(clockTimer);
6484
6819
  if (recentTimeTimer !== null) clearInterval(recentTimeTimer);
6820
+ if (syncThrottleTimer !== null) clearTimeout(syncThrottleTimer);
6821
+ // 流式派生合并窗口:逐 detail 清理在途 timer(T-127)。
6822
+ for (const detail of sessionDetailsById.values()) {
6823
+ if (detail.logDeriveTimer) clearTimeout(detail.logDeriveTimer);
6824
+ }
6485
6825
  if (e2eListReleaseTimer !== null) clearTimeout(e2eListReleaseTimer);
6486
6826
  for (const [timer, resolve] of e2eModelDelayWaiters) {
6487
6827
  clearTimeout(timer);