dsh-activity-pane 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // 数据源约定(来自 @deepseek-ai/dsh-client-runtime 的 sessions.list 快照):
5
5
  // ids: SessionId[] —— 宿主列表顺序(祖先在前的 lineage 顺序)
6
- // byId[id]: { id, displayTitle, title?, cwd?, parentId?, running, completed?,
6
+ // byId[id]: { id, displayTitle, title?, cwd?, parentId?, origin?, running, completed?,
7
7
  // pendingInteraction?: 'approval'|'plan-review'|'question', blank, ... }
8
8
  // current: SessionId | undefined
9
9
  // subagentsByParent: { [parentId]: { entries: [{ id, label, ... }] } }
@@ -11,8 +11,8 @@
11
11
  // 以及 workspaces.list 的 items: [{ title, path, sessionIds }]。
12
12
  //
13
13
  // 展示规则:
14
- // 主会话(无有效 parentId):running || completed || pendingInteraction 都显示;
15
- // 子代理:仅 running || pendingInteraction 时显示(结束后即消失);
14
+ // 主会话(非有效子代理行):running || completed || pendingInteraction 都显示;
15
+ // 子代理(origin === 'subagent' 且 parentId 有效):仅 running || pendingInteraction 时显示(结束后即消失);
16
16
  // pendingInteraction 总是优先视为"等待用户行动"(即使在 running 中)。
17
17
 
18
18
  const PENDING_LABELS = {
@@ -1067,6 +1067,31 @@ export function usageSummary({ uncachedInputTokens = null, cacheReadTokens = nul
1067
1067
  return { inputTokens, cacheHitPct };
1068
1068
  }
1069
1069
 
1070
+ /** 从 sessions.list 的投影统一归一卡片统计:运行卡与最近卡共享同一口径。 */
1071
+ export function statsFromProjection(projection = null, elapsedMs = null) {
1072
+ const usage = isRecord(projection?.tokenUsage) ? projection.tokenUsage : {};
1073
+ const sessionStats = isRecord(projection?.sessionStats) ? projection.sessionStats : {};
1074
+ const rateTokS = Number.isFinite(sessionStats.decodeMs) && sessionStats.decodeMs > 0
1075
+ ? sessionStats.decodeTokens / (sessionStats.decodeMs / 1000)
1076
+ : null;
1077
+ return {
1078
+ ...runtimeStats({ elapsedMs, outputTokens: usage.outputTokens, rateTokS }),
1079
+ ...usageSummary(usage),
1080
+ };
1081
+ }
1082
+
1083
+ /** 统计字段按字段合并:当前值有效时优先,否则回退到上一份已知值;不合并 elapsedMs。 */
1084
+ export function mergeRuntimeStats(current = null, fallback = null) {
1085
+ const currentStats = isRecord(current) ? current : {};
1086
+ const fallbackStats = isRecord(fallback) ? fallback : {};
1087
+ return {
1088
+ outputTokens: currentStats.outputTokens ?? fallbackStats.outputTokens ?? null,
1089
+ inputTokens: currentStats.inputTokens ?? fallbackStats.inputTokens ?? null,
1090
+ cacheHitPct: currentStats.cacheHitPct ?? fallbackStats.cacheHitPct ?? null,
1091
+ rateTokS: currentStats.rateTokS ?? fallbackStats.rateTokS ?? null,
1092
+ };
1093
+ }
1094
+
1070
1095
  /** 需要用户行动的种类的展示文案。 */
1071
1096
  export function pendingText(kind) {
1072
1097
  return PENDING_LABELS[kind] ?? PENDING_UNKNOWN_LABEL;
@@ -1168,8 +1193,8 @@ export function escapeCssString(value) {
1168
1193
  * windowComplete(R-01-009/AC-06、R-01-012/AC-12 冷窗口兜底):快照已就绪但窗口缺
1169
1194
  * 锚点数据(开放回合起点或可锚用户行在窗口外)时为 false——此时仍发起一次 history
1170
1195
  * 补读,供进度锚点与指令锚行兜底。previewFallbackNeeded 表示最近卡的快照预览
1171
- * 不完整,同样补读一次 history(R-01-013/AC-03、AC-04);durationFallbackNeeded 表示等待卡
1172
- * 需要在已加载的旧 history 之后再取一次最新回合边界(R-01-009/AC-12)。 */
1196
+ * 不完整,同样补读一次 history(R-01-013/AC-03、AC-04);durationFallbackNeeded 表示等待卡或最近卡
1197
+ * 需要在已加载的旧 history 之后再取一次最新回合边界(R-01-009/AC-12、R-01-013/AC-12)。 */
1173
1198
  export function detailLoadPlan({
1174
1199
  detail = {},
1175
1200
  isSubagent = false,
@@ -1250,11 +1275,11 @@ export function workspaceInfoForSession(sessionId, workspaceItems, byId = {}) {
1250
1275
  }
1251
1276
 
1252
1277
  /**
1253
- * 工作区徽标色相(R-01-003/AC-08、AC-09):以工作区身份为唯一输入的纯函数——
1278
+ * 工作区徽标基色色相(R-01-003/AC-08、AC-09):以工作区身份为唯一输入的纯函数——
1254
1279
  * djb2 哈希经雪崩终混(异或右移 + 乘法,把高位熵折入低位,消除 djb2 低位分布
1255
1280
  * 聚集;每步 >>> 0 保持无符号,异或结果可能带符号位)后在避开红色警戒区的
1256
1281
  * 色相弧 [30°,320°] 上均匀取色(30 + hash % 291),输出 [30,320] 整数。
1257
- * 同一身份恒得同一色相,与工作区列表顺序、会话状态及持久化存储无关,页面
1282
+ * 同一身份恒得同一基色色相,与工作区列表顺序、会话状态及持久化存储无关,页面
1258
1283
  * 刷新后不变;空身份返回 null。
1259
1284
  */
1260
1285
  export function workspaceHue(key) {
@@ -1273,34 +1298,99 @@ export function workspaceHue(key) {
1273
1298
  return 30 + (hash % 291);
1274
1299
  }
1275
1300
 
1276
- const WORKSPACE_HUE_ANCHORS = [55, 100, 145, 190, 235, 280, 325];
1301
+ export const WORKSPACE_COLOR_SLOTS = Object.freeze([
1302
+ { slot: 0, hue: 55, darkL: 0.78, darkC: 0.16, lightL: 0.48, lightC: 0.15 },
1303
+ { slot: 1, hue: 100, darkL: 0.78, darkC: 0.16, lightL: 0.48, lightC: 0.15 },
1304
+ { slot: 2, hue: 145, darkL: 0.78, darkC: 0.16, lightL: 0.48, lightC: 0.15 },
1305
+ { slot: 3, hue: 190, darkL: 0.78, darkC: 0.16, lightL: 0.48, lightC: 0.15 },
1306
+ { slot: 4, hue: 235, darkL: 0.78, darkC: 0.16, lightL: 0.48, lightC: 0.15 },
1307
+ { slot: 5, hue: 280, darkL: 0.78, darkC: 0.16, lightL: 0.48, lightC: 0.15 },
1308
+ { slot: 6, hue: 325, darkL: 0.78, darkC: 0.16, lightL: 0.48, lightC: 0.15 },
1309
+ { slot: 7, hue: 77, darkL: 0.64, darkC: 0.15, lightL: 0.36, lightC: 0.15 },
1310
+ { slot: 8, hue: 122, darkL: 0.64, darkC: 0.15, lightL: 0.36, lightC: 0.15 },
1311
+ { slot: 9, hue: 167, darkL: 0.64, darkC: 0.15, lightL: 0.36, lightC: 0.15 },
1312
+ { slot: 10, hue: 257, darkL: 0.64, darkC: 0.15, lightL: 0.36, lightC: 0.15 },
1313
+ { slot: 11, hue: 302, darkL: 0.64, darkC: 0.15, lightL: 0.36, lightC: 0.15 },
1314
+ ].map(Object.freeze));
1315
+
1316
+ export const WORKSPACE_BACKGROUND_SLOTS = Object.freeze([
1317
+ {
1318
+ slot: 0,
1319
+ dark: Object.freeze({ l: 0.30, c: 0.05, mix: "26%", borderMix: "46%" }),
1320
+ light: Object.freeze({ l: 0.92, c: 0.05, mix: "20%", borderMix: "34%" }),
1321
+ },
1322
+ {
1323
+ slot: 1,
1324
+ dark: Object.freeze({ l: 0.38, c: 0.06, mix: "31%", borderMix: "52%" }),
1325
+ light: Object.freeze({ l: 0.84, c: 0.06, mix: "27%", borderMix: "42%" }),
1326
+ },
1327
+ {
1328
+ slot: 2,
1329
+ dark: Object.freeze({ l: 0.46, c: 0.07, mix: "36%", borderMix: "58%" }),
1330
+ light: Object.freeze({ l: 0.76, c: 0.07, mix: "34%", borderMix: "50%" }),
1331
+ },
1332
+ ].map(Object.freeze));
1333
+
1334
+ const WORKSPACE_PRIMARY_SLOT_COUNT = 7;
1335
+ const WORKSPACE_SECONDARY_SLOT_COUNT = WORKSPACE_COLOR_SLOTS.length - WORKSPACE_PRIMARY_SLOT_COUNT;
1336
+
1337
+ function workspaceSlotProbe(start, count, step) {
1338
+ return Array.from({ length: count }, (_, offset) => (start + offset * step) % count);
1339
+ }
1277
1340
 
1278
1341
  /**
1279
- * 同屏工作区色相消解(R-01-003/AC-08、AC-12):身份去重排序后,以稳定基色
1280
- * 确定七个 OKLCH 感知锚点的起始槽;撞槽时按步进 3 跨色区探测。超过七个
1281
- * 工作区后选择当前使用次数最少的槽,使复用均衡且确定。
1342
+ * 同屏工作区复合颜色槽位消解(R-01-003/AC-08、AC-12):前七个身份沿原七色
1343
+ * OKLCH 主槽位分配,主槽位占满后再使用五个受控明度/色相补充槽位;每个前景槽位
1344
+ * 内优先使用未用的三个背景变体;超过三十六个复合身份后按槽位使用次数均衡复用。
1282
1345
  */
1283
- export function resolveWorkspaceHues(keys) {
1346
+ export function resolveWorkspaceColors(keys) {
1284
1347
  const identities = [...new Set((Array.isArray(keys) ? keys : []).map(cleanText).filter(Boolean))].sort();
1285
- const uses = WORKSPACE_HUE_ANCHORS.map(() => 0);
1348
+ const uses = WORKSPACE_COLOR_SLOTS.map(() => 0);
1349
+ const backgroundUses = WORKSPACE_COLOR_SLOTS.map(() => WORKSPACE_BACKGROUND_SLOTS.map(() => 0));
1286
1350
  const resolved = new Map();
1287
1351
  for (const identity of identities) {
1288
- const start = (workspaceHue(identity) - 30) % WORKSPACE_HUE_ANCHORS.length;
1289
- let chosen = start;
1290
- for (let offset = 0; offset < WORKSPACE_HUE_ANCHORS.length; offset += 1) {
1291
- const candidate = (start + offset * 3) % WORKSPACE_HUE_ANCHORS.length;
1292
- if (uses[candidate] < uses[chosen]) chosen = candidate;
1293
- if (uses[candidate] === 0) {
1294
- chosen = candidate;
1295
- break;
1296
- }
1352
+ const baseHue = workspaceHue(identity);
1353
+ const primaryOrder = workspaceSlotProbe(
1354
+ (baseHue - 30) % WORKSPACE_PRIMARY_SLOT_COUNT,
1355
+ WORKSPACE_PRIMARY_SLOT_COUNT,
1356
+ 3,
1357
+ );
1358
+ const secondaryOrder = workspaceSlotProbe(
1359
+ (baseHue - 30) % WORKSPACE_SECONDARY_SLOT_COUNT,
1360
+ WORKSPACE_SECONDARY_SLOT_COUNT,
1361
+ 2,
1362
+ ).map((index) => WORKSPACE_PRIMARY_SLOT_COUNT + index);
1363
+ const order = [...primaryOrder, ...secondaryOrder];
1364
+ const backgroundOrder = workspaceSlotProbe(
1365
+ (baseHue - 30) % WORKSPACE_BACKGROUND_SLOTS.length,
1366
+ WORKSPACE_BACKGROUND_SLOTS.length,
1367
+ 2,
1368
+ );
1369
+ const availableForegroundOrder = order.filter((index) => backgroundOrder.some((backgroundSlot) => backgroundUses[index][backgroundSlot] === 0));
1370
+ const foregroundOrder = availableForegroundOrder.length > 0 ? availableForegroundOrder : order;
1371
+ let foregroundSlot = foregroundOrder.find((index) => uses[index] === 0);
1372
+ if (foregroundSlot === undefined) {
1373
+ foregroundSlot = foregroundOrder.reduce(
1374
+ (best, index) => (uses[index] < uses[best] ? index : best),
1375
+ foregroundOrder[0],
1376
+ );
1297
1377
  }
1298
- uses[chosen] += 1;
1299
- resolved.set(identity, WORKSPACE_HUE_ANCHORS[chosen]);
1378
+ uses[foregroundSlot] += 1;
1379
+ const backgroundUsesForForeground = backgroundUses[foregroundSlot];
1380
+ const backgroundSlot = backgroundOrder.reduce(
1381
+ (best, index) => (backgroundUsesForForeground[index] < backgroundUsesForForeground[best] ? index : best),
1382
+ backgroundOrder[0],
1383
+ );
1384
+ backgroundUsesForForeground[backgroundSlot] += 1;
1385
+ resolved.set(identity, {
1386
+ foreground: WORKSPACE_COLOR_SLOTS[foregroundSlot],
1387
+ background: WORKSPACE_BACKGROUND_SLOTS[backgroundSlot],
1388
+ });
1300
1389
  }
1301
1390
  return resolved;
1302
1391
  }
1303
1392
 
1393
+
1304
1394
  /** 主会话按左侧工作区顺序排序的权重;不在任何 workspace 的排在最后保持 lineage 顺序。 */
1305
1395
  function workspaceRank(workspaceItems) {
1306
1396
  const wsIndex = new Map();
@@ -1355,6 +1445,7 @@ export function mainTitle(byId, id) {
1355
1445
  * - 主会话 running(且无 pending)或处于委托周期(含后代耗尽空窗)→ 'running'
1356
1446
  * - 主会话 pendingInteraction / completed / errorReminder → 'awaiting'(等待用户行动)
1357
1447
  * - 子代理 running / pending 或存在活动后代 → 'subagent',自身与后代均不活动则不显示
1448
+ * - 普通 fork 虽有 parentId,但 origin 缺失,仍按独立主会话处理
1358
1449
  * completions(完成确认与错误提醒,R-01-002/AC-05、AC-13、R-01-010/AC-06):Map id →
1359
1450
  * { lastTurnEnd, lastTurnEndKind, lastTurnEndError, ackedAt },由渲染层从宿主侧 ack 状态
1360
1451
  * 通道注入;其中完成提醒成立(completionReminder)或错误提醒成立(errorReminder)的
@@ -1362,21 +1453,38 @@ export function mainTitle(byId, id) {
1362
1453
  * 记账派生):集合内会话视同处于委托周期——后代耗尽至 settle 处理回合启动的
1363
1454
  * 空窗内仍保持运行呈现、完成/错误提醒不生效;条目的 descendantActive 字段
1364
1455
  * 始终为当帧原始后代活性(供进度锚点记账判定耗尽),不受 delegatingIds 影响。
1456
+ * archivedIds(工作区服务的注册表全局归档集合):归档会话及其有效子代理子树不产出活动条目,
1457
+ * 普通 fork 仅因共享来源 parentId 不受影响;避免会话服务仍保留旧行或完成提醒时在窗格中滞留。
1365
1458
  */
1366
- export function buildEntries(snapshot, workspaceItems, detailsById = {}, completions = null, delegatingIds = null) {
1459
+ export function buildEntries(snapshot, workspaceItems, detailsById = {}, completions = null, delegatingIds = null, archivedIds = []) {
1367
1460
  const byId = isRecord(snapshot) && isRecord(snapshot.byId) ? snapshot.byId : {};
1368
1461
  const ids = Array.isArray(snapshot?.ids) ? snapshot.ids : [];
1369
1462
  const current = snapshot?.current ?? null;
1370
1463
  const subagentsByParent = isRecord(snapshot?.subagentsByParent)
1371
1464
  ? snapshot.subagentsByParent
1372
1465
  : {};
1466
+ const archived = archivedIds instanceof Set
1467
+ ? new Set([...archivedIds].map((id) => String(id)))
1468
+ : new Set((archivedIds ?? []).map((id) => String(id)));
1469
+ const isArchived = (id) => {
1470
+ const seen = new Set();
1471
+ let currentId = id;
1472
+ while (currentId !== undefined && currentId !== null) {
1473
+ const key = String(currentId);
1474
+ if (archived.has(key) || seen.has(key)) return archived.has(key);
1475
+ seen.add(key);
1476
+ currentId = subagentParentId(byId[key], byId);
1477
+ }
1478
+ return false;
1479
+ };
1373
1480
  const rank = workspaceRank(workspaceItems ?? []);
1374
- const descendantIds = descendantActiveIds(byId);
1481
+ const descendantIds = descendantActiveIds(byId, isArchived);
1375
1482
  // 第一遍:层级关系 + 显示判定(show = 自身活动 || 委托周期 || 完成提醒,单点实现避免漂移)。
1376
1483
  const rootIds = [];
1377
1484
  const childIds = new Map();
1378
1485
  const meta = new Map();
1379
1486
  for (const id of ids) {
1487
+ if (isArchived(id)) continue;
1380
1488
  const row = byId[id];
1381
1489
  if (!isRecord(row)) continue;
1382
1490
  const hasParent = isSubagentRow(row, byId);
@@ -1594,10 +1702,15 @@ export function listLoadState(snapshot) {
1594
1702
  return "ready";
1595
1703
  }
1596
1704
 
1597
- /** 会话行是否为某主会话的直属子代理。 */
1705
+ /** 会话行是否为某主会话的直属子代理:需同时满足 origin 与有效父会话。 */
1598
1706
  export function isSubagentRow(row, byId = {}) {
1599
1707
  const id = row?.parentId;
1600
- return id !== undefined && id !== null && isRecord(byId[id]);
1708
+ return row?.origin === "subagent" && id !== undefined && id !== null && isRecord(byId[id]);
1709
+ }
1710
+
1711
+ /** 取实际子代理的直属母会话 id;普通 fork 的 parentId 只是来源关系,不参与子代理树。 */
1712
+ function subagentParentId(row, byId = {}) {
1713
+ return isSubagentRow(row, byId) ? row.parentId : undefined;
1601
1714
  }
1602
1715
 
1603
1716
  /** 会话行是否满足自身状态的活动判定,不含后代活动继承。
@@ -1611,31 +1724,32 @@ function isOwnActiveRow(row, byId = {}) {
1611
1724
  return running || pending;
1612
1725
  }
1613
1726
 
1614
- /** 沿自身活动会话的有效 parentId 链上溯收集会话 id:includeSelf 含活动会话自身,
1727
+ /** 沿实际子代理关系的有效 parentId 链上溯收集会话 id:includeSelf 含活动会话自身,
1615
1728
  * 否则只收祖先(存在活动后代的母会话)。活动区与历史区显示判定的单点实现。 */
1616
- function lineageActiveIds(byId, includeSelf) {
1729
+ function lineageActiveIds(byId, includeSelf, isExcluded = null) {
1617
1730
  const ids = new Set();
1618
1731
  for (const [id, row] of Object.entries(byId)) {
1619
- if (!isOwnActiveRow(row, byId)) continue;
1732
+ if (isExcluded?.(id) || !isOwnActiveRow(row, byId)) continue;
1620
1733
  const seen = new Set();
1621
- let currentId = includeSelf ? id : row?.parentId;
1734
+ let currentId = includeSelf ? id : subagentParentId(row, byId);
1622
1735
  while (currentId !== undefined && currentId !== null && isRecord(byId[currentId]) && !seen.has(String(currentId))) {
1736
+ if (isExcluded?.(currentId)) break;
1623
1737
  seen.add(String(currentId));
1624
1738
  ids.add(String(currentId));
1625
- currentId = byId[currentId]?.parentId;
1739
+ currentId = subagentParentId(byId[currentId], byId);
1626
1740
  }
1627
1741
  }
1628
1742
  return ids;
1629
1743
  }
1630
1744
 
1631
- /** 沿活动会话的有效 parentId 链补齐活动祖先(含活动会话自身),供历史区显示判定。 */
1745
+ /** 沿实际子代理关系补齐活动祖先(含活动会话自身),供历史区显示判定。 */
1632
1746
  export function activeSessionIds(byId = {}) {
1633
1747
  return lineageActiveIds(byId, true);
1634
1748
  }
1635
1749
 
1636
1750
  /** 「存在活动后代」的母会话集合(不含活动会话自身),供 buildEntries 判定委托周期(R-01-003/AC-05)。 */
1637
- function descendantActiveIds(byId = {}) {
1638
- return lineageActiveIds(byId, false);
1751
+ function descendantActiveIds(byId = {}, isExcluded = null) {
1752
+ return lineageActiveIds(byId, false, isExcluded);
1639
1753
  }
1640
1754
 
1641
1755
  /** 判断活动条目是否需要建立轮内状态订阅:以宿主 running 为准、与呈现 kind 解耦——
@@ -47,6 +47,43 @@ export function openSession(sessions, sessionId) {
47
47
  }
48
48
  }
49
49
 
50
+ /**
51
+ * 以最小必要距离把卡片滚入窗格滚动视口;不会居中,也不会滚动外层页面。
52
+ * 默认使用原生 smooth scroll,调用方可传 auto 适配降低动效偏好;缺少 scrollTo 或 options 不受支持时回退为同步滚动
53
+ * (R-01-006/AC-02)。
54
+ */
55
+ export function scrollCardIntoView(scroll, card, behavior = "smooth") {
56
+ if (typeof scroll?.getBoundingClientRect !== "function" || typeof card?.getBoundingClientRect !== "function")
57
+ return false;
58
+ const viewport = scroll.getBoundingClientRect();
59
+ const rect = card.getBoundingClientRect();
60
+ const delta = rect.top < viewport.top
61
+ ? rect.top - viewport.top
62
+ : rect.bottom > viewport.bottom
63
+ ? rect.bottom - viewport.bottom
64
+ : 0;
65
+ if (delta === 0) return false;
66
+ const currentTop = Number(scroll.scrollTop);
67
+ if (!Number.isFinite(currentTop)) return false;
68
+ const scrollHeight = Number(scroll.scrollHeight);
69
+ const clientHeight = Number(scroll.clientHeight);
70
+ const maxTop = Number.isFinite(scrollHeight) && Number.isFinite(clientHeight)
71
+ ? Math.max(0, scrollHeight - clientHeight)
72
+ : Infinity;
73
+ const nextTop = Math.min(maxTop, Math.max(0, currentTop + delta));
74
+ if (!Number.isFinite(nextTop) || nextTop === currentTop) return false;
75
+ if (typeof scroll.scrollTo === "function") {
76
+ try {
77
+ scroll.scrollTo({ top: nextTop, behavior });
78
+ if (behavior !== "auto" || Number(scroll.scrollTop) !== currentTop) return true;
79
+ } catch (error) {
80
+ if (!(error instanceof TypeError)) throw error;
81
+ }
82
+ }
83
+ scroll.scrollTop = nextTop;
84
+ return Number(scroll.scrollTop) !== currentTop;
85
+ }
86
+
50
87
  /** 原生会话输入框(dsh-client-ui-conversation composer 的 textarea)。 */
51
88
  export const COMPOSER_SELECTOR = "textarea[data-phase]";
52
89