dsh-project-room 0.2.3 → 0.2.5

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.
Files changed (2) hide show
  1. package/client.js +2246 -2160
  2. package/package.json +51 -51
package/client.js CHANGED
@@ -1,2160 +1,2246 @@
1
- /**
2
- * dsh-project-room · Client 半
3
- *
4
- * 做两件事:
5
- * 1. 接管侧栏浏览区(`sidebar.workspaces`,priority -2):把平铺的会话列表改成
6
- * 「项目分组 + 成员芯片 + 每组今日运行时长 + 搜索 + 折叠/置顶」。
7
- * 分组依据 = 会话标题里的分隔符(默认 ' · ',「项目 · 成员」→ 项目「项目」)。
8
- * 2. 一个项目房间浮层(`shell.overlay`):公共看板 / 任务线分列 / 向成员投递消息。
9
- *
10
- * 数据来源全部是 DSH 原生 + 本插件自己的 Host 半端点:
11
- * - 会话与运行状态:slot props 的 `useSessions` / `useWorkspaces`(原生 `running`、`updatedAt`)
12
- * - 看板/任务/待办/投递:`/project-room/*`(见 index.js)
13
- * 不读任何其它插件的 DOM 或私有文件。
14
- */
15
-
16
- window.__ModuleLoader__.load({
17
- id: 'dsh-project-room',
18
- factory(require) {
19
- const React = require('react');
20
- const h = React.createElement;
21
-
22
- const SEP = ' · ';
23
- const STYLE_ID = 'project-room-style';
24
- const API = '/project-room';
25
- /** 标题里没有分隔符的会话归到这个名字下(默认不显示,见 showLoose)。 */
26
- const LOOSE_GROUP = '未分组';
27
- /** 默认项目管理者名;用户可在侧栏齿轮里改(localStorage: projectRoom.managerName)。 */
28
- const DEFAULT_MANAGER = 'Yat2';
29
-
30
- // ───────────────────────── 小工具 ─────────────────────────
31
-
32
- function projectOfTitle(title) {
33
- const text = String(title || '');
34
- const at = text.lastIndexOf(SEP);
35
- if (at <= 0) return null;
36
- const project = text.slice(0, at).trim();
37
- const member = text.slice(at + SEP.length).trim();
38
- if (!project || !member) return null;
39
- return { project, member };
40
- }
41
-
42
- function formatDuration(ms) {
43
- const total = Math.max(0, Math.floor(ms / 1000));
44
- const hh = Math.floor(total / 3600);
45
- const mm = Math.floor((total % 3600) / 60);
46
- const ss = total % 60;
47
- if (hh > 0) return `${hh}h${String(mm).padStart(2, '0')}m`;
48
- if (mm > 0) return `${mm}m${String(ss).padStart(2, '0')}s`;
49
- return `${ss}s`;
50
- }
51
-
52
- /**
53
- * 会话「最后一次活动」时间(毫秒)。
54
- * ⚠️ 不能用 session.updatedAt:DSH 的 sessions 投影没有这个字段
55
- * (全仓 `session.updatedAt || 0` 都是防御写法,实测恒 0)——
56
- * 这正是用户两次报「侧边栏分身还是没有计时」的根因。
57
- * 键与 hub-desktop-ui 共用 `hub.lastActive`,两边互相补。
58
- */
59
- /**
60
- * 「距上次活动 / X 小时前运行」这类相对时长是否显示(用户令 2026-11-09:
61
- * 「在设置里加上 agent 多少多少小时前运行这个显示的开关」)。
62
- * 默认开;关掉后只显示运行中时长,空闲行不再出现相对时间。
63
- */
64
- const SHOW_IDLE_AGO_KEY = 'projectRoom.showIdleAgo';
65
- let showIdleAgo = (() => {
66
- try {
67
- const raw = localStorage.getItem(SHOW_IDLE_AGO_KEY);
68
- return raw === null ? true : raw === '1';
69
- } catch { return true; }
70
- })();
71
- function setShowIdleAgo(on) {
72
- showIdleAgo = Boolean(on);
73
- try { localStorage.setItem(SHOW_IDLE_AGO_KEY, showIdleAgo ? '1' : '0'); } catch { /* 存不了就本次生效 */ }
74
- }
75
-
76
- const LAST_ACTIVE_KEY = 'hub.lastActive';
77
- const lastActiveTimes = (() => {
78
- try { return JSON.parse(localStorage.getItem(LAST_ACTIVE_KEY) || '{}') || {}; } catch { return {}; }
79
- })();
80
- function markLastActive(id, at) {
81
- const key = String(id || '');
82
- if (!key) return;
83
- lastActiveTimes[key] = Number(at) || Date.now();
84
- try { localStorage.setItem(LAST_ACTIVE_KEY, JSON.stringify(lastActiveTimes)); } catch { /* 存储不可用 */ }
85
- }
86
- function lastActiveOf(id) {
87
- return Number(lastActiveTimes[String(id || '')]) || 0;
88
- }
89
-
90
- function relTime(ts) {
91
- if (!ts) return '';
92
- const diff = Date.now() - ts;
93
- if (diff < 60000) return '刚刚';
94
- if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`;
95
- if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`;
96
- return `${Math.floor(diff / 86400000)} 天前`;
97
- }
98
-
99
- function dayKey() {
100
- const d = new Date();
101
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
102
- }
103
-
104
- function hashHue(text) {
105
- let sum = 0;
106
- for (let i = 0; i < String(text).length; i += 1) sum = (sum * 31 + String(text).charCodeAt(i)) % 360;
107
- return sum;
108
- }
109
-
110
- /** 成员芯片的「头像」:名字首字符 + 由名字推导的稳定色相。 */
111
- function memberGlyph(name) {
112
- const text = String(name || '?').trim();
113
- return text ? Array.from(text)[0] : '?';
114
- }
115
-
116
- /**
117
- * 成员排序权重(越小越前):
118
- * 0 = Yat2(项目管理者,用户要求它始终排第一)
119
- * 1 = 运行中
120
- * 2 = 其它
121
- */
122
- /**
123
- * 项目管理者的名字(默认 Yat2,可在侧栏齿轮里改,存 localStorage)。
124
- * 排序、新建项目默认成员、房间成员归属都读它。
125
- * 注意边界:hub-desktop-ui 里还硬编码着 MANAGER_NAMES = ['Yat2','Hermes'] 用于
126
- * 注入管理者协议——改这里的名字**不会**改变那边认谁是管理者。
127
- */
128
- function managerName() {
129
- try {
130
- const raw = window.localStorage.getItem('projectRoom.managerName');
131
- const value = raw ? String(raw).trim() : '';
132
- return value || DEFAULT_MANAGER;
133
- } catch { return DEFAULT_MANAGER; }
134
- }
135
-
136
- /**
137
- * 极简 Markdown → React 元素(不引第三方库,够黑板用):
138
- * 标题 #~####、无序列表 -/*、行内 **粗体** 与 `代码`、其余按段落。
139
- */
140
- function renderMarkdown(text) {
141
- const lines = String(text || '').split('\n');
142
- const out = [];
143
- let listBuffer = [];
144
- const flushList = () => {
145
- if (!listBuffer.length) return;
146
- out.push(h('ul', { key: `ul-${out.length}`, className: 'pr-md-ul' }, listBuffer));
147
- listBuffer = [];
148
- };
149
- const inline = (line, key) => {
150
- const regex = /(\*\*[^*]+\*\*|`[^`]+`)/g;
151
- const parts = [];
152
- let last = 0;
153
- let match;
154
- let index = 0;
155
- while ((match = regex.exec(line)) !== null) {
156
- if (match.index > last) parts.push(line.slice(last, match.index));
157
- const token = match[0];
158
- if (token.startsWith('**')) parts.push(h('strong', { key: `${key}-b${index++}` }, token.slice(2, -2)));
159
- else parts.push(h('code', { key: `${key}-c${index++}`, className: 'pr-md-code' }, token.slice(1, -1)));
160
- last = match.index + token.length;
161
- }
162
- if (last < line.length) parts.push(line.slice(last));
163
- return parts.length ? parts : [line];
164
- };
165
- lines.forEach((raw, lineIndex) => {
166
- const line = raw.replace(/\s+$/, '');
167
- if (!line.trim()) { flushList(); return; }
168
- const heading = /^(#{1,4})\s+(.*)$/.exec(line);
169
- if (heading) {
170
- flushList();
171
- const level = Math.min(heading[1].length + 2, 6);
172
- out.push(h(`h${level}`, { key: `h-${lineIndex}`, className: 'pr-md-h' }, ...inline(heading[2], `h${lineIndex}`)));
173
- return;
174
- }
175
- const bullet = /^[-*]\s+(.*)$/.exec(line);
176
- if (bullet) {
177
- listBuffer.push(h('li', { key: `li-${lineIndex}` }, ...inline(bullet[1], `li${lineIndex}`)));
178
- return;
179
- }
180
- flushList();
181
- out.push(h('p', { key: `p-${lineIndex}`, className: 'pr-md-p' }, ...inline(line, `p${lineIndex}`)));
182
- });
183
- flushList();
184
- return out;
185
- }
186
-
187
- function memberRank(member) {
188
- const name = String((member && (member.base || member.name)) || '').trim();
189
- if (name && name.toLowerCase() === managerName().toLowerCase()) return 0;
190
- return member && member.running ? 1 : 2;
191
- }
192
-
193
- function loadJson(key, fallback) {
194
- try {
195
- const raw = window.localStorage.getItem(key);
196
- return raw ? JSON.parse(raw) : fallback;
197
- } catch { return fallback; }
198
- }
199
-
200
- function saveJson(key, value) {
201
- try { window.localStorage.setItem(key, JSON.stringify(value)); } catch { /* 配额满了就放弃持久化 */ }
202
- }
203
-
204
- /** 插件上下文(apply 时赋值),用来取宿主 Client 服务。 */
205
- let clientCtx = null;
206
-
207
- /**
208
- * 打开一个会话。
209
- * 正确通道是宿主 Client 服务 `uiWorkspace.openSession(id)`——不是 slot props
210
- * (`sidebar.workspaces` 只给 useWorkspaces / useSessions)。
211
- * 早期版本错用了 props.openSession 并在失败时退回「打开项目房间」,
212
- * 结果点任何芯片都弹出公共看板。
213
- */
214
- function openSessionById(id) {
215
- if (!id) return false;
216
- try {
217
- const svc = clientCtx && typeof clientCtx.get === 'function' ? clientCtx.get('uiWorkspace') : null;
218
- if (svc && typeof svc.openSession === 'function') {
219
- svc.openSession(id);
220
- return true;
221
- }
222
- } catch (error) {
223
- console.error('[project-room] openSession failed', error);
224
- }
225
- return false;
226
- }
227
-
228
- /** 当前打开的会话 id(宿主写在会话内容区的 data 属性上) */
229
- function currentSessionId() {
230
- try {
231
- const el = document.querySelector('[data-conversation-content]');
232
- return el ? (el.getAttribute('data-conversation-session') || '') : '';
233
- } catch { return ''; }
234
- }
235
-
236
- /**
237
- * 量出「对话内容区」在屏幕上的位置,让房间页正好占住这一块
238
- * (侧栏、顶栏都保留),而不是盖住整个窗口。
239
- * 量不到就退回到「从左栏后面开始」。
240
- */
241
- function conversationBox() {
242
- try {
243
- const el = document.querySelector('[data-conversation-content]');
244
- if (el) {
245
- const rect = el.getBoundingClientRect();
246
- if (rect.width > 120 && rect.height > 120) {
247
- return { left: Math.round(rect.left), top: Math.round(rect.top) };
248
- }
249
- }
250
- } catch { /* 量不到走兜底 */ }
251
- return { left: 264, top: 0 };
252
- }
253
-
254
- // ───────────────────── 房间打开总线(侧栏 → 浮层) ─────────────────────
255
-
256
- const roomBus = {
257
- current: null,
258
- listeners: new Set(),
259
- /** 侧栏每次渲染都会刷新这里,房间浮层据此拿到「本项目成员」。 */
260
- sessionIndex: new Map(),
261
- open(project) {
262
- this.current = project;
263
- for (const fn of this.listeners) { try { fn(project); } catch { /* ignore */ } }
264
- },
265
- close() {
266
- this.current = null;
267
- for (const fn of this.listeners) { try { fn(null); } catch { /* ignore */ } }
268
- },
269
- subscribe(fn) {
270
- this.listeners.add(fn);
271
- return () => this.listeners.delete(fn);
272
- },
273
- };
274
-
275
- // ───────────────────────── 样式 ─────────────────────────
276
-
277
- // 视觉基准 = AI-Agent-Hub 现有侧栏(HubWorkspaces):同样的行高、间距、圆角、芯片网格。
278
- // 数值来自 hub-desktop-ui 的实测样式(.hub-proj / .hub-chip 等),照搬以保证观感一致。
279
- const CSS = `
280
- .pr-root {
281
- display: flex; flex-direction: column;
282
- flex: 1 1 auto; min-height: 0; height: 100%;
283
- overflow-y: auto; overflow-x: hidden;
284
- padding: 0 4px 8px; box-sizing: border-box;
285
- }
286
- .pr-search { display: flex; align-items: center; gap: 4px; padding: 0 6px 6px; flex: 0 0 auto; }
287
- .pr-search input {
288
- flex: 1; min-width: 0; height: 22px; box-sizing: border-box; padding: 0 6px;
289
- border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1);
290
- background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary);
291
- font-size: 11px; outline: none;
292
- }
293
- .pr-head { display: flex; align-items: center; gap: 6px; padding: 2px 6px 4px; flex: 0 0 auto; }
294
- .pr-head-title { font-size: 11.5px; letter-spacing: 0.04em; font-weight: 600; color: var(--dsw-alias-label-secondary); }
295
- .pr-head-count { font-size: 10px; color: var(--dsw-alias-label-secondary); opacity: .75; margin-left: auto; }
296
- /* 「未分组」那一行:折叠按钮 + 清理按钮 */
297
- .pr-loose-row { display: flex; gap: 4px; align-items: center; padding: 0 6px 6px; flex: 0 0 auto; }
298
- .pr-loose-toggle {
299
- padding: 1px 6px;
300
- border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px;
301
- background: transparent; color: var(--dsw-alias-label-secondary);
302
- font-size: 10px; line-height: 1.6; cursor: pointer; flex: 0 0 auto;
303
- }
304
- .pr-loose-toggle:hover { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
305
- .pr-chip-badge {
306
- flex: 0 0 auto; min-width: 14px; height: 14px; padding: 0 3px; border-radius: 7px;
307
- display: inline-flex; align-items: center; justify-content: center;
308
- background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary);
309
- font-size: 9px; line-height: 1; font-variant-numeric: tabular-nums;
310
- }
311
- .pr-chip-elapsed { flex: 0 0 auto; font-size: 9px; line-height: 1; font-variant-numeric: tabular-nums; color: var(--dsw-alias-state-success-primary); }
312
- /* 分身子行:树状(├ / └ 竖线缩进) */
313
- .pr-chip-clones { display: flex; flex-direction: column; gap: 1px; margin-top: 1px; padding-left: 0; }
314
- .pr-clone-row {
315
- display: flex; align-items: center; gap: 3px;
316
- font-size: 9.5px; line-height: 1.5; color: var(--dsw-alias-label-secondary);
317
- cursor: pointer; border-radius: 3px; padding: 0;
318
- }
319
- .pr-clone-row:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.06)); color: var(--dsw-alias-label-primary); }
320
- .pr-tree-rail { flex: 0 0 auto; width: 7px; color: var(--dsw-alias-border-l2, #4a4a4a); font-size: 9px; line-height: 1.5; }
321
- .pr-clone-dot { flex: 0 0 auto; width: 5px; height: 5px; border-radius: 50%; background: transparent; box-shadow: inset 0 0 0 1px var(--dsw-alias-label-secondary); }
322
- .pr-clone-dot.on { background: var(--dsw-alias-state-success-primary, #4ade80); box-shadow: none; }
323
- /* 同理:分身子行名不再 ellipsis,长任务名完整显示(行高会自动撑开)。 */
324
- .pr-clone-name { min-width: 0; white-space: normal; overflow-wrap: anywhere; word-break: break-word; }
325
- .pr-clone-elapsed {
326
- flex: 0 0 auto; margin-left: 3px; font-size: 9px; line-height: 1;
327
- font-variant-numeric: tabular-nums;
328
- color: var(--dsw-alias-state-success-primary);
329
- }
330
- .pr-clone-elapsed.idle, .pr-chip-elapsed.idle { color: var(--dsw-alias-label-secondary); }
331
- .pr-menu {
332
- position: fixed; z-index: 1000; min-width: 168px; padding: 4px;
333
- border-radius: 8px; border: 1px solid var(--dsw-alias-border-l2);
334
- background: var(--dsw-alias-bg-layer-2); box-shadow: 0 12px 32px rgba(0,0,0,.45);
335
- font-size: 12px;
336
- }
337
- .pr-menu-item {
338
- display: block; width: 100%; text-align: left; padding: 5px 8px; border: none; border-radius: 5px;
339
- background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 12px; cursor: pointer;
340
- }
341
- .pr-menu-item:hover { background: var(--dsw-alias-bg-layer-1); }
342
- .pr-menu-msg { padding: 4px 8px; font-size: 10.5px; color: var(--dsw-alias-label-secondary); }
343
- .pr-modal { position: fixed; inset: 0; z-index: 1001; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.45); }
344
- .pr-modal-box { width: min(420px, 88vw); padding: 14px; border-radius: 10px; background: var(--dsw-alias-bg-base); border: 1px solid var(--dsw-alias-border-l2); }
345
- .pr-modal-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
346
- .pr-modal-input { width: 100%; box-sizing: border-box; height: 30px; padding: 0 8px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-size: 12.5px; outline: none; }
347
- .pr-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
348
- /* 头部「+ 新项目」按钮 */
349
- .pr-new-btn {
350
- flex: 0 0 auto; height: 18px; padding: 0 6px; border-radius: 5px;
351
- border: 1px solid var(--dsw-alias-border-l1); background: transparent;
352
- color: var(--dsw-alias-label-secondary); font-size: 10px; line-height: 1; cursor: pointer;
353
- }
354
- .pr-new-btn:hover { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
355
- /* 房间关闭按钮:显式带文字,避免全屏遮罩下只剩一个小图标找不着 */
356
- .pr-close-btn {
357
- flex: 0 0 auto; height: 22px; padding: 0 10px; border-radius: 6px;
358
- border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2);
359
- color: var(--dsw-alias-label-primary); font-size: 11.5px; line-height: 1; cursor: pointer;
360
- }
361
- .pr-close-btn:hover { border-color: var(--dsw-alias-brand-primary); }
362
- /* 一页三段布局 */
363
- /* 房间主体:左 1/3 看板、右 2/3 任务线分列 */
364
- .pr-room-body {
365
- flex: 1; min-height: 0;
366
- display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); grid-template-rows: minmax(0, 1fr);
367
- gap: 12px; align-items: stretch;
368
- }
369
- .pr-room-body > .pr-section { margin-bottom: 0; min-height: 0; }
370
- .pr-board-section { display: flex; flex-direction: column; min-height: 0; }
371
- .pr-tasks-section { display: flex; flex-direction: column; min-height: 0; }
372
- /* 2026-11-09 用户报「群聊视图下面还是有好多空白」:原来这里写死 height/max-height: 56vh,而房间是 100vh → 下方约半个视口永远是空的。这批 vh 兜底是早先为绕开「任务线滚不动」的父级 flex 断点加的,真根因(.pr-lane-msg 的 overflow:hidden)已在 cc84ac7 修掉,兜底就该还原成自适应。 */
373
- .pr-tasks-section .pr-lanes { flex: 1 1 auto; min-height: 100px; height: auto; max-height: none; overflow-x: auto; overflow-y: hidden; align-items: stretch; }
374
- .pr-tasks-section .pr-lane-body { flex: 1 1 auto; min-height: 100px; height: auto; max-height: none; overflow-y: auto; }
375
- .pr-board-section .pr-md-view { flex: 1 1 auto; min-height: 120px; height: auto; max-height: none; overflow-y: auto; }
376
- .pr-board-section .pr-board-edit { flex: 1 1 auto; min-height: 120px; height: auto; max-height: none; }
377
- /* 滚轮各自独立:看板区与每条线的消息区都 contain,滚到底不传递给外层;外层整体不可滚 */
378
- .pr-board-section .pr-md-view { overscroll-behavior: contain; }
379
- .pr-tasks-section .pr-lane-body { overscroll-behavior: contain; }
380
- .pr-board-section .pr-board-edit { overscroll-behavior: contain; }
381
- .pr-section { margin-bottom: 16px; }
382
- .pr-section:last-child { margin-bottom: 4px; }
383
- .pr-section-title {
384
- font-size: 11px; font-weight: 700; letter-spacing: .06em;
385
- color: var(--dsw-alias-label-secondary); text-transform: uppercase;
386
- margin-bottom: 6px;
387
- }
388
- .pr-loose-msg { font-size: 10px; color: var(--dsw-alias-label-secondary); padding: 0 6px 6px; line-height: 1.5; flex: 0 0 auto; }
389
-
390
- /* 运行中快捷栏(对齐 AI-Agent-Hub):两段竖排 —— 上「运行中」、下「完成未读」 */
391
- .pr-runbar { display: flex; flex-direction: column; gap: 4px; padding: 2px 4px 6px; flex: 0 0 auto; min-width: 0; }
392
- /* 2026-11-11 用户令「快捷栏正在运行和完成未读的分开放」:
393
- 每段 = 一行小标题(色点/✓ + 文案 + 计数)+ 可折行的 pill 区,段间用极细线分隔。 */
394
- .pr-runbar-sec { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
395
- .pr-runbar-sec + .pr-runbar-sec {
396
- border-top: 1px solid var(--dsw-alias-border-l1);
397
- padding-top: 3px;
398
- }
399
- .pr-runbar-sec-title {
400
- display: flex; align-items: center; gap: 4px; min-width: 0;
401
- padding: 0 2px; font-size: 9px; line-height: 1.4;
402
- color: var(--dsw-alias-label-secondary); letter-spacing: .03em;
403
- }
404
- .pr-runbar-sec[data-kind="done"] .pr-runbar-sec-title { color: var(--dsw-alias-brand-primary); }
405
- .pr-runbar-sec-dot {
406
- width: 5px; height: 5px; border-radius: 50%; flex: 0 0 auto;
407
- background: var(--dsw-alias-state-success-primary);
408
- }
409
- .pr-runbar-sec-check {
410
- width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto;
411
- display: inline-flex; align-items: center; justify-content: center;
412
- background: var(--dsw-alias-brand-primary); color: #fff;
413
- font-size: 8px; font-weight: 700; line-height: 1;
414
- }
415
- .pr-runbar-sec-label { flex: 0 0 auto; white-space: nowrap; }
416
- .pr-runbar-sec-count {
417
- flex: 0 0 auto; font-variant-numeric: tabular-nums; opacity: .75;
418
- }
419
- .pr-runbar-sec-body { display: flex; flex-wrap: wrap; gap: 3px; min-width: 0; }
420
- /* 快捷栏 pill 也显示分身:每个 pill 变成「按钮 + 树状子行」的竖排小单元,
421
- 子行样式与侧栏芯片的 pr-clone-row / pr-tree-rail 完全一致。 */
422
- .pr-run-wrap { display: inline-flex; flex-direction: column; gap: 1px; align-items: flex-start; }
423
- .pr-run-clones { display: flex; flex-direction: column; gap: 1px; padding-left: 6px; margin: 0 0 1px; }
424
- .pr-run-clones .pr-clone-row { font-size: 9px; line-height: 1.4; }
425
- .pr-readall {
426
- /* 2026-11-11:一键已读搬进「完成未读」区的小标题里,靠右对齐 */
427
- flex: 0 0 auto; margin: 0 0 0 auto; padding: 1px 6px; border-radius: 8px;
428
- border: 1px solid var(--dsw-alias-border-l1); background: transparent;
429
- color: var(--dsw-alias-brand-primary); font-size: 9.5px; line-height: 1.4;
430
- white-space: nowrap; cursor: pointer;
431
- }
432
- .pr-readall:hover { background: var(--dsw-alias-bg-layer-2); }
433
- .pr-run-pill {
434
- display: inline-flex; align-items: center; gap: 4px;
435
- flex: 0 0 auto; max-width: 100%; min-width: 0; overflow: hidden;
436
- min-height: 20px; padding: 2px 7px 2px 3px; border-radius: 11px;
437
- border: 1px solid transparent; background: transparent;
438
- color: var(--dsw-alias-label-secondary);
439
- font: inherit; font-size: 11.5px; line-height: 1.2; white-space: nowrap; cursor: pointer;
440
- }
441
- .pr-run-pill:hover { background: var(--dsw-alias-bg-layer-1); }
442
- .pr-run-pill[data-active="1"] {
443
- border-color: var(--dsw-alias-border-l1);
444
- background: var(--dsw-alias-bg-layer-2);
445
- color: var(--dsw-alias-label-primary);
446
- }
447
- .pr-run-avatar { width: 16px; height: 16px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; line-height: 1; }
448
- .pr-run-project { display: inline-flex; align-items: center; gap: 3px; flex: 0 0 auto; min-width: 0; }
449
- .pr-run-project-dot { width: 6px; height: 6px; border-radius: 2px; flex: 0 0 auto; }
450
- .pr-run-project-name { font-size: 9px; line-height: 1; font-variant-numeric: tabular-nums; white-space: nowrap; }
451
- /* 2026-11-09 用户令「标题要显示全」:快捷栏 pill 名字原来 nowrap+ellipsis 会被吃掉,改为可换行。 */
452
- .pr-run-name { min-width: 0; flex: 0 1 auto; white-space: normal; overflow-wrap: anywhere; word-break: break-word; }
453
- .pr-run-badge {
454
- flex: 0 0 auto; min-width: 14px; height: 14px; padding: 0 3px; border-radius: 7px;
455
- display: inline-flex; align-items: center; justify-content: center;
456
- background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary);
457
- font-size: 9px; line-height: 1; font-variant-numeric: tabular-nums;
458
- }
459
- .pr-run-done { display: inline-flex; align-items: center; gap: 3px; flex: 0 0 auto; font-size: 9px; line-height: 1; color: var(--dsw-alias-brand-primary); }
460
- .pr-run-check {
461
- width: 10px; height: 10px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
462
- background: var(--dsw-alias-brand-primary); color: #fff; font-size: 8px; font-weight: 700; line-height: 1;
463
- }
464
- .pr-run-elapsed { flex: 0 0 auto; font-size: 9px; line-height: 1; font-variant-numeric: tabular-nums; color: var(--dsw-alias-state-success-primary); }
465
-
466
- /* 项目行:22px 紧凑行高 + 顶部细线,读起来像「项目」而不是一行会话 */
467
- .pr-proj {
468
- display: flex; align-items: center; gap: 6px;
469
- height: 22px; min-height: 22px; padding: 0 6px;
470
- margin-top: 6px; border-radius: 6px;
471
- font-size: 12px; font-weight: 600;
472
- box-shadow: inset 0 1px 0 var(--dsw-alias-border-l1);
473
- cursor: pointer; overflow: hidden;
474
- }
475
- .pr-proj:first-of-type { margin-top: 0; }
476
- .pr-proj:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.06)); }
477
- .pr-dot { width: 6px; height: 6px; border-radius: 2px; flex: 0 0 auto; }
478
- .pr-proj-name { flex: 0 1 auto; max-width: 100%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
479
- .pr-spacer { flex: 1 1 auto; min-width: 4px; }
480
- .pr-meta { font-size: 10px; font-weight: 500; color: var(--dsw-alias-label-secondary); flex: 0 0 auto; font-variant-numeric: tabular-nums; }
481
- .pr-icon-btn {
482
- width: 18px; height: 18px; flex: 0 0 auto; border: none; padding: 0; border-radius: 5px;
483
- background: transparent; color: var(--dsw-alias-label-secondary);
484
- cursor: pointer; font-size: 10px; line-height: 1;
485
- }
486
- .pr-icon-btn:hover { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
487
- /* 图钉:默认隐藏,悬停显形;已置顶常显 */
488
- .pr-pin { opacity: 0; transition: opacity 120ms ease; }
489
- .pr-proj:hover .pr-pin, .pr-pin[data-pinned="1"] { opacity: 1; color: var(--dsw-alias-brand-primary); }
490
-
491
- /* 成员芯片网格 */
492
- .pr-grid { display: flex; flex-wrap: wrap; gap: 4px; padding: 2px 6px 4px 18px; }
493
- /* 2026-11-09 用户报「头像又单独放一行」:真因不是 pr-chip-head(已 revert 且与本文件 249ee81 完全一致),
494
- 而是 .pr-chip 的 min-width: 0 + flex: 0 1 auto —— chip 作为 .pr-grid 的 flex item 可以被压到
495
- **比内容还窄**,一旦宽度不够,chip 内部的 flex-wrap: wrap 就把头像和名字拆成两行。
496
- 修法:min-width: min-content(不压到内容以下)+ flex: 0 0 auto(不参与收缩),
497
- 放不下时由 .pr-grid 把**整个 chip** 换到下一行,chip 内部始终一行。max-width:100% 仍兜住超宽情况。 */
498
- .pr-chip {
499
- /* 2026-11-09:改成 block 容器,本体行(.pr-chip-head)与分身树(.pr-chip-clones)各占一行,
500
- 两者背景互不影响 —— 布局已在 dsh/cordis-notes/shots/chip-layout-probe.html + headless Chrome 验证过。
501
- 上次失败的原因是 chip 仍为 inline-flex,包裹层变成 flex item 被 wrap 挤开;改成 block 后不会。 */
502
- display: block;
503
- max-width: 100%; min-width: min-content; flex: 0 0 auto;
504
- padding: 0; border-radius: 11px;
505
- border: 1px solid transparent; background: transparent;
506
- color: var(--dsw-alias-label-secondary);
507
- font: inherit; font-size: 11.5px; line-height: 1.2;
508
- cursor: pointer; box-sizing: border-box; overflow-wrap: anywhere;
509
- }
510
- /* 本体那一行(头像 + 名字 + badge + 圆点 + 时长):单独成盒,选中背景只作用在它上面 */
511
- .pr-chip-head {
512
- display: flex; flex-wrap: wrap; align-items: center; gap: 3px 5px;
513
- max-width: 100%; min-width: 0;
514
- padding: 3px 7px 3px 2px; border-radius: 11px;
515
- border: 1px solid transparent; box-sizing: border-box;
516
- }
517
- /* 2026-11-09 用户令「hover 预选也要单独高亮本体或分身,不要高亮整体」:
518
- 原来 .pr-chip:hover 给**整块**(含分身子行)加底色 —— 与 active 一样是盒子套错了。
519
- 改成给 .pr-chip-head 自身的 :hover(鼠标真在本体行上才亮),分身行仍由 .pr-clone-row:hover 负责;
520
- 整块 chip 不再有任何统一 hover 底色。 */
521
- .pr-chip:hover:not([data-active="1"]) { background: transparent; border-color: transparent; }
522
- .pr-chip-head:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.06)); border-color: var(--dsw-alias-border-l2); color: var(--dsw-alias-label-primary); }
523
- /* 2026-11-09 用户令「单独高亮本体像快捷栏那样就行」:与 .pr-run-pill[data-active=1] 完全同一套
524
- —— 细边框(border-l1) + 底色(bg-layer-2) + 主色文字,不要主色强调条(那是上一版我加的,太重)。
525
- hover 已用 :not([data-active="1"]) 降级,所以选中与悬停仍能区分。 */
526
- /* 2026-11-09:选中背景只作用在本体那一行(.pr-chip-head),不碰分身子行。 */
527
- .pr-chip[data-active="1"] { border-color: transparent; background: transparent; }
528
- .pr-chip[data-active="1"] .pr-chip-head {
529
- border-color: var(--dsw-alias-border-l1);
530
- background: var(--dsw-alias-bg-layer-2);
531
- color: var(--dsw-alias-label-primary);
532
- }
533
- /* 2026-11-09 用户两次报「还是没有单独高亮本体」:原来 active 与 :hover 用**同一个底色**,
534
- 视觉上分不清「选中」和「鼠标停在这儿」。active 加左侧强调条 + 主色描边 + 名字加粗,
535
- hover 降级为只描边不填色,两者一眼可分。 */
536
- .pr-chip[data-active="1"] .pr-chip-name { color: var(--dsw-alias-label-primary); font-weight: 600; }
537
- /* 2026-11-09 用户报「侧边栏的分身怎么没有选中效果」:分身子行原来只有 hover、没有选中态。
538
- 与 .pr-chip[data-active="1"] 同一套视觉(底色 + 主色文字 + 左侧强调条)。 */
539
- .pr-clone-row[data-active="1"] {
540
- background: var(--dsw-alias-bg-layer-2);
541
- color: var(--dsw-alias-label-primary);
542
- }
543
- .pr-clone-row[data-active="1"] .pr-clone-name { color: var(--dsw-alias-label-primary); font-weight: 600; }
544
- /* 2026-11-09 用户令「标题要显示全」:DSH 原生顶部会话标题栏把
545
- 「应用开发 · DSH工程师|讲解已启用插件各自作用」截成「…|讲解...」。
546
- 这里放开标题链上的 max-width / ellipsis,让它完整显示;只作用于会话标题栏,
547
- 不碰侧栏与消息区。 */
548
- [data-slot="conversation.session.header"] span,
549
- [data-slot="conversation.session.header"] div {
550
- max-width: none !important;
551
- text-overflow: clip !important;
552
- overflow: visible !important;
553
- white-space: nowrap;
554
- }
555
- [data-slot="conversation.session.header"] { max-width: none !important; }
556
- .pr-avatar {
557
- width: 16px; height: 16px; border-radius: 50%; display: inline-flex;
558
- align-items: center; justify-content: center; flex: 0 0 auto; line-height: 1;
559
- }
560
- .pr-chip-name { min-width: 0; flex: 0 1 auto; max-width: 100%; white-space: normal; word-break: break-word; overflow-wrap: anywhere; line-height: 1.2; }
561
- @keyframes pr-pulse {
562
- 0% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--dsw-alias-state-success-primary) 55%, transparent); }
563
- 60% { transform: scale(1.45); opacity: .55; box-shadow: 0 0 0 4px transparent; }
564
- 100% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 transparent; }
565
- }
566
- .pr-running-dot { width: 6px; height: 6px; border-radius: 50%; flex: 0 0 auto; background: var(--dsw-alias-state-success-primary); animation: pr-pulse 1.4s ease-in-out infinite; }
567
- .pr-empty { padding: 10px 8px; font-size: 11px; color: var(--dsw-alias-label-secondary); text-align: center; }
568
-
569
- /* 房间浮层 */
570
- .pr-overlay { position: fixed; z-index: 900; display: flex; flex-direction: column; background: var(--dsw-alias-bg-base); border-left: 1px solid var(--dsw-alias-border-l1); }
571
- .pr-panel { width: 100%; height: 100%; display: flex; flex-direction: column; background: var(--dsw-alias-bg-base); }
572
- .pr-panel-head { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
573
- .pr-panel-title { font-size: 13px; font-weight: 700; }/* 2026-11-10 用户令「群聊页字体太大」:14px→13px,与分区标题/正文一起收一档 */
574
- .pr-tabs { display: flex; gap: 2px; margin-left: 10px; }
575
- .pr-tab { padding: 3px 10px; border-radius: 6px; border: none; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 12px; cursor: pointer; }
576
- .pr-tab.on { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-weight: 600; }
577
- .pr-panel-body { flex: 1; min-height: 0; overflow: hidden; padding: 12px 14px; display: flex; flex-direction: column; }
578
- .pr-panel-foot { border-top: 1px solid var(--dsw-alias-border-l1); padding: 8px 14px; display: flex; gap: 8px; align-items: flex-end; }
579
- .pr-textarea { flex: 1; min-height: 34px; max-height: 120px; resize: vertical; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-size: 12px; font-family: inherit; outline: none; }
580
- .pr-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-size: 12px; cursor: pointer; white-space: nowrap; }
581
- .pr-btn:hover { border-color: var(--dsw-alias-brand-primary); }
582
- .pr-btn.primary { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); color: #08111a; font-weight: 600; }
583
- .pr-btn:disabled { opacity: .5; cursor: default; }
584
- .pr-board { width: 100%; min-height: 200px; resize: vertical; padding: 10px; border-radius: 8px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-size: 12.5px; line-height: 1.6; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; outline: none; }
585
- .pr-todo { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px dashed var(--dsw-alias-border-l1); font-size: 12.5px; }
586
- .pr-todo input[type=checkbox] { flex: 0 0 auto; }
587
- .pr-todo-text { flex: 1; min-width: 0; }
588
- .pr-todo.done .pr-todo-text { text-decoration: line-through; opacity: .55; }
589
- /* 任务线分列:每列一个成员,横向滚动,列内是各自最近的消息气泡 */
590
- .pr-lanes { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; align-items: stretch; }
591
- /* 列宽自适应(2026-11-09 用户令「任务线宽度要自适应,不要只有两个任务线右边还空这么多」):
592
- 原来是 flex: 0 0 232px(定宽、永不伸缩)→ 只有 2 条线时右边空掉一大片。
593
- 改成「232px 只是基准,可增长」:
594
- · 列少(2~3 条)→ 各列按比例吃满 .pr-lanes 的宽度,右侧不留空白;
595
- · 列多(基准之和 > 容器)→ flex-shrink 被 min-width 夹住,列不窄于可读宽度,
596
- 超出部分交给 .pr-lanes 的 overflow-x 横向滚动(不裁切、不重叠)。
597
- ⚠️ 本规则只碰宽度;overflow / 高度相关属性属主诉(压扁/滚不动)修复范围,勿在此改。 */
598
- .pr-lane {
599
- flex: 1 1 232px; min-width: 232px; max-width: none; display: flex; flex-direction: column;
600
- border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; overflow: hidden;
601
- background: var(--dsw-alias-bg-layer-1);
602
- }
603
- .pr-lane-head {
604
- display: flex; align-items: flex-start; gap: 5px; padding: 5px 8px;
605
- border-bottom: 1px solid var(--dsw-alias-border-l1);
606
- background: var(--dsw-alias-bg-layer-2);
607
- font-size: 11.5px; font-weight: 600;/* 2026-11-09 用户令:lane 标题完整显示 */
608
- }
609
- /* ⚠️ 2026-11-10 补回上面这条规则的收尾花括号:58ca983 改 lane 标题换行时把块尾的 } 弄丢了,
610
- 浏览器(Chrome 120+ 支持 CSS 嵌套)会把 .pr-lane-name 之后的**全部规则**当成
611
- .pr-lane-head 的嵌套规则(& .pr-lane-name …)→ 28 条规则失效,其中 .pr-md-view /
612
- .pr-md-h 一失效,看板正文就掉回宿主 14px、标题掉回浏览器默认 ≈16.4px(用户报「群聊页字体太大」)。 */
613
- .pr-lane-name { min-width: 0; white-space: normal; overflow-wrap: anywhere; word-break: break-word; }
614
- .pr-lane-archive {
615
- margin-left: auto; flex: 0 0 auto; height: 18px; padding: 0 6px; border-radius: 5px;
616
- border: 1px solid transparent; background: transparent;
617
- color: var(--dsw-alias-label-secondary); font-size: 10px; line-height: 1; cursor: pointer;
618
- }
619
- .pr-lane-archive:hover {
620
- border-color: var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1);
621
- color: var(--dsw-alias-label-primary);
622
- }
623
- .pr-lane-dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 auto; }
624
- .pr-lane-sub {
625
- padding: 0 8px 4px; font-size: 9.5px; color: var(--dsw-alias-label-secondary);
626
- border-bottom: 1px solid var(--dsw-alias-border-l1);
627
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
628
- }
629
- .pr-lane-body { flex: 1; min-height: 90px; max-height: 210px; overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 5px; }
630
- .pr-lane-msg {
631
- font-size: 11px; line-height: 1.4; color: var(--dsw-alias-label-primary);
632
- background: var(--dsw-alias-bg-base); border-radius: 6px; padding: 4px 6px;
633
- display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
634
- white-space: pre-wrap; word-break: break-word;
635
- }
636
- .pr-lane-msg-user {
637
- border-left: 2px solid var(--dsw-alias-brand-primary);
638
- color: var(--dsw-alias-label-primary);
639
- }
640
- .pr-lane-msg-agent { color: var(--dsw-alias-label-secondary); }
641
- /* 看板:渲染视图(纵向滚动)+ 编辑切换 */
642
- .pr-board-section { display: flex; flex-direction: column; min-height: 0; }
643
- .pr-section-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
644
- .pr-board-toggle { height: 22px; padding: 0 9px; font-size: 11px; }
645
- .pr-board-count {
646
- margin-left: auto; font-size: 10px; color: var(--dsw-alias-label-secondary);
647
- font-variant-numeric: tabular-nums; white-space: nowrap;
648
- }
649
- .pr-md-view {
650
- flex: 1; min-height: 120px; max-height: 42vh; overflow-y: auto;
651
- padding: 8px 10px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px;
652
- background: var(--dsw-alias-bg-layer-1); font-size: 12px; line-height: 1.55;
653
- word-break: break-word;
654
- }
655
- .pr-md-h { margin: 6px 0 4px; font-size: 12.5px; font-weight: 700; color: var(--dsw-alias-label-primary); }
656
- .pr-md-h:first-child { margin-top: 0; }
657
- .pr-md-p { margin: 2px 0; color: var(--dsw-alias-label-primary); white-space: pre-wrap; }
658
- .pr-md-ul { margin: 3px 0; padding-left: 16px; }
659
- .pr-md-ul li { margin: 1px 0; }
660
- .pr-md-code {
661
- padding: 0 4px; border-radius: 4px; background: var(--dsw-alias-bg-layer-2);
662
- font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
663
- }
664
- .pr-board-edit { min-height: 200px; max-height: 42vh; }
665
- .pr-board-actions { display: flex; gap: 8px; margin-top: 6px; align-items: center; }
666
- /* 任务线列:消息完整显示 + 列内上下滚动 */
667
- .pr-lane-body { max-height: 300px; overflow-y: auto; }
668
- /* ⚠️ 关键(2026-11-09 根因):原规则里的 overflow:hidden(给 -webkit-line-clamp 用)只被
669
- -webkit-line-clamp: unset + display: block 覆盖了外观,overflow 仍然生效。
670
- 而 .pr-lane-body 是 display:flex; flex-direction:column 的滚动容器:flex 子项的
671
- min-height:auto 只在其 overflow 为 visible 时才等于内容高度;一旦子项自身 overflow:hidden,
672
- min-height 就解析为 0 → 每条消息被压扁到几像素、全部挤进可视区 →
673
- scrollHeight 永远等于 clientHeight → 既压扁又不滚。这里必须把 overflow 显式改回 visible,
674
- 再加 flex:0 0 auto 双保险。 */
675
- .pr-lane-msg {
676
- -webkit-line-clamp: unset;
677
- display: block;
678
- overflow: hidden;
679
- flex: 0 0 auto;
680
- white-space: nowrap; text-overflow: ellipsis;
681
- }
682
- .pr-hint { font-size: 11px; color: var(--dsw-alias-label-secondary); padding: 6px 0; }
683
- .pr-err { font-size: 11.5px; color: #f87171; padding: 4px 0; }
684
- .pr-send-row { display: flex; gap: 6px; align-items: center; width: 100%; }
685
- .pr-select { max-width: 200px; height: 30px; padding: 0 6px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-size: 12px; }
686
- `;
687
-
688
- function ensureStyle() {
689
- let style = document.getElementById(STYLE_ID);
690
- if (!style) {
691
- style = document.createElement('style');
692
- style.id = STYLE_ID;
693
- style.dataset.plugin = 'project-room';
694
- document.head.appendChild(style);
695
- }
696
- if (style.textContent !== CSS) style.textContent = CSS;
697
- return style;
698
- }
699
-
700
- /**
701
- * 从一条会话消息里提取「一句要点」当卡片摘要。
702
- *
703
- * 为什么不是盲截断(2026-11-10 分诊纠偏):直接把前 N 字塞进去,等于把
704
- * 「### 建议顺手改」「**你问的**」「--- 分隔线」这些格式噪音也搬进任务线卡片。
705
- * 这里按结构剥:代码块/表格/引用/分隔线/标题符号/固定尾块全部丢掉,
706
- * 只留第一条真正有信息的正文句子——不保留原文副本(分诊 2026-11-10:存与渲染两端都只留摘要)。
707
- */
708
- function summarizeMessage(raw) {
709
- let text = String(raw || '');
710
- if (!text.trim()) return '';
711
- // 去掉围栏代码块、行内代码、表格行、引用行、纯分隔线
712
- text = text
713
- .replace(/```[\s\S]*?```/g, ' ')
714
- .replace(/`[^`]*`/g, ' ')
715
- .replace(/^\s*\|.*\|\s*$/gm, ' ')
716
- .replace(/^\s*>.*$/gm, ' ')
717
- .replace(/^\s*[-*_]{3,}\s*$/gm, ' ');
718
- // 逐行挑第一句有内容的
719
- const lines = text.split(/\r?\n/);
720
- const NOISE = /^(#{1,6}\s*|\*\*你问的\*\*|\*\*答\*\*|\*\*本轮结果\*\*|你问的|答[::]|本轮结果[::]|[-*+]\s+|\d+[.、]\s*)/;
721
- for (const line of lines) {
722
- let s = line.trim();
723
- if (!s) continue;
724
- s = s.replace(NOISE, '').replace(/\*\*/g, '').trim();
725
- if (!s || s.length < 4) continue;
726
- // 单行压平 + 控制长度(超过 80 字在第二句边界断开)
727
- s = s.replace(/\s+/g, ' ').trim();
728
- if (s.length > 80) {
729
- const cut = s.slice(0, 80);
730
- const at = Math.max(cut.lastIndexOf('。'), cut.lastIndexOf(';'), cut.lastIndexOf(','));
731
- s = (at > 40 ? cut.slice(0, at + 1) : `${cut.slice(0, 78)}…`).trim();
732
- }
733
- return s;
734
- }
735
- return '';
736
- }
737
-
738
- // ───────────────────────── 侧栏 ─────────────────────────
739
-
740
- function ProjectSidebar(props) {
741
- const workspaces = props.useWorkspaces((s) => s);
742
- const sessions = props.useSessions((s) => s);
743
- const [query, setQuery] = React.useState('');
744
- const [closed, setClosed] = React.useState(() => loadJson('projectRoom.closed', {}));
745
- const [pinned, setPinned] = React.useState(() => loadJson('projectRoom.pinned', []));
746
- const [showLoose, setShowLoose] = React.useState(() => Boolean(loadJson('projectRoom.showLoose', false)));
747
- const [clearArmed, setClearArmed] = React.useState(false);
748
- const [clearMsg, setClearMsg] = React.useState('');
749
- const [menu, setMenu] = React.useState(null); // { id, name, x, y }
750
- // 点空白处关闭右键菜单(菜单自身 onMouseDown 已阻止冒泡)
751
- React.useEffect(() => {
752
- if (!menu) return undefined;
753
- const onDown = () => { setMenu(null); setMenuMsg(''); };
754
- window.addEventListener('mousedown', onDown);
755
- return () => window.removeEventListener('mousedown', onDown);
756
- }, [menu]);
757
- const [renaming, setRenaming] = React.useState(null); // { id, value }
758
- const [newProject, setNewProject] = React.useState(null); // { name, member, busy, msg }
759
- const [settings, setSettings] = React.useState(null); // { manager, msg } 或 null
760
- const [menuMsg, setMenuMsg] = React.useState('');
761
- const [room, setRoom] = React.useState(roomBus.current);
762
- const [tick, setTick] = React.useState(Date.now());
763
- const [avatars, setAvatars] = React.useState({});
764
- const [cursorSessionId, setCursorSessionId] = React.useState('');
765
- // 2026-11-09:手动点击后 3s 内不让宿主属性覆盖 cursor(否则点分身会被 paint 回本体)
766
- const manualPickUntilRef = React.useRef(0);
767
- // 2026-11-09 用户报「快捷栏高亮分身之后,本体的高亮要延迟好几秒才消失」:
768
- // 点击只做了 setCursorSessionId(React 异步重渲染),而 DOM 直改的 paint() 定义在下面的 effect 里,
769
- // 点不到 → 本体 pill 要等 React 渲染完才灭。这里把 paint 暴露出来,点击时同一帧直接改 DOM。
770
- const paintRef = React.useRef(null);
771
-
772
- React.useEffect(() => roomBus.subscribe(setRoom), []);
773
-
774
- // 当前打开的会话(用于芯片高亮):宿主写在会话内容区的 data 属性上。
775
- // ⚠️ 2026-11-09 用户两次反馈「切换对话后侧栏选中要过好几秒」:
776
- // 第一版是 setInterval(read, 3000)(最坏延迟 3 秒)→ 换 MutationObserver 仍不够快,
777
- // 因为真正的瓶颈是**React 全量重渲染侧栏**(195 个会话、多项目、多个 useMemo),
778
- // 一次 setState 触发的重渲染本身就要几百毫秒甚至更久。
779
- // 所以这里改成「双层」:
780
- // ① 直接改 DOM 的 data-active(绕过 React,同一帧生效,视觉零延迟);
781
- // ② 同时 setState,让 React 树与 DOM 一致(下次渲染不会闪回)。
782
- // 观察器补 childList:true —— 切换会话时宿主可能整块替换内容区节点而不是改属性。
783
- React.useEffect(() => {
784
- const paint = (id) => {
785
- try {
786
- const val = String(id || '');
787
- for (const node of document.querySelectorAll('.pr-chip[data-session-id]')) {
788
- node.setAttribute('data-active', node.getAttribute('data-session-id') === val ? '1' : '0');
789
- }
790
- for (const node of document.querySelectorAll('.pr-run-pill[data-session-ids]')) {
791
- const ids = String(node.getAttribute('data-session-ids') || '').split(',');
792
- node.setAttribute('data-active', ids.includes(val) ? '1' : '0');
793
- }
794
- // 2026-11-09:分身子行也要跟着切选中态(用户报『侧栏分身没有选中效果』)
795
- for (const node of document.querySelectorAll('.pr-clone-row[data-session-id]')) {
796
- node.setAttribute('data-active', node.getAttribute('data-session-id') === val ? '1' : '0');
797
- }
798
- } catch { /* 改不动就交给 React */ }
799
- };
800
- paintRef.current = paint;
801
- let lastId = null;
802
- const read = () => {
803
- try {
804
- // 2026-11-09:手动点击后 3s 内不让宿主属性覆盖(否则点分身会被 paint 回本体,
805
- // 表现为「侧边栏没有单独高亮本体」——其实是一直亮在本体上)。
806
- if (Date.now() < manualPickUntilRef.current) return;
807
- const el = document.querySelector('[data-conversation-content]');
808
- const id = el ? (el.getAttribute('data-conversation-session') || '') : '';
809
- if (id === lastId) return;
810
- lastId = id;
811
- paint(id); // ① 先让 DOM 立刻变(用户看到的就是这一下)
812
- setCursorSessionId(id); // ② React 跟上
813
- } catch { /* 拿不到就不高亮 */ }
814
- };
815
- read();
816
- let observer = null;
817
- try {
818
- if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined' && document.body) {
819
- observer = new MutationObserver(read);
820
- observer.observe(document.body, {
821
- attributes: true,
822
- attributeFilter: ['data-conversation-session'],
823
- childList: true, // 节点被整块替换时也要抓到
824
- subtree: true,
825
- });
826
- }
827
- } catch { observer = null; }
828
- // 兜底:observer 不可用 / DOM 整体替换时,2 秒重读一次(正常路径由观察器即时驱动)
829
- const timer = setInterval(read, 2000);
830
- return () => {
831
- if (observer) { try { observer.disconnect(); } catch { /* ignore */ } }
832
- clearInterval(timer);
833
- };
834
- }, []);
835
-
836
- // 头像表(可选增强:有就显示自定义 emoji,没有就退回名字首字符色块)
837
- React.useEffect(() => {
838
- let cancelled = false;
839
- const load = async () => {
840
- try {
841
- const res = await fetch(`${API}/avatars`);
842
- const data = await res.json();
843
- if (!cancelled && data && data.ok && data.avatars) setAvatars(data.avatars);
844
- } catch { /* 没有头像表就用 fallback */ }
845
- };
846
- load();
847
- const timer = setInterval(load, 60000); // 一分钟刷新一次,改了头像表不用重启
848
- return () => { cancelled = true; clearInterval(timer); };
849
-
850
- }, []);
851
- // ── 幽灵会话「事后隐藏」(2026-11-10 用户令:白名单改事后隐藏)──────────────
852
- // 背景:曾经用「磁盘活会话白名单」全量过滤(每 120s 拉 /project-room/alive,
853
- // 不在白名单里的会话直接不显示)。它确实能藏住「前端快照有、磁盘目录没了」的幽灵
854
- // (归档必报 no such session,DSH 又没有删除 API),但代价是**新建会话要等下一次
855
- // 轮询才认识** —— 分诊分身只活 30 秒,正好卡在两次轮询之间,侧栏从来没显示过它。
856
- //
857
- // 用户 2026-11-10 明确选了「事后隐藏」:默认**不过滤任何会话**,只有真的点归档且
858
- // 失败时,才把那个 id 记进 localStorage 的隐藏集,下次渲染跳过它。
859
- // 好处:新会话零延迟显示;幽灵删一次之后就消失。
860
- const HIDDEN_KEY = 'project-room:hidden-sessions';
861
- const bareId = (v) => String(v || '').replace(/^session-/, '').toLowerCase();
862
- const [hiddenIds, setHiddenIds] = React.useState(() => {
863
- try {
864
- const raw = window.localStorage.getItem(HIDDEN_KEY);
865
- const arr = raw ? JSON.parse(raw) : [];
866
- return new Set(Array.isArray(arr) ? arr.map(bareId) : []);
867
- } catch (error) { return new Set(); }
868
- });
869
- const hideSession = React.useCallback((id) => {
870
- const bare = bareId(id);
871
- if (!bare) return;
872
- setHiddenIds((prev) => {
873
- if (prev.has(bare)) return prev;
874
- const next = new Set(prev);
875
- next.add(bare);
876
- try {
877
- window.localStorage.setItem(HIDDEN_KEY, JSON.stringify([...next]));
878
- } catch (error) { /* 存不下也不影响本次隐藏 */ }
879
- return next;
880
- });
881
- }, []);
882
- const unhideAll = React.useCallback(() => {
883
- setHiddenIds(new Set());
884
- try { window.localStorage.removeItem(HIDDEN_KEY); } catch (error) { /* ignore */ }
885
- }, []);
886
-
887
- // 1s 心跳:驱动「今日运行时长」与相对时间
888
- React.useEffect(() => {
889
- const timer = setInterval(() => setTick(Date.now()), 1000);
890
- return () => clearInterval(timer);
891
- }, []);
892
-
893
- const byId = (sessions && sessions.byId) || {};
894
- const archived = new Set((workspaces && workspaces.archivedSessionIds) || []);
895
-
896
- // 会话 → (项目, 成员) 归组;无法解析标题的会话单独归入「未分组」
897
- const groups = React.useMemo(() => {
898
- const map = new Map();
899
- for (const [id, session] of Object.entries(byId)) {
900
- if (!session || session.blank) continue;
901
- if (archived.has(String(id))) continue;
902
- // 2026-11-10(用户令):白名单改「事后隐藏」——只跳过**归档失败过**的会话,
903
- // 不再用磁盘白名单全量过滤(那样会让新建会话因轮询延迟而整段生命期不可见)。
904
- if (hiddenIds.has(bareId(id))) continue;
905
- const parsed = projectOfTitle(session.displayTitle || session.title);
906
- const project = parsed ? parsed.project : LOOSE_GROUP;
907
- const member = parsed ? parsed.member : (session.displayTitle || session.title || String(id).slice(-6));
908
- // 2026-11-09 用户报「这两个副手是哪儿来的?删掉」:这是 2026-10 早期概念留下的历史会话
909
- // (标题「<项目> · <成员>|副手」,早已不用于新分身)。DSH 没有删除会话的 API,
910
- // 所以这里在**渲染层**把它们滤掉——不再出现在侧栏/快捷栏,也不再占用 +N 计数。
911
- if (/|副手$/.test(String(member || '').trim())) continue;
912
- if (!map.has(project)) map.set(project, []);
913
- map.get(project).push({
914
- id,
915
- name: member,
916
- project,
917
- /** 分身名(父名|标签)→ base 用于快捷栏聚合 */
918
- base: String(member).split('|')[0].trim() || member,
919
- isClone: String(member).includes('|'),
920
- running: Boolean(session.running),
921
- // 真数据源:会话自带 updatedAt(若有)→ 本地 lastActive 记录
922
- updatedAt: (session.updatedAt || 0) || lastActiveOf(id),
923
- });
924
- }
925
- return map;
926
- }, [byId, archived, hiddenIds]);
927
-
928
- // 空闲时长补全(2026-11-09):对还没有 lastActive 的会话拉 /hub/session-events?limit=1
929
- // 取最后一条事件的 time。分身(标题带|)优先——用户最常盯的就是分身行。
930
- const [, setLastActiveTick] = React.useState(0);
931
- React.useEffect(() => {
932
- let cancelled = false;
933
- const ids = Object.keys(byId)
934
- .filter((id) => !lastActiveOf(id))
935
- .sort((a, b) => {
936
- const isClone = (x) => (String((byId[x] && (byId[x].displayTitle || byId[x].title)) || '').includes('|') ? 1 : 0);
937
- return isClone(b) - isClone(a);
938
- })
939
- .slice(0, 200);
940
- if (ids.length === 0) return undefined;
941
- (async () => {
942
- for (const id of ids) {
943
- if (cancelled) return;
944
- try {
945
- const res = await fetch(`/hub/session-events?session=${encodeURIComponent(id)}&limit=1`);
946
- const data = await res.json();
947
- const events = (data && data.events) || [];
948
- const last = events[events.length - 1];
949
- if (last && last.time) markLastActive(id, last.time);
950
- } catch { /* 单个失败跳过 */ }
951
- }
952
- if (!cancelled) setLastActiveTick((n2) => n2 + 1);
953
- })();
954
- return () => { cancelled = true; };
955
- }, [byId]);
956
- // 今日运行时长累计(localStorage 持久,按项目与日期分桶)
957
- // ⚠️ groupsRef 必须先声明再被下面的 effect 使用(hub-desktop-ui 曾因 TDZ 整块侧栏回退)
958
- const groupsRef = React.useRef(groups);
959
- groupsRef.current = groups;
960
-
961
- // 运行态账本:runStart 记每个会话本轮 시작时刻;未读记「跑完还没看」的会话
962
- const runStartRef = React.useRef(new Map());
963
- // 2026-11-09 用户报「侧边栏的运行计时怎么老是莫名其妙重置」:
964
- // 原来「这一拍 running=false」就立刻删 runStart → DSH 的 running 投影抖动时会
965
- // 下一拍又 true 再 set(Date.now()) → 计时从 0 重来。这里记「首次见到 false 的时刻」,
966
- // 连续 >3s 都没恢复才真正删除。
967
- const runFalseSinceRef = React.useRef(new Map());
968
- const prevRunningRef = React.useRef(new Set());
969
- const [unread, setUnread] = React.useState(() => loadJson('projectRoom.unread', {}));
970
-
971
- React.useEffect(() => {
972
- const day = dayKey();
973
- const timer = setInterval(() => {
974
- let changed = false;
975
- const runningNow = new Set();
976
- for (const [project, members] of groupsRef.current) {
977
- let runningCount = 0;
978
- for (const member of members) {
979
- if (member.running) {
980
- runningCount += 1;
981
- runningNow.add(member.id);
982
- if (!runStartRef.current.has(member.id)) runStartRef.current.set(member.id, Date.now());
983
- runFalseSinceRef.current.delete(member.id);
984
- } else {
985
- const falseSince = runFalseSinceRef.current.get(member.id);
986
- if (!falseSince) {
987
- runFalseSinceRef.current.set(member.id, Date.now());
988
- } else if (Date.now() - falseSince > 3000) {
989
- runStartRef.current.delete(member.id);
990
- runFalseSinceRef.current.delete(member.id);
991
- }
992
- }
993
- }
994
- if (!runningCount) continue;
995
- const key = `projectRoom.runTotal.${project}.${day}`;
996
- const prev = Number(loadJson(key, 0) || 0);
997
- saveJson(key, prev + runningCount * 1000);
998
- changed = true;
999
- }
1000
- // running → false 的会话记成「完成未读」(当前正看着的那个不算)
1001
- const prevRunning = prevRunningRef.current;
1002
- const finished = [...prevRunning].filter((id) => !runningNow.has(id));
1003
- if (finished.length) {
1004
- const current = currentSessionId();
1005
- const next = { ...loadJson('projectRoom.unread', {}) };
1006
- let touched = false;
1007
- for (const id of finished) {
1008
- if (current && String(current) === String(id)) continue; // 正在看,直接算已读
1009
- next[id] = { at: Date.now() };
1010
- touched = true;
1011
- }
1012
- if (touched) {
1013
- saveJson('projectRoom.unread', next);
1014
- setUnread(next);
1015
- changed = true;
1016
- }
1017
- }
1018
- prevRunningRef.current = runningNow;
1019
- if (changed) setTick(Date.now());
1020
- }, 1000);
1021
- return () => clearInterval(timer);
1022
- }, []);
1023
-
1024
- // 把「项目 → 成员」快照交给房间浮层(浮层没有 slot props,只能这样拿)
1025
- React.useEffect(() => {
1026
- roomBus.sessionIndex = groups;
1027
- }, [groups]);
1028
-
1029
- const projectNames = React.useMemo(() => {
1030
- const names = Array.from(groups.keys());
1031
- const pinnedList = pinned.filter((name) => names.includes(name));
1032
- const rest = names.filter((name) => !pinnedList.includes(name));
1033
- rest.sort((a, b) => {
1034
- const ra = (groups.get(a) || []).some((m) => m.running) ? 1 : 0;
1035
- const rb = (groups.get(b) || []).some((m) => m.running) ? 1 : 0;
1036
- if (ra !== rb) return rb - ra;
1037
- const ta = Math.max(0, ...(groups.get(a) || []).map((m) => m.updatedAt));
1038
- const tb = Math.max(0, ...(groups.get(b) || []).map((m) => m.updatedAt));
1039
- return tb - ta;
1040
- });
1041
- return [...pinnedList, ...rest];
1042
- }, [groups, pinned]);
1043
-
1044
- // 「未分组」= 标题里没有分隔符的会话(历史测试/一次性对话)。
1045
- // 默认不显示,避免一大坨散落会话把项目列表挤下去;需要时点头部按钮展开。
1046
- const looseAll = groups.get(LOOSE_GROUP) || [];
1047
- // 2026-11-10(用户令):白名单改「事后隐藏」后,groups 里已经按 hiddenIds 过滤过了,
1048
- // 这里不再二次过滤(否则又回到「新建会话要等白名单」的老问题)。
1049
- const looseMembers = looseAll;
1050
- const shownProjects = React.useMemo(
1051
- () => projectNames.filter((name) => showLoose || name !== LOOSE_GROUP),
1052
- [projectNames, showLoose],
1053
- );
1054
-
1055
- const q = query.trim().toLowerCase();
1056
- const visible = shownProjects.filter((name) => {
1057
- if (!q) return true;
1058
- if (name.toLowerCase().includes(q)) return true;
1059
- return (groups.get(name) || []).some((m) => m.name.toLowerCase().includes(q));
1060
- });
1061
-
1062
- const totalSessions = Array.from(groups.values()).reduce((acc, list) => acc + list.length, 0);
1063
-
1064
- // ── 运行中快捷栏(对齐 AI-Agent-Hub 侧栏):按「项目 + 父名」聚合,分身折成 +N ──
1065
- const unreadMap = unread && typeof unread === 'object' ? unread : {};
1066
- const runGroups = React.useMemo(() => {
1067
- const all = [];
1068
- for (const [project, members] of groups) {
1069
- for (const member of members) {
1070
- // 分身一律不进快捷栏:它只在本体芯片的树里显示(避免同一个会话出现两处)
1071
- // 分身允许进快捷栏:归到父组的 clones,在 pill 下方以树状子行显示(与侧栏芯片一致)
1072
- const isUnread = !member.running && unreadMap[member.id];
1073
- if (!member.running && !isUnread) continue;
1074
- all.push({ ...member, done: !member.running });
1075
- }
1076
- }
1077
- const buckets = new Map();
1078
- for (const item of all) {
1079
- // 分身且有父会话 → 归父组;否则自成一组
1080
- let key = `${item.project}\u0000${item.base}`;
1081
- let groupName = item.base;
1082
- if (item.isClone) {
1083
- const hasParent = (groups.get(item.project) || []).some((m) => !m.isClone && m.base === item.base);
1084
- if (!hasParent) { key = `${item.project}\u0000${item.name}`; groupName = item.name; }
1085
- }
1086
- let bucket = buckets.get(key);
1087
- if (!bucket) {
1088
- bucket = { key, project: item.project, name: groupName, running: [], done: [], clones: [] };
1089
- buckets.set(key, bucket);
1090
- }
1091
- if (item.done) bucket.done.push(item); else bucket.running.push(item);
1092
- if (item.isClone) bucket.clones.push(item);
1093
- }
1094
- return [...buckets.values()].map((bucket) => {
1095
- const members = [...bucket.running, ...bucket.done];
1096
- const openTarget = members.slice().sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))[0];
1097
- return {
1098
- ...bucket,
1099
- members,
1100
- openTarget,
1101
- mode: bucket.running.length ? 'running' : 'done',
1102
- extra: bucket.clones.length,
1103
- };
1104
- }).sort((a, b) => {
1105
- if (a.mode !== b.mode) return a.mode === 'done' ? -1 : 1; // 完成未读置顶
1106
- return (b.openTarget?.updatedAt || 0) - (a.openTarget?.updatedAt || 0);
1107
- });
1108
- }, [groups, unreadMap]);
1109
-
1110
- // 2026-11-11 用户令「快捷栏正在运行和完成未读的分开放」:
1111
- // 原来两种 pill 混在一个 flex 行里(完成未读还置顶排最前),用户扫一眼分不清谁在跑。
1112
- // 现在拆成两个区、各带标题;渲染顺序按用户第二次要求 = **完成未读在上、运行中在下**。
1113
- const runningGroups = runGroups.filter((group) => group.mode === 'running');
1114
- const doneUnreadGroups = runGroups.filter((group) => group.mode === 'done');
1115
- // 完成未读的组数(决定是否显示「全部已读」按钮)
1116
- const doneGroups = doneUnreadGroups.length;
1117
-
1118
- const setPin = (name) => {
1119
- const next = pinned.includes(name) ? pinned.filter((n) => n !== name) : [...pinned, name];
1120
- setPinned(next);
1121
- saveJson('projectRoom.pinned', next);
1122
- };
1123
-
1124
- const toggleClosed = (name) => {
1125
- const next = { ...closed, [name]: !closed[name] };
1126
- setClosed(next);
1127
- saveJson('projectRoom.closed', next);
1128
- };
1129
-
1130
- /**
1131
- * 渲染一个「运行中/完成未读」快捷 pill:一个父名一个 pill,分身折成 +N 小标签。
1132
- * 对齐 AI-Agent-Hub 侧栏快捷栏:emoji 头像 + 项目色点与名 + agent 名 + 绿点/✓ + 时长。
1133
- */
1134
- const renderRunPill = (group) => {
1135
- const target = group.openTarget;
1136
- if (!target) return null;
1137
- // 2026-11-09 用户令「本体和分身选中分开」:pill 只在**当前打开的是本体**时高亮,
1138
- // 打开分身时只让分身行高亮(原来用 group.members.some,含分身 → 两个都亮)。
1139
- const bodyIds = group.members.filter((m) => !m.isClone).map((m) => String(m.id));
1140
- const isCurrent = Boolean(cursorSessionId) && bodyIds.includes(String(cursorSessionId));
1141
- const done = group.mode === 'done';
1142
- // 2026-11-11 修「本体没有运行、快捷栏还在显示计时,而侧边栏正常」(用户截图):
1143
- // 这个 pill 代表的是**本体**——名字取 group.name(本体名)、点击开本体、data-session-ids
1144
- // 也只有本体。所以绿点与已运行时长必须来自**本体自己的 running**。
1145
- // 原实现取 `group.running` 里最近活跃的那个成员:分身还在跑时,那一项就是分身,
1146
- // 于是分身的已运行时长被画到本体 pill 上(截图里 pill 的 2m29s 与分身子行的 2m29s
1147
- // 完全相等,而侧栏本体芯片没有时长 = 本体当时是空闲的)。
1148
- // 侧栏一直是对的:本体芯片按 m.running 判、分身行按 c.running 判,两条各算各的。
1149
- // 分身的运行状态由下面 .pr-run-clones 子行自己显示,信息不丢。
1150
- // 孤儿分身组(本体已归档/改名,group.name 就是分身名)没有本体可参照 → 仍用组内运行成员。
1151
- const bodyMemberOfGroup = (() => {
1152
- const list = (groups && typeof groups.get === 'function') ? (groups.get(group.project) || []) : [];
1153
- return list.find((m) => !m.isClone && m.name === group.name) || null;
1154
- })();
1155
- const runMember = (bodyMemberOfGroup ? [bodyMemberOfGroup] : (group.running.length ? group.running : group.members))
1156
- .filter((m) => m && m.running)
1157
- .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))[0];
1158
- const bodyRunning = Boolean(runMember);
1159
- const startedAt = runMember ? runStartRef.current.get(runMember.id) : undefined;
1160
- const elapsed = !done && bodyRunning && startedAt ? formatDuration(tick - startedAt) : '';
1161
- const emoji = avatars && typeof avatars[group.name] === 'string' ? avatars[group.name].trim() : '';
1162
- const hue = hashHue(group.project || group.name);
1163
- return h('div', { className: 'pr-run-wrap', key: 'wrap-' + group.key }, h('button', {
1164
- key: `run-${group.key}`,
1165
- type: 'button',
1166
- className: 'pr-run-pill',
1167
- 'data-mode': group.mode,
1168
- // 2026-11-09 用户令「本体和分身选中分开」:pill 的 data-active 只看**本体**,
1169
- // 原来放全组成员 → 点分身时 pill(代表本体)也一起高亮。
1170
- 'data-session-ids': group.members.filter((mb) => !mb.isClone).map((mb) => String(mb.id)).join(','),
1171
- title: `${group.project ? group.project + ' · ' : ''}${group.name}`
1172
- // 2026-11-11:不再对空闲的本体谎称「运行中」——本体没跑时 pill 只报「分身运行中」。
1173
- + (done ? ' · 完成待查看'
1174
- : (bodyRunning ? (elapsed ? ` · 运行中 ${elapsed}` : ' · 运行中') : ' · 本体空闲(分身运行中)'))
1175
- + (group.extra ? `(含 ${group.extra} 个分身)` : ''),
1176
- onClick: (event) => {
1177
- event.stopPropagation();
1178
- // 点开即清该组的未读
1179
- if (done) {
1180
- const next = { ...unreadMap };
1181
- for (const member of group.members) delete next[member.id];
1182
- saveJson('projectRoom.unread', next);
1183
- setUnread(next);
1184
- }
1185
- // 打开会话前先关掉房间:否则房间盖在对话区上,用户看不到刚切过去的会话
1186
- roomBus.close();
1187
- // 2026-11-09 用户两次报「快捷栏还是点不了本体,还是会跳到分身」的真因:
1188
- // 原实现 openSessionById(group.parentId || target.id),而 target = 组内最近活跃成员
1189
- // —— 父会话冷/归档/改名时 parentId 为空,就落到最近活跃的**分身**上。
1190
- // 现在显式现查本体:同项目 + 同成员名 + 不是分身;查不到就只提示、不跳(绝不打开分身)。
1191
- const projMembers = (groups && typeof groups.get === 'function') ? (groups.get(group.project) || []) : [];
1192
- const bodyMember = projMembers.find((x) => !x.isClone && x.name === group.name);
1193
- if (!bodyMember) {
1194
- setNotice(`找不到「${group.name}」的本体会话(可能已归档或改名)——不跳转,避免误开分身`);
1195
- return;
1196
- }
1197
- // 2026-11-09 用户报「在快捷栏选本体的时候高亮怎么没变」:pill 的 onClick 只 openSessionById,
1198
- // 没有像芯片/分身行那样 setCursorSessionId + paintRef,所以点了 pill 高亮不动。这里补齐。
1199
- if (paintRef.current) paintRef.current(String(bodyMember.id));
1200
- setCursorSessionId(String(bodyMember.id));
1201
- manualPickUntilRef.current = Date.now() + 3000;
1202
- openSessionById(bodyMember.id);
1203
- },
1204
- },
1205
- h('span', {
1206
- className: 'pr-run-avatar',
1207
- style: emoji
1208
- ? { fontSize: '12px', fontWeight: 600, color: 'inherit', background: 'transparent' }
1209
- : { fontSize: '9px', fontWeight: 600, color: '#fff', background: `hsl(${hue} 40% 40%)` },
1210
- }, emoji || (String(group.name).trim()[0] || '·').toUpperCase()),
1211
- group.project
1212
- ? h('span', { className: 'pr-run-project', title: `所属项目:${group.project}` },
1213
- h('span', { className: 'pr-run-project-dot', style: { background: `hsl(${hue} 45% 50%)` } }),
1214
- h('span', { className: 'pr-run-project-name', style: { color: `hsl(${hue} 50% 68%)` } }, group.project),
1215
- )
1216
- : null,
1217
- h('span', { className: 'pr-run-name' }, group.name),
1218
- group.extra > 0 ? h('span', { className: 'pr-run-badge', title: `${group.extra} 个分身` }, `+${group.extra}`) : null,
1219
- done
1220
- ? h('span', { className: 'pr-run-done', title: '已完成,点开查看' }, h('span', { className: 'pr-run-check' }, '✓'), '完成')
1221
- // 2026-11-11:绿点也只跟本体走——本体空闲、只剩分身在跑时不给本体挂绿点
1222
- // (分身子行自己有 pr-clone-dot.on,不丢状态)。
1223
- : (bodyRunning ? h('span', { className: 'pr-running-dot', title: '运行中' }) : null),
1224
- elapsed ? h('span', { className: 'pr-run-elapsed' }, elapsed) : null,
1225
- ),
1226
- group.clones && group.clones.length
1227
- ? h('div', { className: 'pr-run-clones' },
1228
- group.clones.map((clone, index) => h('div', {
1229
- className: 'pr-clone-row',
1230
- 'data-active': String(cursorSessionId || '') === String(clone.id) ? '1' : '0',
1231
- 'data-session-id': String(clone.id),
1232
- key: `run-clone-${clone.id}`,
1233
- title: clone.name,
1234
- onClick: (event) => {
1235
- event.stopPropagation();
1236
- roomBus.close();
1237
- if (paintRef.current) paintRef.current(String(clone.id)); // 同一帧先改 DOM,别等 React
1238
- setCursorSessionId(String(clone.id));
1239
- manualPickUntilRef.current = Date.now() + 3000;
1240
- openSessionById(clone.id);
1241
- },
1242
- },
1243
- h('span', { className: 'pr-tree-rail' },
1244
- index === group.clones.length - 1 ? '└' : '├'),
1245
- h('span', {
1246
- className: `pr-clone-dot${clone.running ? ' on' : ''}`,
1247
- }),
1248
- h('span', { className: 'pr-clone-name' },
1249
- String(clone.name).split('|').pop()),
1250
- // 2026-11-09 用户三次报「快捷栏的分身还是没有显示运行时间」:这里(快捷栏 pill 的分身树子行)
1251
- // 之前**只有 ├/└ + 圆点 + 名字**,根本没有时长渲染。上次改的是侧栏芯片那条路径,不是这条。
1252
- (() => {
1253
- const cStarted = runStartRef.current.get(clone.id);
1254
- const cElapsed = clone.running && cStarted ? formatDuration(tick - cStarted) : '';
1255
- const cLast = clone.updatedAt || 0;
1256
- if (!cElapsed && (!cLast || !showIdleAgo)) return null;
1257
- return h('span', {
1258
- className: clone.running ? 'pr-clone-elapsed' : 'pr-clone-elapsed idle',
1259
- title: clone.running ? `已运行 ${cElapsed}` : '距上次活动',
1260
- }, clone.running && cElapsed ? cElapsed : relTime(cLast));
1261
- })(),
1262
- )))
1263
- : null,
1264
- );
1265
- };
1266
-
1267
- const archiveLoose = async () => {
1268
- const list = groups.get(LOOSE_GROUP) || [];
1269
- if (!list.length) { setClearMsg('没有未分组的会话'); return; }
1270
- if (!clearArmed) {
1271
- // 两段式确认:桌面端 window.confirm 可能被静默拦掉,改用按钮自身状态
1272
- setClearArmed(true);
1273
- setClearMsg(`再点一次「确认清理」→ 归档 ${list.length} 个(4 秒后自动取消)`);
1274
- setTimeout(() => { setClearArmed(false); setClearMsg(''); }, 4000);
1275
- return;
1276
- }
1277
- setClearArmed(false);
1278
- setClearMsg(`正在归档 ${list.length} 个…`);
1279
- try {
1280
- const res = await fetch(`${API}/archive`, {
1281
- method: 'POST',
1282
- headers: { 'content-type': 'application/json' },
1283
- body: JSON.stringify({ sessionIds: list.map((m) => m.id) }),
1284
- });
1285
- const data = await res.json();
1286
- if (data && data.ok) {
1287
- // 2026-11-10(用户令「事后隐藏」):归档失败 = 磁盘上根本没这个会话(幽灵)。
1288
- // 既然删不掉,就记进隐藏集,下次渲染跳过它。
1289
- const failedList = Array.isArray(data.failed) ? data.failed : [];
1290
- for (const f of failedList) hideSession(f && f.id);
1291
- const failNote = failedList.length
1292
- ? `,失败 ${failedList.length}(${String((failedList[0] && failedList[0].error) || '').slice(0, 60)};已隐藏)`
1293
- : '';
1294
- setClearMsg(`已归档 ${data.archived}/${data.total}${failNote}`);
1295
- console.log('[project-room] archive result', data);
1296
- } else {
1297
- setClearMsg(`失败:${(data && data.error) || '未知'}`);
1298
- }
1299
- } catch (error) {
1300
- setClearMsg(`失败:${String((error && error.message) || error)}`);
1301
- }
1302
- };
1303
-
1304
- /** 右键菜单:改名 / 归档 / 复制 sessionId / 复制标题 */
1305
- const renderMenu = () => {
1306
- if (!menu) return null;
1307
- const close = () => { setMenu(null); setMenuMsg(''); };
1308
- const copy = async (text, label) => {
1309
- try {
1310
- await navigator.clipboard.writeText(text);
1311
- setMenuMsg(`已复制${label}`);
1312
- } catch {
1313
- setMenuMsg('复制失败(剪贴板不可用)');
1314
- }
1315
- };
1316
- return h('div', {
1317
- className: 'pr-menu',
1318
- onMouseDown: (event) => event.stopPropagation(),
1319
- style: { left: `${menu.x}px`, top: `${menu.y}px` },
1320
- onClick: (event) => event.stopPropagation(),
1321
- onContextMenu: (event) => { event.preventDefault(); event.stopPropagation(); },
1322
- },
1323
- h('button', { className: 'pr-menu-item', onClick: () => { setRenaming({ id: menu.id, value: menu.name }); close(); } }, '改名…'),
1324
- h('button', {
1325
- className: 'pr-menu-item',
1326
- onClick: async () => {
1327
- setMenuMsg('归档中…');
1328
- try {
1329
- const res = await fetch(`${API}/archive`, {
1330
- method: 'POST', headers: { 'content-type': 'application/json' },
1331
- body: JSON.stringify({ sessionIds: [menu.id] }),
1332
- });
1333
- const data = await res.json();
1334
- const okArchived = Boolean(data && data.ok && data.archived);
1335
- if (okArchived) {
1336
- setMenuMsg('已归档');
1337
- } else {
1338
- // 2026-11-10(用户令「事后隐藏」):单个归档失败 = 幽灵会话,记进隐藏集。
1339
- hideSession(menu.id);
1340
- const reason = (data && data.failed && data.failed[0] && data.failed[0].error)
1341
- || (data && data.error) || '未知';
1342
- setMenuMsg(`失败:${reason}(已隐藏)`.slice(0, 90));
1343
- }
1344
- } catch (error) {
1345
- hideSession(menu.id);
1346
- setMenuMsg(`失败:${String((error && error.message) || error)}(已隐藏)`.slice(0, 90));
1347
- }
1348
- },
1349
- }, '归档这个会话'),
1350
- h('button', { className: 'pr-menu-item', onClick: () => { copy(menu.id, ' sessionId'); } }, '复制 sessionId'),
1351
- h('button', { className: 'pr-menu-item', onClick: () => { copy(menu.name, '标题'); } }, '复制标题'),
1352
- h('button', { className: 'pr-menu-item', onClick: close }, '关闭'),
1353
- menuMsg ? h('div', { className: 'pr-menu-msg' }, menuMsg) : null,
1354
- );
1355
- };
1356
-
1357
- /** 设置:项目管理者名称(默认 Yat2,存 localStorage) */
1358
- const renderSettings = () => {
1359
- if (!settings) return null;
1360
- const save = () => {
1361
- const name = String(settings.manager || '').trim();
1362
- if (!name) { setSettings({ ...settings, msg: '名称不能为空' }); return; }
1363
- try { window.localStorage.setItem('projectRoom.managerName', name); } catch { /* 存不了就本次生效 */ }
1364
- setSettings(null);
1365
- };
1366
- return h('div', { className: 'pr-modal', onClick: () => setSettings(null) },
1367
- h('div', { className: 'pr-modal-box', onClick: (event) => event.stopPropagation() },
1368
- h('div', { className: 'pr-modal-title' }, '设置'),
1369
- h('div', { className: 'pr-hint' }, '项目管理者名称(排序时永远排第一、新建项目的默认成员名)'),
1370
- h('input', {
1371
- className: 'pr-modal-input',
1372
- value: settings.manager || '',
1373
- autoFocus: true,
1374
- placeholder: DEFAULT_MANAGER,
1375
- onChange: (event) => setSettings({ ...settings, manager: event.target.value, msg: '' }),
1376
- onKeyDown: (event) => { if (event.key === 'Enter') save(); if (event.key === 'Escape') setSettings(null); },
1377
- }),
1378
- h('div', { className: 'pr-hint', style: { marginTop: '6px' } },
1379
- '注意:这只影响本插件的排序与默认名。若要让「管理者协议注入」也认这个名字,'
1380
- + '还需要改 hub-desktop-ui 里的 MANAGER_NAMES(那是另一个插件的硬编码)。'),
1381
- settings.msg ? h('div', { className: 'pr-err' }, settings.msg) : null,
1382
- // 2026-11-10(用户令「事后隐藏」):归档失败过的会话被记进 localStorage 隐藏集,
1383
- // 这里提供恢复入口,免得误隐藏之后找不回来。
1384
- h('div', { className: 'pr-hint', style: { marginTop: '10px' } },
1385
- `已隐藏的会话:${hiddenIds.size} 个`),
1386
- h('button', {
1387
- type: 'button',
1388
- disabled: hiddenIds.size === 0,
1389
- style: {
1390
- marginTop: '4px', padding: '4px 10px', borderRadius: '6px', fontSize: '11px',
1391
- border: '1px solid var(--dsw-alias-border-secondary, #3a3a42)',
1392
- background: 'transparent', color: 'inherit',
1393
- cursor: hiddenIds.size === 0 ? 'default' : 'pointer',
1394
- opacity: hiddenIds.size === 0 ? 0.45 : 1,
1395
- },
1396
- onClick: () => { unhideAll(); setSettings({ ...settings, msg: '已恢复全部隐藏的会话' }); },
1397
- }, '恢复全部隐藏的会话'),
1398
- h('div', { className: 'pr-hint', style: { fontSize: '10px' } },
1399
- '(这些是归档时报 no such session 的幽灵会话,磁盘上已不存在;恢复后会重新出现在侧栏)'),
1400
- h('div', { className: 'pr-hint', style: { marginTop: '10px' } }, '显示'),
1401
- h('label', { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px', cursor: 'pointer', padding: '3px 0' } },
1402
- h('input', {
1403
- type: 'checkbox',
1404
- checked: showIdleAgo,
1405
- onChange: (event) => {
1406
- setShowIdleAgo(event.target.checked);
1407
- setSettings({ ...settings }); // 触发重渲染
1408
- },
1409
- }),
1410
- h('span', null, '显示「多久前运行 / 距上次活动」'),
1411
- ),
1412
- h('div', { className: 'pr-hint', style: { fontSize: '10px' } },
1413
- '关闭后空闲项不再显示相对时间,运行中仍显示本轮已运行时长。'),
1414
- h('div', { className: 'pr-modal-actions' },
1415
- h('button', { className: 'pr-btn', onClick: () => setSettings(null) }, '取消'),
1416
- h('button', { className: 'pr-btn primary', onClick: save }, '保存'),
1417
- ),
1418
- ),
1419
- );
1420
- };
1421
-
1422
- /** 新建项目弹窗:建会话 → 按「项目 · 成员」命名 → 初始化看板 */
1423
- const renderNewProject = () => {
1424
- if (!newProject) return null;
1425
- const submit = async () => {
1426
- const project = String(newProject.name || '').trim();
1427
- const member = String(newProject.member || managerName()).trim() || managerName();
1428
- if (!project) return;
1429
- setNewProject({ ...newProject, busy: true, msg: '创建中…' });
1430
- try {
1431
- const svc = clientCtx && typeof clientCtx.get === 'function' ? clientCtx.get('uiWorkspace') : null;
1432
- if (!svc) { setNewProject({ ...newProject, busy: false, msg: 'uiWorkspace 不可用' }); return; }
1433
- const items = (workspaces && workspaces.items) || [];
1434
- const workspaceId = items.length ? items[0].workspaceId : undefined;
1435
- let sessionId = '';
1436
- if (typeof svc.connectWorkspace === 'function' && workspaceId !== undefined) {
1437
- const raw = await svc.connectWorkspace(workspaceId, { agentPreset: 'cordis' });
1438
- sessionId = typeof raw === 'string' ? raw : ((raw && (raw.sessionId || raw.id)) || '');
1439
- }
1440
- if (!sessionId && typeof svc.startSession === 'function') {
1441
- svc.startSession(workspaceId, { agentPreset: 'cordis' });
1442
- }
1443
- if (sessionId) {
1444
- await fetch(`${API}/rename`, {
1445
- method: 'POST', headers: { 'content-type': 'application/json' },
1446
- body: JSON.stringify({ sessionId, title: `${project} · ${member}` }),
1447
- });
1448
- }
1449
- // 初始化房间看板(不存在则建模板)
1450
- try { await fetch(`${API}/board?project=${encodeURIComponent(project)}`, { method: 'POST' }); } catch { /* 看板可选 */ }
1451
- setNewProject(null);
1452
- } catch (error) {
1453
- setNewProject({ ...newProject, busy: false, msg: `失败:${String((error && error.message) || error)}` });
1454
- }
1455
- };
1456
- return h('div', { className: 'pr-modal', onClick: () => (newProject.busy ? null : setNewProject(null)) },
1457
- h('div', { className: 'pr-modal-box', onClick: (event) => event.stopPropagation() },
1458
- h('div', { className: 'pr-modal-title' }, '新建项目'),
1459
- h('input', {
1460
- className: 'pr-modal-input',
1461
- placeholder: '项目名,例如 骨骼微环境',
1462
- value: newProject.name || '',
1463
- autoFocus: true,
1464
- onChange: (event) => setNewProject({ ...newProject, name: event.target.value, msg: '' }),
1465
- onKeyDown: (event) => { if (event.key === 'Enter') submit(); if (event.key === 'Escape') setNewProject(null); },
1466
- }),
1467
- h('div', { style: { height: '8px' } }),
1468
- h('input', {
1469
- className: 'pr-modal-input',
1470
- placeholder: `成员的 agent 名(默认 ${managerName()})`,
1471
- value: newProject.member || '',
1472
- onChange: (event) => setNewProject({ ...newProject, member: event.target.value, msg: '' }),
1473
- onKeyDown: (event) => { if (event.key === 'Enter') submit(); if (event.key === 'Escape') setNewProject(null); },
1474
- }),
1475
- h('div', { style: { marginTop: '6px', fontSize: '10.5px', color: 'var(--dsw-alias-label-secondary)' } },
1476
- `创建后会话标题为「${String(newProject.name || '项目名').trim()} · ${String(newProject.member || 'Yat2').trim() || 'Yat2'}」`),
1477
- newProject.msg ? h('div', { className: 'pr-hint' }, newProject.msg) : null,
1478
- h('div', { className: 'pr-modal-actions' },
1479
- h('button', { className: 'pr-btn', onClick: () => setNewProject(null), disabled: newProject.busy }, '取消'),
1480
- h('button', { className: 'pr-btn primary', onClick: submit, disabled: newProject.busy }, newProject.busy ? '创建中…' : '创建'),
1481
- ),
1482
- ),
1483
- );
1484
- };
1485
-
1486
- /** 改名弹窗(双击芯片或右键「改名…」触发) */
1487
- const renderRename = () => {
1488
- if (!renaming) return null;
1489
- const submit = async () => {
1490
- const title = String(renaming.value || '').trim();
1491
- if (!title) return;
1492
- try {
1493
- const res = await fetch(`${API}/rename`, {
1494
- method: 'POST', headers: { 'content-type': 'application/json' },
1495
- body: JSON.stringify({ sessionId: renaming.id, title }),
1496
- });
1497
- const data = await res.json();
1498
- if (data && data.ok) { setRenaming(null); return; }
1499
- setMenuMsg(`改名失败:${(data && data.error) || '未知'}`);
1500
- } catch (error) {
1501
- setMenuMsg(`改名失败:${String((error && error.message) || error)}`);
1502
- }
1503
- };
1504
- return h('div', { className: 'pr-modal', onClick: () => setRenaming(null) },
1505
- h('div', { className: 'pr-modal-box', onClick: (event) => event.stopPropagation() },
1506
- h('div', { className: 'pr-modal-title' }, '改会话标题'),
1507
- h('input', {
1508
- className: 'pr-modal-input',
1509
- value: renaming.value,
1510
- autoFocus: true,
1511
- onChange: (event) => setRenaming({ ...renaming, value: event.target.value }),
1512
- onKeyDown: (event) => {
1513
- if (event.key === 'Enter') submit();
1514
- if (event.key === 'Escape') setRenaming(null);
1515
- },
1516
- }),
1517
- h('div', { className: 'pr-modal-actions' },
1518
- h('button', { className: 'pr-btn', onClick: () => setRenaming(null) }, '取消'),
1519
- h('button', { className: 'pr-btn primary', onClick: submit }, '保存'),
1520
- ),
1521
- ),
1522
- );
1523
- };
1524
-
1525
- return h('div', { className: 'pr-root' },
1526
- renderMenu(),
1527
- renderRename(),
1528
- renderNewProject(),
1529
- renderSettings(),
1530
- h('div', { className: 'pr-runbar' },
1531
- // ── 区①:完成未读(2026-11-11 用户令:这一区排在运行中前面;一键已读只清这一区)──
1532
- doneUnreadGroups.length
1533
- ? h('div', { className: 'pr-runbar-sec', 'data-kind': 'done' },
1534
- h('div', { className: 'pr-runbar-sec-title' },
1535
- h('span', { className: 'pr-runbar-sec-check' }, '✓'),
1536
- h('span', { className: 'pr-runbar-sec-label' }, '完成未读'),
1537
- h('span', { className: 'pr-runbar-sec-count' }, String(doneUnreadGroups.length)),
1538
- h('button', {
1539
- className: 'pr-readall',
1540
- title: `全部已读(清掉 ${doneGroups} 个完成待查看)`,
1541
- onClick: (event) => {
1542
- event.stopPropagation();
1543
- saveJson('projectRoom.unread', {});
1544
- setUnread({});
1545
- },
1546
- }, '全部已读'),
1547
- ),
1548
- h('div', { className: 'pr-runbar-sec-body' }, doneUnreadGroups.map(renderRunPill)),
1549
- )
1550
- : null,
1551
- // ── 区②:运行中 ──
1552
- runningGroups.length
1553
- ? h('div', { className: 'pr-runbar-sec', 'data-kind': 'running' },
1554
- h('div', { className: 'pr-runbar-sec-title' },
1555
- h('span', { className: 'pr-runbar-sec-dot', title: '有 agent 正在运行' }),
1556
- h('span', { className: 'pr-runbar-sec-label' }, '运行中'),
1557
- h('span', { className: 'pr-runbar-sec-count' }, String(runningGroups.length)),
1558
- ),
1559
- h('div', { className: 'pr-runbar-sec-body' }, runningGroups.map(renderRunPill)),
1560
- )
1561
- : null,
1562
- ),
1563
- h('div', { className: 'pr-head' },
1564
- h('span', { className: 'pr-head-title' }, 'Projects'),
1565
- h('span', { className: 'pr-head-count' }, `${shownProjects.length} 项目 · ${totalSessions} agent`),
1566
- h('button', { className: 'pr-new-btn', title: '新建项目(会按「项目 · 成员」命名新会话)', onClick: () => setNewProject({ name: '', member: managerName(), busy: false, msg: '' }) }, '+ 新项目'),
1567
- h('button', { className: 'pr-new-btn', title: '设置项目管理者名称', onClick: () => setSettings({ manager: managerName(), msg: '' }) }, '⚙'),
1568
- ),
1569
- looseMembers.length
1570
- ? h('div', { className: 'pr-loose-row' },
1571
- h('button', {
1572
- className: 'pr-loose-toggle',
1573
- title: showLoose ? '隐藏标题里没有分隔符的会话' : `显示 ${looseMembers.length} 个标题里没有分隔符的会话(多为历史测试)`,
1574
- onClick: () => {
1575
- const next = !showLoose;
1576
- setShowLoose(next);
1577
- saveJson('projectRoom.showLoose', next);
1578
- },
1579
- }, `${showLoose ? '▾' : '▸'} 未分组 ${looseMembers.length}`),
1580
- h('button', {
1581
- className: 'pr-loose-toggle',
1582
- title: '把这些没有项目前缀的会话归档(可恢复)',
1583
- onClick: archiveLoose,
1584
- }, clearArmed ? '确认清理' : '清理'),
1585
- clearMsg ? h('span', { className: 'pr-loose-msg' }, clearMsg) : null,
1586
- )
1587
- : null,
1588
- h('div', { className: 'pr-search' },
1589
- h('input', {
1590
- type: 'text',
1591
- value: query,
1592
- placeholder: '搜索项目 / agent',
1593
- onChange: (event) => setQuery(event.target.value),
1594
- onKeyDown: (event) => { if (event.key === 'Escape') setQuery(''); },
1595
- }),
1596
- query
1597
- ? h('button', { className: 'pr-icon-btn', title: '清空搜索', onClick: () => setQuery('') }, '×')
1598
- : null,
1599
- ),
1600
- visible.length === 0
1601
- ? h('div', { className: 'pr-empty' }, q ? '没有匹配的项目或 agent' : '还没有会话')
1602
- : visible.map((name, index) => {
1603
- const members = groups.get(name) || [];
1604
- const isClosed = !q && Boolean(closed[name]);
1605
- const isPinned = pinned.includes(name);
1606
- const latest = Math.max(0, ...members.map((m) => m.updatedAt));
1607
- const anyRunning = members.some((m) => m.running);
1608
- const day = dayKey();
1609
- const runTotal = Number(loadJson(`projectRoom.runTotal.${name}.${day}`, 0) || 0);
1610
- return h('section', {
1611
- key: name,
1612
- style: index > 0
1613
- ? { borderTop: '1px solid var(--dsw-alias-border-l1)', marginTop: '6px', paddingTop: '6px', marginBottom: '4px' }
1614
- : { marginBottom: '4px' },
1615
- },
1616
- h('div', {
1617
- className: `pr-proj${room === name ? ' open' : ''}`,
1618
- title: `${name} · 点击进入项目房间`,
1619
- onClick: () => roomBus.open(name),
1620
- },
1621
- h('span', { className: 'pr-dot', style: { background: `hsl(${hashHue(name)} 45% 50%)` } }),
1622
- h('span', { className: 'pr-proj-name', style: { color: `hsl(${hashHue(name)} 50% 68%)` } }, name),
1623
- h('span', { className: 'pr-spacer' }),
1624
- anyRunning ? h('span', { className: 'pr-meta', title: '有 agent 正在运行', style: { color: 'var(--dsw-alias-brand-primary)' } }, '●') : null,
1625
- runTotal > 0
1626
- ? h('span', { className: 'pr-meta', title: '今日该项目所有 agent 累计运行时长' }, `今日运行 ${formatDuration(runTotal)}`)
1627
- : null,
1628
- latest ? h('span', { className: 'pr-meta' }, relTime(latest)) : null,
1629
- h('button', {
1630
- className: 'pr-icon-btn pr-pin',
1631
- 'data-pinned': isPinned ? '1' : '0',
1632
- title: isPinned ? '取消置顶' : '置顶到项目列表顶部',
1633
- onClick: (event) => { event.stopPropagation(); setPin(name); },
1634
- }, isPinned ? '★' : '☆'),
1635
- h('button', {
1636
- className: 'pr-icon-btn',
1637
- title: isClosed ? '展开' : '折叠',
1638
- onClick: (event) => { event.stopPropagation(); toggleClosed(name); },
1639
- }, isClosed ? '▸' : '▾'),
1640
- ),
1641
- isClosed ? null : h('div', { className: 'pr-grid' },
1642
- // ⚠️ 有本体的分身不单独出芯片:它只在下面本体的树里显示。
1643
- // 否则一个分身会同时占「自己的芯片」和「本体芯片的 └─ 子行」两处(用户两次报的重复)。
1644
- members
1645
- .slice()
1646
- .filter((m) => {
1647
- if (!m.isClone) return true;
1648
- return !members.some((other) => !other.isClone && other.base === m.base);
1649
- })
1650
- // 排序:**Yat2 永远排第一**(用户令:它是项目管理者),其后运行中优先,
1651
- // 同档内按最近活跃倒序。
1652
- .sort((a, b) => (memberRank(a) - memberRank(b)) || ((b.updatedAt || 0) - (a.updatedAt || 0)))
1653
- .map((m) => {
1654
- const emoji = avatars && typeof avatars[m.name] === 'string' ? avatars[m.name].trim() : '';
1655
- const initial = (String(m.name || '·').trim()[0] || '·').toUpperCase();
1656
- const startedAt = runStartRef.current.get(m.id);
1657
- const elapsed = m.running && startedAt ? formatDuration(tick - startedAt) : '';
1658
- // 该 agent 的分身(同名不同项目不算;这里按「项目 + base 名」聚合)
1659
- const cloneList = m.isClone ? [] : members.filter((x) => x.isClone && x.base === m.base && x.id !== m.id);
1660
- return h('button', {
1661
- key: m.id,
1662
- type: 'button',
1663
- className: 'pr-chip',
1664
- 'data-session-id': m.id,
1665
- 'data-active': String(cursorSessionId || '') === String(m.id) ? '1' : '0',
1666
- title: `${m.name} · ${m.running ? '运行中' : '空闲'}${elapsed ? ' ' + elapsed : ''}${m.updatedAt ? ' · ' + relTime(m.updatedAt) : ''}`,
1667
- onContextMenu: (event) => {
1668
- event.preventDefault();
1669
- event.stopPropagation();
1670
- setMenu({ id: m.id, name: m.name, x: event.clientX, y: event.clientY });
1671
- },
1672
- onDoubleClick: (event) => {
1673
- event.stopPropagation();
1674
- setRenaming({ id: m.id, value: m.name });
1675
- },
1676
- onClick: (event) => {
1677
- event.stopPropagation();
1678
- // 点成员 = 要看这个对话:先关房间,否则房间会盖住对话区
1679
- roomBus.close();
1680
- // 2026-11-09 用户报「侧边栏还是没有单独高亮本体」:openSessionById 之后依赖宿主回写
1681
- // data-conversation-session 才更新 cursor;分身点击时它有时不回写 → 本体芯片一直亮。
1682
- // 这里点击即认账,不依赖宿主。
1683
- if (paintRef.current) paintRef.current(String(m.id)); // 同一帧先改 DOM,别等 React
1684
- setCursorSessionId(String(m.id));
1685
- manualPickUntilRef.current = Date.now() + 3000;
1686
- if (!openSessionById(m.id)) {
1687
- console.warn('[project-room] uiWorkspace.openSession 不可用,无法打开会话');
1688
- }
1689
- },
1690
- },
1691
- // 本体那一行单独成盒,选中背景只作用在它上面(布局已在隔离 HTML + headless Chrome 验证)
1692
- h('span', { className: 'pr-chip-head' },
1693
- h('span', {
1694
- className: 'pr-avatar',
1695
- style: emoji
1696
- ? { fontSize: '12px', fontWeight: 600, color: 'inherit', background: 'transparent' }
1697
- : { fontSize: '9px', fontWeight: 600, color: '#fff', background: `hsl(${hashHue(m.name)} 40% 40%)` },
1698
- }, emoji || initial),
1699
- h('span', { className: 'pr-chip-name' }, m.name),
1700
- cloneList.length
1701
- ? h('span', { className: 'pr-chip-badge', title: `${cloneList.length} 个分身 · 见下方子行,点哪个开哪个的独立对话(分列视图已于 2026-11-09 取消)` }, `+${cloneList.length}`)
1702
- : null,
1703
- m.running ? h('span', { className: 'pr-running-dot', title: '运行中' }) : null,
1704
- // 2026-11-09:空闲也显示(原实现 elapsed 为空就 null)。运行中=已运行,空闲=距上次活动。
1705
- (() => {
1706
- const mLast = m.updatedAt || 0;
1707
- if (!elapsed && (!mLast || !showIdleAgo)) return null;
1708
- return h('span', {
1709
- className: elapsed ? 'pr-chip-elapsed' : 'pr-chip-elapsed idle',
1710
- title: elapsed ? `已运行 ${elapsed}` : '距上次活动',
1711
- }, elapsed || relTime(mLast));
1712
- })(),
1713
- ),
1714
- cloneList.length
1715
- ? h('div', { className: 'pr-chip-clones' },
1716
- cloneList.slice().sort((a, b) => (memberRank(a) - memberRank(b)) || ((b.updatedAt || 0) - (a.updatedAt || 0))).map((c, cloneIndex, arr) => h('div', {
1717
- key: c.id,
1718
- className: 'pr-clone-row',
1719
- 'data-active': String(cursorSessionId || '') === String(c.id) ? '1' : '0',
1720
- 'data-session-id': String(c.id),
1721
- title: `${c.name} · ${c.running ? '运行中' : '空闲'}`,
1722
- onClick: (event) => {
1723
- event.stopPropagation();
1724
- roomBus.close();
1725
- if (paintRef.current) paintRef.current(String(c.id)); // 同一帧先改 DOM,别等 React
1726
- setCursorSessionId(String(c.id));
1727
- manualPickUntilRef.current = Date.now() + 3000;
1728
- openSessionById(c.id);
1729
- },
1730
- },
1731
- h('span', { className: 'pr-tree-rail' }, cloneIndex < arr.length - 1 ? '├' : '└'),
1732
- h('span', { className: `pr-clone-dot${c.running ? ' on' : ''}` }),
1733
- h('span', { className: 'pr-clone-name' }, c.name.split('|').pop() || c.name),
1734
- // 2026-11-09 用户两次报「分身还是没有显示计时」:这里原来**没有任何时长渲染**。
1735
- // 口径与本体芯片一致:运行中=已运行时长(绿),空闲=距上次活动(次要色)。
1736
- (() => {
1737
- const startedAt = runStartRef.current.get(c.id);
1738
- const cElapsed = c.running && startedAt ? formatDuration(tick - startedAt) : '';
1739
- const cLast = c.updatedAt || 0;
1740
- if (!cElapsed && (!cLast || !showIdleAgo)) return null;
1741
- return h('span', {
1742
- className: c.running ? 'pr-clone-elapsed' : 'pr-clone-elapsed idle',
1743
- title: c.running ? `已运行 ${cElapsed}` : '距上次活动',
1744
- }, c.running && cElapsed ? cElapsed : relTime(cLast));
1745
- })(),
1746
- )),
1747
- )
1748
- : null,
1749
- );
1750
- }),
1751
- ),
1752
- );
1753
- }),
1754
- );
1755
- }
1756
-
1757
- // ───────────────────────── 房间浮层 ─────────────────────────
1758
-
1759
- function ProjectRoom() {
1760
- const [project, setProject] = React.useState(roomBus.current);
1761
- const [board, setBoard] = React.useState('');
1762
- const [boardDirty, setBoardDirty] = React.useState(false);
1763
- const [boardEditing, setBoardEditing] = React.useState(false);
1764
- const [tasks, setTasks] = React.useState(null);
1765
- const [archiving, setArchiving] = React.useState(null); // { taskId, armed } 两段式确认
1766
- const [taskTick, setTaskTick] = React.useState(0); // 加一即重拉 tasks.json
1767
- const [members, setMembers] = React.useState([]);
1768
- // 任务线分列:每个成员一列,列内显示该会话最近的消息流。
1769
- // 事件源用 Host 的 /hub/session-events(由 hub-desktop-ui 提供);取不到就降级为提示。
1770
- const [lanes, setLanes] = React.useState({}); // { [sessionId]: { events, loading, error } }
1771
- const [target, setTarget] = React.useState('');
1772
- const [draft, setDraft] = React.useState('');
1773
- const [busy, setBusy] = React.useState(false);
1774
- const [error, setError] = React.useState('');
1775
- const [notice, setNotice] = React.useState('');
1776
-
1777
- React.useEffect(() => roomBus.subscribe((next) => {
1778
- setProject(next);
1779
- setError('');
1780
- setNotice('');
1781
- setBoardDirty(false);
1782
- }), []);
1783
-
1784
- // Esc 关闭房间:全屏/半屏遮罩最怕"关不掉",键盘退路必须有
1785
- React.useEffect(() => {
1786
- if (!project) return undefined;
1787
- const onKey = (event) => { if (event.key === 'Escape') roomBus.close(); };
1788
- window.addEventListener('keydown', onKey);
1789
- return () => window.removeEventListener('keydown', onKey);
1790
- }, [project]);
1791
-
1792
- // 打开项目时拉看板/任务/待办
1793
- React.useEffect(() => {
1794
- if (!project) return;
1795
- let cancelled = false;
1796
- (async () => {
1797
- try {
1798
- const [b, t] = await Promise.all([
1799
- fetch(`${API}/board?project=${encodeURIComponent(project)}`).then((r) => r.json()),
1800
- fetch(`${API}/tasks?project=${encodeURIComponent(project)}`).then((r) => r.json()),
1801
- ]);
1802
- if (cancelled) return;
1803
- setBoard((b && b.board) || '');
1804
- setTasks(t && t.tasks);
1805
- } catch (err) {
1806
- if (!cancelled) setError(`读取房间失败:${String(err && err.message || err)}`);
1807
- }
1808
- })();
1809
- return () => { cancelled = true; };
1810
- }, [project, taskTick]);
1811
-
1812
- // 成员列表:读侧栏同步过来的快照(房间浮层拿不到 slot props)
1813
- React.useEffect(() => {
1814
- if (!project) return undefined;
1815
- const collect = () => {
1816
- const groups = roomBus.sessionIndex;
1817
- const list = groups && groups.get ? (groups.get(project) || []) : [];
1818
- setMembers(list.map((m) => ({ id: m.id, name: m.name, running: m.running })));
1819
- };
1820
- collect();
1821
- const timer = setInterval(collect, 2000);
1822
- return () => clearInterval(timer);
1823
- }, [project]);
1824
-
1825
-
1826
- // 投递目标:默认选管理者(Yat2),并按项目记住上次选的人 ——
1827
- // 用户反馈「每次都要让我选」,原实现 target 初始是 ''、select 停在「选择成员…」。
1828
- React.useEffect(() => {
1829
- if (!members.length) { setTarget(''); return; }
1830
- const saved = loadJson(`projectRoom.target.${project}`, '');
1831
- const pick = members.find((m) => m.id === saved)
1832
- || members.find((m) => m.name === managerName())
1833
- || members[0];
1834
- setTarget((cur) => (cur && members.some((m) => m.id === cur) ? cur : (pick ? pick.id : '')));
1835
- }, [project, members]);
1836
-
1837
- /** 选目标时同时记住,下次打开房间直接用它。 */
1838
- const pickTarget = (id) => {
1839
- setTarget(id);
1840
- saveJson(`projectRoom.target.${project}`, id);
1841
- };
1842
- // 按「任务线」拉消息(用户澄清:分列是按任务分,不是按 agent 分)。
1843
- // 一条线一列:列内合并该线各成员的会话消息,按时间排序取最后几条。
1844
- // 只收 user/message 与 assistant/message 的文本(工具/step/思考不进气泡)。
1845
- // limit 取 60:实测 60 条事件里 assistant/message 只有 2 条,取 12 会显示成「暂无消息」。
1846
- React.useEffect(() => {
1847
- if (!project) return undefined;
1848
- let cancelled = false;
1849
- const load = async () => {
1850
- const list = roomBus.sessionIndex && roomBus.sessionIndex.get
1851
- ? (roomBus.sessionIndex.get(project) || [])
1852
- : [];
1853
- const sessionOf = (name) => list.find((m) => m.name === name);
1854
- const activeTasks = (tasks && Array.isArray(tasks.tasks) ? tasks.tasks : [])
1855
- .filter((task) => String(task.status || '').toLowerCase() !== 'archived')
1856
- .slice(0, 8);
1857
- const next = {};
1858
- // ⚠️ 跨列去重:同一个成员可能属于多条任务线(实测 DSH工程师 同时在
1859
- // quota-panel / t2 / plugin-publish 三条 active 线里),若每条线各自聚合他的会话,
1860
- // 他的消息就会在每一列重复出现——用户两次反馈的「重复显示」就是这个。
1861
- // 规则:独占成员的消息归它唯一所属的线;共享成员的消息只归「第一条」包含它的线
1862
- // (按 tasks 数组顺序),其余线不再重复显示。
1863
- const sharedSeen = new Set(); // sessionId:seq 已分配
1864
- const memberCount = new Map(); // 成员名 → 出现在几条线
1865
- for (const task of activeTasks) {
1866
- for (const name of (Array.isArray(task.members) ? task.members : [])) {
1867
- memberCount.set(name, (memberCount.get(name) || 0) + 1);
1868
- }
1869
- }
1870
- // 串行处理(先到先得需要确定顺序)
1871
- for (const [index, task] of activeTasks.entries()) {
1872
- const key = task.id || `task-${index}`;
1873
- const names = Array.isArray(task.members) ? task.members : [];
1874
- const targets = names.map((name) => sessionOf(name)).filter(Boolean);
1875
- if (targets.length === 0) {
1876
- next[key] = { messages: [], note: '这条线没有可用的成员会话' };
1877
- continue;
1878
- }
1879
- const collected = [];
1880
- for (const member of targets) {
1881
- // 共享成员:如果它已经被前面的线拿走了,这一列不再显示它的消息
1882
- const claimedKey = `member:${member.id}`;
1883
- const isShared = (memberCount.get(member.name) || 0) > 1;
1884
- if (isShared && sharedSeen.has(claimedKey)) continue;
1885
- try {
1886
- const res = await fetch(`/hub/session-events?session=${encodeURIComponent(member.id)}&limit=60`);
1887
- const data = await res.json();
1888
- const events = Array.isArray(data && data.events) ? data.events : [];
1889
- for (const event of events) {
1890
- if (!event || (event.type !== 'user/message' && event.type !== 'assistant/message')) continue;
1891
- const content = event.data && event.data.message && event.data.message.content;
1892
- const text = Array.isArray(content)
1893
- ? content.filter((part) => part && part.type === 'text').map((part) => part.text || '').join('\n')
1894
- : '';
1895
- const value = String(text || '').trim();
1896
- if (!value) continue;
1897
- const msgKey = `${member.id}:${event.seq}`;
1898
- if (sharedSeen.has(msgKey)) continue;
1899
- sharedSeen.add(msgKey);
1900
- // 2026-11-10 用户报「群聊卡片把整段会话原文当消息渲染」:
1901
- // 卡片是进度视图,只放一句摘要要点。
1902
- // 卡片只渲染这句要点——不保留原文副本(2026-11-10 分诊定调:存与渲染两端都只留摘要)。
1903
- const summary = summarizeMessage(value);
1904
- collected.push({
1905
- seq: event.seq,
1906
- time: event.time || 0,
1907
- role: event.type === 'user/message' ? 'user' : 'agent',
1908
- from: member.name,
1909
- text: summary,
1910
- });
1911
- }
1912
- if (isShared) sharedSeen.add(claimedKey);
1913
- } catch { /* 单个成员失败不影响整列 */ }
1914
- }
1915
- collected.sort((a, b) => (a.time || 0) - (b.time || 0));
1916
- next[key] = { messages: collected.slice(-6) };
1917
- }
1918
- if (!cancelled) setLanes(next);
1919
- };
1920
- load();
1921
- const timer = setInterval(load, 30000);
1922
- return () => { cancelled = true; clearInterval(timer); };
1923
- }, [project, tasks]);
1924
-
1925
- if (!project) return null;
1926
-
1927
- const saveBoard = async () => {
1928
- setBusy(true);
1929
- setError('');
1930
- setNotice('');
1931
- try {
1932
- const res = await fetch(`${API}/board?project=${encodeURIComponent(project)}`, {
1933
- method: 'POST',
1934
- headers: { 'content-type': 'application/json' },
1935
- body: JSON.stringify({ content: board }),
1936
- });
1937
- const data = await res.json();
1938
- if (data && data.ok) { setNotice('看板已保存到 board.md'); setBoardDirty(false); }
1939
- else setError(`保存失败:${(data && data.error) || '未知'}`);
1940
- } catch (err) {
1941
- setError(`保存失败:${String(err && err.message || err)}`);
1942
- } finally { setBusy(false); }
1943
- };
1944
-
1945
- /** 归档/恢复一条任务线:改 tasks.json 的 status,成功后重拉。 */
1946
- /** 归档/恢复一条任务线:改 tasks.json 的 status,成功后重拉。 */
1947
- const setTaskStatus = async (taskId, status) => {
1948
- try {
1949
- const res = await fetch(`${API}/task-status`, {
1950
- method: 'POST',
1951
- headers: { 'content-type': 'application/json' },
1952
- body: JSON.stringify({ project, taskId, status }),
1953
- });
1954
- // 不能直接 res.json():端点未加载时 DSH 会返回空体,json() 会抛
1955
- // "Unexpected end of JSON input",用户只看到一句看不懂的错。
1956
- const raw = await res.text();
1957
- let data = null;
1958
- try { data = raw ? JSON.parse(raw) : null; } catch { data = null; }
1959
- if (!data || !data.ok) {
1960
- const reason = (data && data.error)
1961
- || (raw && raw.slice(0, 80))
1962
- || `HTTP ${res.status} 且响应为空 —— 多半是这个端点还没加载,需要重启 DSH`;
1963
- setError(`改任务线失败:${reason}`);
1964
- return;
1965
- }
1966
- setArchiving(null);
1967
- setTaskTick((n) => n + 1); // 触发重拉,归档后该列立即消失
1968
- } catch (error) {
1969
- setError(`改任务线失败:${String((error && error.message) || error)}`);
1970
- }
1971
- };
1972
-
1973
-
1974
- const send = async () => {
1975
- if (!target || !draft.trim()) return;
1976
- setBusy(true);
1977
- setError('');
1978
- setNotice('');
1979
- try {
1980
- const res = await fetch(`${API}/prompt`, {
1981
- method: 'POST',
1982
- headers: { 'content-type': 'application/json' },
1983
- body: JSON.stringify({ sessionId: target, text: draft }),
1984
- });
1985
- const data = await res.json();
1986
- if (data && data.ok) {
1987
- setNotice(`已投递给 ${target.slice(-8)}`);
1988
- setDraft('');
1989
- } else {
1990
- setError(`投递失败:${(data && data.error) || '未知'}`);
1991
- }
1992
- } catch (err) {
1993
- setError(`投递失败:${String(err && err.message || err)}`);
1994
- } finally { setBusy(false); }
1995
- };
1996
-
1997
- // 活跃任务线(按任务分列用;archived 的不显示,最多 8 列)
1998
- const activeTasks = (tasks && Array.isArray(tasks.tasks) ? tasks.tasks : [])
1999
- .filter((task) => String(task.status || '').toLowerCase() !== 'archived')
2000
- .slice(0, 8);
2001
- const activeTaskCount = activeTasks.length;
2002
- // 一页展示三段(看板 / 任务 / 待办),不再用 tab 切换(用户要求整合到一页)
2003
- const body = h('div', { className: 'pr-room-body' },
2004
- h('section', { className: 'pr-section pr-board-section' },
2005
- h('div', { className: 'pr-section-head' },
2006
- h('div', { className: 'pr-section-title' }, '公共看板'),
2007
- h('span', { className: 'pr-board-count', title: 'board.md 字符数(含空白)' }, `${board.length.toLocaleString()} 字`),
2008
- h('button', {
2009
- className: 'pr-btn pr-board-toggle',
2010
- onClick: () => setBoardEditing((value) => !value),
2011
- title: boardEditing ? '切到渲染视图' : '切到编辑(直接改 board.md)',
2012
- }, boardEditing ? '完成' : '编辑'),
2013
- ),
2014
- boardEditing
2015
- ? h('div', null,
2016
- h('div', { className: 'pr-hint' }, 'board.md 原文 —— 这里的改动会写回同一份文件。'),
2017
- h('textarea', {
2018
- className: 'pr-board pr-board-edit',
2019
- value: board,
2020
- spellCheck: false,
2021
- onChange: (event) => { setBoard(event.target.value); setBoardDirty(true); },
2022
- }),
2023
- h('div', { className: 'pr-board-actions' },
2024
- h('button', { className: 'pr-btn primary', onClick: saveBoard, disabled: busy || !boardDirty }, '保存看板'),
2025
- boardDirty ? h('span', { className: 'pr-hint' }, '有未保存改动') : null,
2026
- ),
2027
- )
2028
- : h('div', { className: 'pr-md-view' },
2029
- board && board.trim() ? renderMarkdown(board) : h('div', { className: 'pr-hint' }, '(看板还是空的)'),
2030
- ),
2031
- ),
2032
- h('section', { className: 'pr-section pr-tasks-section' },
2033
- h('div', { className: 'pr-section-title' }, `任务线分列 · ${activeTaskCount}`),
2034
- activeTaskCount === 0
2035
- ? h('div', { className: 'pr-hint' }, '这个项目当前没有活跃任务线(tasks.json 里 status 均为 archived)。')
2036
- : h('div', { className: 'pr-lanes' },
2037
- activeTasks.map((task, taskIndex) => {
2038
- const key = task.id || `task-${taskIndex}`;
2039
- const lane = lanes[key] || {};
2040
- const state = String(task.status || '').toLowerCase();
2041
- const tone = state.includes('block') ? 'var(--dsw-alias-state-warning-primary, #c9a227)' : 'var(--dsw-alias-brand-primary)';
2042
- return h('div', { className: 'pr-lane', key },
2043
- h('div', { className: 'pr-lane-head', title: `${task.title || ''} · ${(task.members || []).join('、')}` },
2044
- h('span', { className: 'pr-lane-dot', style: { background: tone } }),
2045
- h('span', { className: 'pr-lane-name' }, task.title || '(未命名任务)'),
2046
- h('button', {
2047
- className: 'pr-lane-archive',
2048
- title: '归档这条任务线(归档后从这里消失,可在 tasks.json 恢复)',
2049
- onClick: (event) => {
2050
- event.stopPropagation();
2051
- if (archiving && archiving.taskId === key && archiving.armed) {
2052
- setTaskStatus(key, 'archived');
2053
- return;
2054
- }
2055
- setArchiving({ taskId: key, armed: true });
2056
- setTimeout(() => {
2057
- setArchiving((cur) => (cur && cur.taskId === key ? null : cur));
2058
- }, 4000);
2059
- },
2060
- }, archiving && archiving.taskId === key && archiving.armed ? '确认归档?' : '⋯'),
2061
- ),
2062
- h('div', { className: 'pr-lane-sub' }, (task.members || []).join('、') || '无成员'),
2063
- h('div', { className: 'pr-lane-body' },
2064
- lane.note
2065
- ? h('div', { className: 'pr-hint' }, lane.note)
2066
- : (lane.messages && lane.messages.length)
2067
- ? lane.messages.map((msg, msgIndex) => h('div', {
2068
- className: `pr-lane-msg pr-lane-msg-${msg.role}`,
2069
- key: `${key}-${msg.seq || msgIndex}`,
2070
- title: `${msg.from ? msg.from + ':' : ''}${msg.text}`,
2071
- }, msg.text))
2072
- : h('div', { className: 'pr-hint' }, lane.messages ? '暂无消息' : '加载中…'),
2073
- ),
2074
- );
2075
- }),
2076
- ),
2077
- ),
2078
- );
2079
-
2080
- const box = conversationBox();
2081
- return h('div', {
2082
- className: 'pr-overlay',
2083
- style: {
2084
- position: 'fixed', zIndex: 900,
2085
- left: `${box.left}px`, top: `${box.top}px`, right: 0, bottom: 0,
2086
- display: 'flex', flexDirection: 'column',
2087
- background: 'var(--dsw-alias-bg-base)',
2088
- },
2089
- },
2090
- h('div', { className: 'pr-panel', onClick: (event) => event.stopPropagation() },
2091
- h('div', { className: 'pr-panel-head' },
2092
- h('span', { className: 'pr-panel-title' }, `#${project}`),
2093
- h('div', { style: { flex: 1 } }),
2094
- h('button', { className: 'pr-close-btn', title: '关闭房间(Esc)', onClick: () => roomBus.close() }, '✕ 关闭'),
2095
- ),
2096
- h('div', { className: 'pr-panel-body' },
2097
- error ? h('div', { className: 'pr-err' }, error) : null,
2098
- notice ? h('div', { className: 'pr-hint' }, notice) : null,
2099
- body,
2100
- ),
2101
- h('div', { className: 'pr-panel-foot' },
2102
- h('div', { className: 'pr-send-row' },
2103
- h('select', {
2104
- className: 'pr-select',
2105
- value: target,
2106
- onChange: (event) => pickTarget(event.target.value),
2107
- },
2108
- h('option', { value: '' }, members.length ? '切换收件人…' : '(暂无成员)'),
2109
- members.map((m) => h('option', { key: m.id, value: m.id }, `${m.name}${m.running ? ' ●' : ''}`)),
2110
- ),
2111
- h('textarea', {
2112
- className: 'pr-textarea',
2113
- value: draft,
2114
- placeholder: '给这个成员发一条消息(唤醒会话,走原生 prompt)',
2115
- onChange: (event) => setDraft(event.target.value),
2116
- onKeyDown: (event) => {
2117
- // 回车直接发送(Shift+Enter 换行)—— 用户要求「回车发送」
2118
- if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); send(); }
2119
- },
2120
- }),
2121
- h('button', { className: 'pr-btn primary', disabled: busy || !target || !draft.trim(), onClick: send }, '投递'),
2122
- ),
2123
- ),
2124
- ),
2125
- );
2126
- }
2127
-
2128
- // ───────────────────────── 插件入口 ─────────────────────────
2129
-
2130
- return {
2131
- inject: ['slots'],
2132
- // 测试钩子:让 smoke.mjs 能直接驱动房间页渲染(生产运行不读)
2133
- __test: { roomBus, conversationBox },
2134
- apply(ctx) {
2135
- clientCtx = ctx;
2136
- // ⚠️ 必须先注入样式表再注册组件:这里曾经漏调 ensureStyle(),
2137
- // 结果整份 CSS(浮层定位 / 侧栏滚动 / 项目行布局)全都没生效,界面错乱。
2138
- const style = ensureStyle();
2139
- ctx.effect(() => () => {
2140
- try { style?.remove(); } catch { /* ignore */ }
2141
- }, 'project-room: stylesheet');
2142
-
2143
- const mount = (key, options, component) => {
2144
- try {
2145
- ctx.slots.inject(key, () => ctx.slots.register({ name: key, ...options }, component));
2146
- } catch (error) {
2147
- console.error(`[project-room] mount ${key} failed`, error);
2148
- }
2149
- };
2150
-
2151
- // priority -2:低于本机既有侧栏插件(-1),确保本插件接管时更优先;
2152
- // 想换回别的侧栏,卸掉本插件即可。
2153
- mount('sidebar.workspaces', { priority: -2 }, ProjectSidebar);
2154
- mount('shell.overlay', { id: 'project-room-page', order: 92 }, ProjectRoom);
2155
-
2156
- console.log('[project-room] client 半已挂载');
2157
- },
2158
- };
2159
- },
2160
- });
1
+ /**
2
+ * dsh-project-room · Client 半
3
+ *
4
+ * 做两件事:
5
+ * 1. 接管侧栏浏览区(`sidebar.workspaces`,priority -2):把平铺的会话列表改成
6
+ * 「项目分组 + 成员芯片 + 每组今日运行时长 + 搜索 + 折叠/置顶」。
7
+ * 分组依据 = 会话标题里的分隔符(默认 ' · ',「项目 · 成员」→ 项目「项目」)。
8
+ * 2. 一个项目房间浮层(`shell.overlay`):公共看板 / 任务线分列 / 向成员投递消息。
9
+ *
10
+ * 数据来源全部是 DSH 原生 + 本插件自己的 Host 半端点:
11
+ * - 会话与运行状态:slot props 的 `useSessions` / `useWorkspaces`(原生 `running`、`updatedAt`)
12
+ * - 看板/任务/待办/投递:`/project-room/*`(见 index.js)
13
+ * 不读任何其它插件的 DOM 或私有文件。
14
+ */
15
+
16
+ window.__ModuleLoader__.load({
17
+ id: 'dsh-project-room',
18
+ factory(require) {
19
+ const React = require('react');
20
+ const h = React.createElement;
21
+
22
+ const SEP = ' · ';
23
+ const STYLE_ID = 'project-room-style';
24
+ const API = '/project-room';
25
+ /** 标题里没有分隔符的会话归到这个名字下(默认不显示,见 showLoose)。 */
26
+ const LOOSE_GROUP = '未分组';
27
+ /** 默认项目管理者名;用户可在侧栏齿轮里改(localStorage: projectRoom.managerName)。 */
28
+ const DEFAULT_MANAGER = 'Yat2';
29
+
30
+ // ───────────────────────── 小工具 ─────────────────────────
31
+
32
+ function projectOfTitle(title) {
33
+ const text = String(title || '');
34
+ const at = text.lastIndexOf(SEP);
35
+ if (at <= 0) return null;
36
+ const project = text.slice(0, at).trim();
37
+ const member = text.slice(at + SEP.length).trim();
38
+ if (!project || !member) return null;
39
+ return { project, member };
40
+ }
41
+
42
+ function formatDuration(ms) {
43
+ const total = Math.max(0, Math.floor(ms / 1000));
44
+ const hh = Math.floor(total / 3600);
45
+ const mm = Math.floor((total % 3600) / 60);
46
+ const ss = total % 60;
47
+ if (hh > 0) return `${hh}h${String(mm).padStart(2, '0')}m`;
48
+ if (mm > 0) return `${mm}m${String(ss).padStart(2, '0')}s`;
49
+ return `${ss}s`;
50
+ }
51
+
52
+ /**
53
+ * 会话「最后一次活动」时间(毫秒)。
54
+ * ⚠️ 不能用 session.updatedAt:DSH 的 sessions 投影没有这个字段
55
+ * (全仓 `session.updatedAt || 0` 都是防御写法,实测恒 0)——
56
+ * 这正是用户两次报「侧边栏分身还是没有计时」的根因。
57
+ * 键与 hub-desktop-ui 共用 `hub.lastActive`,两边互相补。
58
+ */
59
+ /**
60
+ * 「距上次活动 / X 小时前运行」这类相对时长是否显示(用户令 2026-11-09:
61
+ * 「在设置里加上 agent 多少多少小时前运行这个显示的开关」)。
62
+ * 默认开;关掉后只显示运行中时长,空闲行不再出现相对时间。
63
+ */
64
+ const SHOW_IDLE_AGO_KEY = 'projectRoom.showIdleAgo';
65
+ let showIdleAgo = (() => {
66
+ try {
67
+ const raw = localStorage.getItem(SHOW_IDLE_AGO_KEY);
68
+ return raw === null ? true : raw === '1';
69
+ } catch { return true; }
70
+ })();
71
+ function setShowIdleAgo(on) {
72
+ showIdleAgo = Boolean(on);
73
+ try { localStorage.setItem(SHOW_IDLE_AGO_KEY, showIdleAgo ? '1' : '0'); } catch { /* 存不了就本次生效 */ }
74
+ }
75
+
76
+ const LAST_ACTIVE_KEY = 'hub.lastActive';
77
+ const lastActiveTimes = (() => {
78
+ try { return JSON.parse(localStorage.getItem(LAST_ACTIVE_KEY) || '{}') || {}; } catch { return {}; }
79
+ })();
80
+ function markLastActive(id, at) {
81
+ const key = String(id || '');
82
+ if (!key) return;
83
+ lastActiveTimes[key] = Number(at) || Date.now();
84
+ try { localStorage.setItem(LAST_ACTIVE_KEY, JSON.stringify(lastActiveTimes)); } catch { /* 存储不可用 */ }
85
+ }
86
+ function lastActiveOf(id) {
87
+ return Number(lastActiveTimes[String(id || '')]) || 0;
88
+ }
89
+
90
+ function relTime(ts) {
91
+ if (!ts) return '';
92
+ const diff = Date.now() - ts;
93
+ if (diff < 60000) return '刚刚';
94
+ if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`;
95
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`;
96
+ return `${Math.floor(diff / 86400000)} 天前`;
97
+ }
98
+
99
+ function dayKey() {
100
+ const d = new Date();
101
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
102
+ }
103
+
104
+ function hashHue(text) {
105
+ let sum = 0;
106
+ for (let i = 0; i < String(text).length; i += 1) sum = (sum * 31 + String(text).charCodeAt(i)) % 360;
107
+ return sum;
108
+ }
109
+
110
+ /** 成员芯片的「头像」:名字首字符 + 由名字推导的稳定色相。 */
111
+ function memberGlyph(name) {
112
+ const text = String(name || '?').trim();
113
+ return text ? Array.from(text)[0] : '?';
114
+ }
115
+
116
+ /**
117
+ * 成员排序权重(越小越前):
118
+ * 0 = Yat2(项目管理者,用户要求它始终排第一)
119
+ * 1 = 运行中
120
+ * 2 = 其它
121
+ */
122
+ /**
123
+ * 项目管理者的名字(默认 Yat2,可在侧栏齿轮里改,存 localStorage)。
124
+ * 排序、新建项目默认成员、房间成员归属都读它。
125
+ * 注意边界:hub-desktop-ui 里还硬编码着 MANAGER_NAMES = ['Yat2','Hermes'] 用于
126
+ * 注入管理者协议——改这里的名字**不会**改变那边认谁是管理者。
127
+ */
128
+ function managerName() {
129
+ try {
130
+ const raw = window.localStorage.getItem('projectRoom.managerName');
131
+ const value = raw ? String(raw).trim() : '';
132
+ return value || DEFAULT_MANAGER;
133
+ } catch { return DEFAULT_MANAGER; }
134
+ }
135
+
136
+ /**
137
+ * 任务线排序:最近活跃的排最前;活跃时间相同按 tasks.json 定义顺序兜底。
138
+ *
139
+ * 为什么单独抽出来:排序规则要能被测试直接跑(test/lane-order.test.mjs 切片真源码),
140
+ * 也避免把「显示顺序」的逻辑散在 JSX 里。纯函数——不改入参、不读全局。
141
+ *
142
+ * @param {Array<{task: object, index: number, lastAt: number}>} entries
143
+ * index = 该线在 tasks.json 里的位置(顺序兜底用);lastAt = 该线最近活跃时间(ms)。
144
+ */
145
+ function orderLanesByActivity(entries) {
146
+ return entries.slice().sort(
147
+ (a, b) => ((Number(b.lastAt) || 0) - (Number(a.lastAt) || 0)) || (a.index - b.index),
148
+ );
149
+ }
150
+
151
+ /**
152
+ * 极简 Markdown → React 元素(不引第三方库,够黑板用):
153
+ * 标题 #~####、无序列表 -/*、行内 **粗体** 与 `代码`、其余按段落。
154
+ */
155
+ function renderMarkdown(text) {
156
+ const lines = String(text || '').split('\n');
157
+ const out = [];
158
+ let listBuffer = [];
159
+ const flushList = () => {
160
+ if (!listBuffer.length) return;
161
+ out.push(h('ul', { key: `ul-${out.length}`, className: 'pr-md-ul' }, listBuffer));
162
+ listBuffer = [];
163
+ };
164
+ const inline = (line, key) => {
165
+ const regex = /(\*\*[^*]+\*\*|`[^`]+`)/g;
166
+ const parts = [];
167
+ let last = 0;
168
+ let match;
169
+ let index = 0;
170
+ while ((match = regex.exec(line)) !== null) {
171
+ if (match.index > last) parts.push(line.slice(last, match.index));
172
+ const token = match[0];
173
+ if (token.startsWith('**')) parts.push(h('strong', { key: `${key}-b${index++}` }, token.slice(2, -2)));
174
+ else parts.push(h('code', { key: `${key}-c${index++}`, className: 'pr-md-code' }, token.slice(1, -1)));
175
+ last = match.index + token.length;
176
+ }
177
+ if (last < line.length) parts.push(line.slice(last));
178
+ return parts.length ? parts : [line];
179
+ };
180
+ lines.forEach((raw, lineIndex) => {
181
+ const line = raw.replace(/\s+$/, '');
182
+ if (!line.trim()) { flushList(); return; }
183
+ const heading = /^(#{1,4})\s+(.*)$/.exec(line);
184
+ if (heading) {
185
+ flushList();
186
+ const level = Math.min(heading[1].length + 2, 6);
187
+ out.push(h(`h${level}`, { key: `h-${lineIndex}`, className: 'pr-md-h' }, ...inline(heading[2], `h${lineIndex}`)));
188
+ return;
189
+ }
190
+ const bullet = /^[-*]\s+(.*)$/.exec(line);
191
+ if (bullet) {
192
+ listBuffer.push(h('li', { key: `li-${lineIndex}` }, ...inline(bullet[1], `li${lineIndex}`)));
193
+ return;
194
+ }
195
+ flushList();
196
+ out.push(h('p', { key: `p-${lineIndex}`, className: 'pr-md-p' }, ...inline(line, `p${lineIndex}`)));
197
+ });
198
+ flushList();
199
+ return out;
200
+ }
201
+
202
+ function memberRank(member) {
203
+ const name = String((member && (member.base || member.name)) || '').trim();
204
+ if (name && name.toLowerCase() === managerName().toLowerCase()) return 0;
205
+ return member && member.running ? 1 : 2;
206
+ }
207
+
208
+ function loadJson(key, fallback) {
209
+ try {
210
+ const raw = window.localStorage.getItem(key);
211
+ return raw ? JSON.parse(raw) : fallback;
212
+ } catch { return fallback; }
213
+ }
214
+
215
+ function saveJson(key, value) {
216
+ try { window.localStorage.setItem(key, JSON.stringify(value)); } catch { /* 配额满了就放弃持久化 */ }
217
+ }
218
+
219
+ /** 插件上下文(apply 时赋值),用来取宿主 Client 服务。 */
220
+ let clientCtx = null;
221
+
222
+ /**
223
+ * 打开一个会话。
224
+ * 正确通道是宿主 Client 服务 `uiWorkspace.openSession(id)`——不是 slot props
225
+ * (`sidebar.workspaces` 只给 useWorkspaces / useSessions)。
226
+ * 早期版本错用了 props.openSession 并在失败时退回「打开项目房间」,
227
+ * 结果点任何芯片都弹出公共看板。
228
+ */
229
+ function openSessionById(id) {
230
+ if (!id) return false;
231
+ try {
232
+ const svc = clientCtx && typeof clientCtx.get === 'function' ? clientCtx.get('uiWorkspace') : null;
233
+ if (svc && typeof svc.openSession === 'function') {
234
+ svc.openSession(id);
235
+ return true;
236
+ }
237
+ } catch (error) {
238
+ console.error('[project-room] openSession failed', error);
239
+ }
240
+ return false;
241
+ }
242
+
243
+ /** 当前打开的会话 id(宿主写在会话内容区的 data 属性上) */
244
+ function currentSessionId() {
245
+ try {
246
+ const el = document.querySelector('[data-conversation-content]');
247
+ return el ? (el.getAttribute('data-conversation-session') || '') : '';
248
+ } catch { return ''; }
249
+ }
250
+
251
+ /**
252
+ * 量出「对话内容区」在屏幕上的位置,让房间页正好占住这一块
253
+ * (侧栏、顶栏都保留),而不是盖住整个窗口。
254
+ * 量不到就退回到「从左栏后面开始」。
255
+ */
256
+ function conversationBox() {
257
+ try {
258
+ const el = document.querySelector('[data-conversation-content]');
259
+ if (el) {
260
+ const rect = el.getBoundingClientRect();
261
+ if (rect.width > 120 && rect.height > 120) {
262
+ return { left: Math.round(rect.left), top: Math.round(rect.top) };
263
+ }
264
+ }
265
+ } catch { /* 量不到走兜底 */ }
266
+ return { left: 264, top: 0 };
267
+ }
268
+
269
+ // ───────────────────── 房间打开总线(侧栏 → 浮层) ─────────────────────
270
+
271
+ const roomBus = {
272
+ current: null,
273
+ listeners: new Set(),
274
+ /** 侧栏每次渲染都会刷新这里,房间浮层据此拿到「本项目成员」。 */
275
+ sessionIndex: new Map(),
276
+ open(project) {
277
+ this.current = project;
278
+ for (const fn of this.listeners) { try { fn(project); } catch { /* ignore */ } }
279
+ },
280
+ close() {
281
+ this.current = null;
282
+ for (const fn of this.listeners) { try { fn(null); } catch { /* ignore */ } }
283
+ },
284
+ subscribe(fn) {
285
+ this.listeners.add(fn);
286
+ return () => this.listeners.delete(fn);
287
+ },
288
+ };
289
+
290
+ // ───────────────────────── 样式 ─────────────────────────
291
+
292
+ // 视觉基准 = AI-Agent-Hub 现有侧栏(HubWorkspaces):同样的行高、间距、圆角、芯片网格。
293
+ // 数值来自 hub-desktop-ui 的实测样式(.hub-proj / .hub-chip 等),照搬以保证观感一致。
294
+ const CSS = `
295
+ .pr-root {
296
+ display: flex; flex-direction: column;
297
+ flex: 1 1 auto; min-height: 0; height: 100%;
298
+ overflow-y: auto; overflow-x: hidden;
299
+ padding: 0 4px 8px; box-sizing: border-box;
300
+ }
301
+ .pr-search { display: flex; align-items: center; gap: 4px; padding: 0 6px 6px; flex: 0 0 auto; }
302
+ .pr-search input {
303
+ flex: 1; min-width: 0; height: 22px; box-sizing: border-box; padding: 0 6px;
304
+ border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1);
305
+ background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary);
306
+ font-size: 11px; outline: none;
307
+ }
308
+ .pr-head { display: flex; align-items: center; gap: 6px; padding: 2px 6px 4px; flex: 0 0 auto; }
309
+ .pr-head-title { font-size: 11.5px; letter-spacing: 0.04em; font-weight: 600; color: var(--dsw-alias-label-secondary); }
310
+ .pr-head-count { font-size: 10px; color: var(--dsw-alias-label-secondary); opacity: .75; margin-left: auto; }
311
+ /* 「未分组」那一行:折叠按钮 + 清理按钮 */
312
+ .pr-loose-row { display: flex; gap: 4px; align-items: center; padding: 0 6px 6px; flex: 0 0 auto; }
313
+ .pr-loose-toggle {
314
+ padding: 1px 6px;
315
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px;
316
+ background: transparent; color: var(--dsw-alias-label-secondary);
317
+ font-size: 10px; line-height: 1.6; cursor: pointer; flex: 0 0 auto;
318
+ }
319
+ .pr-loose-toggle:hover { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
320
+ .pr-chip-badge {
321
+ flex: 0 0 auto; min-width: 14px; height: 14px; padding: 0 3px; border-radius: 7px;
322
+ display: inline-flex; align-items: center; justify-content: center;
323
+ background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary);
324
+ font-size: 9px; line-height: 1; font-variant-numeric: tabular-nums;
325
+ }
326
+ .pr-chip-elapsed { flex: 0 0 auto; font-size: 9px; line-height: 1; font-variant-numeric: tabular-nums; color: var(--dsw-alias-state-success-primary); }
327
+ /* 分身子行:树状(├ / └ 竖线缩进) */
328
+ .pr-chip-clones { display: flex; flex-direction: column; gap: 1px; margin-top: 1px; padding-left: 0; }
329
+ .pr-clone-row {
330
+ display: flex; align-items: center; gap: 3px;
331
+ font-size: 9.5px; line-height: 1.5; color: var(--dsw-alias-label-secondary);
332
+ cursor: pointer; border-radius: 3px; padding: 0;
333
+ }
334
+ .pr-clone-row:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.06)); color: var(--dsw-alias-label-primary); }
335
+ .pr-tree-rail { flex: 0 0 auto; width: 7px; color: var(--dsw-alias-border-l2, #4a4a4a); font-size: 9px; line-height: 1.5; }
336
+ .pr-clone-dot { flex: 0 0 auto; width: 5px; height: 5px; border-radius: 50%; background: transparent; box-shadow: inset 0 0 0 1px var(--dsw-alias-label-secondary); }
337
+ .pr-clone-dot.on { background: var(--dsw-alias-state-success-primary, #4ade80); box-shadow: none; }
338
+ /* 同理:分身子行名不再 ellipsis,长任务名完整显示(行高会自动撑开)。 */
339
+ .pr-clone-name { min-width: 0; white-space: normal; overflow-wrap: anywhere; word-break: break-word; }
340
+ .pr-clone-elapsed {
341
+ flex: 0 0 auto; margin-left: 3px; font-size: 9px; line-height: 1;
342
+ font-variant-numeric: tabular-nums;
343
+ color: var(--dsw-alias-state-success-primary);
344
+ }
345
+ .pr-clone-elapsed.idle, .pr-chip-elapsed.idle { color: var(--dsw-alias-label-secondary); }
346
+ .pr-menu {
347
+ position: fixed; z-index: 1000; min-width: 168px; padding: 4px;
348
+ border-radius: 8px; border: 1px solid var(--dsw-alias-border-l2);
349
+ background: var(--dsw-alias-bg-layer-2); box-shadow: 0 12px 32px rgba(0,0,0,.45);
350
+ font-size: 12px;
351
+ }
352
+ .pr-menu-item {
353
+ display: block; width: 100%; text-align: left; padding: 5px 8px; border: none; border-radius: 5px;
354
+ background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 12px; cursor: pointer;
355
+ }
356
+ .pr-menu-item:hover { background: var(--dsw-alias-bg-layer-1); }
357
+ .pr-menu-msg { padding: 4px 8px; font-size: 10.5px; color: var(--dsw-alias-label-secondary); }
358
+ .pr-modal { position: fixed; inset: 0; z-index: 1001; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.45); }
359
+ .pr-modal-box { width: min(420px, 88vw); padding: 14px; border-radius: 10px; background: var(--dsw-alias-bg-base); border: 1px solid var(--dsw-alias-border-l2); }
360
+ .pr-modal-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
361
+ .pr-modal-input { width: 100%; box-sizing: border-box; height: 30px; padding: 0 8px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-size: 12.5px; outline: none; }
362
+ .pr-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
363
+ /* 头部「+ 新项目」按钮 */
364
+ .pr-new-btn {
365
+ flex: 0 0 auto; height: 18px; padding: 0 6px; border-radius: 5px;
366
+ border: 1px solid var(--dsw-alias-border-l1); background: transparent;
367
+ color: var(--dsw-alias-label-secondary); font-size: 10px; line-height: 1; cursor: pointer;
368
+ }
369
+ .pr-new-btn:hover { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
370
+ /* 房间关闭按钮:显式带文字,避免全屏遮罩下只剩一个小图标找不着 */
371
+ .pr-close-btn {
372
+ flex: 0 0 auto; height: 22px; padding: 0 10px; border-radius: 6px;
373
+ border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2);
374
+ color: var(--dsw-alias-label-primary); font-size: 11.5px; line-height: 1; cursor: pointer;
375
+ }
376
+ .pr-close-btn:hover { border-color: var(--dsw-alias-brand-primary); }
377
+ /* 一页三段布局 */
378
+ /* 房间主体:左 1/3 看板、右 2/3 任务线分列 */
379
+ .pr-room-body {
380
+ flex: 1; min-height: 0;
381
+ display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); grid-template-rows: minmax(0, 1fr);
382
+ gap: 12px; align-items: stretch;
383
+ }
384
+ .pr-room-body > .pr-section { margin-bottom: 0; min-height: 0; }
385
+ .pr-board-section { display: flex; flex-direction: column; min-height: 0; }
386
+ .pr-tasks-section { display: flex; flex-direction: column; min-height: 0; }
387
+ /* 2026-11-09 用户报「群聊视图下面还是有好多空白」:原来这里写死 height/max-height: 56vh,而房间是 100vh → 下方约半个视口永远是空的。这批 vh 兜底是早先为绕开「任务线滚不动」的父级 flex 断点加的,真根因(.pr-lane-msg 的 overflow:hidden)已在 cc84ac7 修掉,兜底就该还原成自适应。 */
388
+ .pr-tasks-section .pr-lanes { flex: 1 1 auto; min-height: 100px; height: auto; max-height: none; overflow-x: auto; overflow-y: hidden; align-items: stretch; }
389
+ .pr-tasks-section .pr-lane-body { flex: 1 1 auto; min-height: 100px; height: auto; max-height: none; overflow-y: auto; }
390
+ .pr-board-section .pr-md-view { flex: 1 1 auto; min-height: 120px; height: auto; max-height: none; overflow-y: auto; }
391
+ .pr-board-section .pr-board-edit { flex: 1 1 auto; min-height: 120px; height: auto; max-height: none; }
392
+ /* 滚轮各自独立:看板区与每条线的消息区都 contain,滚到底不传递给外层;外层整体不可滚 */
393
+ .pr-board-section .pr-md-view { overscroll-behavior: contain; }
394
+ .pr-tasks-section .pr-lane-body { overscroll-behavior: contain; }
395
+ .pr-board-section .pr-board-edit { overscroll-behavior: contain; }
396
+ .pr-section { margin-bottom: 16px; }
397
+ .pr-section:last-child { margin-bottom: 4px; }
398
+ .pr-section-title {
399
+ font-size: 11px; font-weight: 700; letter-spacing: .06em;
400
+ color: var(--dsw-alias-label-secondary); text-transform: uppercase;
401
+ margin-bottom: 6px;
402
+ }
403
+ .pr-loose-msg { font-size: 10px; color: var(--dsw-alias-label-secondary); padding: 0 6px 6px; line-height: 1.5; flex: 0 0 auto; }
404
+
405
+ /* 运行中快捷栏(对齐 AI-Agent-Hub):两段竖排 —— 上「运行中」、下「完成未读」 */
406
+ .pr-runbar { display: flex; flex-direction: column; gap: 4px; padding: 2px 4px 6px; flex: 0 0 auto; min-width: 0; }
407
+ /* 2026-11-11 用户令「快捷栏正在运行和完成未读的分开放」:
408
+ 每段 = 一行小标题(色点/✓ + 文案 + 计数)+ 可折行的 pill 区,段间用极细线分隔。 */
409
+ .pr-runbar-sec { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
410
+ .pr-runbar-sec + .pr-runbar-sec {
411
+ border-top: 1px solid var(--dsw-alias-border-l1);
412
+ padding-top: 3px;
413
+ }
414
+ .pr-runbar-sec-title {
415
+ display: flex; align-items: center; gap: 4px; min-width: 0;
416
+ padding: 0 2px; font-size: 9px; line-height: 1.4;
417
+ color: var(--dsw-alias-label-secondary); letter-spacing: .03em;
418
+ }
419
+ .pr-runbar-sec[data-kind="done"] .pr-runbar-sec-title { color: #d97757; }
420
+ .pr-runbar-sec-dot {
421
+ width: 5px; height: 5px; border-radius: 50%; flex: 0 0 auto;
422
+ background: var(--dsw-alias-state-success-primary);
423
+ }
424
+ .pr-runbar-sec-check {
425
+ width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto;
426
+ display: inline-flex; align-items: center; justify-content: center;
427
+ background: #d97757; color: #fff;
428
+ font-size: 8px; font-weight: 700; line-height: 1;
429
+ }
430
+ .pr-runbar-sec-label { flex: 0 0 auto; white-space: nowrap; }
431
+ .pr-runbar-sec-count {
432
+ flex: 0 0 auto; font-variant-numeric: tabular-nums; opacity: .75;
433
+ }
434
+ .pr-runbar-sec-body { display: flex; flex-wrap: wrap; gap: 3px; min-width: 0; }
435
+ /* 快捷栏 pill 也显示分身:每个 pill 变成「按钮 + 树状子行」的竖排小单元,
436
+ 子行样式与侧栏芯片的 pr-clone-row / pr-tree-rail 完全一致。 */
437
+ .pr-run-wrap { display: inline-flex; flex-direction: column; gap: 1px; align-items: flex-start; }
438
+ .pr-run-clones { display: flex; flex-direction: column; gap: 1px; padding-left: 6px; margin: 0 0 1px; }
439
+ .pr-run-clones .pr-clone-row { font-size: 9px; line-height: 1.4; }
440
+ .pr-readall {
441
+ /* 2026-11-11:一键已读搬进「完成未读」区的小标题里,靠右对齐 */
442
+ flex: 0 0 auto; margin: 0 0 0 auto; padding: 1px 6px; border-radius: 8px;
443
+ border: 1px solid var(--dsw-alias-border-l1); background: transparent;
444
+ color: #d97757; font-size: 9.5px; line-height: 1.4;
445
+ white-space: nowrap; cursor: pointer;
446
+ }
447
+ .pr-readall:hover { background: var(--dsw-alias-bg-layer-2); }
448
+ .pr-run-pill {
449
+ display: inline-flex; align-items: center; gap: 4px;
450
+ flex: 0 0 auto; max-width: 100%; min-width: 0; overflow: hidden;
451
+ min-height: 20px; padding: 2px 7px 2px 3px; border-radius: 11px;
452
+ border: 1px solid transparent; background: transparent;
453
+ color: var(--dsw-alias-label-secondary);
454
+ font: inherit; font-size: 11.5px; line-height: 1.2; white-space: nowrap; cursor: pointer;
455
+ }
456
+ .pr-run-pill:hover { background: var(--dsw-alias-bg-layer-1); }
457
+ .pr-run-pill[data-active="1"] {
458
+ border-color: var(--dsw-alias-border-l1);
459
+ background: var(--dsw-alias-bg-layer-2);
460
+ color: var(--dsw-alias-label-primary);
461
+ }
462
+ .pr-run-avatar { width: 16px; height: 16px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; line-height: 1; }
463
+ .pr-run-project { display: inline-flex; align-items: center; gap: 3px; flex: 0 0 auto; min-width: 0; }
464
+ .pr-run-project-dot { width: 6px; height: 6px; border-radius: 2px; flex: 0 0 auto; }
465
+ .pr-run-project-name { font-size: 9px; line-height: 1; font-variant-numeric: tabular-nums; white-space: nowrap; }
466
+ /* 2026-11-09 用户令「标题要显示全」:快捷栏 pill 名字原来 nowrap+ellipsis 会被吃掉,改为可换行。 */
467
+ .pr-run-name { min-width: 0; flex: 0 1 auto; white-space: normal; overflow-wrap: anywhere; word-break: break-word; }
468
+ .pr-run-badge {
469
+ flex: 0 0 auto; min-width: 14px; height: 14px; padding: 0 3px; border-radius: 7px;
470
+ display: inline-flex; align-items: center; justify-content: center;
471
+ background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary);
472
+ font-size: 9px; line-height: 1; font-variant-numeric: tabular-nums;
473
+ }
474
+ .pr-run-done { display: inline-flex; align-items: center; gap: 3px; flex: 0 0 auto; font-size: 9px; line-height: 1; color: var(--dsw-alias-brand-primary); }
475
+ .pr-run-check {
476
+ width: 10px; height: 10px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
477
+ background: #d97757; color: #fff; font-size: 8px; font-weight: 700; line-height: 1;
478
+ }
479
+ .pr-run-elapsed { flex: 0 0 auto; font-size: 9px; line-height: 1; font-variant-numeric: tabular-nums; color: var(--dsw-alias-state-success-primary); }
480
+
481
+ /* 项目行:22px 紧凑行高 + 顶部细线,读起来像「项目」而不是一行会话 */
482
+ .pr-proj {
483
+ display: flex; align-items: center; gap: 6px;
484
+ height: 22px; min-height: 22px; padding: 0 6px;
485
+ margin-top: 6px; border-radius: 6px;
486
+ font-size: 12px; font-weight: 600;
487
+ box-shadow: inset 0 1px 0 var(--dsw-alias-border-l1);
488
+ cursor: pointer; overflow: hidden;
489
+ }
490
+ .pr-proj:first-of-type { margin-top: 0; }
491
+ .pr-proj:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.06)); }
492
+ .pr-dot { width: 6px; height: 6px; border-radius: 2px; flex: 0 0 auto; }
493
+ .pr-proj-name { flex: 0 1 auto; max-width: 100%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
494
+ .pr-spacer { flex: 1 1 auto; min-width: 4px; }
495
+ .pr-meta { font-size: 10px; font-weight: 500; color: var(--dsw-alias-label-secondary); flex: 0 0 auto; font-variant-numeric: tabular-nums; }
496
+ .pr-icon-btn {
497
+ width: 18px; height: 18px; flex: 0 0 auto; border: none; padding: 0; border-radius: 5px;
498
+ background: transparent; color: var(--dsw-alias-label-secondary);
499
+ cursor: pointer; font-size: 10px; line-height: 1;
500
+ }
501
+ .pr-icon-btn:hover { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
502
+ /* 图钉:默认隐藏,悬停显形;已置顶常显 */
503
+ .pr-pin { opacity: 0; transition: opacity 120ms ease; }
504
+ .pr-proj:hover .pr-pin, .pr-pin[data-pinned="1"] { opacity: 1; color: var(--dsw-alias-brand-primary); }
505
+
506
+ /* 成员芯片网格 */
507
+ .pr-grid { display: flex; flex-wrap: wrap; gap: 4px; padding: 2px 6px 4px 18px; }
508
+ /* 2026-11-09 用户报「头像又单独放一行」:真因不是 pr-chip-head(已 revert 且与本文件 249ee81 完全一致),
509
+ 而是 .pr-chip 的 min-width: 0 + flex: 0 1 auto —— chip 作为 .pr-grid 的 flex item 可以被压到
510
+ **比内容还窄**,一旦宽度不够,chip 内部的 flex-wrap: wrap 就把头像和名字拆成两行。
511
+ 修法:min-width: min-content(不压到内容以下)+ flex: 0 0 auto(不参与收缩),
512
+ 放不下时由 .pr-grid 把**整个 chip** 换到下一行,chip 内部始终一行。max-width:100% 仍兜住超宽情况。 */
513
+ .pr-chip {
514
+ /* 2026-11-09:改成 block 容器,本体行(.pr-chip-head)与分身树(.pr-chip-clones)各占一行,
515
+ 两者背景互不影响 —— 布局已在 dsh/cordis-notes/shots/chip-layout-probe.html + headless Chrome 验证过。
516
+ 上次失败的原因是 chip 仍为 inline-flex,包裹层变成 flex item 被 wrap 挤开;改成 block 后不会。 */
517
+ display: block;
518
+ max-width: 100%; min-width: min-content; flex: 0 0 auto;
519
+ padding: 0; border-radius: 11px;
520
+ border: 1px solid transparent; background: transparent;
521
+ color: var(--dsw-alias-label-secondary);
522
+ font: inherit; font-size: 11.5px; line-height: 1.2;
523
+ cursor: pointer; box-sizing: border-box; overflow-wrap: anywhere;
524
+ }
525
+ /* 本体那一行(头像 + 名字 + badge + 圆点 + 时长):单独成盒,选中背景只作用在它上面 */
526
+ .pr-chip-head {
527
+ display: flex; flex-wrap: wrap; align-items: center; gap: 3px 5px;
528
+ max-width: 100%; min-width: 0;
529
+ padding: 3px 7px 3px 2px; border-radius: 11px;
530
+ border: 1px solid transparent; box-sizing: border-box;
531
+ }
532
+ /* 2026-11-09 用户令「hover 预选也要单独高亮本体或分身,不要高亮整体」:
533
+ 原来 .pr-chip:hover 给**整块**(含分身子行)加底色 —— 与 active 一样是盒子套错了。
534
+ 改成给 .pr-chip-head 自身的 :hover(鼠标真在本体行上才亮),分身行仍由 .pr-clone-row:hover 负责;
535
+ 整块 chip 不再有任何统一 hover 底色。 */
536
+ .pr-chip:hover:not([data-active="1"]) { background: transparent; border-color: transparent; }
537
+ .pr-chip-head:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.06)); border-color: var(--dsw-alias-border-l2); color: var(--dsw-alias-label-primary); }
538
+ /* 2026-11-09 用户令「单独高亮本体像快捷栏那样就行」:与 .pr-run-pill[data-active=1] 完全同一套
539
+ —— 细边框(border-l1) + 底色(bg-layer-2) + 主色文字,不要主色强调条(那是上一版我加的,太重)。
540
+ hover 已用 :not([data-active="1"]) 降级,所以选中与悬停仍能区分。 */
541
+ /* 2026-11-09:选中背景只作用在本体那一行(.pr-chip-head),不碰分身子行。 */
542
+ .pr-chip[data-active="1"] { border-color: transparent; background: transparent; }
543
+ .pr-chip[data-active="1"] .pr-chip-head {
544
+ border-color: var(--dsw-alias-border-l1);
545
+ background: var(--dsw-alias-bg-layer-2);
546
+ color: var(--dsw-alias-label-primary);
547
+ }
548
+ /* 2026-11-09 用户两次报「还是没有单独高亮本体」:原来 active 与 :hover 用**同一个底色**,
549
+ 视觉上分不清「选中」和「鼠标停在这儿」。active 加左侧强调条 + 主色描边 + 名字加粗,
550
+ hover 降级为只描边不填色,两者一眼可分。 */
551
+ .pr-chip[data-active="1"] .pr-chip-name { color: var(--dsw-alias-label-primary); font-weight: 600; }
552
+ /* 2026-11-09 用户报「侧边栏的分身怎么没有选中效果」:分身子行原来只有 hover、没有选中态。
553
+ 与 .pr-chip[data-active="1"] 同一套视觉(底色 + 主色文字 + 左侧强调条)。 */
554
+ .pr-clone-row[data-active="1"] {
555
+ background: var(--dsw-alias-bg-layer-2);
556
+ color: var(--dsw-alias-label-primary);
557
+ }
558
+ .pr-clone-row[data-active="1"] .pr-clone-name { color: var(--dsw-alias-label-primary); font-weight: 600; }
559
+ /* 2026-11-09 用户令「标题要显示全」:DSH 原生顶部会话标题栏把
560
+ 「应用开发 · DSH工程师|讲解已启用插件各自作用」截成「…|讲解...」。
561
+ 这里放开标题链上的 max-width / ellipsis,让它完整显示;只作用于会话标题栏,
562
+ 不碰侧栏与消息区。 */
563
+ [data-slot="conversation.session.header"] span,
564
+ [data-slot="conversation.session.header"] div {
565
+ max-width: none !important;
566
+ text-overflow: clip !important;
567
+ overflow: visible !important;
568
+ white-space: nowrap;
569
+ }
570
+ [data-slot="conversation.session.header"] { max-width: none !important; }
571
+ .pr-avatar {
572
+ width: 16px; height: 16px; border-radius: 50%; display: inline-flex;
573
+ align-items: center; justify-content: center; flex: 0 0 auto; line-height: 1;
574
+ }
575
+ .pr-chip-name { min-width: 0; flex: 0 1 auto; max-width: 100%; white-space: normal; word-break: break-word; overflow-wrap: anywhere; line-height: 1.2; }
576
+ @keyframes pr-pulse {
577
+ 0% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--dsw-alias-state-success-primary) 55%, transparent); }
578
+ 60% { transform: scale(1.45); opacity: .55; box-shadow: 0 0 0 4px transparent; }
579
+ 100% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 transparent; }
580
+ }
581
+ .pr-running-dot { width: 6px; height: 6px; border-radius: 50%; flex: 0 0 auto; background: var(--dsw-alias-state-success-primary); animation: pr-pulse 1.4s ease-in-out infinite; }
582
+ .pr-empty { padding: 10px 8px; font-size: 11px; color: var(--dsw-alias-label-secondary); text-align: center; }
583
+
584
+ /* 房间浮层 */
585
+ .pr-overlay { position: fixed; z-index: 900; display: flex; flex-direction: column; background: var(--dsw-alias-bg-base); border-left: 1px solid var(--dsw-alias-border-l1); }
586
+ .pr-panel { width: 100%; height: 100%; display: flex; flex-direction: column; background: var(--dsw-alias-bg-base); }
587
+ .pr-panel-head { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
588
+ .pr-panel-title { font-size: 13px; font-weight: 700; }/* 2026-11-10 用户令「群聊页字体太大」:14px→13px,与分区标题/正文一起收一档 */
589
+ .pr-tabs { display: flex; gap: 2px; margin-left: 10px; }
590
+ .pr-tab { padding: 3px 10px; border-radius: 6px; border: none; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 12px; cursor: pointer; }
591
+ .pr-tab.on { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-weight: 600; }
592
+ .pr-panel-body { flex: 1; min-height: 0; overflow: hidden; padding: 12px 14px; display: flex; flex-direction: column; }
593
+ .pr-panel-foot { border-top: 1px solid var(--dsw-alias-border-l1); padding: 8px 14px; display: flex; gap: 8px; align-items: flex-end; }
594
+ .pr-textarea { flex: 1; min-height: 34px; max-height: 120px; resize: vertical; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-size: 12px; font-family: inherit; outline: none; }
595
+ .pr-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-size: 12px; cursor: pointer; white-space: nowrap; }
596
+ .pr-btn:hover { border-color: var(--dsw-alias-brand-primary); }
597
+ .pr-btn.primary { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); color: #08111a; font-weight: 600; }
598
+ .pr-btn:disabled { opacity: .5; cursor: default; }
599
+ .pr-board { width: 100%; min-height: 200px; resize: vertical; padding: 10px; border-radius: 8px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-size: 12.5px; line-height: 1.6; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; outline: none; }
600
+ .pr-todo { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px dashed var(--dsw-alias-border-l1); font-size: 12.5px; }
601
+ .pr-todo input[type=checkbox] { flex: 0 0 auto; }
602
+ .pr-todo-text { flex: 1; min-width: 0; }
603
+ .pr-todo.done .pr-todo-text { text-decoration: line-through; opacity: .55; }
604
+ /* 任务线分列:每列一个成员,横向滚动,列内是各自最近的消息气泡 */
605
+ .pr-lanes { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; align-items: stretch; }
606
+ /* 列宽自适应(2026-11-09 用户令「任务线宽度要自适应,不要只有两个任务线右边还空这么多」):
607
+ 原来是 flex: 0 0 232px(定宽、永不伸缩)→ 只有 2 条线时右边空掉一大片。
608
+ 改成「232px 只是基准,可增长」:
609
+ · 列少(2~3 条)→ 各列按比例吃满 .pr-lanes 的宽度,右侧不留空白;
610
+ · 列多(基准之和 > 容器)→ flex-shrink 被 min-width 夹住,列不窄于可读宽度,
611
+ 超出部分交给 .pr-lanes 的 overflow-x 横向滚动(不裁切、不重叠)。
612
+ ⚠️ 本规则只碰宽度;overflow / 高度相关属性属主诉(压扁/滚不动)修复范围,勿在此改。 */
613
+ .pr-lane {
614
+ flex: 1 1 232px; min-width: 232px; max-width: none; display: flex; flex-direction: column;
615
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; overflow: hidden;
616
+ background: var(--dsw-alias-bg-layer-1);
617
+ }
618
+ .pr-lane-head {
619
+ display: flex; align-items: flex-start; gap: 5px; padding: 5px 8px;
620
+ border-bottom: 1px solid var(--dsw-alias-border-l1);
621
+ background: var(--dsw-alias-bg-layer-2);
622
+ font-size: 11.5px; font-weight: 600;/* 2026-11-09 用户令:lane 标题完整显示 */
623
+ }
624
+ /* ⚠️ 2026-11-10 补回上面这条规则的收尾花括号:58ca983 改 lane 标题换行时把块尾的 } 弄丢了,
625
+ 浏览器(Chrome 120+ 支持 CSS 嵌套)会把 .pr-lane-name 之后的**全部规则**当成
626
+ .pr-lane-head 的嵌套规则(& .pr-lane-name …)→ 28 条规则失效,其中 .pr-md-view /
627
+ .pr-md-h 一失效,看板正文就掉回宿主 14px、标题掉回浏览器默认 ≈16.4px(用户报「群聊页字体太大」)。 */
628
+ .pr-lane-name { min-width: 0; white-space: normal; overflow-wrap: anywhere; word-break: break-word; }
629
+ .pr-lane-archive {
630
+ margin-left: auto; flex: 0 0 auto; height: 18px; padding: 0 6px; border-radius: 5px;
631
+ border: 1px solid transparent; background: transparent;
632
+ color: var(--dsw-alias-label-secondary); font-size: 10px; line-height: 1; cursor: pointer;
633
+ }
634
+ .pr-lane-archive:hover {
635
+ border-color: var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1);
636
+ color: var(--dsw-alias-label-primary);
637
+ }
638
+ .pr-lane-dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 auto; }
639
+ .pr-lane-sub {
640
+ padding: 0 8px 4px; font-size: 9.5px; color: var(--dsw-alias-label-secondary);
641
+ border-bottom: 1px solid var(--dsw-alias-border-l1);
642
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
643
+ }
644
+ .pr-lane-body { flex: 1; min-height: 90px; max-height: 210px; overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 5px; }
645
+ .pr-lane-msg {
646
+ font-size: 11px; line-height: 1.4; color: var(--dsw-alias-label-primary);
647
+ background: var(--dsw-alias-bg-base); border-radius: 6px; padding: 4px 6px;
648
+ display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
649
+ white-space: pre-wrap; word-break: break-word;
650
+ }
651
+ .pr-lane-msg-user {
652
+ border-left: 2px solid var(--dsw-alias-brand-primary);
653
+ color: var(--dsw-alias-label-primary);
654
+ }
655
+ .pr-lane-msg-agent { color: var(--dsw-alias-label-secondary); }
656
+ /* 看板:渲染视图(纵向滚动)+ 编辑切换 */
657
+ .pr-board-section { display: flex; flex-direction: column; min-height: 0; }
658
+ .pr-section-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
659
+ .pr-board-toggle { height: 22px; padding: 0 9px; font-size: 11px; }
660
+ .pr-board-count {
661
+ margin-left: auto; font-size: 10px; color: var(--dsw-alias-label-secondary);
662
+ font-variant-numeric: tabular-nums; white-space: nowrap;
663
+ }
664
+ .pr-md-view {
665
+ flex: 1; min-height: 120px; max-height: 42vh; overflow-y: auto;
666
+ padding: 8px 10px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px;
667
+ background: var(--dsw-alias-bg-layer-1); font-size: 12px; line-height: 1.55;
668
+ word-break: break-word;
669
+ }
670
+ .pr-md-h { margin: 6px 0 4px; font-size: 12.5px; font-weight: 700; color: var(--dsw-alias-label-primary); }
671
+ .pr-md-h:first-child { margin-top: 0; }
672
+ .pr-md-p { margin: 2px 0; color: var(--dsw-alias-label-primary); white-space: pre-wrap; }
673
+ .pr-md-ul { margin: 3px 0; padding-left: 16px; }
674
+ .pr-md-ul li { margin: 1px 0; }
675
+ .pr-md-code {
676
+ padding: 0 4px; border-radius: 4px; background: var(--dsw-alias-bg-layer-2);
677
+ font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
678
+ }
679
+ .pr-board-edit { min-height: 200px; max-height: 42vh; }
680
+ .pr-board-actions { display: flex; gap: 8px; margin-top: 6px; align-items: center; }
681
+ /* 任务线列:消息完整显示 + 列内上下滚动 */
682
+ .pr-lane-body { max-height: 300px; overflow-y: auto; }
683
+ /* ⚠️ 关键(2026-11-09 根因):原规则里的 overflow:hidden(给 -webkit-line-clamp 用)只被
684
+ -webkit-line-clamp: unset + display: block 覆盖了外观,overflow 仍然生效。
685
+ 而 .pr-lane-body 是 display:flex; flex-direction:column 的滚动容器:flex 子项的
686
+ min-height:auto 只在其 overflow 为 visible 时才等于内容高度;一旦子项自身 overflow:hidden,
687
+ min-height 就解析为 0 → 每条消息被压扁到几像素、全部挤进可视区 →
688
+ scrollHeight 永远等于 clientHeight → 既压扁又不滚。这里必须把 overflow 显式改回 visible,
689
+ 再加 flex:0 0 auto 双保险。 */
690
+ .pr-lane-msg {
691
+ -webkit-line-clamp: unset;
692
+ display: block;
693
+ overflow: visible;
694
+ flex: 0 0 auto;
695
+ /* 2026-11-11 用户令「任务线卡片正文要显示全,不要省略号」:
696
+ 这里原来(aea43437)是 white-space:nowrap + text-overflow:ellipsis + overflow:hidden,
697
+ 每条消息被压成一行、末尾挂「…」。改成按内容换行完整显示;overflow 必须回到 visible,
698
+ 否则又会踩上面那条 2026-11-09 的坑(flex 子项 min-height 解析成 0 → 压扁不滚)。 */
699
+ white-space: pre-wrap;
700
+ overflow-wrap: anywhere;
701
+ text-overflow: clip;
702
+ }
703
+ .pr-hint { font-size: 11px; color: var(--dsw-alias-label-secondary); padding: 6px 0; }
704
+ .pr-err { font-size: 11.5px; color: #f87171; padding: 4px 0; }
705
+ .pr-send-row { display: flex; gap: 6px; align-items: center; width: 100%; }
706
+ .pr-select { max-width: 200px; height: 30px; padding: 0 6px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-size: 12px; }
707
+ `;
708
+
709
+ function ensureStyle() {
710
+ let style = document.getElementById(STYLE_ID);
711
+ if (!style) {
712
+ style = document.createElement('style');
713
+ style.id = STYLE_ID;
714
+ style.dataset.plugin = 'project-room';
715
+ document.head.appendChild(style);
716
+ }
717
+ if (style.textContent !== CSS) style.textContent = CSS;
718
+ return style;
719
+ }
720
+
721
+ /**
722
+ * 从一条会话消息里提取「一句要点」当卡片摘要。
723
+ *
724
+ * 为什么不是盲截断(2026-11-10 分诊纠偏):直接把前 N 字塞进去,等于把
725
+ * 「### 建议顺手改」「**你问的**」「--- 分隔线」这些格式噪音也搬进任务线卡片。
726
+ * 这里按结构剥:代码块/表格/引用/分隔线/标题符号/固定尾块全部丢掉,
727
+ * 只留第一条真正有信息的正文句子——不保留原文副本(分诊 2026-11-10:存与渲染两端都只留摘要)。
728
+ */
729
+ function summarizeMessage(raw) {
730
+ let text = String(raw || '');
731
+ if (!text.trim()) return '';
732
+ // 去掉围栏代码块、行内代码、表格行、引用行、纯分隔线
733
+ text = text
734
+ .replace(/```[\s\S]*?```/g, ' ')
735
+ .replace(/`[^`]*`/g, ' ')
736
+ .replace(/^\s*\|.*\|\s*$/gm, ' ')
737
+ .replace(/^\s*>.*$/gm, ' ')
738
+ .replace(/^\s*[-*_]{3,}\s*$/gm, ' ');
739
+ // 逐行挑第一句有内容的
740
+ const lines = text.split(/\r?\n/);
741
+ const NOISE = /^(#{1,6}\s*|\*\*你问的\*\*|\*\*答\*\*|\*\*本轮结果\*\*|你问的|答[::]|本轮结果[::]|[-*+]\s+|\d+[.、]\s*)/;
742
+ /* 前导符号噪音(2026-11-11):模型经常先吐一个 "." 再吐正文,实测 assistant/message 原文形如
743
+ ".## 做完了:…" → 老规则只认行首 # 号,被这个 "." 挡住后整行"##"原样进卡片。
744
+ 这里只剥**符号**(不含数字/文字),并按叠加情况剥到不动为止。 */
745
+ const LEAD_SYMBOL = /^[.。,,、;;::·•…\-*+#>]+\s*/;
746
+ /* 有可见字符才算有内容:纯标点/纯符号("."、"..."、"…"、"——")在卡片里只会渲染成一条
747
+ 没有文字的灰条,6 条连着看起来就是"骨架屏加载中"(2026-11-11 用户贴图报的就是这个)。 */
748
+ const VISIBLE = /[\p{L}\p{N}]/u;
749
+ for (const line of lines) {
750
+ let s = line.trim();
751
+ if (!s) continue;
752
+ s = s.replace(NOISE, '');
753
+ for (let i = 0; i < 3; i += 1) s = s.replace(LEAD_SYMBOL, '');
754
+ s = s.replace(/\*\*/g, '').trim();
755
+ if (!s || s.length < 4) continue;
756
+ if (!VISIBLE.test(s)) continue;
757
+ // 单行压平(2026-11-11 用户令「任务线卡片正文要显示全,不要省略号」:
758
+ // 原「超 80 字按句读断开、断不了就截 78 字 + …」已删——存进卡片的就是完整一句正文)。
759
+ s = s.replace(/\s+/g, ' ').trim();
760
+ return s;
761
+ }
762
+ return '';
763
+ }
764
+
765
+ // ───────────────────────── 侧栏 ─────────────────────────
766
+
767
+ function ProjectSidebar(props) {
768
+ const workspaces = props.useWorkspaces((s) => s);
769
+ const sessions = props.useSessions((s) => s);
770
+ const [query, setQuery] = React.useState('');
771
+ const [closed, setClosed] = React.useState(() => loadJson('projectRoom.closed', {}));
772
+ const [pinned, setPinned] = React.useState(() => loadJson('projectRoom.pinned', []));
773
+ const [showLoose, setShowLoose] = React.useState(() => Boolean(loadJson('projectRoom.showLoose', false)));
774
+ const [clearArmed, setClearArmed] = React.useState(false);
775
+ const [clearMsg, setClearMsg] = React.useState('');
776
+ const [menu, setMenu] = React.useState(null); // { id, name, x, y }
777
+ // 点空白处关闭右键菜单(菜单自身 onMouseDown 已阻止冒泡)
778
+ React.useEffect(() => {
779
+ if (!menu) return undefined;
780
+ const onDown = () => { setMenu(null); setMenuMsg(''); };
781
+ window.addEventListener('mousedown', onDown);
782
+ return () => window.removeEventListener('mousedown', onDown);
783
+ }, [menu]);
784
+ const [renaming, setRenaming] = React.useState(null); // { id, value }
785
+ const [newProject, setNewProject] = React.useState(null); // { name, member, busy, msg }
786
+ const [settings, setSettings] = React.useState(null); // { manager, msg } 或 null
787
+ const [menuMsg, setMenuMsg] = React.useState('');
788
+ const [room, setRoom] = React.useState(roomBus.current);
789
+ const [tick, setTick] = React.useState(Date.now());
790
+ const [avatars, setAvatars] = React.useState({});
791
+ const [cursorSessionId, setCursorSessionId] = React.useState('');
792
+ // 2026-11-09:手动点击后 3s 内不让宿主属性覆盖 cursor(否则点分身会被 paint 回本体)
793
+ const manualPickUntilRef = React.useRef(0);
794
+ // 2026-11-09 用户报「快捷栏高亮分身之后,本体的高亮要延迟好几秒才消失」:
795
+ // 点击只做了 setCursorSessionId(React 异步重渲染),而 DOM 直改的 paint() 定义在下面的 effect 里,
796
+ // 点不到 → 本体 pill 要等 React 渲染完才灭。这里把 paint 暴露出来,点击时同一帧直接改 DOM。
797
+ const paintRef = React.useRef(null);
798
+
799
+ React.useEffect(() => roomBus.subscribe(setRoom), []);
800
+
801
+ // 当前打开的会话(用于芯片高亮):宿主写在会话内容区的 data 属性上。
802
+ // ⚠️ 2026-11-09 用户两次反馈「切换对话后侧栏选中要过好几秒」:
803
+ // 第一版是 setInterval(read, 3000)(最坏延迟 3 秒)→ 换 MutationObserver 仍不够快,
804
+ // 因为真正的瓶颈是**React 全量重渲染侧栏**(195 个会话、多项目、多个 useMemo),
805
+ // 一次 setState 触发的重渲染本身就要几百毫秒甚至更久。
806
+ // 所以这里改成「双层」:
807
+ // ① 直接改 DOM 的 data-active(绕过 React,同一帧生效,视觉零延迟);
808
+ // ② 同时 setState,让 React 树与 DOM 一致(下次渲染不会闪回)。
809
+ // 观察器补 childList:true —— 切换会话时宿主可能整块替换内容区节点而不是改属性。
810
+ React.useEffect(() => {
811
+ const paint = (id) => {
812
+ try {
813
+ const val = String(id || '');
814
+ for (const node of document.querySelectorAll('.pr-chip[data-session-id]')) {
815
+ node.setAttribute('data-active', node.getAttribute('data-session-id') === val ? '1' : '0');
816
+ }
817
+ for (const node of document.querySelectorAll('.pr-run-pill[data-session-ids]')) {
818
+ const ids = String(node.getAttribute('data-session-ids') || '').split(',');
819
+ node.setAttribute('data-active', ids.includes(val) ? '1' : '0');
820
+ }
821
+ // 2026-11-09:分身子行也要跟着切选中态(用户报『侧栏分身没有选中效果』)
822
+ for (const node of document.querySelectorAll('.pr-clone-row[data-session-id]')) {
823
+ node.setAttribute('data-active', node.getAttribute('data-session-id') === val ? '1' : '0');
824
+ }
825
+ } catch { /* 改不动就交给 React */ }
826
+ };
827
+ paintRef.current = paint;
828
+ let lastId = null;
829
+ const read = () => {
830
+ try {
831
+ // 2026-11-09:手动点击后 3s 内不让宿主属性覆盖(否则点分身会被 paint 回本体,
832
+ // 表现为「侧边栏没有单独高亮本体」——其实是一直亮在本体上)。
833
+ if (Date.now() < manualPickUntilRef.current) return;
834
+ const el = document.querySelector('[data-conversation-content]');
835
+ const id = el ? (el.getAttribute('data-conversation-session') || '') : '';
836
+ if (id === lastId) return;
837
+ lastId = id;
838
+ paint(id); // ① 先让 DOM 立刻变(用户看到的就是这一下)
839
+ setCursorSessionId(id); // ② React 跟上
840
+ } catch { /* 拿不到就不高亮 */ }
841
+ };
842
+ read();
843
+ let observer = null;
844
+ try {
845
+ if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined' && document.body) {
846
+ observer = new MutationObserver(read);
847
+ observer.observe(document.body, {
848
+ attributes: true,
849
+ attributeFilter: ['data-conversation-session'],
850
+ childList: true, // 节点被整块替换时也要抓到
851
+ subtree: true,
852
+ });
853
+ }
854
+ } catch { observer = null; }
855
+ // 兜底:observer 不可用 / DOM 整体替换时,2 秒重读一次(正常路径由观察器即时驱动)
856
+ const timer = setInterval(read, 2000);
857
+ return () => {
858
+ if (observer) { try { observer.disconnect(); } catch { /* ignore */ } }
859
+ clearInterval(timer);
860
+ };
861
+ }, []);
862
+
863
+ // 头像表(可选增强:有就显示自定义 emoji,没有就退回名字首字符色块)
864
+ React.useEffect(() => {
865
+ let cancelled = false;
866
+ const load = async () => {
867
+ try {
868
+ const res = await fetch(`${API}/avatars`);
869
+ const data = await res.json();
870
+ if (!cancelled && data && data.ok && data.avatars) setAvatars(data.avatars);
871
+ } catch { /* 没有头像表就用 fallback */ }
872
+ };
873
+ load();
874
+ const timer = setInterval(load, 60000); // 一分钟刷新一次,改了头像表不用重启
875
+ return () => { cancelled = true; clearInterval(timer); };
876
+
877
+ }, []);
878
+ // ── 幽灵会话「事后隐藏」(2026-11-10 用户令:白名单改事后隐藏)──────────────
879
+ // 背景:曾经用「磁盘活会话白名单」全量过滤(每 120s 拉 /project-room/alive,
880
+ // 不在白名单里的会话直接不显示)。它确实能藏住「前端快照有、磁盘目录没了」的幽灵
881
+ // (归档必报 no such session,DSH 又没有删除 API),但代价是**新建会话要等下一次
882
+ // 轮询才认识** —— 分诊分身只活 30 秒,正好卡在两次轮询之间,侧栏从来没显示过它。
883
+ //
884
+ // 用户 2026-11-10 明确选了「事后隐藏」:默认**不过滤任何会话**,只有真的点归档且
885
+ // 失败时,才把那个 id 记进 localStorage 的隐藏集,下次渲染跳过它。
886
+ // 好处:新会话零延迟显示;幽灵删一次之后就消失。
887
+ const HIDDEN_KEY = 'project-room:hidden-sessions';
888
+ const bareId = (v) => String(v || '').replace(/^session-/, '').toLowerCase();
889
+ const [hiddenIds, setHiddenIds] = React.useState(() => {
890
+ try {
891
+ const raw = window.localStorage.getItem(HIDDEN_KEY);
892
+ const arr = raw ? JSON.parse(raw) : [];
893
+ return new Set(Array.isArray(arr) ? arr.map(bareId) : []);
894
+ } catch (error) { return new Set(); }
895
+ });
896
+ const hideSession = React.useCallback((id) => {
897
+ const bare = bareId(id);
898
+ if (!bare) return;
899
+ setHiddenIds((prev) => {
900
+ if (prev.has(bare)) return prev;
901
+ const next = new Set(prev);
902
+ next.add(bare);
903
+ try {
904
+ window.localStorage.setItem(HIDDEN_KEY, JSON.stringify([...next]));
905
+ } catch (error) { /* 存不下也不影响本次隐藏 */ }
906
+ return next;
907
+ });
908
+ }, []);
909
+ const unhideAll = React.useCallback(() => {
910
+ setHiddenIds(new Set());
911
+ try { window.localStorage.removeItem(HIDDEN_KEY); } catch (error) { /* ignore */ }
912
+ }, []);
913
+
914
+ // 1s 心跳:驱动「今日运行时长」与相对时间
915
+ React.useEffect(() => {
916
+ const timer = setInterval(() => setTick(Date.now()), 1000);
917
+ return () => clearInterval(timer);
918
+ }, []);
919
+
920
+ const byId = (sessions && sessions.byId) || {};
921
+ const archived = new Set((workspaces && workspaces.archivedSessionIds) || []);
922
+
923
+ // 会话 → (项目, 成员) 归组;无法解析标题的会话单独归入「未分组」
924
+ const groups = React.useMemo(() => {
925
+ const map = new Map();
926
+ for (const [id, session] of Object.entries(byId)) {
927
+ if (!session || session.blank) continue;
928
+ if (archived.has(String(id))) continue;
929
+ // 2026-11-10(用户令):白名单改「事后隐藏」——只跳过**归档失败过**的会话,
930
+ // 不再用磁盘白名单全量过滤(那样会让新建会话因轮询延迟而整段生命期不可见)。
931
+ if (hiddenIds.has(bareId(id))) continue;
932
+ const parsed = projectOfTitle(session.displayTitle || session.title);
933
+ const project = parsed ? parsed.project : LOOSE_GROUP;
934
+ const member = parsed ? parsed.member : (session.displayTitle || session.title || String(id).slice(-6));
935
+ // 2026-11-09 用户报「这两个副手是哪儿来的?删掉」:这是 2026-10 早期概念留下的历史会话
936
+ // (标题「<项目> · <成员>|副手」,早已不用于新分身)。DSH 没有删除会话的 API,
937
+ // 所以这里在**渲染层**把它们滤掉——不再出现在侧栏/快捷栏,也不再占用 +N 计数。
938
+ if (/|副手$/.test(String(member || '').trim())) continue;
939
+ if (!map.has(project)) map.set(project, []);
940
+ map.get(project).push({
941
+ id,
942
+ name: member,
943
+ project,
944
+ /** 分身名(父名|标签)→ base 用于快捷栏聚合 */
945
+ base: String(member).split('|')[0].trim() || member,
946
+ isClone: String(member).includes('|'),
947
+ running: Boolean(session.running),
948
+ // 真数据源:会话自带 updatedAt(若有)→ 本地 lastActive 记录
949
+ updatedAt: (session.updatedAt || 0) || lastActiveOf(id),
950
+ });
951
+ }
952
+ return map;
953
+ }, [byId, archived, hiddenIds]);
954
+
955
+ // 空闲时长补全(2026-11-09):对还没有 lastActive 的会话拉 /hub/session-events?limit=1
956
+ // 取最后一条事件的 time。分身(标题带|)优先——用户最常盯的就是分身行。
957
+ const [, setLastActiveTick] = React.useState(0);
958
+ React.useEffect(() => {
959
+ let cancelled = false;
960
+ const ids = Object.keys(byId)
961
+ .filter((id) => !lastActiveOf(id))
962
+ .sort((a, b) => {
963
+ const isClone = (x) => (String((byId[x] && (byId[x].displayTitle || byId[x].title)) || '').includes('|') ? 1 : 0);
964
+ return isClone(b) - isClone(a);
965
+ })
966
+ .slice(0, 200);
967
+ if (ids.length === 0) return undefined;
968
+ (async () => {
969
+ for (const id of ids) {
970
+ if (cancelled) return;
971
+ try {
972
+ const res = await fetch(`/hub/session-events?session=${encodeURIComponent(id)}&limit=1`);
973
+ const data = await res.json();
974
+ const events = (data && data.events) || [];
975
+ const last = events[events.length - 1];
976
+ if (last && last.time) markLastActive(id, last.time);
977
+ } catch { /* 单个失败跳过 */ }
978
+ }
979
+ if (!cancelled) setLastActiveTick((n2) => n2 + 1);
980
+ })();
981
+ return () => { cancelled = true; };
982
+ }, [byId]);
983
+ // 今日运行时长累计(localStorage 持久,按项目与日期分桶)
984
+ // ⚠️ groupsRef 必须先声明再被下面的 effect 使用(hub-desktop-ui 曾因 TDZ 整块侧栏回退)
985
+ const groupsRef = React.useRef(groups);
986
+ groupsRef.current = groups;
987
+
988
+ // 运行态账本:runStart 记每个会话本轮 시작时刻;未读记「跑完还没看」的会话
989
+ const runStartRef = React.useRef(new Map());
990
+ // 2026-11-09 用户报「侧边栏的运行计时怎么老是莫名其妙重置」:
991
+ // 原来「这一拍 running=false」就立刻删 runStart → DSH 的 running 投影抖动时会
992
+ // 下一拍又 true 再 set(Date.now()) → 计时从 0 重来。这里记「首次见到 false 的时刻」,
993
+ // 连续 >3s 都没恢复才真正删除。
994
+ const runFalseSinceRef = React.useRef(new Map());
995
+ const prevRunningRef = React.useRef(new Set());
996
+ const [unread, setUnread] = React.useState(() => loadJson('projectRoom.unread', {}));
997
+
998
+ React.useEffect(() => {
999
+ const day = dayKey();
1000
+ const timer = setInterval(() => {
1001
+ let changed = false;
1002
+ const runningNow = new Set();
1003
+ for (const [project, members] of groupsRef.current) {
1004
+ let runningCount = 0;
1005
+ for (const member of members) {
1006
+ if (member.running) {
1007
+ runningCount += 1;
1008
+ runningNow.add(member.id);
1009
+ if (!runStartRef.current.has(member.id)) runStartRef.current.set(member.id, Date.now());
1010
+ runFalseSinceRef.current.delete(member.id);
1011
+ } else {
1012
+ const falseSince = runFalseSinceRef.current.get(member.id);
1013
+ if (!falseSince) {
1014
+ runFalseSinceRef.current.set(member.id, Date.now());
1015
+ } else if (Date.now() - falseSince > 3000) {
1016
+ runStartRef.current.delete(member.id);
1017
+ runFalseSinceRef.current.delete(member.id);
1018
+ }
1019
+ }
1020
+ }
1021
+ if (!runningCount) continue;
1022
+ const key = `projectRoom.runTotal.${project}.${day}`;
1023
+ const prev = Number(loadJson(key, 0) || 0);
1024
+ saveJson(key, prev + runningCount * 1000);
1025
+ changed = true;
1026
+ }
1027
+ // running → false 的会话记成「完成未读」(当前正看着的那个不算)
1028
+ const prevRunning = prevRunningRef.current;
1029
+ const finished = [...prevRunning].filter((id) => !runningNow.has(id));
1030
+ if (finished.length) {
1031
+ const current = currentSessionId();
1032
+ const next = { ...loadJson('projectRoom.unread', {}) };
1033
+ let touched = false;
1034
+ for (const id of finished) {
1035
+ if (current && String(current) === String(id)) continue; // 正在看,直接算已读
1036
+ next[id] = { at: Date.now() };
1037
+ touched = true;
1038
+ }
1039
+ if (touched) {
1040
+ saveJson('projectRoom.unread', next);
1041
+ setUnread(next);
1042
+ changed = true;
1043
+ }
1044
+ }
1045
+ prevRunningRef.current = runningNow;
1046
+ if (changed) setTick(Date.now());
1047
+ }, 1000);
1048
+ return () => clearInterval(timer);
1049
+ }, []);
1050
+
1051
+ // 把「项目 → 成员」快照交给房间浮层(浮层没有 slot props,只能这样拿)
1052
+ React.useEffect(() => {
1053
+ roomBus.sessionIndex = groups;
1054
+ }, [groups]);
1055
+
1056
+ const projectNames = React.useMemo(() => {
1057
+ const names = Array.from(groups.keys());
1058
+ const pinnedList = pinned.filter((name) => names.includes(name));
1059
+ const rest = names.filter((name) => !pinnedList.includes(name));
1060
+ rest.sort((a, b) => {
1061
+ const ra = (groups.get(a) || []).some((m) => m.running) ? 1 : 0;
1062
+ const rb = (groups.get(b) || []).some((m) => m.running) ? 1 : 0;
1063
+ if (ra !== rb) return rb - ra;
1064
+ const ta = Math.max(0, ...(groups.get(a) || []).map((m) => m.updatedAt));
1065
+ const tb = Math.max(0, ...(groups.get(b) || []).map((m) => m.updatedAt));
1066
+ return tb - ta;
1067
+ });
1068
+ return [...pinnedList, ...rest];
1069
+ }, [groups, pinned]);
1070
+
1071
+ // 「未分组」= 标题里没有分隔符的会话(历史测试/一次性对话)。
1072
+ // 默认不显示,避免一大坨散落会话把项目列表挤下去;需要时点头部按钮展开。
1073
+ const looseAll = groups.get(LOOSE_GROUP) || [];
1074
+ // 2026-11-10(用户令):白名单改「事后隐藏」后,groups 里已经按 hiddenIds 过滤过了,
1075
+ // 这里不再二次过滤(否则又回到「新建会话要等白名单」的老问题)。
1076
+ const looseMembers = looseAll;
1077
+ const shownProjects = React.useMemo(
1078
+ () => projectNames.filter((name) => showLoose || name !== LOOSE_GROUP),
1079
+ [projectNames, showLoose],
1080
+ );
1081
+
1082
+ const q = query.trim().toLowerCase();
1083
+ const visible = shownProjects.filter((name) => {
1084
+ if (!q) return true;
1085
+ if (name.toLowerCase().includes(q)) return true;
1086
+ return (groups.get(name) || []).some((m) => m.name.toLowerCase().includes(q));
1087
+ });
1088
+
1089
+ const totalSessions = Array.from(groups.values()).reduce((acc, list) => acc + list.length, 0);
1090
+
1091
+ // ── 运行中快捷栏(对齐 AI-Agent-Hub 侧栏):按「项目 + 父名」聚合,分身折成 +N ──
1092
+ const unreadMap = unread && typeof unread === 'object' ? unread : {};
1093
+ const runGroups = React.useMemo(() => {
1094
+ const all = [];
1095
+ for (const [project, members] of groups) {
1096
+ for (const member of members) {
1097
+ // 分身一律不进快捷栏:它只在本体芯片的树里显示(避免同一个会话出现两处)
1098
+ // 分身允许进快捷栏:归到父组的 clones,在 pill 下方以树状子行显示(与侧栏芯片一致)
1099
+ const isUnread = !member.running && unreadMap[member.id];
1100
+ if (!member.running && !isUnread) continue;
1101
+ all.push({ ...member, done: !member.running });
1102
+ }
1103
+ }
1104
+ const buckets = new Map();
1105
+ for (const item of all) {
1106
+ // 分身且有父会话 → 归父组;否则自成一组
1107
+ let key = `${item.project}\u0000${item.base}`;
1108
+ let groupName = item.base;
1109
+ if (item.isClone) {
1110
+ const hasParent = (groups.get(item.project) || []).some((m) => !m.isClone && m.base === item.base);
1111
+ if (!hasParent) { key = `${item.project}\u0000${item.name}`; groupName = item.name; }
1112
+ }
1113
+ let bucket = buckets.get(key);
1114
+ if (!bucket) {
1115
+ bucket = { key, project: item.project, name: groupName, running: [], done: [], clones: [] };
1116
+ buckets.set(key, bucket);
1117
+ }
1118
+ if (item.done) bucket.done.push(item); else bucket.running.push(item);
1119
+ if (item.isClone) bucket.clones.push(item);
1120
+ }
1121
+ return [...buckets.values()].map((bucket) => {
1122
+ const members = [...bucket.running, ...bucket.done];
1123
+ const openTarget = members.slice().sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))[0];
1124
+ return {
1125
+ ...bucket,
1126
+ members,
1127
+ openTarget,
1128
+ mode: bucket.running.length ? 'running' : 'done',
1129
+ extra: bucket.clones.length,
1130
+ };
1131
+ }).sort((a, b) => {
1132
+ if (a.mode !== b.mode) return a.mode === 'done' ? -1 : 1; // 完成未读置顶
1133
+ return (b.openTarget?.updatedAt || 0) - (a.openTarget?.updatedAt || 0);
1134
+ });
1135
+ }, [groups, unreadMap]);
1136
+
1137
+ // 2026-11-11 用户令「快捷栏正在运行和完成未读的分开放」:
1138
+ // 原来两种 pill 混在一个 flex 行里(完成未读还置顶排最前),用户扫一眼分不清谁在跑。
1139
+ // 现在拆成两个区、各带标题;渲染顺序按用户第二次要求 = **完成未读在上、运行中在下**。
1140
+ const runningGroups = runGroups.filter((group) => group.mode === 'running');
1141
+ const doneUnreadGroups = runGroups.filter((group) => group.mode === 'done');
1142
+ // 完成未读的组数(决定是否显示「全部已读」按钮)
1143
+ const doneGroups = doneUnreadGroups.length;
1144
+
1145
+ const setPin = (name) => {
1146
+ const next = pinned.includes(name) ? pinned.filter((n) => n !== name) : [...pinned, name];
1147
+ setPinned(next);
1148
+ saveJson('projectRoom.pinned', next);
1149
+ };
1150
+
1151
+ const toggleClosed = (name) => {
1152
+ const next = { ...closed, [name]: !closed[name] };
1153
+ setClosed(next);
1154
+ saveJson('projectRoom.closed', next);
1155
+ };
1156
+
1157
+ /**
1158
+ * 渲染一个「运行中/完成未读」快捷 pill:一个父名一个 pill,分身折成 +N 小标签。
1159
+ * 对齐 AI-Agent-Hub 侧栏快捷栏:emoji 头像 + 项目色点与名 + agent 名 + 绿点/✓ + 时长。
1160
+ */
1161
+ const renderRunPill = (group) => {
1162
+ const target = group.openTarget;
1163
+ if (!target) return null;
1164
+ // 2026-11-09 用户令「本体和分身选中分开」:pill 只在**当前打开的是本体**时高亮,
1165
+ // 打开分身时只让分身行高亮(原来用 group.members.some,含分身 → 两个都亮)。
1166
+ const bodyIds = group.members.filter((m) => !m.isClone).map((m) => String(m.id));
1167
+ const isCurrent = Boolean(cursorSessionId) && bodyIds.includes(String(cursorSessionId));
1168
+ const done = group.mode === 'done';
1169
+ // 2026-11-11 修「本体没有运行、快捷栏还在显示计时,而侧边栏正常」(用户截图):
1170
+ // 这个 pill 代表的是**本体**——名字取 group.name(本体名)、点击开本体、data-session-ids
1171
+ // 也只有本体。所以绿点与已运行时长必须来自**本体自己的 running**。
1172
+ // 原实现取 `group.running` 里最近活跃的那个成员:分身还在跑时,那一项就是分身,
1173
+ // 于是分身的已运行时长被画到本体 pill 上(截图里 pill 的 2m29s 与分身子行的 2m29s
1174
+ // 完全相等,而侧栏本体芯片没有时长 = 本体当时是空闲的)。
1175
+ // 侧栏一直是对的:本体芯片按 m.running 判、分身行按 c.running 判,两条各算各的。
1176
+ // 分身的运行状态由下面 .pr-run-clones 子行自己显示,信息不丢。
1177
+ // 孤儿分身组(本体已归档/改名,group.name 就是分身名)没有本体可参照 → 仍用组内运行成员。
1178
+ const bodyMemberOfGroup = (() => {
1179
+ const list = (groups && typeof groups.get === 'function') ? (groups.get(group.project) || []) : [];
1180
+ return list.find((m) => !m.isClone && m.name === group.name) || null;
1181
+ })();
1182
+ const runMember = (bodyMemberOfGroup ? [bodyMemberOfGroup] : (group.running.length ? group.running : group.members))
1183
+ .filter((m) => m && m.running)
1184
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))[0];
1185
+ const bodyRunning = Boolean(runMember);
1186
+ const startedAt = runMember ? runStartRef.current.get(runMember.id) : undefined;
1187
+ const elapsed = !done && bodyRunning && startedAt ? formatDuration(tick - startedAt) : '';
1188
+ const emoji = avatars && typeof avatars[group.name] === 'string' ? avatars[group.name].trim() : '';
1189
+ const hue = hashHue(group.project || group.name);
1190
+ return h('div', { className: 'pr-run-wrap', key: 'wrap-' + group.key }, h('button', {
1191
+ key: `run-${group.key}`,
1192
+ type: 'button',
1193
+ className: 'pr-run-pill',
1194
+ 'data-mode': group.mode,
1195
+ // 2026-11-09 用户令「本体和分身选中分开」:pill 的 data-active 只看**本体**,
1196
+ // 原来放全组成员 → 点分身时 pill(代表本体)也一起高亮。
1197
+ 'data-session-ids': group.members.filter((mb) => !mb.isClone).map((mb) => String(mb.id)).join(','),
1198
+ title: `${group.project ? group.project + ' · ' : ''}${group.name}`
1199
+ // 2026-11-11:不再对空闲的本体谎称「运行中」——本体没跑时 pill 只报「分身运行中」。
1200
+ + (done ? ' · 完成待查看'
1201
+ : (bodyRunning ? (elapsed ? ` · 运行中 ${elapsed}` : ' · 运行中') : ' · 本体空闲(分身运行中)'))
1202
+ + (group.extra ? `(含 ${group.extra} 个分身)` : ''),
1203
+ onClick: (event) => {
1204
+ event.stopPropagation();
1205
+ // 点开即清该组的未读
1206
+ if (done) {
1207
+ const next = { ...unreadMap };
1208
+ for (const member of group.members) delete next[member.id];
1209
+ saveJson('projectRoom.unread', next);
1210
+ setUnread(next);
1211
+ }
1212
+ // 打开会话前先关掉房间:否则房间盖在对话区上,用户看不到刚切过去的会话
1213
+ roomBus.close();
1214
+ // 2026-11-09 用户两次报「快捷栏还是点不了本体,还是会跳到分身」的真因:
1215
+ // 原实现 openSessionById(group.parentId || target.id),而 target = 组内最近活跃成员
1216
+ // —— 父会话冷/归档/改名时 parentId 为空,就落到最近活跃的**分身**上。
1217
+ // 现在显式现查本体:同项目 + 同成员名 + 不是分身;查不到就只提示、不跳(绝不打开分身)。
1218
+ const projMembers = (groups && typeof groups.get === 'function') ? (groups.get(group.project) || []) : [];
1219
+ const bodyMember = projMembers.find((x) => !x.isClone && x.name === group.name);
1220
+ if (!bodyMember) {
1221
+ setNotice(`找不到「${group.name}」的本体会话(可能已归档或改名)——不跳转,避免误开分身`);
1222
+ return;
1223
+ }
1224
+ // 2026-11-09 用户报「在快捷栏选本体的时候高亮怎么没变」:pill 的 onClick 只 openSessionById,
1225
+ // 没有像芯片/分身行那样 setCursorSessionId + paintRef,所以点了 pill 高亮不动。这里补齐。
1226
+ if (paintRef.current) paintRef.current(String(bodyMember.id));
1227
+ setCursorSessionId(String(bodyMember.id));
1228
+ manualPickUntilRef.current = Date.now() + 3000;
1229
+ openSessionById(bodyMember.id);
1230
+ },
1231
+ },
1232
+ h('span', {
1233
+ className: 'pr-run-avatar',
1234
+ style: emoji
1235
+ ? { fontSize: '12px', fontWeight: 600, color: 'inherit', background: 'transparent' }
1236
+ : { fontSize: '9px', fontWeight: 600, color: '#fff', background: `hsl(${hue} 40% 40%)` },
1237
+ }, emoji || (String(group.name).trim()[0] || '·').toUpperCase()),
1238
+ group.project
1239
+ ? h('span', { className: 'pr-run-project', title: `所属项目:${group.project}` },
1240
+ h('span', { className: 'pr-run-project-dot', style: { background: `hsl(${hue} 45% 50%)` } }),
1241
+ h('span', { className: 'pr-run-project-name', style: { color: `hsl(${hue} 50% 68%)` } }, group.project),
1242
+ )
1243
+ : null,
1244
+ h('span', { className: 'pr-run-name' }, group.name),
1245
+ group.extra > 0 ? h('span', { className: 'pr-run-badge', title: `${group.extra} 个分身` }, `+${group.extra}`) : null,
1246
+ done
1247
+ ? h('span', { className: 'pr-run-done', title: '已完成,点开查看' }, h('span', { className: 'pr-run-check' }, '✓'), '完成')
1248
+ // 2026-11-11:绿点也只跟本体走——本体空闲、只剩分身在跑时不给本体挂绿点
1249
+ // (分身子行自己有 pr-clone-dot.on,不丢状态)。
1250
+ : (bodyRunning ? h('span', { className: 'pr-running-dot', title: '运行中' }) : null),
1251
+ elapsed ? h('span', { className: 'pr-run-elapsed' }, elapsed) : null,
1252
+ ),
1253
+ group.clones && group.clones.length
1254
+ ? h('div', { className: 'pr-run-clones' },
1255
+ group.clones.map((clone, index) => h('div', {
1256
+ className: 'pr-clone-row',
1257
+ 'data-active': String(cursorSessionId || '') === String(clone.id) ? '1' : '0',
1258
+ 'data-session-id': String(clone.id),
1259
+ key: `run-clone-${clone.id}`,
1260
+ title: clone.name,
1261
+ onClick: (event) => {
1262
+ event.stopPropagation();
1263
+ roomBus.close();
1264
+ if (paintRef.current) paintRef.current(String(clone.id)); // 同一帧先改 DOM,别等 React
1265
+ setCursorSessionId(String(clone.id));
1266
+ manualPickUntilRef.current = Date.now() + 3000;
1267
+ openSessionById(clone.id);
1268
+ },
1269
+ },
1270
+ h('span', { className: 'pr-tree-rail' },
1271
+ index === group.clones.length - 1 ? '└' : '├'),
1272
+ h('span', {
1273
+ className: `pr-clone-dot${clone.running ? ' on' : ''}`,
1274
+ }),
1275
+ h('span', { className: 'pr-clone-name' },
1276
+ String(clone.name).split('|').pop()),
1277
+ // 2026-11-09 用户三次报「快捷栏的分身还是没有显示运行时间」:这里(快捷栏 pill 的分身树子行)
1278
+ // 之前**只有 ├/└ + 圆点 + 名字**,根本没有时长渲染。上次改的是侧栏芯片那条路径,不是这条。
1279
+ (() => {
1280
+ const cStarted = runStartRef.current.get(clone.id);
1281
+ const cElapsed = clone.running && cStarted ? formatDuration(tick - cStarted) : '';
1282
+ const cLast = clone.updatedAt || 0;
1283
+ if (!cElapsed && (!cLast || !showIdleAgo)) return null;
1284
+ return h('span', {
1285
+ className: clone.running ? 'pr-clone-elapsed' : 'pr-clone-elapsed idle',
1286
+ title: clone.running ? `已运行 ${cElapsed}` : '距上次活动',
1287
+ }, clone.running && cElapsed ? cElapsed : relTime(cLast));
1288
+ })(),
1289
+ )))
1290
+ : null,
1291
+ );
1292
+ };
1293
+
1294
+ const archiveLoose = async () => {
1295
+ const list = groups.get(LOOSE_GROUP) || [];
1296
+ if (!list.length) { setClearMsg('没有未分组的会话'); return; }
1297
+ if (!clearArmed) {
1298
+ // 两段式确认:桌面端 window.confirm 可能被静默拦掉,改用按钮自身状态
1299
+ setClearArmed(true);
1300
+ setClearMsg(`再点一次「确认清理」→ 归档 ${list.length} 个(4 秒后自动取消)`);
1301
+ setTimeout(() => { setClearArmed(false); setClearMsg(''); }, 4000);
1302
+ return;
1303
+ }
1304
+ setClearArmed(false);
1305
+ setClearMsg(`正在归档 ${list.length} 个…`);
1306
+ try {
1307
+ const res = await fetch(`${API}/archive`, {
1308
+ method: 'POST',
1309
+ headers: { 'content-type': 'application/json' },
1310
+ body: JSON.stringify({ sessionIds: list.map((m) => m.id) }),
1311
+ });
1312
+ const data = await res.json();
1313
+ if (data && data.ok) {
1314
+ // 2026-11-10(用户令「事后隐藏」):归档失败 = 磁盘上根本没这个会话(幽灵)。
1315
+ // 既然删不掉,就记进隐藏集,下次渲染跳过它。
1316
+ const failedList = Array.isArray(data.failed) ? data.failed : [];
1317
+ for (const f of failedList) hideSession(f && f.id);
1318
+ const failNote = failedList.length
1319
+ ? `,失败 ${failedList.length}(${String((failedList[0] && failedList[0].error) || '').slice(0, 60)};已隐藏)`
1320
+ : '';
1321
+ setClearMsg(`已归档 ${data.archived}/${data.total}${failNote}`);
1322
+ console.log('[project-room] archive result', data);
1323
+ } else {
1324
+ setClearMsg(`失败:${(data && data.error) || '未知'}`);
1325
+ }
1326
+ } catch (error) {
1327
+ setClearMsg(`失败:${String((error && error.message) || error)}`);
1328
+ }
1329
+ };
1330
+
1331
+ /** 右键菜单:改名 / 归档 / 复制 sessionId / 复制标题 */
1332
+ const renderMenu = () => {
1333
+ if (!menu) return null;
1334
+ const close = () => { setMenu(null); setMenuMsg(''); };
1335
+ const copy = async (text, label) => {
1336
+ try {
1337
+ await navigator.clipboard.writeText(text);
1338
+ setMenuMsg(`已复制${label}`);
1339
+ } catch {
1340
+ setMenuMsg('复制失败(剪贴板不可用)');
1341
+ }
1342
+ };
1343
+ return h('div', {
1344
+ className: 'pr-menu',
1345
+ onMouseDown: (event) => event.stopPropagation(),
1346
+ style: { left: `${menu.x}px`, top: `${menu.y}px` },
1347
+ onClick: (event) => event.stopPropagation(),
1348
+ onContextMenu: (event) => { event.preventDefault(); event.stopPropagation(); },
1349
+ },
1350
+ h('button', { className: 'pr-menu-item', onClick: () => { setRenaming({ id: menu.id, value: menu.name }); close(); } }, '改名…'),
1351
+ h('button', {
1352
+ className: 'pr-menu-item',
1353
+ onClick: async () => {
1354
+ setMenuMsg('归档中…');
1355
+ try {
1356
+ const res = await fetch(`${API}/archive`, {
1357
+ method: 'POST', headers: { 'content-type': 'application/json' },
1358
+ body: JSON.stringify({ sessionIds: [menu.id] }),
1359
+ });
1360
+ const data = await res.json();
1361
+ const okArchived = Boolean(data && data.ok && data.archived);
1362
+ if (okArchived) {
1363
+ setMenuMsg('已归档');
1364
+ } else {
1365
+ // 2026-11-10(用户令「事后隐藏」):单个归档失败 = 幽灵会话,记进隐藏集。
1366
+ hideSession(menu.id);
1367
+ const reason = (data && data.failed && data.failed[0] && data.failed[0].error)
1368
+ || (data && data.error) || '未知';
1369
+ setMenuMsg(`失败:${reason}(已隐藏)`.slice(0, 90));
1370
+ }
1371
+ } catch (error) {
1372
+ hideSession(menu.id);
1373
+ setMenuMsg(`失败:${String((error && error.message) || error)}(已隐藏)`.slice(0, 90));
1374
+ }
1375
+ },
1376
+ }, '归档这个会话'),
1377
+ h('button', { className: 'pr-menu-item', onClick: () => { copy(menu.id, ' sessionId'); } }, '复制 sessionId'),
1378
+ h('button', { className: 'pr-menu-item', onClick: () => { copy(menu.name, '标题'); } }, '复制标题'),
1379
+ h('button', { className: 'pr-menu-item', onClick: close }, '关闭'),
1380
+ menuMsg ? h('div', { className: 'pr-menu-msg' }, menuMsg) : null,
1381
+ );
1382
+ };
1383
+
1384
+ /** 设置:项目管理者名称(默认 Yat2,存 localStorage) */
1385
+ const renderSettings = () => {
1386
+ if (!settings) return null;
1387
+ const save = () => {
1388
+ const name = String(settings.manager || '').trim();
1389
+ if (!name) { setSettings({ ...settings, msg: '名称不能为空' }); return; }
1390
+ try { window.localStorage.setItem('projectRoom.managerName', name); } catch { /* 存不了就本次生效 */ }
1391
+ setSettings(null);
1392
+ };
1393
+ return h('div', { className: 'pr-modal', onClick: () => setSettings(null) },
1394
+ h('div', { className: 'pr-modal-box', onClick: (event) => event.stopPropagation() },
1395
+ h('div', { className: 'pr-modal-title' }, '设置'),
1396
+ h('div', { className: 'pr-hint' }, '项目管理者名称(排序时永远排第一、新建项目的默认成员名)'),
1397
+ h('input', {
1398
+ className: 'pr-modal-input',
1399
+ value: settings.manager || '',
1400
+ autoFocus: true,
1401
+ placeholder: DEFAULT_MANAGER,
1402
+ onChange: (event) => setSettings({ ...settings, manager: event.target.value, msg: '' }),
1403
+ onKeyDown: (event) => { if (event.key === 'Enter') save(); if (event.key === 'Escape') setSettings(null); },
1404
+ }),
1405
+ h('div', { className: 'pr-hint', style: { marginTop: '6px' } },
1406
+ '注意:这只影响本插件的排序与默认名。若要让「管理者协议注入」也认这个名字,'
1407
+ + '还需要改 hub-desktop-ui 里的 MANAGER_NAMES(那是另一个插件的硬编码)。'),
1408
+ settings.msg ? h('div', { className: 'pr-err' }, settings.msg) : null,
1409
+ // 2026-11-10(用户令「事后隐藏」):归档失败过的会话被记进 localStorage 隐藏集,
1410
+ // 这里提供恢复入口,免得误隐藏之后找不回来。
1411
+ h('div', { className: 'pr-hint', style: { marginTop: '10px' } },
1412
+ `已隐藏的会话:${hiddenIds.size} 个`),
1413
+ h('button', {
1414
+ type: 'button',
1415
+ disabled: hiddenIds.size === 0,
1416
+ style: {
1417
+ marginTop: '4px', padding: '4px 10px', borderRadius: '6px', fontSize: '11px',
1418
+ border: '1px solid var(--dsw-alias-border-secondary, #3a3a42)',
1419
+ background: 'transparent', color: 'inherit',
1420
+ cursor: hiddenIds.size === 0 ? 'default' : 'pointer',
1421
+ opacity: hiddenIds.size === 0 ? 0.45 : 1,
1422
+ },
1423
+ onClick: () => { unhideAll(); setSettings({ ...settings, msg: '已恢复全部隐藏的会话' }); },
1424
+ }, '恢复全部隐藏的会话'),
1425
+ h('div', { className: 'pr-hint', style: { fontSize: '10px' } },
1426
+ '(这些是归档时报 no such session 的幽灵会话,磁盘上已不存在;恢复后会重新出现在侧栏)'),
1427
+ h('div', { className: 'pr-hint', style: { marginTop: '10px' } }, '显示'),
1428
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px', cursor: 'pointer', padding: '3px 0' } },
1429
+ h('input', {
1430
+ type: 'checkbox',
1431
+ checked: showIdleAgo,
1432
+ onChange: (event) => {
1433
+ setShowIdleAgo(event.target.checked);
1434
+ setSettings({ ...settings }); // 触发重渲染
1435
+ },
1436
+ }),
1437
+ h('span', null, '显示「多久前运行 / 距上次活动」'),
1438
+ ),
1439
+ h('div', { className: 'pr-hint', style: { fontSize: '10px' } },
1440
+ '关闭后空闲项不再显示相对时间,运行中仍显示本轮已运行时长。'),
1441
+ h('div', { className: 'pr-modal-actions' },
1442
+ h('button', { className: 'pr-btn', onClick: () => setSettings(null) }, '取消'),
1443
+ h('button', { className: 'pr-btn primary', onClick: save }, '保存'),
1444
+ ),
1445
+ ),
1446
+ );
1447
+ };
1448
+
1449
+ /** 新建项目弹窗:建会话 → 按「项目 · 成员」命名 → 初始化看板 */
1450
+ const renderNewProject = () => {
1451
+ if (!newProject) return null;
1452
+ const submit = async () => {
1453
+ const project = String(newProject.name || '').trim();
1454
+ const member = String(newProject.member || managerName()).trim() || managerName();
1455
+ if (!project) return;
1456
+ setNewProject({ ...newProject, busy: true, msg: '创建中…' });
1457
+ try {
1458
+ const svc = clientCtx && typeof clientCtx.get === 'function' ? clientCtx.get('uiWorkspace') : null;
1459
+ if (!svc) { setNewProject({ ...newProject, busy: false, msg: 'uiWorkspace 不可用' }); return; }
1460
+ const items = (workspaces && workspaces.items) || [];
1461
+ const workspaceId = items.length ? items[0].workspaceId : undefined;
1462
+ let sessionId = '';
1463
+ if (typeof svc.connectWorkspace === 'function' && workspaceId !== undefined) {
1464
+ const raw = await svc.connectWorkspace(workspaceId, { agentPreset: 'cordis' });
1465
+ sessionId = typeof raw === 'string' ? raw : ((raw && (raw.sessionId || raw.id)) || '');
1466
+ }
1467
+ if (!sessionId && typeof svc.startSession === 'function') {
1468
+ svc.startSession(workspaceId, { agentPreset: 'cordis' });
1469
+ }
1470
+ if (sessionId) {
1471
+ await fetch(`${API}/rename`, {
1472
+ method: 'POST', headers: { 'content-type': 'application/json' },
1473
+ body: JSON.stringify({ sessionId, title: `${project} · ${member}` }),
1474
+ });
1475
+ }
1476
+ // 初始化房间看板(不存在则建模板)
1477
+ try { await fetch(`${API}/board?project=${encodeURIComponent(project)}`, { method: 'POST' }); } catch { /* 看板可选 */ }
1478
+ setNewProject(null);
1479
+ } catch (error) {
1480
+ setNewProject({ ...newProject, busy: false, msg: `失败:${String((error && error.message) || error)}` });
1481
+ }
1482
+ };
1483
+ return h('div', { className: 'pr-modal', onClick: () => (newProject.busy ? null : setNewProject(null)) },
1484
+ h('div', { className: 'pr-modal-box', onClick: (event) => event.stopPropagation() },
1485
+ h('div', { className: 'pr-modal-title' }, '新建项目'),
1486
+ h('input', {
1487
+ className: 'pr-modal-input',
1488
+ placeholder: '项目名,例如 骨骼微环境',
1489
+ value: newProject.name || '',
1490
+ autoFocus: true,
1491
+ onChange: (event) => setNewProject({ ...newProject, name: event.target.value, msg: '' }),
1492
+ onKeyDown: (event) => { if (event.key === 'Enter') submit(); if (event.key === 'Escape') setNewProject(null); },
1493
+ }),
1494
+ h('div', { style: { height: '8px' } }),
1495
+ h('input', {
1496
+ className: 'pr-modal-input',
1497
+ placeholder: `成员的 agent 名(默认 ${managerName()})`,
1498
+ value: newProject.member || '',
1499
+ onChange: (event) => setNewProject({ ...newProject, member: event.target.value, msg: '' }),
1500
+ onKeyDown: (event) => { if (event.key === 'Enter') submit(); if (event.key === 'Escape') setNewProject(null); },
1501
+ }),
1502
+ h('div', { style: { marginTop: '6px', fontSize: '10.5px', color: 'var(--dsw-alias-label-secondary)' } },
1503
+ `创建后会话标题为「${String(newProject.name || '项目名').trim()} · ${String(newProject.member || 'Yat2').trim() || 'Yat2'}」`),
1504
+ newProject.msg ? h('div', { className: 'pr-hint' }, newProject.msg) : null,
1505
+ h('div', { className: 'pr-modal-actions' },
1506
+ h('button', { className: 'pr-btn', onClick: () => setNewProject(null), disabled: newProject.busy }, '取消'),
1507
+ h('button', { className: 'pr-btn primary', onClick: submit, disabled: newProject.busy }, newProject.busy ? '创建中…' : '创建'),
1508
+ ),
1509
+ ),
1510
+ );
1511
+ };
1512
+
1513
+ /** 改名弹窗(双击芯片或右键「改名…」触发) */
1514
+ const renderRename = () => {
1515
+ if (!renaming) return null;
1516
+ const submit = async () => {
1517
+ const title = String(renaming.value || '').trim();
1518
+ if (!title) return;
1519
+ try {
1520
+ const res = await fetch(`${API}/rename`, {
1521
+ method: 'POST', headers: { 'content-type': 'application/json' },
1522
+ body: JSON.stringify({ sessionId: renaming.id, title }),
1523
+ });
1524
+ const data = await res.json();
1525
+ if (data && data.ok) { setRenaming(null); return; }
1526
+ setMenuMsg(`改名失败:${(data && data.error) || '未知'}`);
1527
+ } catch (error) {
1528
+ setMenuMsg(`改名失败:${String((error && error.message) || error)}`);
1529
+ }
1530
+ };
1531
+ return h('div', { className: 'pr-modal', onClick: () => setRenaming(null) },
1532
+ h('div', { className: 'pr-modal-box', onClick: (event) => event.stopPropagation() },
1533
+ h('div', { className: 'pr-modal-title' }, '改会话标题'),
1534
+ h('input', {
1535
+ className: 'pr-modal-input',
1536
+ value: renaming.value,
1537
+ autoFocus: true,
1538
+ onChange: (event) => setRenaming({ ...renaming, value: event.target.value }),
1539
+ onKeyDown: (event) => {
1540
+ if (event.key === 'Enter') submit();
1541
+ if (event.key === 'Escape') setRenaming(null);
1542
+ },
1543
+ }),
1544
+ h('div', { className: 'pr-modal-actions' },
1545
+ h('button', { className: 'pr-btn', onClick: () => setRenaming(null) }, '取消'),
1546
+ h('button', { className: 'pr-btn primary', onClick: submit }, '保存'),
1547
+ ),
1548
+ ),
1549
+ );
1550
+ };
1551
+
1552
+ return h('div', { className: 'pr-root' },
1553
+ renderMenu(),
1554
+ renderRename(),
1555
+ renderNewProject(),
1556
+ renderSettings(),
1557
+ h('div', { className: 'pr-runbar' },
1558
+ // ── 区①:完成未读(2026-11-11 用户令:这一区排在运行中前面;一键已读只清这一区)──
1559
+ doneUnreadGroups.length
1560
+ ? h('div', { className: 'pr-runbar-sec', 'data-kind': 'done' },
1561
+ h('div', { className: 'pr-runbar-sec-title' },
1562
+ h('span', { className: 'pr-runbar-sec-check' }, '✓'),
1563
+ h('span', { className: 'pr-runbar-sec-label' }, '完成未读'),
1564
+ h('span', { className: 'pr-runbar-sec-count' }, String(doneUnreadGroups.length)),
1565
+ h('button', {
1566
+ className: 'pr-readall',
1567
+ title: `全部已读(清掉 ${doneGroups} 个完成待查看)`,
1568
+ onClick: (event) => {
1569
+ event.stopPropagation();
1570
+ saveJson('projectRoom.unread', {});
1571
+ setUnread({});
1572
+ },
1573
+ }, '全部已读'),
1574
+ ),
1575
+ h('div', { className: 'pr-runbar-sec-body' }, doneUnreadGroups.map(renderRunPill)),
1576
+ )
1577
+ : null,
1578
+ // ── 区②:运行中 ──
1579
+ runningGroups.length
1580
+ ? h('div', { className: 'pr-runbar-sec', 'data-kind': 'running' },
1581
+ h('div', { className: 'pr-runbar-sec-title' },
1582
+ h('span', { className: 'pr-runbar-sec-dot', title: '有 agent 正在运行' }),
1583
+ h('span', { className: 'pr-runbar-sec-label' }, '运行中'),
1584
+ h('span', { className: 'pr-runbar-sec-count' }, String(runningGroups.length)),
1585
+ ),
1586
+ h('div', { className: 'pr-runbar-sec-body' }, runningGroups.map(renderRunPill)),
1587
+ )
1588
+ : null,
1589
+ ),
1590
+ h('div', { className: 'pr-head' },
1591
+ h('span', { className: 'pr-head-title' }, 'Projects'),
1592
+ h('span', { className: 'pr-head-count' }, `${shownProjects.length} 项目 · ${totalSessions} agent`),
1593
+ h('button', { className: 'pr-new-btn', title: '新建项目(会按「项目 · 成员」命名新会话)', onClick: () => setNewProject({ name: '', member: managerName(), busy: false, msg: '' }) }, '+ 新项目'),
1594
+ h('button', { className: 'pr-new-btn', title: '设置项目管理者名称', onClick: () => setSettings({ manager: managerName(), msg: '' }) }, '⚙'),
1595
+ ),
1596
+ looseMembers.length
1597
+ ? h('div', { className: 'pr-loose-row' },
1598
+ h('button', {
1599
+ className: 'pr-loose-toggle',
1600
+ title: showLoose ? '隐藏标题里没有分隔符的会话' : `显示 ${looseMembers.length} 个标题里没有分隔符的会话(多为历史测试)`,
1601
+ onClick: () => {
1602
+ const next = !showLoose;
1603
+ setShowLoose(next);
1604
+ saveJson('projectRoom.showLoose', next);
1605
+ },
1606
+ }, `${showLoose ? '▾' : '▸'} 未分组 ${looseMembers.length}`),
1607
+ h('button', {
1608
+ className: 'pr-loose-toggle',
1609
+ title: '把这些没有项目前缀的会话归档(可恢复)',
1610
+ onClick: archiveLoose,
1611
+ }, clearArmed ? '确认清理' : '清理'),
1612
+ clearMsg ? h('span', { className: 'pr-loose-msg' }, clearMsg) : null,
1613
+ )
1614
+ : null,
1615
+ h('div', { className: 'pr-search' },
1616
+ h('input', {
1617
+ type: 'text',
1618
+ value: query,
1619
+ placeholder: '搜索项目 / agent',
1620
+ onChange: (event) => setQuery(event.target.value),
1621
+ onKeyDown: (event) => { if (event.key === 'Escape') setQuery(''); },
1622
+ }),
1623
+ query
1624
+ ? h('button', { className: 'pr-icon-btn', title: '清空搜索', onClick: () => setQuery('') }, '×')
1625
+ : null,
1626
+ ),
1627
+ visible.length === 0
1628
+ ? h('div', { className: 'pr-empty' }, q ? '没有匹配的项目或 agent' : '还没有会话')
1629
+ : visible.map((name, index) => {
1630
+ const members = groups.get(name) || [];
1631
+ const isClosed = !q && Boolean(closed[name]);
1632
+ const isPinned = pinned.includes(name);
1633
+ const latest = Math.max(0, ...members.map((m) => m.updatedAt));
1634
+ const anyRunning = members.some((m) => m.running);
1635
+ const day = dayKey();
1636
+ const runTotal = Number(loadJson(`projectRoom.runTotal.${name}.${day}`, 0) || 0);
1637
+ return h('section', {
1638
+ key: name,
1639
+ style: index > 0
1640
+ ? { borderTop: '1px solid var(--dsw-alias-border-l1)', marginTop: '6px', paddingTop: '6px', marginBottom: '4px' }
1641
+ : { marginBottom: '4px' },
1642
+ },
1643
+ h('div', {
1644
+ className: `pr-proj${room === name ? ' open' : ''}`,
1645
+ title: `${name} · 点击进入项目房间`,
1646
+ onClick: () => roomBus.open(name),
1647
+ },
1648
+ h('span', { className: 'pr-dot', style: { background: `hsl(${hashHue(name)} 45% 50%)` } }),
1649
+ h('span', { className: 'pr-proj-name', style: { color: `hsl(${hashHue(name)} 50% 68%)` } }, name),
1650
+ h('span', { className: 'pr-spacer' }),
1651
+ anyRunning ? h('span', { className: 'pr-meta', title: '有 agent 正在运行', style: { color: 'var(--dsw-alias-brand-primary)' } }, '●') : null,
1652
+ runTotal > 0
1653
+ ? h('span', { className: 'pr-meta', title: '今日该项目所有 agent 累计运行时长' }, `今日运行 ${formatDuration(runTotal)}`)
1654
+ : null,
1655
+ latest ? h('span', { className: 'pr-meta' }, relTime(latest)) : null,
1656
+ h('button', {
1657
+ className: 'pr-icon-btn pr-pin',
1658
+ 'data-pinned': isPinned ? '1' : '0',
1659
+ title: isPinned ? '取消置顶' : '置顶到项目列表顶部',
1660
+ onClick: (event) => { event.stopPropagation(); setPin(name); },
1661
+ }, isPinned ? '★' : '☆'),
1662
+ h('button', {
1663
+ className: 'pr-icon-btn',
1664
+ title: isClosed ? '展开' : '折叠',
1665
+ onClick: (event) => { event.stopPropagation(); toggleClosed(name); },
1666
+ }, isClosed ? '▸' : '▾'),
1667
+ ),
1668
+ isClosed ? null : h('div', { className: 'pr-grid' },
1669
+ // ⚠️ 有本体的分身不单独出芯片:它只在下面本体的树里显示。
1670
+ // 否则一个分身会同时占「自己的芯片」和「本体芯片的 └─ 子行」两处(用户两次报的重复)。
1671
+ members
1672
+ .slice()
1673
+ .filter((m) => {
1674
+ if (!m.isClone) return true;
1675
+ return !members.some((other) => !other.isClone && other.base === m.base);
1676
+ })
1677
+ // 排序:**Yat2 永远排第一**(用户令:它是项目管理者),其后运行中优先,
1678
+ // 同档内按最近活跃倒序。
1679
+ .sort((a, b) => (memberRank(a) - memberRank(b)) || ((b.updatedAt || 0) - (a.updatedAt || 0)))
1680
+ .map((m) => {
1681
+ const emoji = avatars && typeof avatars[m.name] === 'string' ? avatars[m.name].trim() : '';
1682
+ const initial = (String(m.name || '·').trim()[0] || '·').toUpperCase();
1683
+ const startedAt = runStartRef.current.get(m.id);
1684
+ const elapsed = m.running && startedAt ? formatDuration(tick - startedAt) : '';
1685
+ // 该 agent 的分身(同名不同项目不算;这里按「项目 + base 名」聚合)
1686
+ const cloneList = m.isClone ? [] : members.filter((x) => x.isClone && x.base === m.base && x.id !== m.id);
1687
+ return h('button', {
1688
+ key: m.id,
1689
+ type: 'button',
1690
+ className: 'pr-chip',
1691
+ 'data-session-id': m.id,
1692
+ 'data-active': String(cursorSessionId || '') === String(m.id) ? '1' : '0',
1693
+ title: `${m.name} · ${m.running ? '运行中' : '空闲'}${elapsed ? ' ' + elapsed : ''}${m.updatedAt ? ' · ' + relTime(m.updatedAt) : ''}`,
1694
+ onContextMenu: (event) => {
1695
+ event.preventDefault();
1696
+ event.stopPropagation();
1697
+ setMenu({ id: m.id, name: m.name, x: event.clientX, y: event.clientY });
1698
+ },
1699
+ onDoubleClick: (event) => {
1700
+ event.stopPropagation();
1701
+ setRenaming({ id: m.id, value: m.name });
1702
+ },
1703
+ onClick: (event) => {
1704
+ event.stopPropagation();
1705
+ // 点成员 = 要看这个对话:先关房间,否则房间会盖住对话区
1706
+ roomBus.close();
1707
+ // 2026-11-09 用户报「侧边栏还是没有单独高亮本体」:openSessionById 之后依赖宿主回写
1708
+ // data-conversation-session 才更新 cursor;分身点击时它有时不回写 → 本体芯片一直亮。
1709
+ // 这里点击即认账,不依赖宿主。
1710
+ if (paintRef.current) paintRef.current(String(m.id)); // 同一帧先改 DOM,别等 React
1711
+ setCursorSessionId(String(m.id));
1712
+ manualPickUntilRef.current = Date.now() + 3000;
1713
+ if (!openSessionById(m.id)) {
1714
+ console.warn('[project-room] uiWorkspace.openSession 不可用,无法打开会话');
1715
+ }
1716
+ },
1717
+ },
1718
+ // 本体那一行单独成盒,选中背景只作用在它上面(布局已在隔离 HTML + headless Chrome 验证)
1719
+ h('span', { className: 'pr-chip-head' },
1720
+ h('span', {
1721
+ className: 'pr-avatar',
1722
+ style: emoji
1723
+ ? { fontSize: '12px', fontWeight: 600, color: 'inherit', background: 'transparent' }
1724
+ : { fontSize: '9px', fontWeight: 600, color: '#fff', background: `hsl(${hashHue(m.name)} 40% 40%)` },
1725
+ }, emoji || initial),
1726
+ h('span', { className: 'pr-chip-name' }, m.name),
1727
+ cloneList.length
1728
+ ? h('span', { className: 'pr-chip-badge', title: `${cloneList.length} 个分身 · 见下方子行,点哪个开哪个的独立对话(分列视图已于 2026-11-09 取消)` }, `+${cloneList.length}`)
1729
+ : null,
1730
+ m.running ? h('span', { className: 'pr-running-dot', title: '运行中' }) : null,
1731
+ // 2026-11-09:空闲也显示(原实现 elapsed 为空就 null)。运行中=已运行,空闲=距上次活动。
1732
+ (() => {
1733
+ const mLast = m.updatedAt || 0;
1734
+ if (!elapsed && (!mLast || !showIdleAgo)) return null;
1735
+ return h('span', {
1736
+ className: elapsed ? 'pr-chip-elapsed' : 'pr-chip-elapsed idle',
1737
+ title: elapsed ? `已运行 ${elapsed}` : '距上次活动',
1738
+ }, elapsed || relTime(mLast));
1739
+ })(),
1740
+ ),
1741
+ cloneList.length
1742
+ ? h('div', { className: 'pr-chip-clones' },
1743
+ cloneList.slice().sort((a, b) => (memberRank(a) - memberRank(b)) || ((b.updatedAt || 0) - (a.updatedAt || 0))).map((c, cloneIndex, arr) => h('div', {
1744
+ key: c.id,
1745
+ className: 'pr-clone-row',
1746
+ 'data-active': String(cursorSessionId || '') === String(c.id) ? '1' : '0',
1747
+ 'data-session-id': String(c.id),
1748
+ title: `${c.name} · ${c.running ? '运行中' : '空闲'}`,
1749
+ onClick: (event) => {
1750
+ event.stopPropagation();
1751
+ roomBus.close();
1752
+ if (paintRef.current) paintRef.current(String(c.id)); // 同一帧先改 DOM,别等 React
1753
+ setCursorSessionId(String(c.id));
1754
+ manualPickUntilRef.current = Date.now() + 3000;
1755
+ openSessionById(c.id);
1756
+ },
1757
+ },
1758
+ h('span', { className: 'pr-tree-rail' }, cloneIndex < arr.length - 1 ? '├' : '└'),
1759
+ h('span', { className: `pr-clone-dot${c.running ? ' on' : ''}` }),
1760
+ h('span', { className: 'pr-clone-name' }, c.name.split('|').pop() || c.name),
1761
+ // 2026-11-09 用户两次报「分身还是没有显示计时」:这里原来**没有任何时长渲染**。
1762
+ // 口径与本体芯片一致:运行中=已运行时长(绿),空闲=距上次活动(次要色)。
1763
+ (() => {
1764
+ const startedAt = runStartRef.current.get(c.id);
1765
+ const cElapsed = c.running && startedAt ? formatDuration(tick - startedAt) : '';
1766
+ const cLast = c.updatedAt || 0;
1767
+ if (!cElapsed && (!cLast || !showIdleAgo)) return null;
1768
+ return h('span', {
1769
+ className: c.running ? 'pr-clone-elapsed' : 'pr-clone-elapsed idle',
1770
+ title: c.running ? `已运行 ${cElapsed}` : '距上次活动',
1771
+ }, c.running && cElapsed ? cElapsed : relTime(cLast));
1772
+ })(),
1773
+ )),
1774
+ )
1775
+ : null,
1776
+ );
1777
+ }),
1778
+ ),
1779
+ );
1780
+ }),
1781
+ );
1782
+ }
1783
+
1784
+ // ───────────────────────── 房间浮层 ─────────────────────────
1785
+
1786
+ function ProjectRoom() {
1787
+ const [project, setProject] = React.useState(roomBus.current);
1788
+ const [board, setBoard] = React.useState('');
1789
+ const [boardDirty, setBoardDirty] = React.useState(false);
1790
+ const [boardEditing, setBoardEditing] = React.useState(false);
1791
+ const [tasks, setTasks] = React.useState(null);
1792
+ // 2026-11-12:任务线不绑成员 → 卡片消息按 routing(eventId → 线 id)归线。
1793
+ // /hub/tasks 的 routing 是顶层字段,setTasks 只存 tasks 数组,所以单独存一份。
1794
+ const [routingMap, setRoutingMap] = React.useState({});
1795
+ const [archiving, setArchiving] = React.useState(null); // { taskId, armed } 两段式确认
1796
+ const [taskTick, setTaskTick] = React.useState(0); // 加一即重拉 tasks.json
1797
+ const [members, setMembers] = React.useState([]);
1798
+ // 任务线分列:每个成员一列,列内显示该会话最近的消息流。
1799
+ // 事件源用 Host 的 /hub/session-events(由 hub-desktop-ui 提供);取不到就降级为提示。
1800
+ const [lanes, setLanes] = React.useState({}); // { [sessionId]: { events, loading, error } }
1801
+ const [target, setTarget] = React.useState('');
1802
+ const [draft, setDraft] = React.useState('');
1803
+ const [busy, setBusy] = React.useState(false);
1804
+ const [error, setError] = React.useState('');
1805
+ const [notice, setNotice] = React.useState('');
1806
+
1807
+ React.useEffect(() => roomBus.subscribe((next) => {
1808
+ setProject(next);
1809
+ setError('');
1810
+ setNotice('');
1811
+ setBoardDirty(false);
1812
+ }), []);
1813
+
1814
+ // Esc 关闭房间:全屏/半屏遮罩最怕"关不掉",键盘退路必须有
1815
+ React.useEffect(() => {
1816
+ if (!project) return undefined;
1817
+ const onKey = (event) => { if (event.key === 'Escape') roomBus.close(); };
1818
+ window.addEventListener('keydown', onKey);
1819
+ return () => window.removeEventListener('keydown', onKey);
1820
+ }, [project]);
1821
+
1822
+ // 打开项目时拉看板/任务/待办
1823
+ React.useEffect(() => {
1824
+ if (!project) return;
1825
+ let cancelled = false;
1826
+ (async () => {
1827
+ try {
1828
+ const [b, t] = await Promise.all([
1829
+ fetch(`${API}/board?project=${encodeURIComponent(project)}`).then((r) => r.json()),
1830
+ fetch(`${API}/tasks?project=${encodeURIComponent(project)}`).then((r) => r.json()),
1831
+ ]);
1832
+ if (cancelled) return;
1833
+ setBoard((b && b.board) || '');
1834
+ setTasks(t && t.tasks);
1835
+ setRoutingMap((t && t.routing && typeof t.routing === 'object') ? t.routing : {});
1836
+ } catch (err) {
1837
+ if (!cancelled) setError(`读取房间失败:${String(err && err.message || err)}`);
1838
+ }
1839
+ })();
1840
+ return () => { cancelled = true; };
1841
+ }, [project, taskTick]);
1842
+
1843
+ // 成员列表:读侧栏同步过来的快照(房间浮层拿不到 slot props)
1844
+ React.useEffect(() => {
1845
+ if (!project) return undefined;
1846
+ const collect = () => {
1847
+ const groups = roomBus.sessionIndex;
1848
+ const list = groups && groups.get ? (groups.get(project) || []) : [];
1849
+ setMembers(list.map((m) => ({ id: m.id, name: m.name, running: m.running })));
1850
+ };
1851
+ collect();
1852
+ const timer = setInterval(collect, 2000);
1853
+ return () => clearInterval(timer);
1854
+ }, [project]);
1855
+
1856
+
1857
+ // 投递目标:默认选管理者(Yat2),并按项目记住上次选的人 ——
1858
+ // 用户反馈「每次都要让我选」,原实现 target 初始是 ''、select 停在「选择成员…」。
1859
+ React.useEffect(() => {
1860
+ if (!members.length) { setTarget(''); return; }
1861
+ const saved = loadJson(`projectRoom.target.${project}`, '');
1862
+ const pick = members.find((m) => m.id === saved)
1863
+ || members.find((m) => m.name === managerName())
1864
+ || members[0];
1865
+ setTarget((cur) => (cur && members.some((m) => m.id === cur) ? cur : (pick ? pick.id : '')));
1866
+ }, [project, members]);
1867
+
1868
+ /** 选目标时同时记住,下次打开房间直接用它。 */
1869
+ const pickTarget = (id) => {
1870
+ setTarget(id);
1871
+ saveJson(`projectRoom.target.${project}`, id);
1872
+ };
1873
+ // 按「任务线」拉消息(用户澄清:分列是按任务分,不是按 agent 分)。
1874
+ // 一条线一列:列内合并该线各成员的会话消息,按时间排序取最后几条。
1875
+ // 只收 user/message 与 assistant/message 的文本(工具/step/思考不进气泡)。
1876
+ // limit 取 60:实测 60 条事件里 assistant/message 只有 2 条,取 12 会显示成「暂无消息」。
1877
+ React.useEffect(() => {
1878
+ if (!project) return undefined;
1879
+ let cancelled = false;
1880
+ const load = async () => {
1881
+ const list = roomBus.sessionIndex && roomBus.sessionIndex.get
1882
+ ? (roomBus.sessionIndex.get(project) || [])
1883
+ : [];
1884
+ const sessionOf = (name) => list.find((m) => m.name === name);
1885
+ // 加载**全部** active 线(不截断):渲染阶段要按「最近活跃」重排后再取前 8 列。
1886
+ // 加载时就截 8,会让 tasks.json 里第 9 条以后的线永远拿不到 lastAt、永远排不上来
1887
+ //(2026-11-11 用户令「最近活跃的任务线排最前」)。
1888
+ const taskList = Array.isArray(tasks) ? tasks
1889
+ : (tasks && Array.isArray(tasks.tasks) ? tasks.tasks : []);
1890
+ const activeTasks = taskList
1891
+ .filter((task) => String(task.status || '').toLowerCase() !== 'archived');
1892
+ const next = {};
1893
+ // 2026-11-12 用户令「任务线不用绑定成员,以任务目标为标准」:
1894
+ // 卡片消息**只按 routing.json 归线**(eventId → 任务线 id),不再按成员会话聚合。
1895
+ // 每个 agent 处理消息时自己判断它属于哪条线(写 routing),这里只负责按归属渲染。
1896
+ // 旧实现按 task.members 找会话、再做「共享成员跨列去重」——成员不再绑定后那条路必然全空,
1897
+ // 于是卡片只剩一行空态提示。整段已换成 routing 驱动。
1898
+ const routing = routingMap && typeof routingMap === 'object' ? routingMap : {};
1899
+ const laneKeys = new Set(activeTasks.map((task, index) => String(task.id || `task-${index}`)));
1900
+ const laneOfEvent = (event, sessionId) => {
1901
+ if (!event) return '';
1902
+ const rawKey = routing[event.id] !== undefined
1903
+ ? routing[event.id]
1904
+ : routing[`${sessionId}:${event.seq}`];
1905
+ const key = rawKey === undefined || rawKey === null ? '' : String(rawKey);
1906
+ if (!key || key === '_none' || key === '_hidden') return '';
1907
+ return laneKeys.has(key) ? key : '';
1908
+ };
1909
+ const buckets = new Map();
1910
+ for (const key of laneKeys) buckets.set(key, []);
1911
+ for (const session of list) {
1912
+ try {
1913
+ const res = await fetch(`/hub/session-events?session=${encodeURIComponent(session.id)}&limit=60`);
1914
+ const data = await res.json();
1915
+ const events = Array.isArray(data && data.events) ? data.events : [];
1916
+ for (const event of events) {
1917
+ if (!event || (event.type !== 'user/message' && event.type !== 'assistant/message')) continue;
1918
+ const laneKey = laneOfEvent(event, session.id);
1919
+ if (!laneKey) continue;
1920
+ const content = event.data && event.data.message && event.data.message.content;
1921
+ const text = Array.isArray(content)
1922
+ ? content.filter((part) => part && part.type === 'text').map((part) => part.text || '').join('\n')
1923
+ : '';
1924
+ const value = String(text || '').trim();
1925
+ if (!value) continue;
1926
+ // 卡片是进度视图,只放一句摘要要点(2026-11-10 分诊定调)。
1927
+ const summary = summarizeMessage(value);
1928
+ // 空摘要(内容只有 "." / 纯代码块 / 纯表格)不进卡片(2026-11-11 修骨架屏)。
1929
+ if (!summary) continue;
1930
+ buckets.get(laneKey).push({
1931
+ seq: event.seq,
1932
+ time: event.time || 0,
1933
+ role: event.type === 'user/message' ? 'user' : 'agent',
1934
+ from: session.name,
1935
+ text: summary,
1936
+ });
1937
+ }
1938
+ } catch { /* 单个会话失败不影响整列 */ }
1939
+ }
1940
+ // 过渡期兼容:老项目 tasks.json 里还写着 members、又完全没有 routing 记录时,
1941
+ // 退回按成员会话收集(新模型只走 routing,这条只在「老数据还没迁」时生效)。
1942
+ if (Object.keys(routing).length === 0) {
1943
+ // 跨线去重:共享成员的消息只归「第一条」包含它的线(保持旧行为,避免同一条消息
1944
+ // 在每条含该成员的线里重复出现)。
1945
+ const claimedMembers = new Set();
1946
+ for (const [index, task] of activeTasks.entries()) {
1947
+ const key = String(task.id || `task-${index}`);
1948
+ if ((buckets.get(key) || []).length > 0) continue;
1949
+ const names = Array.isArray(task.members) ? task.members : [];
1950
+ for (const name of names) {
1951
+ const member = sessionOf(name);
1952
+ if (!member) continue;
1953
+ if (claimedMembers.has(member.id)) continue;
1954
+ claimedMembers.add(member.id);
1955
+ try {
1956
+ const res = await fetch(`/hub/session-events?session=${encodeURIComponent(member.id)}&limit=60`);
1957
+ const data = await res.json();
1958
+ const events = Array.isArray(data && data.events) ? data.events : [];
1959
+ for (const event of events) {
1960
+ if (!event || (event.type !== 'user/message' && event.type !== 'assistant/message')) continue;
1961
+ const content = event.data && event.data.message && event.data.message.content;
1962
+ const text = Array.isArray(content)
1963
+ ? content.filter((part) => part && part.type === 'text').map((part) => part.text || '').join('\n')
1964
+ : '';
1965
+ const value = String(text || '').trim();
1966
+ if (!value) continue;
1967
+ const summary = summarizeMessage(value);
1968
+ if (!summary) continue;
1969
+ buckets.get(key).push({
1970
+ seq: event.seq, time: event.time || 0,
1971
+ role: event.type === 'user/message' ? 'user' : 'agent',
1972
+ from: member.name, text: summary,
1973
+ });
1974
+ }
1975
+ } catch { /* 单个成员失败不影响整列 */ }
1976
+ }
1977
+ }
1978
+ }
1979
+ for (const [index, task] of activeTasks.entries()) {
1980
+ const key = String(task.id || `task-${index}`);
1981
+ const collected = (buckets.get(key) || []).slice()
1982
+ .sort((a, b) => (a.time || 0) - (b.time || 0));
1983
+ // 最近活跃时间 = 该线已归线消息里最大时间;空线记 0(排在最后)。
1984
+ const lastAt = collected.length ? (collected[collected.length - 1].time || 0) : 0;
1985
+ next[key] = { messages: collected.slice(-6), lastAt };
1986
+ }
1987
+ if (!cancelled) setLanes(next);
1988
+ };
1989
+ load();
1990
+ const timer = setInterval(load, 30000);
1991
+ return () => { cancelled = true; clearInterval(timer); };
1992
+ }, [project, tasks, routingMap]);
1993
+
1994
+ if (!project) return null;
1995
+
1996
+ const saveBoard = async () => {
1997
+ setBusy(true);
1998
+ setError('');
1999
+ setNotice('');
2000
+ try {
2001
+ const res = await fetch(`${API}/board?project=${encodeURIComponent(project)}`, {
2002
+ method: 'POST',
2003
+ headers: { 'content-type': 'application/json' },
2004
+ body: JSON.stringify({ content: board }),
2005
+ });
2006
+ const data = await res.json();
2007
+ if (data && data.ok) { setNotice('看板已保存到 board.md'); setBoardDirty(false); }
2008
+ else setError(`保存失败:${(data && data.error) || '未知'}`);
2009
+ } catch (err) {
2010
+ setError(`保存失败:${String(err && err.message || err)}`);
2011
+ } finally { setBusy(false); }
2012
+ };
2013
+
2014
+ /** 归档/恢复一条任务线:改 tasks.json 的 status,成功后重拉。 */
2015
+ /** 归档/恢复一条任务线:改 tasks.json 的 status,成功后重拉。 */
2016
+ const setTaskStatus = async (taskId, status) => {
2017
+ try {
2018
+ const res = await fetch(`${API}/task-status`, {
2019
+ method: 'POST',
2020
+ headers: { 'content-type': 'application/json' },
2021
+ body: JSON.stringify({ project, taskId, status }),
2022
+ });
2023
+ // 不能直接 res.json():端点未加载时 DSH 会返回空体,json() 会抛
2024
+ // "Unexpected end of JSON input",用户只看到一句看不懂的错。
2025
+ const raw = await res.text();
2026
+ let data = null;
2027
+ try { data = raw ? JSON.parse(raw) : null; } catch { data = null; }
2028
+ if (!data || !data.ok) {
2029
+ const reason = (data && data.error)
2030
+ || (raw && raw.slice(0, 80))
2031
+ || `HTTP ${res.status} 且响应为空 —— 多半是这个端点还没加载,需要重启 DSH`;
2032
+ setError(`改任务线失败:${reason}`);
2033
+ return;
2034
+ }
2035
+ setArchiving(null);
2036
+ setTaskTick((n) => n + 1); // 触发重拉,归档后该列立即消失
2037
+ } catch (error) {
2038
+ setError(`改任务线失败:${String((error && error.message) || error)}`);
2039
+ }
2040
+ };
2041
+
2042
+
2043
+ const send = async () => {
2044
+ if (!target || !draft.trim()) return;
2045
+ setBusy(true);
2046
+ setError('');
2047
+ setNotice('');
2048
+ try {
2049
+ const res = await fetch(`${API}/prompt`, {
2050
+ method: 'POST',
2051
+ headers: { 'content-type': 'application/json' },
2052
+ body: JSON.stringify({ sessionId: target, text: draft }),
2053
+ });
2054
+ const data = await res.json();
2055
+ if (data && data.ok) {
2056
+ setNotice(`已投递给 ${target.slice(-8)}`);
2057
+ setDraft('');
2058
+ } else {
2059
+ setError(`投递失败:${(data && data.error) || '未知'}`);
2060
+ }
2061
+ } catch (err) {
2062
+ setError(`投递失败:${String(err && err.message || err)}`);
2063
+ } finally { setBusy(false); }
2064
+ };
2065
+
2066
+ // 活跃任务线(按任务分列用;archived 的不显示,最多 8 列)。
2067
+ // 排序(2026-11-11 用户令「任务线排序要按活跃时间排,把最近活跃的任务线排在最前面」):
2068
+ // 按每条线的 lastAt 降序,活跃时间相同按 tasks.json 定义顺序兜底;
2069
+ // **排序之后再截 8 列**——否则最近活跃但排在 tasks.json 第 9 位的线永远看不到。
2070
+ // 只影响显示顺序,不改 tasks.json、不影响归线、不影响归档。
2071
+ const activeTaskEntries = orderLanesByActivity(
2072
+ (tasks && Array.isArray(tasks.tasks) ? tasks.tasks : [])
2073
+ .filter((task) => String(task.status || '').toLowerCase() !== 'archived')
2074
+ .map((task, index) => ({
2075
+ task,
2076
+ index,
2077
+ lastAt: (lanes[task.id || `task-${index}`] || {}).lastAt || 0,
2078
+ })),
2079
+ ).slice(0, 8);
2080
+ const activeTasks = activeTaskEntries.map((entry) => entry.task);
2081
+ const activeTaskCount = activeTasks.length;
2082
+ // 一页展示三段(看板 / 任务 / 待办),不再用 tab 切换(用户要求整合到一页)
2083
+ const body = h('div', { className: 'pr-room-body' },
2084
+ h('section', { className: 'pr-section pr-board-section' },
2085
+ h('div', { className: 'pr-section-head' },
2086
+ h('div', { className: 'pr-section-title' }, '公共看板'),
2087
+ h('span', { className: 'pr-board-count', title: 'board.md 字符数(含空白)' }, `${board.length.toLocaleString()} 字`),
2088
+ h('button', {
2089
+ className: 'pr-btn pr-board-toggle',
2090
+ onClick: () => setBoardEditing((value) => !value),
2091
+ title: boardEditing ? '切到渲染视图' : '切到编辑(直接改 board.md)',
2092
+ }, boardEditing ? '完成' : '编辑'),
2093
+ ),
2094
+ boardEditing
2095
+ ? h('div', null,
2096
+ h('div', { className: 'pr-hint' }, 'board.md 原文 —— 这里的改动会写回同一份文件。'),
2097
+ h('textarea', {
2098
+ className: 'pr-board pr-board-edit',
2099
+ value: board,
2100
+ spellCheck: false,
2101
+ onChange: (event) => { setBoard(event.target.value); setBoardDirty(true); },
2102
+ }),
2103
+ h('div', { className: 'pr-board-actions' },
2104
+ h('button', { className: 'pr-btn primary', onClick: saveBoard, disabled: busy || !boardDirty }, '保存看板'),
2105
+ boardDirty ? h('span', { className: 'pr-hint' }, '有未保存改动') : null,
2106
+ ),
2107
+ )
2108
+ : h('div', { className: 'pr-md-view' },
2109
+ board && board.trim() ? renderMarkdown(board) : h('div', { className: 'pr-hint' }, '(看板还是空的)'),
2110
+ ),
2111
+ ),
2112
+ h('section', { className: 'pr-section pr-tasks-section' },
2113
+ h('div', { className: 'pr-section-title' }, `任务线分列 · ${activeTaskCount}`),
2114
+ activeTaskCount === 0
2115
+ ? h('div', { className: 'pr-hint' }, '这个项目当前没有活跃任务线(tasks.json 里 status 均为 archived)。')
2116
+ : h('div', { className: 'pr-lanes' },
2117
+ activeTaskEntries.map((entry) => {
2118
+ const task = entry.task;
2119
+ // key 必须按 tasks.json 里的**原始位置**取:排序后下标会漂,任务线卡片会串内容
2120
+ const key = task.id || `task-${entry.index}`;
2121
+ const lane = lanes[key] || {};
2122
+ const state = String(task.status || '').toLowerCase();
2123
+ const tone = state.includes('block') ? 'var(--dsw-alias-state-warning-primary, #c9a227)' : 'var(--dsw-alias-brand-primary)';
2124
+ return h('div', { className: 'pr-lane', key },
2125
+ h('div', { className: 'pr-lane-head', title: `${task.title || ''} · ${(task.members || []).join('、')}` },
2126
+ h('span', { className: 'pr-lane-dot', style: { background: tone } }),
2127
+ h('span', { className: 'pr-lane-name' }, task.title || '(未命名任务)'),
2128
+ h('button', {
2129
+ className: 'pr-lane-archive',
2130
+ title: '归档这条任务线(归档后从这里消失,可在 tasks.json 恢复)',
2131
+ onClick: (event) => {
2132
+ event.stopPropagation();
2133
+ if (archiving && archiving.taskId === key && archiving.armed) {
2134
+ setTaskStatus(key, 'archived');
2135
+ return;
2136
+ }
2137
+ setArchiving({ taskId: key, armed: true });
2138
+ setTimeout(() => {
2139
+ setArchiving((cur) => (cur && cur.taskId === key ? null : cur));
2140
+ }, 4000);
2141
+ },
2142
+ }, archiving && archiving.taskId === key && archiving.armed ? '确认归档?' : '⋯'),
2143
+ ),
2144
+ // 2026-11-12:任务线不绑成员 → 没有成员行就不渲染,不再显示「无成员」
2145
+ ((task.members || []).length
2146
+ ? h('div', { className: 'pr-lane-sub' }, (task.members || []).join('、'))
2147
+ : null),
2148
+ h('div', { className: 'pr-lane-body' },
2149
+ lane.note
2150
+ ? h('div', { className: 'pr-hint' }, lane.note)
2151
+ : (lane.messages && lane.messages.length)
2152
+ ? lane.messages.map((msg, msgIndex) => h('div', {
2153
+ className: `pr-lane-msg pr-lane-msg-${msg.role}`,
2154
+ key: `${key}-${msg.seq || msgIndex}`,
2155
+ title: `${msg.from ? msg.from + ':' : ''}${msg.text}`,
2156
+ }, msg.text))
2157
+ : h('div', { className: 'pr-hint' }, lane.messages ? '暂无消息' : '加载中…'),
2158
+ ),
2159
+ );
2160
+ }),
2161
+ ),
2162
+ ),
2163
+ );
2164
+
2165
+ const box = conversationBox();
2166
+ return h('div', {
2167
+ className: 'pr-overlay',
2168
+ style: {
2169
+ position: 'fixed', zIndex: 900,
2170
+ left: `${box.left}px`, top: `${box.top}px`, right: 0, bottom: 0,
2171
+ display: 'flex', flexDirection: 'column',
2172
+ background: 'var(--dsw-alias-bg-base)',
2173
+ },
2174
+ },
2175
+ h('div', { className: 'pr-panel', onClick: (event) => event.stopPropagation() },
2176
+ h('div', { className: 'pr-panel-head' },
2177
+ h('span', { className: 'pr-panel-title' }, `#${project}`),
2178
+ h('div', { style: { flex: 1 } }),
2179
+ h('button', { className: 'pr-close-btn', title: '关闭房间(Esc)', onClick: () => roomBus.close() }, '✕ 关闭'),
2180
+ ),
2181
+ h('div', { className: 'pr-panel-body' },
2182
+ error ? h('div', { className: 'pr-err' }, error) : null,
2183
+ notice ? h('div', { className: 'pr-hint' }, notice) : null,
2184
+ body,
2185
+ ),
2186
+ h('div', { className: 'pr-panel-foot' },
2187
+ h('div', { className: 'pr-send-row' },
2188
+ h('select', {
2189
+ className: 'pr-select',
2190
+ value: target,
2191
+ onChange: (event) => pickTarget(event.target.value),
2192
+ },
2193
+ h('option', { value: '' }, members.length ? '切换收件人…' : '(暂无成员)'),
2194
+ members.map((m) => h('option', { key: m.id, value: m.id }, `${m.name}${m.running ? ' ●' : ''}`)),
2195
+ ),
2196
+ h('textarea', {
2197
+ className: 'pr-textarea',
2198
+ value: draft,
2199
+ placeholder: '给这个成员发一条消息(唤醒会话,走原生 prompt)',
2200
+ onChange: (event) => setDraft(event.target.value),
2201
+ onKeyDown: (event) => {
2202
+ // 回车直接发送(Shift+Enter 换行)—— 用户要求「回车发送」
2203
+ if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); send(); }
2204
+ },
2205
+ }),
2206
+ h('button', { className: 'pr-btn primary', disabled: busy || !target || !draft.trim(), onClick: send }, '投递'),
2207
+ ),
2208
+ ),
2209
+ ),
2210
+ );
2211
+ }
2212
+
2213
+ // ───────────────────────── 插件入口 ─────────────────────────
2214
+
2215
+ return {
2216
+ inject: ['slots'],
2217
+ // 测试钩子:让 smoke.mjs 能直接驱动房间页渲染(生产运行不读)
2218
+ // summarizeMessage 一并暴露:卡片摘要的「空摘要→灰条」回归要能直接断言(2026-11-11)。
2219
+ __test: { roomBus, conversationBox, summarizeMessage },
2220
+ apply(ctx) {
2221
+ clientCtx = ctx;
2222
+ // ⚠️ 必须先注入样式表再注册组件:这里曾经漏调 ensureStyle(),
2223
+ // 结果整份 CSS(浮层定位 / 侧栏滚动 / 项目行布局)全都没生效,界面错乱。
2224
+ const style = ensureStyle();
2225
+ ctx.effect(() => () => {
2226
+ try { style?.remove(); } catch { /* ignore */ }
2227
+ }, 'project-room: stylesheet');
2228
+
2229
+ const mount = (key, options, component) => {
2230
+ try {
2231
+ ctx.slots.inject(key, () => ctx.slots.register({ name: key, ...options }, component));
2232
+ } catch (error) {
2233
+ console.error(`[project-room] mount ${key} failed`, error);
2234
+ }
2235
+ };
2236
+
2237
+ // priority -2:低于本机既有侧栏插件(-1),确保本插件接管时更优先;
2238
+ // 想换回别的侧栏,卸掉本插件即可。
2239
+ mount('sidebar.workspaces', { priority: -2 }, ProjectSidebar);
2240
+ mount('shell.overlay', { id: 'project-room-page', order: 92 }, ProjectRoom);
2241
+
2242
+ console.log('[project-room] client 半已挂载');
2243
+ },
2244
+ };
2245
+ },
2246
+ });