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/src/client.mjs CHANGED
@@ -467,23 +467,41 @@ const CSS = `
467
467
  /* 复审修复(C-040、C-043):状态点光晕三类同强度、仅换色相——阻塞金、完成绿与错误红光晕一致。 */
468
468
  /* 工作区徽标「图标+文本」双段:文件夹图标与左边栏工作区条目同源(R-01-003/AC-06);
469
469
  名称字号不低于 10.5px(AC-07),行高保持 14px 以维持胶囊与卡片高度。
470
- 着色(AC-08~AC-11):核心映射提供 OKLCH hue;深色主题文字取高明度中高彩度,
471
- 浅色主题文字取低明度中高彩度。文字直接使用调色板色,底色与描边在 OKLCH
472
- 空间按透明度混合;前景与背景始终同色相、明度对比拉开。 */
470
+ 着色(AC-08~AC-12):核心映射提供 12 个前景身份槽与每槽 3 个背景变体;
471
+ 深色/浅色主题分别使用前景与背景槽位的 L/C 参数,文字使用前景调色板色,
472
+ 底色使用独立背景源色、描边使用前景调色板色,分别在 OKLCH 空间按变体混合强度呈现;前景与背景保持同色相族。 */
473
473
  [data-dsh-activity-pane] .dap-workspace {
474
474
  width: fit-content; max-width: 100%; display: flex; align-items: center; gap: 3px;
475
475
  overflow: hidden;
476
476
  font-size: 10.5px; line-height: 14px;
477
- --dap-workspace-color: oklch(0.78 0.16 var(--dap-workspace-hue, 235));
477
+ --dap-workspace-color: oklch(
478
+ var(--dap-workspace-dark-l, 0.78)
479
+ var(--dap-workspace-dark-c, 0.16)
480
+ var(--dap-workspace-hue, 235)
481
+ );
482
+ --dap-workspace-background-color: oklch(
483
+ var(--dap-workspace-bg-dark-l, 0.38)
484
+ var(--dap-workspace-bg-dark-c, 0.06)
485
+ var(--dap-workspace-hue, 235)
486
+ );
478
487
  color: var(--dap-workspace-color);
479
- background: color-mix(in oklch, var(--dap-workspace-color) 14%, transparent);
480
- border: 1px solid color-mix(in oklch, var(--dap-workspace-color) 34%, transparent);
488
+ background: color-mix(in oklch, var(--dap-workspace-background-color) var(--dap-workspace-bg-dark-mix, 31%), transparent);
489
+ border: 1px solid color-mix(in oklch, var(--dap-workspace-color) var(--dap-workspace-bg-dark-border-mix, 52%), transparent);
481
490
  border-radius: 999px; padding: 0 7px;
482
491
  }
483
492
  body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
484
- --dap-workspace-color: oklch(0.48 0.15 var(--dap-workspace-hue, 235));
485
- background: color-mix(in oklch, var(--dap-workspace-color) 10%, transparent);
486
- border-color: color-mix(in oklch, var(--dap-workspace-color) 28%, transparent);
493
+ --dap-workspace-color: oklch(
494
+ var(--dap-workspace-light-l, 0.48)
495
+ var(--dap-workspace-light-c, 0.15)
496
+ var(--dap-workspace-hue, 235)
497
+ );
498
+ --dap-workspace-background-color: oklch(
499
+ var(--dap-workspace-bg-light-l, 0.84)
500
+ var(--dap-workspace-bg-light-c, 0.06)
501
+ var(--dap-workspace-hue, 235)
502
+ );
503
+ background: color-mix(in oklch, var(--dap-workspace-background-color) var(--dap-workspace-bg-light-mix, 27%), transparent);
504
+ border-color: color-mix(in oklch, var(--dap-workspace-color) var(--dap-workspace-bg-light-border-mix, 42%), transparent);
487
505
  }
488
506
  [data-dsh-activity-pane] .dap-workspace-icon { flex: none; display: inline-flex; }
489
507
  [data-dsh-activity-pane] .dap-workspace-icon svg { display: block; }
@@ -1026,16 +1044,18 @@ function apply(ctx) {
1026
1044
  /** 等待条目 id/类别队列签名:变化时统一重启数量胶囊与等待卡末行动画对相(R-01-002/AC-07、AC-08)。 */
1027
1045
  let pulseSignature = "";
1028
1046
  // E2E-only deterministic seams(T-089/T-090):显式 URL fragment 最多把本页首次列表 ready 呈现
1029
- // 或正式 models RPC 延后 1s;默认路径为 0,不伪造服务响应、快照或生产时序。
1047
+ // 或正式 models RPC 延后 1s;列表延迟从首个窗格 render 开始计时,避免宿主启动耗时
1048
+ // 把 pending 观察窗口提前消耗;默认路径为 0,不伪造服务响应、快照或生产时序。
1030
1049
  const e2eParams = new URLSearchParams(window.location.hash.slice(1));
1031
1050
  const requestedListDelay = Number(e2eParams.get("dap-e2e-list-delay"));
1032
- const e2eListReadyAt = Number.isFinite(requestedListDelay) && requestedListDelay > 0
1033
- ? Date.now() + Math.min(requestedListDelay, 1_000)
1051
+ const e2eListDelayMs = Number.isFinite(requestedListDelay) && requestedListDelay > 0
1052
+ ? Math.min(requestedListDelay, 1_000)
1034
1053
  : 0;
1035
1054
  const requestedModelDelay = Number(e2eParams.get("dap-e2e-model-delay"));
1036
1055
  const e2eModelDelayMs = Number.isFinite(requestedModelDelay) && requestedModelDelay > 0
1037
1056
  ? Math.min(requestedModelDelay, 1_000)
1038
1057
  : 0;
1058
+ let e2eListReadyAt = 0;
1039
1059
  let e2eListReleaseTimer = null;
1040
1060
  const e2eModelDelayWaiters = new Map();
1041
1061
  function delayedModelCall(call) {
@@ -1056,6 +1076,9 @@ function apply(ctx) {
1056
1076
  let paneWidth = readStoredPaneWidth();
1057
1077
  /** 用户最近一次激活的卡片 id;打开重试链被更新的激活意图取代即取消。 */
1058
1078
  let lastActivatedId = null;
1079
+ /** 最近一次已处理的当前卡片;同一卡片的运行时重绘不反复打断用户手动滚动。 */
1080
+ let autoScrolledCurrentId = null;
1081
+ let autoScrolledCurrentCard = null;
1059
1082
  /** 完成确认状态(R-01-002/AC-03、AC-10~AC-12):id → { lastTurnEnd, ackedAt },
1060
1083
  * 来自宿主侧 ack 通道(SSE 全量快照),随每次推送整体替换。 */
1061
1084
  const completeAcksById = new Map();
@@ -2204,22 +2227,40 @@ function apply(ctx) {
2204
2227
  renderTrace(container, entry.timeline, { lastOnly });
2205
2228
  }
2206
2229
 
2207
- function renderCardInto(el, entry, hueByWorkspace) {
2230
+ function renderCardInto(el, entry, colorByWorkspace) {
2208
2231
  const workspaceLabel = el.querySelector(".dap-workspace");
2209
2232
  if (workspaceLabel !== null) {
2210
2233
  const workspaceText = workspaceLabel.querySelector(".dap-workspace-text");
2211
- if (entry.workspaceTitle !== "") {
2234
+ const color = colorByWorkspace.get(entry.workspaceKey);
2235
+ const foreground = color?.foreground;
2236
+ const background = color?.background;
2237
+ const colorVariables = {
2238
+ "--dap-workspace-hue": foreground?.hue,
2239
+ "--dap-workspace-dark-l": foreground?.darkL,
2240
+ "--dap-workspace-dark-c": foreground?.darkC,
2241
+ "--dap-workspace-light-l": foreground?.lightL,
2242
+ "--dap-workspace-light-c": foreground?.lightC,
2243
+ "--dap-workspace-bg-dark-l": background?.dark?.l,
2244
+ "--dap-workspace-bg-dark-c": background?.dark?.c,
2245
+ "--dap-workspace-bg-dark-mix": background?.dark?.mix,
2246
+ "--dap-workspace-bg-dark-border-mix": background?.dark?.borderMix,
2247
+ "--dap-workspace-bg-light-l": background?.light?.l,
2248
+ "--dap-workspace-bg-light-c": background?.light?.c,
2249
+ "--dap-workspace-bg-light-mix": background?.light?.mix,
2250
+ "--dap-workspace-bg-light-border-mix": background?.light?.borderMix,
2251
+ };
2252
+ if (entry.workspaceTitle !== "" && foreground !== undefined && background !== undefined) {
2212
2253
  if (workspaceText !== null) restoreTextField(workspaceText, entry.workspaceTitle);
2213
- const hue = hueByWorkspace.get(entry.workspaceKey) ?? workspaceHue(entry.workspaceKey);
2214
- const hueText = hue === null ? "" : String(hue);
2215
- if (workspaceLabel.style.getPropertyValue("--dap-workspace-hue") !== hueText) {
2216
- if (hue === null) workspaceLabel.style.removeProperty("--dap-workspace-hue");
2217
- else workspaceLabel.style.setProperty("--dap-workspace-hue", hueText);
2254
+ for (const [property, value] of Object.entries(colorVariables)) {
2255
+ const next = value === undefined ? "" : String(value);
2256
+ if (workspaceLabel.style.getPropertyValue(property) === next) continue;
2257
+ if (next === "") workspaceLabel.style.removeProperty(property);
2258
+ else workspaceLabel.style.setProperty(property, next);
2218
2259
  }
2219
2260
  workspaceLabel.removeAttribute("hidden");
2220
2261
  } else {
2221
2262
  if (workspaceText !== null) restoreTextField(workspaceText, "");
2222
- workspaceLabel.style.removeProperty("--dap-workspace-hue");
2263
+ for (const property of Object.keys(colorVariables)) workspaceLabel.style.removeProperty(property);
2223
2264
  workspaceLabel.setAttribute("hidden", "");
2224
2265
  }
2225
2266
  }
@@ -2598,7 +2639,7 @@ function apply(ctx) {
2598
2639
 
2599
2640
  /** 渲染某一张卡片进指定列表容器(活动/历史通用)。index 是条目在卡片序列中的
2600
2641
  * 序号,offset 是容器内首个卡片前的非卡片子节点数(活动区有轨道层、历史区有段头)。 */
2601
- function renderCardIntoList(list, entry, reuseMap, index, offset, hueByWorkspace) {
2642
+ function renderCardIntoList(list, entry, reuseMap, index, offset, colorByWorkspace) {
2602
2643
  let rec = reuseMap.get(entry.id);
2603
2644
  if (rec === undefined) {
2604
2645
  const el = document.createElement("div");
@@ -2655,7 +2696,7 @@ function apply(ctx) {
2655
2696
  recentTimeText ? "," + recentTimeText : ""
2656
2697
  }`,
2657
2698
  );
2658
- renderCardInto(rec.el, entry, hueByWorkspace);
2699
+ renderCardInto(rec.el, entry, colorByWorkspace);
2659
2700
  // 只有顺序/归属真正变化时才移动 DOM:每次渲染无条件 appendChild 会把所有
2660
2701
  // 卡片瞬时移除再插回——按下/抬起之间经过的移动让浏览器取消 click;焦点卡
2661
2702
  // 被瞬时断开而失焦;悬停卡的 :hover 也随之丢失且不再补发。会话活跃期间
@@ -2665,6 +2706,37 @@ function apply(ctx) {
2665
2706
  return true;
2666
2707
  }
2667
2708
 
2709
+ /** 原生侧栏切换当前会话后,只把已呈现且未完整可见的当前卡片滚入窗格;降低动效偏好时不平滑。 */
2710
+ function ensureCurrentCardVisible(scroll, currentId) {
2711
+ const id = currentId === null || currentId === undefined ? null : String(currentId);
2712
+ if (id === null || id === "") {
2713
+ autoScrolledCurrentId = null;
2714
+ autoScrolledCurrentCard = null;
2715
+ return;
2716
+ }
2717
+ // 当前会话卡片只有在活动列表中才参与自动定位;完成确认后同一 current
2718
+ // 会话会进入历史区,不能因仍带 data-current 把窗格滚到历史卡片。
2719
+ const card = scroll?.querySelector?.(`.${LIST_CLASS} .${CARD_CLASS}[data-current]`) ?? null;
2720
+ if (
2721
+ card === null ||
2722
+ card.dataset.sessionId !== id ||
2723
+ scroll.clientHeight <= 0 ||
2724
+ card.getBoundingClientRect().height <= 0
2725
+ )
2726
+ return;
2727
+ if (autoScrolledCurrentId === id && autoScrolledCurrentCard === card) return;
2728
+ if (scrollCardIntoView(scroll, card, prefersReducedMotion() ? "auto" : "smooth")) {
2729
+ autoScrolledCurrentId = id;
2730
+ autoScrolledCurrentCard = card;
2731
+ return;
2732
+ }
2733
+ const viewport = scroll.getBoundingClientRect();
2734
+ const rect = card.getBoundingClientRect();
2735
+ if (rect.height <= viewport.height && (rect.top < viewport.top || rect.bottom > viewport.bottom)) return;
2736
+ autoScrolledCurrentId = id;
2737
+ autoScrolledCurrentCard = card;
2738
+ }
2739
+
2668
2740
  function pruneCards(reuseMap, alive) {
2669
2741
  for (const [id, rec] of reuseMap) {
2670
2742
  if (alive.has(id)) continue;
@@ -2848,6 +2920,8 @@ function apply(ctx) {
2848
2920
  recentTotal = 0;
2849
2921
  recentHasMore = false;
2850
2922
  recentAppendQueued = false;
2923
+ autoScrolledCurrentId = null;
2924
+ autoScrolledCurrentCard = null;
2851
2925
  // 旧窗格已脱离文档:其在飞平移的 transitionend 不再触发,逐元素取消避免残留。
2852
2926
  for (const el of [...shiftCleanups.keys()]) cancelShift(el);
2853
2927
  }
@@ -2856,6 +2930,7 @@ function apply(ctx) {
2856
2930
  const recentSection = pane.querySelector(`.${RECENT_CLASS}`);
2857
2931
  if (activeList === null || recentSection === null) return;
2858
2932
 
2933
+ if (e2eListReadyAt === 0 && e2eListDelayMs > 0) e2eListReadyAt = Date.now() + e2eListDelayMs;
2859
2934
  let snapshot = getSnapshot(sessions, "list");
2860
2935
  if (e2eListReadyAt > Date.now() && snapshot?.phase !== "error") {
2861
2936
  snapshot = { phase: "pending" };
@@ -2880,7 +2955,7 @@ function apply(ctx) {
2880
2955
  for (const [id, state] of progressAnchorById) {
2881
2956
  if (delegationActive(state, now)) delegatingIds.add(id);
2882
2957
  }
2883
- const active = buildEntries(snapshot, workspaceItems, sessionDetailsById, completeAcksById, delegatingIds);
2958
+ const active = buildEntries(snapshot, workspaceItems, sessionDetailsById, completeAcksById, delegatingIds, archivedSessionIds);
2884
2959
  // 轮内订阅仅对"运行中"会话建立(主会话 + 运行中的子代理),保持在运行中的订阅
2885
2960
  // 数量 == 运行中会话数量(R-02-004/AC-01);暂停等待的子代理只显示标题。
2886
2961
  const runLikeIds = new Set(
@@ -3078,12 +3153,32 @@ function apply(ctx) {
3078
3153
  const recentTimeSignature = recent.map((entry) => fmtRecentTime(entry.activityAt));
3079
3154
  const sig = JSON.stringify([listState, cardSignature(visibleEntries), pulseSurface, recentTimeSignature]);
3080
3155
  if (sig === lastSig) return;
3081
- const hueByWorkspace = resolveWorkspaceHues(visibleEntries.map((entry) => entry.workspaceKey));
3156
+ const colorByWorkspace = resolveWorkspaceColors(visibleEntries.map((entry) => entry.workspaceKey));
3082
3157
  // 跨区迁移(双向,R-01-010/AC-07):DOM 写入前量取旧卡矩形并克隆 ghost。
3083
3158
  const migrations = [
3084
3159
  ...movedToRecentIds(prevRenderedActiveIds, active, recent).map((id) => ({ id, from: cardsById, to: recentCardsById })),
3085
3160
  ...movedToActiveIds(prevRenderedRecentIds, active, recent).map((id) => ({ id, from: recentCardsById, to: cardsById })),
3086
3161
  ];
3162
+ // 活动卡带焦点迁入历史时,把键盘焦点交给其上一个仍在活动区的卡片;
3163
+ // 若迁移卡已在活动区首位,则退到下一张,避免焦点落到历史区或 body。
3164
+ let focusAfterMigrationId = null;
3165
+ const focusedElement = document.activeElement;
3166
+ const activeIds = new Set(active.map((entry) => String(entry.id)));
3167
+ for (const { id, from } of migrations) {
3168
+ if (from !== cardsById) continue;
3169
+ const source = from.get(id)?.el;
3170
+ if (source === undefined || !source.contains(focusedElement)) continue;
3171
+ const activeCards = [...activeList.querySelectorAll(`.${CARD_CLASS}`)];
3172
+ const sourceIndex = activeCards.indexOf(source);
3173
+ if (sourceIndex < 0) continue;
3174
+ const target = activeCards
3175
+ .slice(0, sourceIndex)
3176
+ .reverse()
3177
+ .find((card) => activeIds.has(String(card.dataset.sessionId)))
3178
+ ?? activeCards.slice(sourceIndex + 1).find((card) => activeIds.has(String(card.dataset.sessionId)));
3179
+ focusAfterMigrationId = target?.dataset.sessionId ?? null;
3180
+ break;
3181
+ }
3087
3182
  const movePlans = prepareMoveGhosts(migrations);
3088
3183
  // 迁移帧内位置受影响的其它卡片与历史区段头:DOM 写入前量取当前视觉矩形
3089
3184
  // (R-01-010/AC-10);reduced-motion 下 movePlans 为空,FLIP 量取整体跳过。
@@ -3097,7 +3192,7 @@ function apply(ctx) {
3097
3192
  const aliveActive = new Set();
3098
3193
  for (const [index, entry] of active.entries()) {
3099
3194
  try {
3100
- renderCardIntoList(activeList, entry, cardsById, index, 1, hueByWorkspace);
3195
+ renderCardIntoList(activeList, entry, cardsById, index, 1, colorByWorkspace);
3101
3196
  } catch (error) {
3102
3197
  renderOk = false;
3103
3198
  logCardRenderError(entry.id, error);
@@ -3114,7 +3209,7 @@ function apply(ctx) {
3114
3209
  // 历史区容器首个子节点是段头(.dap-recent-head),卡片从 offset 1 开始。
3115
3210
  for (const [index, entry] of recent.entries()) {
3116
3211
  try {
3117
- renderCardIntoList(recentSection, entry, recentCardsById, index, 1, hueByWorkspace);
3212
+ renderCardIntoList(recentSection, entry, recentCardsById, index, 1, colorByWorkspace);
3118
3213
  } catch (error) {
3119
3214
  renderOk = false;
3120
3215
  logCardRenderError(entry.id, error);
@@ -3133,6 +3228,8 @@ function apply(ctx) {
3133
3228
  }
3134
3229
  runMoveGhosts(movePlans);
3135
3230
  if (shiftRects !== null) runShiftAnimations(shiftRects);
3231
+ ensureCurrentCardVisible(pane.querySelector(".dap-scroll"), snapshot?.current ?? null);
3232
+ if (focusAfterMigrationId !== null) cardsById.get(focusAfterMigrationId)?.el.focus();
3136
3233
  // 区域已有条目但列表仍在途时,在区头部显示行内加载指示(R-01-014/AC-01)。
3137
3234
  const headerEl = pane.querySelector(".dap-header");
3138
3235
  const recentHeadEl = recentSection?.querySelector(".dap-recent-head") ?? null;
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 = {
@@ -1275,11 +1275,11 @@ export function workspaceInfoForSession(sessionId, workspaceItems, byId = {}) {
1275
1275
  }
1276
1276
 
1277
1277
  /**
1278
- * 工作区徽标色相(R-01-003/AC-08、AC-09):以工作区身份为唯一输入的纯函数——
1278
+ * 工作区徽标基色色相(R-01-003/AC-08、AC-09):以工作区身份为唯一输入的纯函数——
1279
1279
  * djb2 哈希经雪崩终混(异或右移 + 乘法,把高位熵折入低位,消除 djb2 低位分布
1280
1280
  * 聚集;每步 >>> 0 保持无符号,异或结果可能带符号位)后在避开红色警戒区的
1281
1281
  * 色相弧 [30°,320°] 上均匀取色(30 + hash % 291),输出 [30,320] 整数。
1282
- * 同一身份恒得同一色相,与工作区列表顺序、会话状态及持久化存储无关,页面
1282
+ * 同一身份恒得同一基色色相,与工作区列表顺序、会话状态及持久化存储无关,页面
1283
1283
  * 刷新后不变;空身份返回 null。
1284
1284
  */
1285
1285
  export function workspaceHue(key) {
@@ -1298,34 +1298,99 @@ export function workspaceHue(key) {
1298
1298
  return 30 + (hash % 291);
1299
1299
  }
1300
1300
 
1301
- 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
+ }
1302
1340
 
1303
1341
  /**
1304
- * 同屏工作区色相消解(R-01-003/AC-08、AC-12):身份去重排序后,以稳定基色
1305
- * 确定七个 OKLCH 感知锚点的起始槽;撞槽时按步进 3 跨色区探测。超过七个
1306
- * 工作区后选择当前使用次数最少的槽,使复用均衡且确定。
1342
+ * 同屏工作区复合颜色槽位消解(R-01-003/AC-08、AC-12):前七个身份沿原七色
1343
+ * OKLCH 主槽位分配,主槽位占满后再使用五个受控明度/色相补充槽位;每个前景槽位
1344
+ * 内优先使用未用的三个背景变体;超过三十六个复合身份后按槽位使用次数均衡复用。
1307
1345
  */
1308
- export function resolveWorkspaceHues(keys) {
1346
+ export function resolveWorkspaceColors(keys) {
1309
1347
  const identities = [...new Set((Array.isArray(keys) ? keys : []).map(cleanText).filter(Boolean))].sort();
1310
- 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));
1311
1350
  const resolved = new Map();
1312
1351
  for (const identity of identities) {
1313
- const start = (workspaceHue(identity) - 30) % WORKSPACE_HUE_ANCHORS.length;
1314
- let chosen = start;
1315
- for (let offset = 0; offset < WORKSPACE_HUE_ANCHORS.length; offset += 1) {
1316
- const candidate = (start + offset * 3) % WORKSPACE_HUE_ANCHORS.length;
1317
- if (uses[candidate] < uses[chosen]) chosen = candidate;
1318
- if (uses[candidate] === 0) {
1319
- chosen = candidate;
1320
- break;
1321
- }
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
+ );
1322
1377
  }
1323
- uses[chosen] += 1;
1324
- 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
+ });
1325
1389
  }
1326
1390
  return resolved;
1327
1391
  }
1328
1392
 
1393
+
1329
1394
  /** 主会话按左侧工作区顺序排序的权重;不在任何 workspace 的排在最后保持 lineage 顺序。 */
1330
1395
  function workspaceRank(workspaceItems) {
1331
1396
  const wsIndex = new Map();
@@ -1380,6 +1445,7 @@ export function mainTitle(byId, id) {
1380
1445
  * - 主会话 running(且无 pending)或处于委托周期(含后代耗尽空窗)→ 'running'
1381
1446
  * - 主会话 pendingInteraction / completed / errorReminder → 'awaiting'(等待用户行动)
1382
1447
  * - 子代理 running / pending 或存在活动后代 → 'subagent',自身与后代均不活动则不显示
1448
+ * - 普通 fork 虽有 parentId,但 origin 缺失,仍按独立主会话处理
1383
1449
  * completions(完成确认与错误提醒,R-01-002/AC-05、AC-13、R-01-010/AC-06):Map id →
1384
1450
  * { lastTurnEnd, lastTurnEndKind, lastTurnEndError, ackedAt },由渲染层从宿主侧 ack 状态
1385
1451
  * 通道注入;其中完成提醒成立(completionReminder)或错误提醒成立(errorReminder)的
@@ -1387,21 +1453,38 @@ export function mainTitle(byId, id) {
1387
1453
  * 记账派生):集合内会话视同处于委托周期——后代耗尽至 settle 处理回合启动的
1388
1454
  * 空窗内仍保持运行呈现、完成/错误提醒不生效;条目的 descendantActive 字段
1389
1455
  * 始终为当帧原始后代活性(供进度锚点记账判定耗尽),不受 delegatingIds 影响。
1456
+ * archivedIds(工作区服务的注册表全局归档集合):归档会话及其有效子代理子树不产出活动条目,
1457
+ * 普通 fork 仅因共享来源 parentId 不受影响;避免会话服务仍保留旧行或完成提醒时在窗格中滞留。
1390
1458
  */
1391
- export function buildEntries(snapshot, workspaceItems, detailsById = {}, completions = null, delegatingIds = null) {
1459
+ export function buildEntries(snapshot, workspaceItems, detailsById = {}, completions = null, delegatingIds = null, archivedIds = []) {
1392
1460
  const byId = isRecord(snapshot) && isRecord(snapshot.byId) ? snapshot.byId : {};
1393
1461
  const ids = Array.isArray(snapshot?.ids) ? snapshot.ids : [];
1394
1462
  const current = snapshot?.current ?? null;
1395
1463
  const subagentsByParent = isRecord(snapshot?.subagentsByParent)
1396
1464
  ? snapshot.subagentsByParent
1397
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
+ };
1398
1480
  const rank = workspaceRank(workspaceItems ?? []);
1399
- const descendantIds = descendantActiveIds(byId);
1481
+ const descendantIds = descendantActiveIds(byId, isArchived);
1400
1482
  // 第一遍:层级关系 + 显示判定(show = 自身活动 || 委托周期 || 完成提醒,单点实现避免漂移)。
1401
1483
  const rootIds = [];
1402
1484
  const childIds = new Map();
1403
1485
  const meta = new Map();
1404
1486
  for (const id of ids) {
1487
+ if (isArchived(id)) continue;
1405
1488
  const row = byId[id];
1406
1489
  if (!isRecord(row)) continue;
1407
1490
  const hasParent = isSubagentRow(row, byId);
@@ -1619,10 +1702,15 @@ export function listLoadState(snapshot) {
1619
1702
  return "ready";
1620
1703
  }
1621
1704
 
1622
- /** 会话行是否为某主会话的直属子代理。 */
1705
+ /** 会话行是否为某主会话的直属子代理:需同时满足 origin 与有效父会话。 */
1623
1706
  export function isSubagentRow(row, byId = {}) {
1624
1707
  const id = row?.parentId;
1625
- 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;
1626
1714
  }
1627
1715
 
1628
1716
  /** 会话行是否满足自身状态的活动判定,不含后代活动继承。
@@ -1636,31 +1724,32 @@ function isOwnActiveRow(row, byId = {}) {
1636
1724
  return running || pending;
1637
1725
  }
1638
1726
 
1639
- /** 沿自身活动会话的有效 parentId 链上溯收集会话 id:includeSelf 含活动会话自身,
1727
+ /** 沿实际子代理关系的有效 parentId 链上溯收集会话 id:includeSelf 含活动会话自身,
1640
1728
  * 否则只收祖先(存在活动后代的母会话)。活动区与历史区显示判定的单点实现。 */
1641
- function lineageActiveIds(byId, includeSelf) {
1729
+ function lineageActiveIds(byId, includeSelf, isExcluded = null) {
1642
1730
  const ids = new Set();
1643
1731
  for (const [id, row] of Object.entries(byId)) {
1644
- if (!isOwnActiveRow(row, byId)) continue;
1732
+ if (isExcluded?.(id) || !isOwnActiveRow(row, byId)) continue;
1645
1733
  const seen = new Set();
1646
- let currentId = includeSelf ? id : row?.parentId;
1734
+ let currentId = includeSelf ? id : subagentParentId(row, byId);
1647
1735
  while (currentId !== undefined && currentId !== null && isRecord(byId[currentId]) && !seen.has(String(currentId))) {
1736
+ if (isExcluded?.(currentId)) break;
1648
1737
  seen.add(String(currentId));
1649
1738
  ids.add(String(currentId));
1650
- currentId = byId[currentId]?.parentId;
1739
+ currentId = subagentParentId(byId[currentId], byId);
1651
1740
  }
1652
1741
  }
1653
1742
  return ids;
1654
1743
  }
1655
1744
 
1656
- /** 沿活动会话的有效 parentId 链补齐活动祖先(含活动会话自身),供历史区显示判定。 */
1745
+ /** 沿实际子代理关系补齐活动祖先(含活动会话自身),供历史区显示判定。 */
1657
1746
  export function activeSessionIds(byId = {}) {
1658
1747
  return lineageActiveIds(byId, true);
1659
1748
  }
1660
1749
 
1661
1750
  /** 「存在活动后代」的母会话集合(不含活动会话自身),供 buildEntries 判定委托周期(R-01-003/AC-05)。 */
1662
- function descendantActiveIds(byId = {}) {
1663
- return lineageActiveIds(byId, false);
1751
+ function descendantActiveIds(byId = {}, isExcluded = null) {
1752
+ return lineageActiveIds(byId, false, isExcluded);
1664
1753
  }
1665
1754
 
1666
1755
  /** 判断活动条目是否需要建立轮内状态订阅:以宿主 running 为准、与呈现 kind 解耦——
@@ -1898,12 +1987,14 @@ export function buildRecent(snapshot, workspaceItems, now, detailsById = {}, arc
1898
1987
  return entries;
1899
1988
  }
1900
1989
 
1901
- /** 毫秒时长的人性化短格式,例如 "47s"、"3m12s"。 */
1990
+ /** 毫秒时长的人性化短格式,例如 "47s"、"3m12s"、"1h2m3s"。 */
1902
1991
  export function fmtElapsedMs(ms) {
1903
1992
  if (!Number.isFinite(ms) || ms < 0) return "";
1904
1993
  const s = Math.round(ms / 1000);
1905
1994
  if (s < 60) return `${s}s`;
1906
- return `${Math.floor(s / 60)}m${s % 60}s`;
1995
+ const m = Math.floor(s / 60);
1996
+ if (m < 60) return `${m}m${s % 60}s`;
1997
+ return `${Math.floor(m / 60)}h${m % 60}m${s % 60}s`;
1907
1998
  }
1908
1999
 
1909
2000
  /** 历史卡相对活动时间:按分钟、小时、天、周、月、年分级;负值返回空。 */
@@ -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