dsh-multi-session 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,1971 @@
1
+ /**
2
+ * dsh-multi-session — Client 半边。
3
+ *
4
+ * Phase 1:在会话内注册一个「多会话」视图,把所有会话按工作区分组列出来,
5
+ * 一眼看到谁在跑、谁刚跑完还没看、谁闲着;点一下直接跳过去。
6
+ * 针对的痛点:开了多个会话在跑,跑完了不知道,还得一个个点开找。
7
+ *
8
+ * ── 数据来源(2026-10-30 实测,别再猜)────────────────────────────────
9
+ * props.useSessions(selector) →
10
+ * { ids: SessionId[], byId: Record<SessionId, SessionMeta>,
11
+ * phase: 'ready'|..., projectionsBySession: Record<SessionId, {values,state,error}> }
12
+ *
13
+ * SessionMeta = { id, title, displayTitle, running: boolean,
14
+ * retainedBy, blank, updatedAt: number(ms), formatStatus,
15
+ * projectionValues, cwd }
16
+ * ← running / cwd / updatedAt / displayTitle 都在这儿,这是主数据源。
17
+ *
18
+ * projectionsBySession[id].values = { title, goal, tokenUsage, contextPressure,
19
+ * inbox, sessionStats, turnOutline[], agentPreset, userQuestions, todos,
20
+ * modelSelection, subagentCatalog[], workingDirectory, ... }
21
+ * ← 轮数、子代理数、token、模型从这儿取,只当补充。
22
+ *
23
+ * props.useWorkspaces(selector) →
24
+ * { items: [{ workspaceId, path, title, sessionIds[], createdAt, updatedAt }],
25
+ * archivedSessionIds: SessionId[], pinnedSessionIds: SessionId[],
26
+ * state, phase, error }
27
+ * ← 只有分组信息和归档名单,没有运行态。
28
+ *
29
+ * 两个 hook 都是实时推送快照:不轮询、不自建库、不落盘。
30
+ *
31
+ * ── 「待查看」标记 ─────────────────────────────────────────────────
32
+ * running 由 true → false 的瞬间标记「刚完成」(内存态 + localStorage 持久化)。
33
+ * 只做面板里的被动标记,不弹任何浮层/通知/标题角标。
34
+ * 「点开就算看过」由 finishedAt 清除 + lastSeen 基线推进两件事一起保证。
35
+ */
36
+
37
+ window.__ModuleLoader__.load({
38
+ id: 'dsh-multi-session',
39
+ factory(require) {
40
+ const React = require('react')
41
+ const h = React.createElement
42
+
43
+ const TAG = '[dsh-multi-session]'
44
+ const ROUTE = '/plugins/dsh-multi-session/probe'
45
+ const VIEW_ID = 'multi-session'
46
+
47
+ /**
48
+ * 开发期诊断层开关:Host 在 index.html 注入 window.__DSH_MULTI_SESSION_DEV__。
49
+ * 正式安装(未开 dev)时为 false,不注册诊断浮层,也不往 /probe 上报。
50
+ */
51
+ const DIAG = (() => {
52
+ try {
53
+ return typeof window !== 'undefined' && window.__DSH_MULTI_SESSION_DEV__ === true
54
+ } catch {
55
+ return false
56
+ }
57
+ })()
58
+
59
+ /** 诊断代号:每次改动递增,用来区分浏览器里跑的是哪一代 bundle。 */
60
+ const BUILD = 'panel-v51'
61
+
62
+ /** apply() 的 ctx,用于点击跳转。 */
63
+ let rootCtx
64
+
65
+ /** 选择器必须是模块级稳定引用,否则快照订阅每帧重订(轻则抖,重则死循环)。 */
66
+ const identity = (state) => state
67
+
68
+ const EMPTY = Object.freeze([])
69
+
70
+ /** 用户当前正在看的会话 id,由 ActiveSessionProbe 在会话头部写入。 */
71
+ let activeSessionId = ''
72
+
73
+ /** Phase 2 探针只跑一次。 */
74
+ let retainProbed = false
75
+ let digestProbed = false
76
+ let promptProbed = false
77
+
78
+ /* ------------------------------------------------------------------ *
79
+ * 完成检测
80
+ * ------------------------------------------------------------------ */
81
+
82
+ const previousRunning = new Map()
83
+ const finishedAt = new Map()
84
+ /** 首帧只当基线:不能把历史会话全标成"刚完成"。 */
85
+ let primed = false
86
+
87
+ /**
88
+ * 「哪些会话完成了、我还没看」必须活过页面刷新。
89
+ * 只放内存的话,开发期一次热重载就把待查看列表清空,等于这个功能白做。
90
+ */
91
+ const FINISHED_STORE = 'dsh-multi-session:finished:v1'
92
+ const FINISHED_STORE_MAX = 200
93
+ /** 上次看到每个会话时的 updatedAt,用来补标"关页面期间完成"的会话。 */
94
+ const SEEN_STORE = 'dsh-multi-session:seen:v1'
95
+ const SEEN_STORE_MAX = 400
96
+ const lastSeen = new Map()
97
+ /** 冷启动补标最多补这么多个,避免几百个子代理会话把浮标刷爆。 */
98
+ const CATCHUP_MAX = 10
99
+ let lastSeenWriteAt = 0
100
+
101
+ function loadFinished() {
102
+ try {
103
+ const raw = window.localStorage.getItem(FINISHED_STORE)
104
+ if (raw === null || raw === '') return
105
+ const parsed = JSON.parse(raw)
106
+ if (parsed === null || typeof parsed !== 'object') return
107
+ for (const key of Object.keys(parsed)) {
108
+ const value = Number(parsed[key])
109
+ if (Number.isFinite(value) && value > 0) finishedAt.set(key, value)
110
+ }
111
+ } catch {
112
+ // localStorage 不可用(隐私模式等)就退化成纯内存,不影响主功能。
113
+ }
114
+ }
115
+
116
+ function saveFinished() {
117
+ try {
118
+ const entries = Array.from(finishedAt.entries())
119
+ const kept = entries.length > FINISHED_STORE_MAX ? entries.slice(-FINISHED_STORE_MAX) : entries
120
+ const plain = {}
121
+ for (const [key, value] of kept) plain[key] = value
122
+ window.localStorage.setItem(FINISHED_STORE, JSON.stringify(plain))
123
+ } catch {
124
+ // 同上:存不下就只保留内存态。
125
+ }
126
+ }
127
+
128
+ loadFinished()
129
+
130
+ function loadSeen() {
131
+ try {
132
+ const raw = window.localStorage.getItem(SEEN_STORE)
133
+ if (raw === null || raw === '') return
134
+ const parsed = JSON.parse(raw)
135
+ if (parsed === null || typeof parsed !== 'object') return
136
+ for (const key of Object.keys(parsed)) {
137
+ const value = Number(parsed[key])
138
+ if (Number.isFinite(value) && value > 0) lastSeen.set(key, value)
139
+ }
140
+ } catch {
141
+ // 同上,读不到就退化成"没有基线",不会误标。
142
+ }
143
+ }
144
+
145
+ function saveSeen(force) {
146
+ const now = Date.now()
147
+ if (!force && now - lastSeenWriteAt < 30000) return
148
+ lastSeenWriteAt = now
149
+ try {
150
+ let entries = Array.from(lastSeen.entries())
151
+ if (entries.length > SEEN_STORE_MAX) {
152
+ // 超量时保留"最近更新"的那批,而不是插入顺序的尾巴——
153
+ // 否则先遍历到的会话基线会被丢掉,冷启动补标对它们永远失效。
154
+ entries.sort((left, right) => right[1] - left[1])
155
+ entries = entries.slice(0, SEEN_STORE_MAX)
156
+ }
157
+ const plain = {}
158
+ for (const [key, value] of entries) plain[key] = value
159
+ window.localStorage.setItem(SEEN_STORE, JSON.stringify(plain))
160
+ } catch {
161
+ // 存不下不影响主功能。
162
+ }
163
+ }
164
+
165
+ loadSeen()
166
+
167
+ /** 钉住的会话:退出面板再进来要还在,否则每次都得重新挑三个。 */
168
+ const PINNED_STORE = 'dsh-multi-session:pinned:v1'
169
+
170
+ function loadPinned() {
171
+ try {
172
+ const raw = window.localStorage.getItem(PINNED_STORE)
173
+ if (raw === null || raw === '') return []
174
+ const parsed = JSON.parse(raw)
175
+ if (!Array.isArray(parsed)) return []
176
+ const ids = []
177
+ for (const value of parsed) {
178
+ if (typeof value === 'string' && value !== '' && ids.indexOf(value) < 0) ids.push(value)
179
+ }
180
+ return ids.slice(0, 3)
181
+ } catch {
182
+ // 读不到就当没钉过,不影响主功能。
183
+ return []
184
+ }
185
+ }
186
+
187
+ function savePinned(ids) {
188
+ try {
189
+ window.localStorage.setItem(PINNED_STORE, JSON.stringify(Array.isArray(ids) ? ids : []))
190
+ } catch {
191
+ // 存不下就只在本次会话里生效。
192
+ }
193
+ }
194
+
195
+ /**
196
+ * 冷启动补标:页面关着的那段时间里跑完的会话,光靠"看着 running 变 false"是发现不了的。
197
+ * 用上次记住的 updatedAt 对比——时间往前走了、现在不在跑、又不是当前会话,就当作"刚完成"。
198
+ */
199
+ function catchUpFinished(entries) {
200
+ const candidates = []
201
+ for (const entry of entries) {
202
+ if (entry.blank || entry.running) continue
203
+ const seen = lastSeen.get(entry.id)
204
+ if (seen === undefined) continue
205
+ if (!(entry.updatedAt > seen)) continue
206
+ if (finishedAt.has(entry.id)) continue
207
+ candidates.push(entry)
208
+ }
209
+ if (candidates.length === 0) return false
210
+ candidates.sort((left, right) => right.updatedAt - left.updatedAt)
211
+ for (const entry of candidates.slice(0, CATCHUP_MAX)) {
212
+ finishedAt.set(entry.id, entry.updatedAt)
213
+ }
214
+ saveFinished()
215
+ return true
216
+ }
217
+
218
+ function detectFinished(entries) {
219
+ let changed = false
220
+ const coldStart = !primed
221
+ // 必须先补标再刷新基线:catchUpFinished 依赖"旧 lastSeen vs 新 updatedAt"的差,
222
+ // 如果先更新 lastSeen,差值就被抹平,补标永远为空。
223
+ if (coldStart) {
224
+ primed = true
225
+ if (catchUpFinished(entries)) changed = true
226
+ }
227
+ for (const entry of entries) {
228
+ const previous = previousRunning.get(entry.id)
229
+ previousRunning.set(entry.id, entry.running)
230
+ if (entry.updatedAt > 0) lastSeen.set(entry.id, entry.updatedAt)
231
+ if (coldStart) continue
232
+ if (previous === true && entry.running === false && !finishedAt.has(entry.id)) {
233
+ finishedAt.set(entry.id, Date.now())
234
+ changed = true
235
+ }
236
+ }
237
+ saveSeen(coldStart)
238
+ if (changed) saveFinished()
239
+ return changed
240
+ }
241
+
242
+ /* ------------------------------------------------------------------ *
243
+ * 快照解析
244
+ * ------------------------------------------------------------------ */
245
+
246
+ function basename(path) {
247
+ if (!path) return ''
248
+ const parts = String(path).split(/[\\/]+/).filter(Boolean)
249
+ return parts.length > 0 ? parts[parts.length - 1] : String(path)
250
+ }
251
+
252
+ function readNumber(value) {
253
+ return typeof value === 'number' && isFinite(value) ? value : 0
254
+ }
255
+
256
+ function tokenTotal(usage) {
257
+ if (!usage || typeof usage !== 'object') return 0
258
+ return (
259
+ readNumber(usage.uncachedInputTokens) +
260
+ readNumber(usage.outputTokens) +
261
+ readNumber(usage.cacheReadTokens) +
262
+ readNumber(usage.cacheWriteTokens)
263
+ )
264
+ }
265
+
266
+ /**
267
+ * 模型名。实测形状(2026-10-30):
268
+ * { lastUsed: { provider, model, reasoningEffort }, next: { provider, model, reasoningEffort } }
269
+ * 优先取 next(即将用于下一轮的),退回 lastUsed,再退回扁平字段。
270
+ */
271
+ function modelLabel(selection) {
272
+ if (!selection) return ''
273
+ if (typeof selection === 'string') return selection
274
+ if (typeof selection !== 'object') return ''
275
+ const layers = [selection.next, selection.lastUsed, selection]
276
+ for (const layer of layers) {
277
+ if (!layer || typeof layer !== 'object') continue
278
+ for (const key of ['model', 'modelId', 'id', 'name']) {
279
+ const candidate = layer[key]
280
+ if (typeof candidate === 'string' && candidate) return candidate
281
+ }
282
+ }
283
+ return ''
284
+ }
285
+
286
+ /** 工作区索引:sessionId → { workspaceId, title, path },外加归档名单。 */
287
+ function readWorkspaces(workspaces) {
288
+ const bySession = new Map()
289
+ const byPath = new Map()
290
+ const groups = []
291
+ const archived = new Set()
292
+ if (!workspaces || typeof workspaces !== 'object') {
293
+ return { bySession, byPath, groups, archived }
294
+ }
295
+ const items = Array.isArray(workspaces.items) ? workspaces.items : EMPTY
296
+ for (const item of items) {
297
+ if (!item || typeof item !== 'object') continue
298
+ const group = {
299
+ workspaceId: item.workspaceId,
300
+ path: typeof item.path === 'string' ? item.path : '',
301
+ title: typeof item.title === 'string' && item.title ? item.title : '',
302
+ sessionIds: Array.isArray(item.sessionIds) ? item.sessionIds : EMPTY,
303
+ }
304
+ groups.push(group)
305
+ for (const sessionId of group.sessionIds) bySession.set(sessionId, group)
306
+ // 子代理会话(parentId 非空)通常不进任何 workspace 的 sessionIds,
307
+ // 但它们带着真实 cwd;按 cwd 归回工作区,比单开一个"其他"桶更符合直觉。
308
+ if (group.path) {
309
+ const key = group.path.toLowerCase()
310
+ if (!byPath.has(key)) byPath.set(key, group)
311
+ }
312
+ }
313
+ if (Array.isArray(workspaces.archivedSessionIds)) {
314
+ for (const sessionId of workspaces.archivedSessionIds) archived.add(sessionId)
315
+ }
316
+ return { bySession, byPath, groups, archived }
317
+ }
318
+
319
+ /**
320
+ * 会话条目。主数据来自 useSessions().byId,投影值来自 projectionsBySession。
321
+ * 不遍历 byId 全表——以 ids 为准,避免把残留键也画出来。
322
+ */
323
+ function readEntries(sessions, workspaceIndex) {
324
+ if (!sessions || typeof sessions !== 'object') return EMPTY
325
+ const byId = sessions.byId
326
+ if (!byId || typeof byId !== 'object') return EMPTY
327
+ const ids = Array.isArray(sessions.ids) ? sessions.ids : Object.keys(byId)
328
+ const projections =
329
+ sessions.projectionsBySession && typeof sessions.projectionsBySession === 'object'
330
+ ? sessions.projectionsBySession
331
+ : {}
332
+
333
+ const entries = []
334
+ for (const sessionId of ids) {
335
+ const meta = byId[sessionId]
336
+ if (!meta || typeof meta !== 'object') continue
337
+ const projection = projections[sessionId]
338
+ const values =
339
+ projection && projection.values && typeof projection.values === 'object'
340
+ ? projection.values
341
+ : meta.projectionValues && typeof meta.projectionValues === 'object'
342
+ ? meta.projectionValues
343
+ : null
344
+
345
+ const cwd = typeof meta.cwd === 'string' ? meta.cwd : values && values.workingDirectory
346
+ const cwdText = typeof cwd === 'string' ? cwd : ''
347
+ const group =
348
+ workspaceIndex.bySession.get(sessionId) ||
349
+ (cwdText ? workspaceIndex.byPath.get(cwdText.toLowerCase()) : undefined)
350
+ const turnOutline = values && Array.isArray(values.turnOutline) ? values.turnOutline : EMPTY
351
+ const subagents = values && Array.isArray(values.subagentCatalog) ? values.subagentCatalog : EMPTY
352
+
353
+ entries.push({
354
+ id: typeof meta.id === 'string' ? meta.id : sessionId,
355
+ title:
356
+ (typeof meta.displayTitle === 'string' && meta.displayTitle) ||
357
+ (typeof meta.title === 'string' && meta.title) ||
358
+ (values && typeof values.title === 'string' && values.title) ||
359
+ '(无标题)',
360
+ running: meta.running === true,
361
+ updatedAt: readNumber(meta.updatedAt),
362
+ cwd: cwdText,
363
+ blank: meta.blank === true,
364
+ parentId: typeof meta.parentId === 'string' ? meta.parentId : '',
365
+ origin: typeof meta.origin === 'string' ? meta.origin : '',
366
+ archived: workspaceIndex.archived.has(sessionId),
367
+ groupId: group ? group.workspaceId : '',
368
+ groupTitle: group ? group.title || basename(group.path) || '工作区' : '',
369
+ groupPath: group ? group.path : '',
370
+ turns: turnOutline.length,
371
+ subagents: subagents.length,
372
+ tokens: tokenTotal(values && values.tokenUsage),
373
+ model: modelLabel(values && values.modelSelection),
374
+ finishedAt: finishedAt.get(sessionId) || 0,
375
+ })
376
+ }
377
+ return entries
378
+ }
379
+
380
+ /* ------------------------------------------------------------------ *
381
+ * 交互
382
+ * ------------------------------------------------------------------ */
383
+
384
+ function getService(name) {
385
+ if (!rootCtx) return undefined
386
+ try {
387
+ return typeof rootCtx.get === 'function' ? rootCtx.get(name) : rootCtx[name]
388
+ } catch (cause) {
389
+ return undefined
390
+ }
391
+ }
392
+
393
+ /**
394
+ * 跳转到某个会话。SessionTarget 的公开形状没有文档,两种写法都试。
395
+ * 失败只告警,不打断面板。
396
+ */
397
+ function openSession(sessionId) {
398
+ const ui = getService('uiWorkspace')
399
+ if (!ui || typeof ui.openSession !== 'function') {
400
+ console.warn(`${TAG} uiWorkspace.openSession unavailable`)
401
+ return
402
+ }
403
+ // SessionTarget = SessionId | SubagentAddress:普通会话就是裸 id 字符串,
404
+ // 传 { sessionId } 这种对象既不是合法 id 也不是地址,会被当成"没有目标"而落到新建会话。
405
+ // 子代理会话要换成它自己的地址对象才认得。
406
+ let target = sessionId
407
+ try {
408
+ const sessions = getService('sessions')
409
+ if (sessions && typeof sessions.subagentAddress === 'function') {
410
+ const address = sessions.subagentAddress(sessionId)
411
+ if (address !== undefined && address !== null) target = address
412
+ }
413
+ } catch {
414
+ // 拿不到地址就按普通会话处理。
415
+ }
416
+ try {
417
+ ui.openSession(target)
418
+ } catch (cause) {
419
+ try {
420
+ ui.openSession(sessionId)
421
+ } catch (fallbackCause) {
422
+ console.warn(`${TAG} openSession failed`, cause, fallbackCause)
423
+ }
424
+ }
425
+ }
426
+
427
+ function post(payload) {
428
+ try {
429
+ fetch(ROUTE, {
430
+ method: 'POST',
431
+ headers: { 'content-type': 'application/json' },
432
+ body: JSON.stringify(payload),
433
+ }).catch(() => {})
434
+ } catch (cause) {
435
+ /* 诊断失败绝不影响面板 */
436
+ }
437
+ }
438
+
439
+ /* ------------------------------------------------------------------ *
440
+ * 格式化
441
+ * ------------------------------------------------------------------ */
442
+
443
+ function relativeTime(timestamp) {
444
+ if (!timestamp) return ''
445
+ const delta = Date.now() - timestamp
446
+ if (delta < 0) return '刚刚'
447
+ const seconds = Math.floor(delta / 1000)
448
+ if (seconds < 45) return '刚刚'
449
+ const minutes = Math.floor(seconds / 60)
450
+ if (minutes < 60) return `${Math.max(minutes, 1)} 分钟前`
451
+ const hours = Math.floor(minutes / 60)
452
+ if (hours < 24) return `${hours} 小时前`
453
+ const days = Math.floor(hours / 24)
454
+ if (days < 30) return `${days} 天前`
455
+ return `${Math.floor(days / 30)} 个月前`
456
+ }
457
+
458
+ function formatTokens(tokens) {
459
+ if (!tokens) return ''
460
+ if (tokens < 1000) return `${tokens} tok`
461
+ if (tokens < 1000000) return `${(tokens / 1000).toFixed(1)}k tok`
462
+ return `${(tokens / 1000000).toFixed(2)}M tok`
463
+ }
464
+
465
+ /** 排序权重:运行中 → 刚完成 → 其余。 */
466
+ function rankOf(entry) {
467
+ if (entry.running) return 0
468
+ if (entry.finishedAt) return 1
469
+ return 2
470
+ }
471
+
472
+ /* ------------------------------------------------------------------ *
473
+ * 样式:只用 DSH 主题 alias token,深浅色自动跟随
474
+ * ------------------------------------------------------------------ */
475
+
476
+ const RUNNING_COLOR = '#22a06b'
477
+ const FINISHED_COLOR = '#3b6cf6'
478
+
479
+ const S = {
480
+ root: {
481
+ display: 'flex',
482
+ flexDirection: 'column',
483
+ height: '100%',
484
+ minHeight: 0,
485
+ background: 'var(--dsw-alias-bg-base)',
486
+ color: 'var(--dsw-alias-label-primary)',
487
+ fontSize: 13,
488
+ },
489
+ header: {
490
+ display: 'flex',
491
+ alignItems: 'center',
492
+ gap: 10,
493
+ padding: '10px 14px',
494
+ borderBottom: '1px solid var(--dsw-alias-border-l1)',
495
+ flex: '0 0 auto',
496
+ flexWrap: 'wrap',
497
+ },
498
+ headerTitle: { fontWeight: 600, fontSize: 14 },
499
+ counter: {
500
+ display: 'inline-flex',
501
+ alignItems: 'center',
502
+ gap: 5,
503
+ padding: '2px 8px',
504
+ borderRadius: 999,
505
+ background: 'var(--dsw-alias-bg-layer-1)',
506
+ border: '1px solid var(--dsw-alias-border-l1)',
507
+ fontSize: 12,
508
+ color: 'var(--dsw-alias-label-secondary)',
509
+ },
510
+ dot: { width: 7, height: 7, borderRadius: '50%', flex: '0 0 auto' },
511
+ spacer: { flex: '1 1 auto' },
512
+ button: {
513
+ border: '1px solid var(--dsw-alias-border-l1)',
514
+ background: 'var(--dsw-alias-bg-layer-1)',
515
+ color: 'var(--dsw-alias-label-primary)',
516
+ borderRadius: 6,
517
+ padding: '3px 9px',
518
+ fontSize: 12,
519
+ cursor: 'pointer',
520
+ },
521
+ body: { flex: '1 1 auto', overflowY: 'auto', minHeight: 0, padding: '6px 0 20px' },
522
+ group: { padding: '8px 14px 2px' },
523
+ groupHeader: {
524
+ display: 'flex',
525
+ alignItems: 'center',
526
+ gap: 8,
527
+ marginBottom: 6,
528
+ color: 'var(--dsw-alias-label-secondary)',
529
+ fontSize: 12,
530
+ },
531
+ groupName: { fontWeight: 600, color: 'var(--dsw-alias-label-primary)' },
532
+ groupPath: {
533
+ fontSize: 11,
534
+ opacity: 0.65,
535
+ overflow: 'hidden',
536
+ textOverflow: 'ellipsis',
537
+ whiteSpace: 'nowrap',
538
+ maxWidth: 420,
539
+ },
540
+ card: {
541
+ display: 'flex',
542
+ gap: 9,
543
+ alignItems: 'flex-start',
544
+ padding: '8px 10px',
545
+ marginBottom: 6,
546
+ borderRadius: 8,
547
+ border: '1px solid var(--dsw-alias-border-l1)',
548
+ background: 'var(--dsw-alias-bg-layer-1)',
549
+ cursor: 'pointer',
550
+ transition: 'background 120ms ease, border-color 120ms ease',
551
+ },
552
+ cardRunning: { borderColor: 'rgba(34,160,107,0.45)' },
553
+ cardFinished: { borderColor: 'rgba(59,108,246,0.45)' },
554
+ cardLast: {
555
+ marginTop: 2,
556
+ fontSize: 11,
557
+ lineHeight: 1.45,
558
+ color: 'var(--dsw-alias-label-secondary)',
559
+ opacity: 0.9,
560
+ whiteSpace: 'nowrap',
561
+ overflow: 'hidden',
562
+ textOverflow: 'ellipsis',
563
+ },
564
+ cardBody: { display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0, flex: '1 1 auto' },
565
+ cardTitleRow: { display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 },
566
+ cardTitle: {
567
+ fontWeight: 500,
568
+ overflow: 'hidden',
569
+ textOverflow: 'ellipsis',
570
+ whiteSpace: 'nowrap',
571
+ minWidth: 0,
572
+ },
573
+ badge: {
574
+ flex: '0 0 auto',
575
+ fontSize: 10.5,
576
+ lineHeight: '16px',
577
+ padding: '0 6px',
578
+ borderRadius: 999,
579
+ border: '1px solid currentColor',
580
+ opacity: 0.9,
581
+ },
582
+ metaRow: {
583
+ display: 'flex',
584
+ flexWrap: 'wrap',
585
+ gap: 8,
586
+ fontSize: 11.5,
587
+ color: 'var(--dsw-alias-label-secondary)',
588
+ },
589
+ empty: {
590
+ padding: '40px 20px',
591
+ textAlign: 'center',
592
+ color: 'var(--dsw-alias-label-secondary)',
593
+ fontSize: 12.5,
594
+ lineHeight: 1.7,
595
+ },
596
+ /* 并排视图(Phase 2) */
597
+ split: {
598
+ display: 'flex',
599
+ flex: 1,
600
+ minHeight: 0,
601
+ gap: 10,
602
+ padding: '0 12px 12px',
603
+ },
604
+ listPane: {
605
+ display: 'flex',
606
+ flexDirection: 'column',
607
+ minHeight: 0,
608
+ maxHeight: 'min(56vh, 560px)',
609
+ overflow: 'auto',
610
+ scrollbarWidth: 'thin',
611
+ scrollbarColor: 'var(--dsw-alias-border-l1) transparent',
612
+ flex: '0 0 300px',
613
+ borderRight: '1px solid var(--dsw-alias-border-l1)',
614
+ paddingRight: 10,
615
+ },
616
+ listPaneWide: {
617
+ flex: 1,
618
+ borderRight: 'none',
619
+ paddingRight: 0,
620
+ },
621
+ board: {
622
+ display: 'flex',
623
+ flex: 1,
624
+ minWidth: 0,
625
+ minHeight: 0,
626
+ gap: 10,
627
+ overflowX: 'auto',
628
+ },
629
+ boardCol: {
630
+ display: 'flex',
631
+ flexDirection: 'column',
632
+ flex: '1 1 0',
633
+ minWidth: 330,
634
+ minHeight: 0,
635
+ border: '1px solid var(--dsw-alias-border-l1)',
636
+ borderRadius: 10,
637
+ background: 'var(--dsw-alias-bg-layer-1)',
638
+ overflow: 'hidden',
639
+ },
640
+ boardColHead: {
641
+ display: 'flex',
642
+ alignItems: 'center',
643
+ gap: 6,
644
+ padding: '7px 9px',
645
+ borderBottom: '1px solid var(--dsw-alias-border-l1)',
646
+ background: 'var(--dsw-alias-bg-layer-2)',
647
+ flex: '0 0 auto',
648
+ },
649
+ boardColTitle: {
650
+ flex: 1,
651
+ minWidth: 0,
652
+ fontSize: 12,
653
+ fontWeight: 600,
654
+ color: 'var(--dsw-alias-label-primary)',
655
+ overflow: 'hidden',
656
+ textOverflow: 'ellipsis',
657
+ whiteSpace: 'nowrap',
658
+ cursor: 'pointer',
659
+ },
660
+ boardColMeta: {
661
+ flex: '0 0 auto',
662
+ fontSize: 10.5,
663
+ color: 'var(--dsw-alias-label-secondary)',
664
+ },
665
+ boardScroll: {
666
+ flex: 1,
667
+ minHeight: 0,
668
+ // 父级高度一旦是 auto(插槽容器不给确定高度),flex:1 就约束不住,
669
+ // 整列会跟着消息条数一直长高、永远不出滚动条。这里用视口兜底封顶。
670
+ // 高度按视口百分比算(44vh,最多 420px):比用 100vh 减常数更可控,
671
+ // 小窗口不会算出负数,大屏也不会一列就顶满整屏。
672
+ maxHeight: 'min(44vh, 420px)',
673
+ minHeight: '120px',
674
+ overflowY: 'auto',
675
+ // 显式声明滚动条宽度/颜色:默认细滚动条在部分主题下几乎看不见。
676
+ scrollbarWidth: 'thin',
677
+ scrollbarColor: 'var(--dsw-alias-border-l1) transparent',
678
+ padding: '8px 9px',
679
+ display: 'flex',
680
+ flexDirection: 'column',
681
+ gap: 7,
682
+ },
683
+ digestItem: {
684
+ display: 'flex',
685
+ flexDirection: 'column',
686
+ gap: 2,
687
+ },
688
+ digestMsgUser: {
689
+ display: 'flex',
690
+ flexDirection: 'column',
691
+ gap: 3,
692
+ padding: '7px 9px',
693
+ borderRadius: 8,
694
+ borderLeft: '2px solid var(--dsw-alias-brand-primary)',
695
+ background: 'var(--dsw-alias-bg-layer-2)',
696
+ },
697
+ digestMsgAssistant: {
698
+ display: 'flex',
699
+ flexDirection: 'column',
700
+ gap: 3,
701
+ padding: '7px 9px',
702
+ borderRadius: 8,
703
+ borderLeft: `2px solid ${RUNNING_COLOR}`,
704
+ background: 'var(--dsw-alias-bg-base)',
705
+ },
706
+ moreBtn: {
707
+ alignSelf: 'stretch',
708
+ marginBottom: 6,
709
+ padding: '4px 8px',
710
+ fontSize: 11,
711
+ color: 'var(--dsw-alias-label-secondary)',
712
+ background: 'var(--dsw-alias-bg-layer-2)',
713
+ border: '1px solid var(--dsw-alias-border-l1)',
714
+ borderRadius: 6,
715
+ cursor: 'pointer',
716
+ },
717
+ sendBox: {
718
+ borderTop: '1px solid var(--dsw-alias-border-l1)',
719
+ padding: '7px 9px',
720
+ display: 'flex',
721
+ flexDirection: 'column',
722
+ gap: 5,
723
+ flex: '0 0 auto',
724
+ },
725
+ sendInput: {
726
+ width: '100%',
727
+ boxSizing: 'border-box',
728
+ resize: 'none',
729
+ padding: '5px 7px',
730
+ fontSize: 12,
731
+ lineHeight: 1.45,
732
+ fontFamily: 'inherit',
733
+ color: 'var(--dsw-alias-label-primary)',
734
+ background: 'var(--dsw-alias-bg-base)',
735
+ border: '1px solid var(--dsw-alias-border-l1)',
736
+ borderRadius: 6,
737
+ outline: 'none',
738
+ },
739
+ sendRow: { display: 'flex', alignItems: 'center', gap: 6 },
740
+ sendBtn: {
741
+ padding: '3px 10px',
742
+ fontSize: 11,
743
+ color: 'var(--dsw-alias-label-primary-foreground)',
744
+ background: 'var(--dsw-alias-brand-primary)',
745
+ border: '1px solid transparent',
746
+ borderRadius: 6,
747
+ cursor: 'pointer',
748
+ },
749
+ sendStop: {
750
+ padding: '3px 10px',
751
+ fontSize: 11,
752
+ color: 'var(--dsw-alias-label-secondary)',
753
+ background: 'var(--dsw-alias-bg-layer-2)',
754
+ border: '1px solid var(--dsw-alias-border-l1)',
755
+ borderRadius: 6,
756
+ cursor: 'pointer',
757
+ },
758
+ sendNote: { fontSize: 10, color: 'var(--dsw-alias-label-secondary)', marginLeft: 'auto' },
759
+ digestRole: {
760
+ fontSize: 10,
761
+ fontWeight: 600,
762
+ letterSpacing: '0.02em',
763
+ textTransform: 'uppercase',
764
+ },
765
+ digestText: {
766
+ fontSize: 12,
767
+ lineHeight: 1.5,
768
+ color: 'var(--dsw-alias-label-primary)',
769
+ whiteSpace: 'pre-wrap',
770
+ wordBreak: 'break-word',
771
+ },
772
+ pinBtn: {
773
+ flex: '0 0 auto',
774
+ border: 'none',
775
+ background: 'transparent',
776
+ color: 'var(--dsw-alias-label-secondary)',
777
+ fontSize: 12,
778
+ lineHeight: 1,
779
+ cursor: 'pointer',
780
+ padding: '0 2px',
781
+ borderRadius: 4,
782
+ },
783
+ pinBtnActive: {
784
+ color: 'var(--dsw-alias-brand-primary)',
785
+ },
786
+ boardEmpty: {
787
+ display: 'flex',
788
+ alignItems: 'center',
789
+ justifyContent: 'center',
790
+ flex: 1,
791
+ fontSize: 12,
792
+ color: 'var(--dsw-alias-label-secondary)',
793
+ padding: 20,
794
+ textAlign: 'center',
795
+ },
796
+ }
797
+
798
+ /* ------------------------------------------------------------------ *
799
+ * 面板
800
+ * ------------------------------------------------------------------ */
801
+
802
+ function StatusDot(props) {
803
+ const color = props.running ? RUNNING_COLOR : props.finished ? FINISHED_COLOR : 'var(--dsw-alias-label-secondary)'
804
+ const style = { ...S.dot, background: color, marginTop: 5 }
805
+ if (props.running) {
806
+ style.boxShadow = `0 0 0 3px rgba(34,160,107,0.16)`
807
+ }
808
+ return h('span', { style })
809
+ }
810
+
811
+ /**
812
+ * 列表卡片上的一行「最后说了什么」。
813
+ * 只对运行中 / 刚完成的卡片挂载,且只拉一次(不轮询)——目的是不用钉住也能知道结果,
814
+ * 所以刻意用很小的 limit,别让列表本身变成 15 个并发重放。
815
+ */
816
+ function LastLine(props) {
817
+ const entry = props.entry
818
+ // 第五个参数 false:卡片末行只拉一次,不参与慢轮询。
819
+ // 否则 15 张卡片会各自每 8 秒打一次 /digest,列表页瞬间变成负载源。
820
+ const digest = useDigest(entry.id, false, 3, 0, false)
821
+ if (digest.error !== '' || digest.items.length === 0) return null
822
+ let text = ''
823
+ for (let index = digest.items.length - 1; index >= 0; index -= 1) {
824
+ const item = digest.items[index]
825
+ if (item && item.kind === 'assistant' && item.text) {
826
+ text = String(item.text)
827
+ break
828
+ }
829
+ }
830
+ if (text === '') {
831
+ // 没有助手正文时退到最后一条(现在只可能是"我"的消息),
832
+ // 工具行在 Host 侧已经被丢掉,这里不会出现。
833
+ const last = digest.items[digest.items.length - 1]
834
+ if (last && last.text) text = `[我] ${last.text}`
835
+ }
836
+ if (text === '') return null
837
+ return h('div', { style: S.cardLast }, clip(text.replace(/\s+/g, ' ').trim(), 160))
838
+ }
839
+
840
+ function SessionCard(props) {
841
+ const entry = props.entry
842
+ const meta = []
843
+ if (entry.running) meta.push('运行中')
844
+ else if (entry.finishedAt) meta.push(`完成于 ${relativeTime(entry.finishedAt)}`)
845
+ else if (entry.updatedAt) meta.push(relativeTime(entry.updatedAt))
846
+ if (entry.turns) meta.push(`${entry.turns} 轮`)
847
+ if (entry.subagents) meta.push(`${entry.subagents} 子代理`)
848
+ if (entry.tokens) meta.push(formatTokens(entry.tokens))
849
+ if (entry.model) meta.push(entry.model)
850
+
851
+ const cardStyle = { ...S.card }
852
+ if (entry.running) Object.assign(cardStyle, S.cardRunning)
853
+ else if (entry.finishedAt) Object.assign(cardStyle, S.cardFinished)
854
+
855
+ return h(
856
+ 'div',
857
+ {
858
+ style: cardStyle,
859
+ onClick: () => props.onOpen(entry.id),
860
+ onMouseEnter: (event) => {
861
+ event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover)'
862
+ },
863
+ onMouseLeave: (event) => {
864
+ event.currentTarget.style.background = 'var(--dsw-alias-bg-layer-1)'
865
+ },
866
+ title: entry.cwd || entry.title,
867
+ },
868
+ h(StatusDot, { running: entry.running, finished: Boolean(entry.finishedAt) }),
869
+ h(
870
+ 'div',
871
+ { style: S.cardBody },
872
+ h(
873
+ 'div',
874
+ { style: S.cardTitleRow },
875
+ h('span', { style: S.cardTitle }, entry.title),
876
+ entry.running
877
+ ? h('span', { style: { ...S.badge, color: RUNNING_COLOR } }, '运行中')
878
+ : null,
879
+ !entry.running && entry.finishedAt
880
+ ? h('span', { style: { ...S.badge, color: FINISHED_COLOR } }, '待查看')
881
+ : null,
882
+ entry.parentId || entry.origin === 'subagent'
883
+ ? h('span', { style: { ...S.badge, color: 'var(--dsw-alias-label-secondary)' } }, '子代理')
884
+ : null,
885
+ h(
886
+ 'button',
887
+ {
888
+ type: 'button',
889
+ style: { ...S.pinBtn, ...(props.pinned ? S.pinBtnActive : {}) },
890
+ title: props.pinned ? '取消钉住' : '钉住,并排看它的对话',
891
+ onClick: (event) => {
892
+ event.stopPropagation()
893
+ props.onPin(entry.id)
894
+ },
895
+ },
896
+ props.pinned ? '⊟' : '⊞',
897
+ ),
898
+ ),
899
+ meta.length > 0 ? h('div', { style: S.metaRow }, meta.map((text, index) => h('span', { key: index }, text))) : null,
900
+ entry.running || entry.finishedAt ? h(LastLine, { entry }) : null,
901
+ ),
902
+ )
903
+ }
904
+
905
+ /** 精简模式下最多列多少个"闲着"的会话(运行中/刚完成的永远全列)。 */
906
+ const IDLE_LIMIT = 15
907
+
908
+ /* ------------------------------------------------------------------ *
909
+ * 并排对话(Phase 2)
910
+ *
911
+ * 客户端事件窗(sessions.retain → eventSource)只对"正被会话视图打开"的会话
912
+ * 填充,实测对历史会话永远是 entries=0,所以对话正文只能走 Host 半边的
913
+ * /digest 路由(sessionQuery.readSession 读真实事件日志再折叠)。
914
+ * ------------------------------------------------------------------ */
915
+
916
+ /**
917
+ * 默认只拉 20 条:并排列的作用是"扫一眼这段在聊什么",不是复现整个会话。
918
+ * 20 条大约一屏,一打开就是最近的对话,不会一上来就是一堵墙。
919
+ * 要往前翻用「载入更早」,每次再加 20 条。
920
+ */
921
+ const DIGEST_LIMIT = 20
922
+ /** 运行中会话的轮询间隔:1.2 秒,接近"看着它写"的体感。 */
923
+ const DIGEST_REFRESH_MS = 1200
924
+ /**
925
+ * 已停下会话的慢轮询:8 秒一次。
926
+ * 为什么停下来了还要轮?
927
+ * "跑完了"这件事必须能在原地看到结果,而运行状态翻转的那一次请求万一拿到
928
+ * 缓冲里的旧内容(或者快照翻转本身晚到一帧),面板就会永远停在"没有回复、
929
+ * 也没有显示完成",只有重开面板才恢复。慢轮询是兜底,成本很低
930
+ * (历史会话 observeSession 约 84ms,最多 3 列,8 秒一次)。
931
+ */
932
+ const DIGEST_IDLE_REFRESH_MS = 8000
933
+ const MAX_PINNED = 3
934
+
935
+ async function fetchDigest(sessionId, limit, live, fresh) {
936
+ const liveFlag = live === true ? '&live=1' : ''
937
+ const freshFlag = fresh === true ? '&fresh=1' : ''
938
+ const response = await fetch(
939
+ `/plugins/dsh-multi-session/digest?sessionId=${encodeURIComponent(sessionId)}&limit=${limit}${liveFlag}${freshFlag}`,
940
+ { cache: 'no-store' },
941
+ )
942
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
943
+ const body = await response.json()
944
+ if (!body || body.ok !== true) throw new Error('digest unavailable')
945
+ return body
946
+ }
947
+
948
+ /**
949
+ * 拉一个会话的对话摘要。
950
+ * 运行中的会话按 DIGEST_REFRESH_MS(1.2 秒)轮询;
951
+ * 停下之后不再停止轮询,而是降到 DIGEST_IDLE_REFRESH_MS(8 秒)继续守着 ——
952
+ * 这样"跑完了"的最后一句话一定会在原地补上,不用重开面板。
953
+ * limit 变化(用户点「载入更早」)会重新拉一次。
954
+ */
955
+ function useDigest(sessionId, live, limit, nonce, poll) {
956
+ const take = Number(limit) > 0 ? Number(limit) : DIGEST_LIMIT
957
+ const bump = Number(nonce) || 0
958
+ /** 是否开启"停下之后继续慢轮询"。列表卡片的末行摘要传 false,只拉一次。 */
959
+ const keepPolling = poll !== false
960
+ const [state, setState] = React.useState({ items: [], turns: 0, endReason: null, error: '', loading: true })
961
+ /**
962
+ * 上一次是不是"运行中"。用来识别"刚跑完"这一帧:
963
+ * 这时候必须绕开缓存拿最新的,否则会显示成"没有回复也没有完成"。
964
+ */
965
+ const wasLiveRef = React.useRef(false)
966
+ React.useEffect(() => {
967
+ let cancelled = false
968
+ // 从运行中变成停下:这一发强制绕过 Host 缓存。
969
+ const justStopped = wasLiveRef.current && !live
970
+ wasLiveRef.current = live
971
+ const load = async (fresh) => {
972
+ try {
973
+ const body = await fetchDigest(sessionId, take, live, fresh === true)
974
+ if (cancelled) return
975
+ setState({
976
+ items: Array.isArray(body.items) ? body.items : [],
977
+ turns: Number(body.turns) || 0,
978
+ endReason: body.endReason || null,
979
+ error: '',
980
+ loading: false,
981
+ })
982
+ } catch (cause) {
983
+ if (cancelled) return
984
+ setState((previous) => ({
985
+ ...previous,
986
+ error: String(cause && cause.message ? cause.message : cause),
987
+ loading: false,
988
+ }))
989
+ }
990
+ }
991
+ setState((previous) => ({ ...previous, loading: true }))
992
+ load(justStopped)
993
+ if (!live && !keepPolling) return () => { cancelled = true }
994
+ const period = live ? DIGEST_REFRESH_MS : DIGEST_IDLE_REFRESH_MS
995
+ const handle = setInterval(() => load(false), period)
996
+ return () => {
997
+ cancelled = true
998
+ clearInterval(handle)
999
+ }
1000
+ }, [sessionId, live, take, bump, keepPolling])
1001
+ return state
1002
+ }
1003
+
1004
+ function clip(text, max) {
1005
+ const value = typeof text === 'string' ? text : ''
1006
+ return value.length > max ? `${value.slice(0, max)}…` : value
1007
+ }
1008
+
1009
+ /**
1010
+ * 渲染一条对话。正文按原样保留换行、只做长度上限,不压成摘要。
1011
+ * 并排视图只保留"我 ↔ AI 的对话正文",
1012
+ * 工具调用/思考过程在 Host 侧折叠阶段就丢掉了,这里再兜一层:
1013
+ * 认不出的类型直接跳过,不画任何噪音行。
1014
+ */
1015
+ function renderDigestItem(item, index) {
1016
+ const kind = item && item.kind
1017
+ if (kind !== 'user' && kind !== 'assistant') return null
1018
+ const isUser = kind === 'user'
1019
+ const roleText = isUser ? '我' : '助手'
1020
+ const color = isUser ? 'var(--dsw-alias-brand-primary)' : RUNNING_COLOR
1021
+ return h(
1022
+ 'div',
1023
+ { key: index, style: isUser ? S.digestMsgUser : S.digestMsgAssistant },
1024
+ h('span', { style: { ...S.digestRole, color } }, roleText),
1025
+ h('div', { style: S.digestText }, clip(item.text, 6000)),
1026
+ )
1027
+ }
1028
+
1029
+ /**
1030
+ * 并排列底部的输入框:不用切到那个会话就能回一句,或把它打断。
1031
+ * 默认 queue(排进收件箱,不打断正在跑的回合);按住 Shift 发送 = steer(立刻插话)。
1032
+ */
1033
+ function SendBox(props) {
1034
+ const entry = props.entry
1035
+ const [text, setText] = React.useState('')
1036
+ const [busy, setBusy] = React.useState(false)
1037
+ const [note, setNote] = React.useState('')
1038
+
1039
+ const post = async (payload) => {
1040
+ const response = await fetch('/plugins/dsh-multi-session/prompt', {
1041
+ method: 'POST',
1042
+ cache: 'no-store',
1043
+ headers: { 'content-type': 'application/json' },
1044
+ body: JSON.stringify(payload),
1045
+ })
1046
+ const body = await response.json().catch(() => null)
1047
+ if (!response.ok || !body || body.ok !== true) {
1048
+ throw new Error((body && body.error) || `HTTP ${response.status}`)
1049
+ }
1050
+ return body
1051
+ }
1052
+
1053
+ const send = async (steer) => {
1054
+ const value = text.trim()
1055
+ if (value === '' || busy) return
1056
+ setBusy(true)
1057
+ setNote('')
1058
+ try {
1059
+ await post({ sessionId: entry.id, text: value, mode: steer ? 'steer' : 'queue' })
1060
+ setText('')
1061
+ setNote(steer ? '已插话' : '已发送')
1062
+ if (typeof props.onSent === 'function') props.onSent()
1063
+ } catch (cause) {
1064
+ setNote(`发送失败:${cause && cause.message ? cause.message : cause}`)
1065
+ } finally {
1066
+ setBusy(false)
1067
+ }
1068
+ }
1069
+
1070
+ const stop = async () => {
1071
+ if (busy) return
1072
+ setBusy(true)
1073
+ setNote('')
1074
+ try {
1075
+ await post({ sessionId: entry.id, action: 'cancel' })
1076
+ setNote('已请求停止')
1077
+ } catch (cause) {
1078
+ setNote(`停止失败:${cause && cause.message ? cause.message : cause}`)
1079
+ } finally {
1080
+ setBusy(false)
1081
+ }
1082
+ }
1083
+
1084
+ return h(
1085
+ 'div',
1086
+ { style: S.sendBox },
1087
+ h('textarea', {
1088
+ style: S.sendInput,
1089
+ rows: 2,
1090
+ value: text,
1091
+ placeholder: entry.running ? '给它排一条消息(Shift+Enter 立即插话)' : '给这个会话发一条消息',
1092
+ onChange: (event) => setText(event.target.value),
1093
+ onKeyDown: (event) => {
1094
+ if (event.key !== 'Enter') return
1095
+ event.preventDefault()
1096
+ send(Boolean(event.shiftKey))
1097
+ },
1098
+ }),
1099
+ h(
1100
+ 'div',
1101
+ { style: S.sendRow },
1102
+ h(
1103
+ 'button',
1104
+ {
1105
+ type: 'button',
1106
+ style: { ...S.sendBtn, opacity: busy || text.trim() === '' ? 0.5 : 1 },
1107
+ disabled: busy || text.trim() === '',
1108
+ onClick: () => send(false),
1109
+ },
1110
+ busy ? '…' : '发送',
1111
+ ),
1112
+ entry.running
1113
+ ? h(
1114
+ 'button',
1115
+ {
1116
+ type: 'button',
1117
+ style: { ...S.sendStop, opacity: busy ? 0.5 : 1 },
1118
+ disabled: busy,
1119
+ title: '打断这个会话当前正在跑的回合',
1120
+ onClick: stop,
1121
+ },
1122
+ '停止',
1123
+ )
1124
+ : null,
1125
+ note !== '' ? h('span', { style: S.sendNote }, note) : null,
1126
+ ),
1127
+ )
1128
+ }
1129
+
1130
+ function PinnedColumn(props) {
1131
+ const entry = props.entry
1132
+ const [limit, setLimit] = React.useState(DIGEST_LIMIT)
1133
+ const [nonce, setNonce] = React.useState(0)
1134
+ const digest = useDigest(entry.id, Boolean(entry.running), limit, nonce)
1135
+ const scrollRef = React.useRef(null)
1136
+ const limitRef = React.useRef(limit)
1137
+ /** 首次加载直接贴底:看别的会话就是想看"它现在说到哪了",从最新一条往回看。 */
1138
+ const firstLoadRef = React.useRef(true)
1139
+ /** 用户是否停在底部附近(决定新消息来了要不要跟着滚)。默认算在底部。 */
1140
+ const stickRef = React.useRef(true)
1141
+
1142
+ /**
1143
+ * 滚动策略:
1144
+ * - 首次加载 → 直接贴底(看的是"现在说到哪了");
1145
+ * - 用户停在底部时跟着新消息滚,往上翻历史时不打断;
1146
+ * - 点「载入更早」导致的条数变化不触发滚动,否则等于白点。
1147
+ */
1148
+ React.useEffect(() => {
1149
+ const node = scrollRef.current
1150
+ if (!node) return
1151
+ if (digest.items.length === 0) return
1152
+ if (limitRef.current !== limit) {
1153
+ // 用户点了「载入更早」:保持视线不动,把新增的更早内容留在上面。
1154
+ limitRef.current = limit
1155
+ return
1156
+ }
1157
+ if (firstLoadRef.current) {
1158
+ firstLoadRef.current = false
1159
+ node.scrollTop = node.scrollHeight
1160
+ return
1161
+ }
1162
+ if (stickRef.current) node.scrollTop = node.scrollHeight
1163
+ }, [digest.items, limit])
1164
+
1165
+ const hasMore = digest.items.length >= limit
1166
+ const meta = entry.running
1167
+ ? '运行中'
1168
+ : digest.turns > 0
1169
+ ? `${digest.turns} 轮`
1170
+ : entry.updatedAt
1171
+ ? relativeTime(entry.updatedAt)
1172
+ : ''
1173
+
1174
+ let content
1175
+ if (digest.loading && digest.items.length === 0) {
1176
+ content = h('div', { style: S.boardEmpty }, '正在读取对话…')
1177
+ } else if (digest.error !== '') {
1178
+ content = h('div', { style: S.boardEmpty }, `读取失败:${digest.error}`)
1179
+ } else if (digest.items.length === 0) {
1180
+ content = h('div', { style: S.boardEmpty }, '这个会话还没有可显示的对话。')
1181
+ } else {
1182
+ content = digest.items.map((item, index) => renderDigestItem(item, index))
1183
+ }
1184
+
1185
+ const head = h(
1186
+ 'div',
1187
+ { style: S.boardColHead },
1188
+ h(StatusDot, { running: entry.running, finished: Boolean(entry.finishedAt) }),
1189
+ h(
1190
+ 'span',
1191
+ {
1192
+ style: S.boardColTitle,
1193
+ title: `${entry.title}\n点击打开这个会话`,
1194
+ onClick: () => props.onOpen(entry.id),
1195
+ },
1196
+ entry.title,
1197
+ ),
1198
+ h('span', { style: S.boardColMeta }, meta),
1199
+ h(
1200
+ 'button',
1201
+ {
1202
+ type: 'button',
1203
+ style: S.pinBtn,
1204
+ title: '取消钉住',
1205
+ onClick: () => props.onUnpin(entry.id),
1206
+ },
1207
+ '×',
1208
+ ),
1209
+ )
1210
+
1211
+ const moreBar =
1212
+ hasMore && digest.error === ''
1213
+ ? h(
1214
+ 'button',
1215
+ {
1216
+ type: 'button',
1217
+ style: S.moreBtn,
1218
+ onClick: () => setLimit((value) => value + DIGEST_LIMIT),
1219
+ },
1220
+ `载入更早(当前 ${digest.items.length} 条)`,
1221
+ )
1222
+ : null
1223
+
1224
+ return h(
1225
+ 'div',
1226
+ { style: S.boardCol },
1227
+ head,
1228
+ h(
1229
+ 'div',
1230
+ {
1231
+ style: S.boardScroll,
1232
+ ref: scrollRef,
1233
+ // 记住用户是否停在底部附近:只有本来就在底部,新消息才跟着滚。
1234
+ onScroll: (event) => {
1235
+ const node = event.currentTarget
1236
+ stickRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < 40
1237
+ },
1238
+ },
1239
+ moreBar,
1240
+ content,
1241
+ ),
1242
+ h(SendBox, { entry, onSent: () => setNonce((value) => value + 1) }),
1243
+ )
1244
+ }
1245
+
1246
+ function MultiSessionPanel(props) {
1247
+ const sessions = props.useSessions(identity)
1248
+ const workspaces = props.useWorkspaces(identity)
1249
+ const [showArchived, setShowArchived] = React.useState(false)
1250
+ const [showSubagents, setShowSubagents] = React.useState(false)
1251
+ const [showAll, setShowAll] = React.useState(false)
1252
+ /**
1253
+ * 钉住的会话必须活过刷新:否则一退出再进来,三个并排列全没了,
1254
+ * 等于每次都要重新挑一遍。写进 localStorage,键值都落盘。
1255
+ */
1256
+ const [pinned, setPinned] = React.useState(() => loadPinned())
1257
+ const [tick, setTick] = React.useState(0)
1258
+
1259
+ const workspaceIndex = React.useMemo(() => readWorkspaces(workspaces), [workspaces])
1260
+ const entries = React.useMemo(
1261
+ () => readEntries(sessions, workspaceIndex),
1262
+ // tick 必须在依赖里:点「打开」时先删掉 finishedAt 再 setTick,
1263
+ // 如果这里只依赖 sessions,entries 不会重算,卡片上的"待查看"就永远不消失。
1264
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1265
+ [sessions, workspaceIndex, tick],
1266
+ )
1267
+
1268
+ /** 完成检测必须在渲染前跑,发现新完成就重渲染一次(拿到 finishedAt)。 */
1269
+ React.useEffect(() => {
1270
+ if (detectFinished(entries)) setTick((value) => value + 1)
1271
+ }, [entries])
1272
+
1273
+ /** 让「3 分钟前」这类相对时间自己走。 */
1274
+ React.useEffect(() => {
1275
+ const handle = setInterval(() => setTick((value) => value + 1), 30000)
1276
+ return () => clearInterval(handle)
1277
+ }, [])
1278
+
1279
+ const visible = React.useMemo(() => {
1280
+ const filtered = entries.filter((entry) => {
1281
+ if (!showArchived && entry.archived) return false
1282
+ if (!showSubagents && (entry.parentId || entry.origin === 'subagent')) return false
1283
+ if (entry.blank) return false
1284
+ return true
1285
+ })
1286
+ if (showAll) return filtered
1287
+ // 精简:运行中和刚完成的必须留,其余按最近活跃取前 IDLE_LIMIT 个。
1288
+ const ranked = filtered
1289
+ .slice()
1290
+ .sort((left, right) => rankOf(left) - rankOf(right) || right.updatedAt - left.updatedAt)
1291
+ const kept = []
1292
+ let idle = 0
1293
+ for (const entry of ranked) {
1294
+ if (entry.running || entry.finishedAt) {
1295
+ kept.push(entry)
1296
+ continue
1297
+ }
1298
+ if (idle < IDLE_LIMIT) {
1299
+ kept.push(entry)
1300
+ idle++
1301
+ }
1302
+ }
1303
+ return kept
1304
+ }, [entries, showArchived, showSubagents, showAll])
1305
+
1306
+ const runningCount = visible.filter((entry) => entry.running).length
1307
+ const finishedCount = visible.filter((entry) => !entry.running && entry.finishedAt).length
1308
+
1309
+ const grouped = React.useMemo(() => {
1310
+ const buckets = new Map()
1311
+ for (const entry of visible) {
1312
+ const key = entry.groupId || entry.cwd || '__other__'
1313
+ let bucket = buckets.get(key)
1314
+ if (!bucket) {
1315
+ bucket = {
1316
+ key,
1317
+ title: entry.groupTitle || basename(entry.cwd) || '其他会话',
1318
+ path: entry.groupPath || entry.cwd || '',
1319
+ entries: [],
1320
+ }
1321
+ buckets.set(key, bucket)
1322
+ }
1323
+ bucket.entries.push(entry)
1324
+ }
1325
+ const rank = rankOf
1326
+ const groups = Array.from(buckets.values())
1327
+ for (const group of groups) {
1328
+ group.entries.sort((left, right) => {
1329
+ const delta = rank(left) - rank(right)
1330
+ if (delta !== 0) return delta
1331
+ if (left.finishedAt !== right.finishedAt) return right.finishedAt - left.finishedAt
1332
+ return right.updatedAt - left.updatedAt
1333
+ })
1334
+ }
1335
+ groups.sort((left, right) => {
1336
+ const leftTop = rank(left.entries[0])
1337
+ const rightTop = rank(right.entries[0])
1338
+ if (leftTop !== rightTop) return leftTop - rightTop
1339
+ return right.entries[0].updatedAt - left.entries[0].updatedAt
1340
+ })
1341
+ return groups
1342
+ }, [visible])
1343
+
1344
+ /** 打开某个会话:清掉待查看标记,并且把"我看到了这个版本"刷进基线。 */
1345
+ const handleOpen = React.useCallback(
1346
+ (sessionId) => {
1347
+ finishedAt.delete(sessionId)
1348
+ saveFinished()
1349
+ // 关键:只删 finishedAt 不够。冷启动补标(catchUpFinished)比的是
1350
+ // "上次记住的 updatedAt vs 现在的 updatedAt",如果基线还停在这之前,
1351
+ // 页面一刷新(primed 归零 → 走冷启动分支)就会把它重新标成待查看,
1352
+ // 看起来就是"点了也白点"。
1353
+ // 基线写 Date.now() 而不是现在这个 updatedAt:语义是"此刻之前的一切我都看过了"。
1354
+ // 打开会话本身也会把 updatedAt 往前推一点,用旧值当基线会被自己顶掉。
1355
+ const entry = entries.find((item) => item.id === sessionId)
1356
+ const seenAt = Math.max(entry && entry.updatedAt > 0 ? entry.updatedAt : 0, Date.now())
1357
+ lastSeen.set(sessionId, seenAt)
1358
+ saveSeen(true)
1359
+ openSession(sessionId)
1360
+ setTick((value) => value + 1)
1361
+ },
1362
+ [entries],
1363
+ )
1364
+
1365
+ /** 全部已读:待查看清空,所有会话的基线一起推进到当前版本。 */
1366
+ const clearFinished = React.useCallback(() => {
1367
+ finishedAt.clear()
1368
+ saveFinished()
1369
+ for (const entry of entries) {
1370
+ if (!entry.blank && entry.updatedAt > 0) lastSeen.set(entry.id, entry.updatedAt)
1371
+ }
1372
+ saveSeen(true)
1373
+ setTick((value) => value + 1)
1374
+ }, [entries])
1375
+
1376
+ /** 钉住/取消钉住;超过 MAX_PINNED 就挤掉最早钉的那个。每次都落盘。 */
1377
+ const togglePin = React.useCallback((sessionId) => {
1378
+ setPinned((current) => {
1379
+ let next
1380
+ if (current.indexOf(sessionId) >= 0) next = current.filter((id) => id !== sessionId)
1381
+ else if (current.length >= MAX_PINNED) next = current.slice(1).concat(sessionId)
1382
+ else next = current.concat(sessionId)
1383
+ savePinned(next)
1384
+ return next
1385
+ })
1386
+ }, [])
1387
+
1388
+ const unpin = React.useCallback((sessionId) => {
1389
+ setPinned((current) => {
1390
+ const next = current.filter((id) => id !== sessionId)
1391
+ savePinned(next)
1392
+ return next
1393
+ })
1394
+ }, [])
1395
+
1396
+ const pinnedEntries = React.useMemo(
1397
+ () =>
1398
+ pinned
1399
+ .map((sessionId) => entries.find((entry) => entry.id === sessionId))
1400
+ .filter((entry) => Boolean(entry)),
1401
+ [pinned, entries],
1402
+ )
1403
+
1404
+ const splitMode = pinnedEntries.length > 0
1405
+
1406
+ const totalVisible = visible.length
1407
+
1408
+ const listContent =
1409
+ grouped.length === 0
1410
+ ? sessions && sessions.phase !== 'ready'
1411
+ ? h('div', { style: S.empty }, '正在加载会话列表…')
1412
+ : h(
1413
+ 'div',
1414
+ { style: S.empty },
1415
+ '还没有可显示的会话。',
1416
+ h('br'),
1417
+ '开一个新会话跑起来,这里就会实时出现。',
1418
+ )
1419
+ : grouped.map((group) =>
1420
+ h(
1421
+ 'div',
1422
+ { key: group.key, style: S.group },
1423
+ h(
1424
+ 'div',
1425
+ { style: S.groupHeader },
1426
+ h('span', { style: S.groupName }, group.title),
1427
+ group.path && group.path !== group.title
1428
+ ? h('span', { style: S.groupPath }, group.path)
1429
+ : null,
1430
+ h('span', { style: { opacity: 0.6 } }, `${group.entries.length}`),
1431
+ ),
1432
+ group.entries.map((entry) =>
1433
+ h(SessionCard, {
1434
+ key: entry.id,
1435
+ entry,
1436
+ onOpen: handleOpen,
1437
+ onPin: togglePin,
1438
+ pinned: pinned.indexOf(entry.id) >= 0,
1439
+ }),
1440
+ ),
1441
+ ),
1442
+ )
1443
+
1444
+ const listTail =
1445
+ !showAll && entries.length > visible.length
1446
+ ? h(
1447
+ 'div',
1448
+ { style: { ...S.empty, padding: '10px 20px' } },
1449
+ `另有 ${entries.length - visible.length} 个较久没动的会话,勾选「全部」展开。`,
1450
+ )
1451
+ : null
1452
+
1453
+ const listPane = h(
1454
+ 'div',
1455
+ { style: splitMode ? S.listPane : S.listPaneWide },
1456
+ listContent,
1457
+ listTail,
1458
+ )
1459
+
1460
+ return h(
1461
+ 'div',
1462
+ { style: S.root },
1463
+ h(
1464
+ 'div',
1465
+ { style: S.header },
1466
+ h('span', { style: S.headerTitle }, '多会话'),
1467
+ runningCount > 0
1468
+ ? h(
1469
+ 'span',
1470
+ { style: S.counter },
1471
+ h('span', { style: { ...S.dot, background: RUNNING_COLOR } }),
1472
+ `${runningCount} 运行中`,
1473
+ )
1474
+ : null,
1475
+ finishedCount > 0
1476
+ ? h(
1477
+ 'span',
1478
+ { style: S.counter },
1479
+ h('span', { style: { ...S.dot, background: FINISHED_COLOR } }),
1480
+ `${finishedCount} 待查看`,
1481
+ )
1482
+ : null,
1483
+ h('span', { style: S.counter }, `${totalVisible} 个会话`),
1484
+ h('span', { style: S.spacer }),
1485
+ h(
1486
+ 'label',
1487
+ { style: { ...S.counter, cursor: 'pointer' } },
1488
+ h('input', {
1489
+ type: 'checkbox',
1490
+ checked: showAll,
1491
+ onChange: (event) => setShowAll(event.target.checked),
1492
+ style: { margin: 0 },
1493
+ }),
1494
+ '全部',
1495
+ ),
1496
+ h(
1497
+ 'label',
1498
+ { style: { ...S.counter, cursor: 'pointer' } },
1499
+ h('input', {
1500
+ type: 'checkbox',
1501
+ checked: showSubagents,
1502
+ onChange: (event) => setShowSubagents(event.target.checked),
1503
+ style: { margin: 0 },
1504
+ }),
1505
+ '子代理',
1506
+ ),
1507
+ h(
1508
+ 'label',
1509
+ { style: { ...S.counter, cursor: 'pointer' } },
1510
+ h('input', {
1511
+ type: 'checkbox',
1512
+ checked: showArchived,
1513
+ onChange: (event) => setShowArchived(event.target.checked),
1514
+ style: { margin: 0 },
1515
+ }),
1516
+ '归档',
1517
+ ),
1518
+ finishedCount > 0
1519
+ ? h(
1520
+ 'button',
1521
+ { style: S.button, onClick: clearFinished, type: 'button' },
1522
+ '清除标记',
1523
+ )
1524
+ : null,
1525
+ ),
1526
+ splitMode
1527
+ ? h(
1528
+ 'div',
1529
+ { style: S.split },
1530
+ listPane,
1531
+ h(
1532
+ 'div',
1533
+ { style: S.board },
1534
+ pinnedEntries.map((entry) =>
1535
+ h(PinnedColumn, {
1536
+ key: entry.id,
1537
+ entry,
1538
+ onOpen: handleOpen,
1539
+ onUnpin: unpin,
1540
+ }),
1541
+ ),
1542
+ ),
1543
+ )
1544
+ : h('div', { style: S.body }, listContent, listTail),
1545
+ )
1546
+ }
1547
+
1548
+ /* ------------------------------------------------------------------ *
1549
+ * 开发期诊断层
1550
+ *
1551
+ * conversation.view 只有用户切到那个页签才会挂载,所以「面板拿到数据了吗」
1552
+ * 没法靠面板自己回报。这里注册一个常驻 overlay(渲染 null),用同一套解析
1553
+ * 逻辑算一遍统计并回传 Host 落盘,页面一刷新就能离线核对。
1554
+ * ------------------------------------------------------------------ */
1555
+
1556
+ /** 每个 phase 只报一次,最多 4 次(pending → ready 这类过渡要抓全)。 */
1557
+ const diagPosted = new Set()
1558
+
1559
+ function DiagOverlay(props) {
1560
+ const sessions = typeof props.useSessions === 'function' ? props.useSessions(identity) : undefined
1561
+ const workspaces =
1562
+ typeof props.useWorkspaces === 'function' ? props.useWorkspaces(identity) : undefined
1563
+ // hook 必须无条件、按固定顺序调用,不能塞进 IIFE。
1564
+ const statusSnap =
1565
+ typeof props.useSessionStatus === 'function' ? props.useSessionStatus(identity) : undefined
1566
+ const phase = (sessions && sessions.phase) || 'none'
1567
+
1568
+ React.useEffect(() => {
1569
+ if (diagPosted.has(phase) || diagPosted.size >= 4) return
1570
+ diagPosted.add(phase)
1571
+ let error = null
1572
+ let payload
1573
+ try {
1574
+ const workspaceIndex = readWorkspaces(workspaces)
1575
+ const entries = readEntries(sessions, workspaceIndex)
1576
+ const visible = entries.filter((entry) => !entry.blank)
1577
+ payload = {
1578
+ phase: 'panel',
1579
+ build: BUILD,
1580
+ sessionsPhase: phase,
1581
+ at: new Date().toISOString(),
1582
+ hasSessions: Boolean(sessions),
1583
+ idsLength: sessions && Array.isArray(sessions.ids) ? sessions.ids.length : -1,
1584
+ total: entries.length,
1585
+ running: visible.filter((entry) => entry.running).length,
1586
+ withCwd: visible.filter((entry) => entry.cwd).length,
1587
+ withUpdatedAt: visible.filter((entry) => entry.updatedAt > 0).length,
1588
+ archived: visible.filter((entry) => entry.archived).length,
1589
+ withParent: visible.filter((entry) => entry.parentId).length,
1590
+ workspaces: workspaceIndex.groups.length,
1591
+ groups: workspaceIndex.groups.map((group) => ({
1592
+ title: group.title,
1593
+ path: group.path,
1594
+ count: group.sessionIds.length,
1595
+ })),
1596
+ sample: visible.slice(0, 5).map((entry) => ({
1597
+ id: entry.id,
1598
+ title: entry.title,
1599
+ running: entry.running,
1600
+ updatedAt: entry.updatedAt,
1601
+ cwd: entry.cwd,
1602
+ group: entry.groupTitle,
1603
+ turns: entry.turns,
1604
+ tokens: entry.tokens,
1605
+ model: entry.model,
1606
+ })),
1607
+ // 一次性探明 modelSelection 的真实形状(之前 model 一直为空)。
1608
+ modelRaw: (() => {
1609
+ try {
1610
+ const table =
1611
+ sessions && sessions.projectionsBySession && typeof sessions.projectionsBySession === 'object'
1612
+ ? sessions.projectionsBySession
1613
+ : {}
1614
+ for (const key of Object.keys(table).slice(0, 12)) {
1615
+ const values = table[key] && table[key].values
1616
+ const selection = values && values.modelSelection
1617
+ if (selection !== undefined && selection !== null) {
1618
+ return JSON.stringify(selection).slice(0, 160)
1619
+ }
1620
+ }
1621
+ return 'none'
1622
+ } catch (cause) {
1623
+ return `threw: ${String(cause)}`
1624
+ }
1625
+ })(),
1626
+ // 一次性探明"当前会话"能不能被识别(决定浮标要不要排除当前会话)。
1627
+ locRaw: (() => {
1628
+ try {
1629
+ return JSON.stringify({
1630
+ path: String(location.pathname || '').slice(0, 120),
1631
+ hash: String(location.hash || '').slice(0, 120),
1632
+ search: String(location.search || '').slice(0, 60),
1633
+ })
1634
+ } catch (cause) {
1635
+ return `threw: ${String(cause)}`
1636
+ }
1637
+ })(),
1638
+ // 列表级 UI 状态里是否带"当前选中"标记。
1639
+ listMetaRaw: (() => {
1640
+ try {
1641
+ const table =
1642
+ sessions && sessions.projectionsBySession && typeof sessions.projectionsBySession === 'object'
1643
+ ? sessions.projectionsBySession
1644
+ : {}
1645
+ for (const key of Object.keys(table).slice(0, 12)) {
1646
+ const values = table[key] && table[key].values
1647
+ const meta = values && values.sessionListMetadata
1648
+ if (meta !== undefined && meta !== null) {
1649
+ return JSON.stringify(meta).slice(0, 240)
1650
+ }
1651
+ }
1652
+ return 'none'
1653
+ } catch (cause) {
1654
+ return `threw: ${String(cause)}`
1655
+ }
1656
+ })(),
1657
+ // useSessionStatus 的真实返回形状。
1658
+ statusRaw: (() => {
1659
+ try {
1660
+ return JSON.stringify(statusSnap).slice(0, 240)
1661
+ } catch (cause) {
1662
+ return `threw: ${String(cause)}`
1663
+ }
1664
+ })(),
1665
+ activeSessionId,
1666
+ }
1667
+ } catch (cause) {
1668
+ error = String(cause)
1669
+ }
1670
+ post({ phase: 'panel', build: BUILD, at: new Date().toISOString(), error, payload })
1671
+
1672
+ // 一次性探明 /prompt 的 content 形状:拿一个不存在的 sessionId 试发。
1673
+ // 期望「会话找不到」类错误——说明 content:[{type:'text',text}] 被接受了;
1674
+ // 如果报的是 content/校验类错误,就说明形状猜错了。
1675
+ if (!promptProbed) {
1676
+ promptProbed = true
1677
+ void (async () => {
1678
+ try {
1679
+ const response = await fetch('/plugins/dsh-multi-session/prompt', {
1680
+ method: 'POST',
1681
+ cache: 'no-store',
1682
+ headers: { 'content-type': 'application/json' },
1683
+ body: JSON.stringify({
1684
+ sessionId: 'session-00000000-0000-0000-0000-000000000000',
1685
+ text: 'shape-probe',
1686
+ }),
1687
+ })
1688
+ const body = await response.json().catch(() => null)
1689
+ post({
1690
+ phase: 'prompt-shape',
1691
+ build: BUILD,
1692
+ at: new Date().toISOString(),
1693
+ status: response.status,
1694
+ ok: Boolean(body && body.ok),
1695
+ error: String((body && body.error) || ''),
1696
+ })
1697
+ } catch (cause) {
1698
+ post({ phase: 'prompt-shape', build: BUILD, at: new Date().toISOString(), error: String(cause) })
1699
+ }
1700
+ })()
1701
+ }
1702
+
1703
+ // 紧跟 panel 探针做一次 /digest 自检(surface 模式 + 缓存命中),
1704
+ // 挂在这里而不是独立 effect,是因为实测独立 effect 有时不触发。
1705
+ if (digestProbed) return
1706
+ digestProbed = true
1707
+ const digestTarget =
1708
+ activeSessionId || (Array.isArray(payload.sample) && payload.sample[0] ? String(payload.sample[0].id) : '')
1709
+ if (digestTarget === '') return
1710
+ void (async () => {
1711
+ const lines = []
1712
+ // 面板真实路径:observe + DIGEST_LIMIT 条,测耗时与载荷大小。
1713
+ const realUrl = `sessionId=${encodeURIComponent(digestTarget)}&limit=${DIGEST_LIMIT}&fresh=1`
1714
+ const probes = [
1715
+ { label: 'panel-live', url: realUrl, bytes: true },
1716
+ { label: 'panel-live-cached', url: realUrl, bytes: true },
1717
+ ]
1718
+ for (const probe of probes) {
1719
+ try {
1720
+ const response = await fetch(`/plugins/dsh-multi-session/digest?${probe.url}`, { cache: 'no-store' })
1721
+ const raw = await response.text()
1722
+ const body = JSON.parse(raw)
1723
+ if (!body || body.ok !== true) {
1724
+ lines.push(`${probe.label} http=${response.status} body=${String(raw).slice(0, 120)}`)
1725
+ continue
1726
+ }
1727
+ const items = Array.isArray(body.items) ? body.items : []
1728
+ lines.push(
1729
+ `${probe.label} ms=${body.ms} cached=${body.cached} source=${body.source} events=${body.eventCount} turns=${body.turns} items=${items.length} bytes=${raw.length}`,
1730
+ )
1731
+ if (probe.bytes === true) {
1732
+ for (const item of items.slice(-2)) {
1733
+ lines.push(` [${item.kind}] ${String(item.text).slice(0, 70).replace(/\n/g, ' ')}`)
1734
+ }
1735
+ }
1736
+ } catch (cause) {
1737
+ lines.push(`${probe.label} threw ${String(cause)}`)
1738
+ }
1739
+ }
1740
+ post({ phase: 'digest', build: BUILD, at: new Date().toISOString(), target: digestTarget, lines })
1741
+ })()
1742
+ }, [phase])
1743
+
1744
+ // 一次性探针:Phase 2「并排对话流」的可行性前提——
1745
+ // 客户端能否 retain 别的会话并读到它的消息事件流。
1746
+ React.useEffect(() => {
1747
+ if (retainProbed) return
1748
+ retainProbed = true
1749
+ const sessionsService = getService('sessions')
1750
+ if (!sessionsService || typeof sessionsService.retain !== 'function') {
1751
+ post({ phase: 'retain', build: BUILD, at: new Date().toISOString(), error: 'no sessions service' })
1752
+ return
1753
+ }
1754
+ const describe = (label, value) => {
1755
+ try {
1756
+ if (value === null || value === undefined) return `${label}: ${String(value)}`
1757
+ if (typeof value !== 'object') return `${label}: ${typeof value} = ${String(value).slice(0, 80)}`
1758
+ const keys = Object.keys(value)
1759
+ return `${label}: object{${keys.slice(0, 12).join(',')}}`
1760
+ } catch (cause) {
1761
+ return `${label}: threw ${String(cause)}`
1762
+ }
1763
+ }
1764
+ let reference
1765
+ const report = {}
1766
+ const lines = []
1767
+ const run = async () => {
1768
+ try {
1769
+ // 选一个"不是当前会话、且有内容"的历史会话,检验事件窗是否懒加载。
1770
+ let targetId = activeSessionId
1771
+ try {
1772
+ const table =
1773
+ sessions && sessions.projectionsBySession && typeof sessions.projectionsBySession === 'object'
1774
+ ? sessions.projectionsBySession
1775
+ : {}
1776
+ for (const key of Object.keys(table)) {
1777
+ if (key === activeSessionId) continue
1778
+ const values = table[key] && table[key].values
1779
+ const outline = values && Array.isArray(values.turnOutline) ? values.turnOutline : []
1780
+ if (outline.length > 0) {
1781
+ targetId = key
1782
+ break
1783
+ }
1784
+ }
1785
+ } catch (cause) {
1786
+ lines.push(`pickTarget threw ${String(cause)}`)
1787
+ }
1788
+ lines.push(`target=${targetId.slice(0, 44)} active=${targetId === activeSessionId}`)
1789
+ reference = sessionsService.retain({ sessionId: targetId }, {})
1790
+ lines.push(describe('reference', reference))
1791
+ // 关键:retain 是异步就绪的,readiness.promise 解析前 eventSource 是空的。
1792
+ const readiness = reference && reference.readiness
1793
+ if (readiness && readiness.promise) {
1794
+ const settled = await Promise.race([
1795
+ readiness.promise.then(() => 'resolved', () => 'rejected'),
1796
+ new Promise((resolve) => setTimeout(() => resolve('timeout'), 4000)),
1797
+ ])
1798
+ lines.push(`readiness=${settled}`)
1799
+ }
1800
+ const binding =
1801
+ (reference && reference.record && reference.record.binding) ||
1802
+ (typeof sessionsService.binding === 'function' ? sessionsService.binding(targetId) : null)
1803
+ lines.push(describe('binding', binding))
1804
+ const source = binding && binding.eventSource
1805
+ lines.push(describe('eventSource', source))
1806
+ // 实测 eventSource 是 {listeners, window, snapshot},不是带 get() 的对象。
1807
+ const window_ =
1808
+ source && source.snapshot && typeof source.snapshot === 'object'
1809
+ ? source.snapshot
1810
+ : source && source.window && typeof source.window === 'object'
1811
+ ? source.window
1812
+ : null
1813
+ lines.push(describe('window(from snapshot)', window_))
1814
+ // 把 window 自身 + 原型的成员全列出来,找分页/加载入口。
1815
+ try {
1816
+ const own = window_ ? Object.keys(window_) : []
1817
+ const proto = window_ ? Object.getOwnPropertyNames(Object.getPrototypeOf(window_)) : []
1818
+ lines.push(`window.own=[${own.join(',')}] proto=[${proto.join(',')}]`)
1819
+ for (const key of own.concat(proto)) {
1820
+ if (key === 'constructor') continue
1821
+ const type = typeof window_[key]
1822
+ const extra = type === 'function' ? ` arity=${window_[key].length}` : ''
1823
+ lines.push(` window.${key}: ${type}${extra}`)
1824
+ }
1825
+ } catch (cause) {
1826
+ lines.push(`window inspect threw ${String(cause)}`)
1827
+ }
1828
+ // eventSource 的订阅接口长什么样。
1829
+ try {
1830
+ const listeners = source && source.listeners
1831
+ const own = listeners ? Object.keys(listeners) : []
1832
+ const proto = listeners ? Object.getOwnPropertyNames(Object.getPrototypeOf(listeners)) : []
1833
+ lines.push(`listeners own=[${own.join(',')}] proto=[${proto.slice(0, 10).join(',')}]`)
1834
+ } catch (cause) {
1835
+ lines.push(`listeners inspect threw ${String(cause)}`)
1836
+ }
1837
+ const entries = window_ && Array.isArray(window_.entries) ? window_.entries : []
1838
+ lines.push(`entries=${entries.length} change=${window_ ? window_.change : 'n/a'}`)
1839
+ const sample = entries.slice(-3).map((entry) => {
1840
+ try {
1841
+ const event = entry && entry.event ? entry.event : entry
1842
+ const kind =
1843
+ event && typeof event === 'object'
1844
+ ? event.type || event.kind || event.role || '?'
1845
+ : typeof event
1846
+ return `${String(kind).slice(0, 24)} :: ${JSON.stringify(event).slice(0, 180)}`
1847
+ } catch (cause) {
1848
+ return `threw ${String(cause)}`
1849
+ }
1850
+ })
1851
+ lines.push(...sample)
1852
+ const sessionFace = binding && binding.session
1853
+ const snapshot =
1854
+ sessionFace && typeof sessionFace.get === 'function'
1855
+ ? sessionFace.get()
1856
+ : sessionFace && sessionFace.snapshot && typeof sessionFace.snapshot === 'object'
1857
+ ? sessionFace.snapshot
1858
+ : null
1859
+ lines.push(describe('sessionSnapshot', snapshot))
1860
+ if (snapshot) {
1861
+ lines.push(`snapshot.running=${snapshot.running} pending=${snapshot.pendingSubmissions}`)
1862
+ }
1863
+ // 端到端验证 Host 侧 /digest:默认走 surface(最快内容路径),
1864
+ // 再打一次同样的请求确认 Host 缓存命中(第二次应该几毫秒返回)。
1865
+ for (const mode of ['surface', 'surface']) {
1866
+ try {
1867
+ const response = await fetch(
1868
+ `/plugins/dsh-multi-session/digest?mode=${mode}&sessionId=${encodeURIComponent(targetId)}&limit=8`,
1869
+ { cache: 'no-store' },
1870
+ )
1871
+ const body = await response.json()
1872
+ if (!body || body.ok !== true) {
1873
+ lines.push(`digest[${mode}] http=${response.status} body=${JSON.stringify(body).slice(0, 120)}`)
1874
+ continue
1875
+ }
1876
+ lines.push(
1877
+ `digest[${mode}] ms=${body.ms} cached=${body.cached} age=${body.age} events=${body.eventCount} turns=${body.turns} items=${Array.isArray(body.items) ? body.items.length : 'n/a'}`,
1878
+ )
1879
+ if (Array.isArray(body.items)) {
1880
+ for (const item of body.items.slice(-3)) {
1881
+ lines.push(` [${item.kind}] ${String(item.text).slice(0, 90).replace(/\n/g, ' ')}`)
1882
+ }
1883
+ }
1884
+ } catch (cause) {
1885
+ lines.push(`digest[${mode}] threw ${String(cause)}`)
1886
+ }
1887
+ }
1888
+ } catch (cause) {
1889
+ report.error = String(cause)
1890
+ } finally {
1891
+ try {
1892
+ // 实测释放方法是 releaseReference(没有 release),必须配对调用,否则会话被永久持有。
1893
+ if (reference && typeof reference.releaseReference === 'function') reference.releaseReference()
1894
+ else if (reference && typeof reference.release === 'function') reference.release()
1895
+ } catch (cause) {
1896
+ report.releaseError = String(cause)
1897
+ }
1898
+ post({ phase: 'retain', build: BUILD, at: new Date().toISOString(), ...report, lines })
1899
+ }
1900
+ }
1901
+ run()
1902
+ }, [])
1903
+
1904
+ return null
1905
+ }
1906
+
1907
+ /**
1908
+ * 零尺寸探针:挂在会话头部,把"用户当前正在看的会话"记到模块变量。
1909
+ *
1910
+ * 为什么需要它:shell.overlay 拿不到当前会话 id(URL 恒为 "/"、
1911
+ * usePanelInfo 会违反 hook 规则、sessionListMetadata 只有 {blank,lastPromptAt}),
1912
+ * 而 conversation.session.header.actions 的 props 里带 sessionId。
1913
+ * 浮标据此排除当前会话,避免"你正看着的这轮结束也弹提醒"的噪音。
1914
+ */
1915
+ function ActiveSessionProbe(props) {
1916
+ const sessionId = typeof props.sessionId === 'string' ? props.sessionId : ''
1917
+ React.useEffect(() => {
1918
+ activeSessionId = sessionId
1919
+ }, [sessionId])
1920
+ return null
1921
+ }
1922
+
1923
+
1924
+ return {
1925
+ inject: ['slots'],
1926
+ // 仅供离线 harness 直接渲染内部组件;宿主只读 inject/apply,多出的键无副作用。
1927
+ __internals: DIAG
1928
+ ? { PinnedColumn, renderDigestItem, fetchDigest, MultiSessionPanel, SessionCard, LastLine, SendBox, openSession }
1929
+ : undefined,
1930
+ apply(ctx) {
1931
+ rootCtx = ctx
1932
+ try {
1933
+ ctx.inject(['slots'], (native) => {
1934
+ native.slots.inject('conversation.view', () =>
1935
+ native.slots.register(
1936
+ { name: 'conversation.view', id: VIEW_ID, order: 20, label: () => '多会话' },
1937
+ MultiSessionPanel,
1938
+ ),
1939
+ )
1940
+ // 零尺寸探针:记录"当前正在看的会话",供浮标排除。
1941
+ native.slots.inject('conversation.session.header.actions', () =>
1942
+ native.slots.register(
1943
+ {
1944
+ name: 'conversation.session.header.actions',
1945
+ id: 'dsh-multi-session-active',
1946
+ order: 90,
1947
+ },
1948
+ ActiveSessionProbe,
1949
+ ),
1950
+ )
1951
+ if (DIAG) {
1952
+ native.slots.inject('shell.overlay', () =>
1953
+ native.slots.register(
1954
+ {
1955
+ name: 'shell.overlay',
1956
+ id: 'dsh-multi-session-diag',
1957
+ order: 901,
1958
+ label: 'MultiSession Diag',
1959
+ },
1960
+ DiagOverlay,
1961
+ ),
1962
+ )
1963
+ }
1964
+ })
1965
+ } catch (cause) {
1966
+ console.warn(`${TAG} failed to register the multi-session view`, cause)
1967
+ }
1968
+ },
1969
+ }
1970
+ },
1971
+ })