# 状态徽标与 peer 角色 rail 上那个彩色点是怎么来的、为什么这么设计,以及它唯一一处放宽的安全边界。 ## 徽标语义 | 颜色 | 含义 | 何时出现 | 何时消失 | |---|---|---|---| | **琥珀** | 远端有会话正卡在**等你回答**(批准 / 提问 / 计划评审) | 请求发出 | 那边被回答 | | **蓝** | 远端有会话正在跑 | 开始运行 | 跑完 | | **绿** | 自你上次打开远程视图之后,有会话被改动过 | 有活动 | **打开远程视图即清零** | | **红** | 该远端的状态读不到 | 读不到 | 恢复后 | 展开时,每个状态是一枚**计数胶囊**(点 + 数字,底色是该状态色的 16% 淡染),整个胶囊组贴在行的**最右端**;收起成 36px rail 时标签没地方放了,就只留一个点挂在图标**右上角**(按 琥珀 > 蓝 > 绿 > 红 取最高优先级),完整分解在 tooltip 里,含每台主机的明细。 **为什么在最右端,而不是紧跟图标**:外壳把面板行渲染成 `[图标槽][文字]`,`renderSlot` 的内容住在图标槽里 —— 徽标只要还在文档流里,就排在文字**前面**,把「远程」两个字顶到右边、和别的行对不齐(第一版就是这样,用户一眼就看出来了)。`SIDEBAR_SEAM_CSS` 因此用 `:has([data-dsh-remote-badge])` **只对本行**把外壳的图标槽 `display:contents` 掉,图标和胶囊才成为行本身的 flex item,胶囊再用 `margin-left:auto` 落到最右。那一列不是随手取的:会话行的时间戳同样距行右缘 8px,两者在同一条竖线上。 胶囊的字号/行高/内边距**照抄设计系统的胶囊规格**(`Tag.module.css` 里那句 "a tag reads as one size everywhere":11px/17px、weight 500、`1px 8px`、全圆角),字体走 UI 栈而不是等宽栈,并加 `tabular-nums` —— 计数是"数量"不是"标识符",设计系统自己的数字胶囊(`StatsPills` / `TurnUsagePanel`)就是这么写的。理由见 [implementation.md](implementation.md#字体是选出来的不是继承来的)。 **琥珀不会因为"你看过了"而消失** —— 它是活的阻塞状态,不是通知。得在远端那边真的把问题答了它才灭。这个优先级和远端自己侧边栏的行一致:一个正等你的会话不是"忙",是"停住了"。 ### 绿点为什么不是"空闲会话数" DSH 自己的状态点里,`done` 同时覆盖"已完成"和"空闲": ```js // packages/client/ui-workspace/src/client/rows/Rows.tsx if (node.completed) return [{ state: 'done', label: '已完成' }] return [{ state: 'done', label: '空闲' }] ``` 而"空闲"是会话的**持续属性**,不是事件。按状态常驻来做,徽标会退化成"远端有几个会话"的计数器 —— **永远不会消失**,也就没有信息量。所以这里按**事件**算,而且对准的正是远端侧边栏自己那个 `completed` 提醒:**跑完了一轮、而你还没打开看过**。 ### 绿点为什么不能用 `ageMs` 算(踩过的坑) peer 上报的 `ageMs` 是 **`SessionSummary.updatedAt`**,而它是: ```js // packages/api/session-controller/src/list.ts function updatedAt(header, metadata) { return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0) } // lastPromptAt 只在 event.type === 'user/message' && data.source.kind === 'user' 时推进 ``` 也就是**最后一次"人"发消息的时间**。跑完一轮**不会**动它。实测这套部署里: ``` 08:52:44 running:true ageMs: 381518 ← 提示发生在 6.4 分钟前 08:53:50 running:false ageMs: 447228 ← 跑完了,但 ageMs 还在从"提示"起算 09:01:01 running:false ageMs: 878482 ← 而且继续涨 ``` 于是"一个 7 分钟前被提示、刚跑完的会话"和"一个闲着没跑的会话"**在单次快照里长得一模一样**。按 `ageMs < 你上次查看距今` 判定,只要你是**在提示之后、跑完之前**打开过远程视图(很正常 —— 你就是去看它干活的),绿点就永远不会亮。 **只有 running→idle 这个边沿能说出这件事,而边沿需要两次观测。** 所以 peer 半面在自己的 register 里盯着它: ```js // packages/interaction/... —— 不是;这是 lib/index.js 里的 createPeerTracker observeRunning(sessionId, live, now) { const previous = prevRunning.get(sessionId) prevRunning.set(sessionId, live) if (live) { completedAt.delete(sessionId); return } // 又跑起来了 → 撤销提醒 if (previous === true) completedAt.set(sessionId, now) // 衰落沿 → 记一笔 } ``` 然后每一行按同一个形状上报`completedAgeMs`(距今多久跑完的),客户端取**这一行上两个时长里较小的那个**: ```js function activityAgeMs(session) { var age = Number.isFinite(Number(session.ageMs)) ? Number(session.ageMs) : Infinity; var finished = Number(session.completedAgeMs); return Number.isFinite(finished) && finished < age ? finished : age; } ``` 几个刻意的选择: - **寄存器按真实 session id 记,而 id 从不出网。** 这是把计数做成"会话数"而不是"信号数"的关键:一个会话同时满足"提示过"和"跑完了"时,两个时长落在**同一行**上,取小值就合成一个。反过来,如果让读的那一侧自己数边沿,它就分不清"一个会话跑完两次"和"两个会话各跑完一次",也分不清"只是被提示过"和"确实跑过"。 - **"活着"包含卡在等你的会话。** 一个会话从"在跑"变成"等你回答"不该被当成跑完 —— 那种情况已经在闪琥珀了。 - **第一次观测只记位、不记完成。** 加载时就已经闲着的会话不会补一条提醒,和远端自己的侧边栏一致。 - **会话再次运行会撤销提醒**,也和浏览器的 session manager 一致。 - 寄存器在 peer 进程里,所以**刷新页面不会丢**;而 peer 重启后它自然为空,不会假装所有会话刚跑完。 `ageMs` 那条规则还在,管的是另一种事:"有人在别处提示了这台远端" —— 那种情况你浏览器可能根本没开着,任何边沿都观测不到。它和 `completedAgeMs` 各自都是 peer 测出的**时长**,所以判定依旧不依赖两台机器的时钟一致。 > **这里踩过一个坑:绿点后面的数字出现过 2,而实际上只有一个会话跑完。** 原因是当时读的那一侧把两个信号**相加**了 —— "提示过"算一个、"跑完边沿"算一个,同一个会话就占了两个;而那个用来兜底的"不超过会话总数"上限,在一个有两台会话的 peer 上恰好是 2,完全挡不住。现在两个信号落在同一行上取小值,结构上就不可能再加起来。 配色和形状取自远端侧边栏自己用的同一批 theme token 和同一套画法,所以和它的会话列表完全一致: - **琥珀 / 绿 / 红**是实心点 —— 和 `StateDot` 一样,一圈 0.10 不透明度的光晕 + 中间 6/10 大小的实心核。 - **蓝**就是会话列表里那个 **8 格追逐动画**:10px 网格上八个 2px 方块,从左上角起顺时针各占一格,每格停在四档离散亮度之一(峰值 1 → 0.6 → 0.35 → 0.15),逐格错开 125ms。关键帧和 `StateDot.module.css` 逐字一致。 关键帧没法用 React 内联样式表达,所以这份 CSS 单独注入一张带 `status-dot` 标记的 `