@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 0.6.43

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 (72) hide show
  1. package/README.md +2 -1
  2. package/README_zh.md +2 -1
  3. package/assets/nexusDist/assets/index-CaA76j0F.js +9 -0
  4. package/assets/nexusDist/index.html +1 -1
  5. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
  6. package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
  7. package/assets/stageDist/index.html +12 -12
  8. package/cli/a2a.mjs +24 -4
  9. package/lib/canvas-store.js +249 -17
  10. package/lib/client.js +62 -72
  11. package/lib/client.js.map +1 -1
  12. package/lib/config.js +1 -0
  13. package/lib/faces/admin.js +103 -63
  14. package/lib/faces/wire.js +3 -1
  15. package/lib/index.js +116 -22
  16. package/lib/origin-claim.js +4 -0
  17. package/lib/skill/SKILL.md +117 -117
  18. package/lib/skill/references/collaboration.md +428 -428
  19. package/lib/skill/references/discovery.md +78 -78
  20. package/lib/skill/references/intake.md +107 -107
  21. package/lib/skill/references/membership.md +73 -73
  22. package/lib/skill/references/pitfalls.md +51 -51
  23. package/lib/skill/references/router.md +96 -96
  24. package/lib/skill/references/routing.md +65 -65
  25. package/lib/types/canvas-store.d.ts +48 -1
  26. package/lib/types/config.d.ts +2 -0
  27. package/lib/types/faces/admin.d.ts +2 -0
  28. package/lib/types/origin-claim.d.ts +2 -0
  29. package/nexus-stage/package.json +21 -21
  30. package/nexus-stage/scripts/mockhost.py +261 -261
  31. package/nexus-stage/src/canvas-ops.ts +191 -191
  32. package/nexus-stage/src/canvas-wire.ts +164 -164
  33. package/nexus-stage/src/cli-home-picker.ts +42 -42
  34. package/nexus-stage/src/fault.ts +56 -56
  35. package/nexus-stage/src/federation-overlay.ts +103 -103
  36. package/nexus-stage/src/federation.ts +241 -241
  37. package/nexus-stage/src/layout-doc.ts +145 -145
  38. package/nexus-stage/src/main.ts +520 -513
  39. package/nexus-stage/src/planning-card-text.ts +73 -73
  40. package/nexus-stage/src/planning-card.ts +63 -63
  41. package/nexus-stage/src/planning-edges.ts +26 -26
  42. package/nexus-stage/src/planning-fit.ts +45 -45
  43. package/nexus-stage/src/planning-frame-hue.ts +13 -13
  44. package/nexus-stage/src/planning-legend.ts +39 -39
  45. package/nexus-stage/src/planning-member-handles.ts +61 -61
  46. package/nexus-stage/src/planning-menus.ts +422 -422
  47. package/nexus-stage/src/planning-mirror.ts +75 -75
  48. package/nexus-stage/src/planning-nodelist.ts +294 -294
  49. package/nexus-stage/src/planning-overlap.ts +159 -159
  50. package/nexus-stage/src/planning-reachability.ts +28 -28
  51. package/nexus-stage/src/planning-relayout.ts +121 -121
  52. package/nexus-stage/src/planning-roster.ts +15 -15
  53. package/nexus-stage/src/planning-search.ts +168 -168
  54. package/nexus-stage/src/planning-types.ts +115 -115
  55. package/nexus-stage/src/planning-undo.ts +68 -68
  56. package/nexus-stage/src/planning-view.ts +1499 -1499
  57. package/nexus-stage/src/planning.css +340 -340
  58. package/nexus-stage/src/seat-name-editor.ts +31 -31
  59. package/nexus-stage/src/session-drag.ts +64 -64
  60. package/nexus-stage/src/viewport.ts +106 -106
  61. package/nexus-stage/src/wb-chat.ts +684 -684
  62. package/nexus-stage/src/wb-directory.ts +237 -237
  63. package/nexus-stage/src/wb-ledger.ts +279 -279
  64. package/nexus-stage/src/wb-names.ts +43 -43
  65. package/nexus-stage/src/wb-rail.ts +147 -147
  66. package/nexus-stage/src/workbench.css +388 -388
  67. package/nexus-stage/src/workbench.ts +1294 -1272
  68. package/nexus-stage/src/world.ts +509 -509
  69. package/nexus-stage/tsconfig.json +24 -24
  70. package/nexus-stage/vite.config.ts +15 -15
  71. package/package.json +1 -1
  72. package/assets/nexusDist/assets/index-NkNSeIx6.js +0 -9
@@ -1,684 +1,684 @@
1
- /**
2
- * The workbench's right-side chat drawer: one conversation per picked node.
3
- *
4
- * Layout: a head (back · status dot · title/route · refresh · info · close),
5
- * an optional info panel (forensics: ids, route, origin, where the records
6
- * live), the full-height thread, and a composer pinned to the bottom.
7
- *
8
- * The thread shows the conversation by default (user ruling 2026-10-23,
9
- * reversing b324460's folded-forensics default for this face): the session
10
- * log's turns as bubbles, then the messages sent from this page — each one
11
- * tracked against the ledger by task id (发送中 → 待回执 → 已结算 / 死信).
12
- * Ledger rows already owed by the same team show even after a reload, so
13
- * the drawer never hides that a message is still waiting for its receipt.
14
- *
15
- * The 3s poll never rebuilds the drawer: `update()` refreshes the head and
16
- * the outgoing statuses in place, so the composer keeps focus and caret and
17
- * the thread keeps its scroll without the old capture/restore workaround.
18
- * Data is attacker-shaped (session titles, log text): textContent only.
19
- * @module nexus-stage/wb-chat
20
- */
21
- import type { ArchivedRow, DeadRow, PendingRow, RemoteRow, StateBody } from './workbench'
22
- import { overHour, VERDICTS } from './wb-ledger'
23
- import { displayName } from './stage-name'
24
-
25
- /** Chat target: a node picked on the board or in the directory. Session and
26
- * log read the local transcript; remote rows and peers read through the
27
- * /a2a/log proxy. Reading, not jumping. */
28
- export type ChatTarget =
29
- | { kind: 'session'; id: string; title: string; sub: string; team?: string; live?: boolean }
30
- | { kind: 'log'; id: string; title: string; sub: string }
31
- | { kind: 'peer'; id: string; title: string; sub: string; url?: string; picked?: string }
32
- | { kind: 'remote'; team: string; title: string; sub: string; row?: RemoteRow }
33
-
34
- type Message = { role: string; text: string }
35
- type Transcript = { ok: boolean; messages?: Message[]; error?: string } | undefined
36
- type PeerListing = { ok: boolean; sessions?: Array<{ id: string; mtimeMs?: number; sizeBytes: number }>; error?: string } | undefined
37
-
38
- export interface ChatDrawerDeps {
39
- now(): number
40
- sessionLog?(id: string): Promise<{ ok: boolean; messages?: Message[]; error?: string }>
41
- remoteSessionLog?(query: { team?: string; peer?: string; id?: string; limit?: number }): Promise<{ ok: boolean; messages?: Message[]; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; error?: string; via?: string }>
42
- dispatchQuick?(team: string, message: string): Promise<{ ok: boolean; task_id?: string; reply?: string; error?: string }>
43
- /** A transcript loaded for `target` (the directory learns 本机日志 titles from it). */
44
- onTranscript?(target: ChatTarget, messages: ReadonlyArray<Message>): void
45
- }
46
-
47
- export interface ChatDrawer {
48
- readonly root: HTMLElement
49
- open(target: ChatTarget): void
50
- close(): void
51
- isOpen(): boolean
52
- /** Poll facts: refreshes the head (title / live) and the outgoing statuses in place. */
53
- update(state: StateBody | null): void
54
- }
55
-
56
- /** The composer's hard limit (dispatch route contract). */
57
- const MAX_CHARS = 4000
58
- /** Within this many px of the bottom, a repaint keeps the thread pinned there. */
59
- const STICK_PX = 32
60
-
61
- /** A message sent from this page, tracked until the ledger settles it. */
62
- interface Outgoing {
63
- readonly text: string
64
- readonly at: number
65
- state: 'sending' | 'sent' | 'failed'
66
- taskId?: string
67
- error?: string
68
- }
69
-
70
- function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] {
71
- const node = document.createElement(tag)
72
- if (className !== undefined) node.className = className
73
- return node
74
- }
75
-
76
- function iconButton(label: string, glyph: string, className = ''): HTMLButtonElement {
77
- const b = el('button', `wb-icon-btn wb-chat-act ${className}`.trim())
78
- b.type = 'button'
79
- b.setAttribute('aria-label', label)
80
- b.title = label
81
- b.textContent = glyph
82
- return b
83
- }
84
-
85
- function ago(ms: number, now: number): string {
86
- const s = Math.max(0, Math.round((now - ms) / 1000))
87
- if (s < 60) return '刚刚'
88
- const m = Math.round(s / 60)
89
- if (m < 60) return `${m} 分钟前`
90
- const h = Math.round(m / 60)
91
- if (h < 48) return `${h} 小时前`
92
- return `${Math.round(h / 24)} 天前`
93
- }
94
-
95
- function fmtSize(n: number): string {
96
- return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} MB` : n >= 1_000 ? `${Math.round(n / 1_000)} KB` : `${n} B`
97
- }
98
-
99
- /** The routing team a target can be dispatched to (none for logs and peer hosts). */
100
- function dispatchTeamOf(t: ChatTarget): string | undefined {
101
- const team = t.kind === 'session' || t.kind === 'remote' ? t.team : undefined
102
- return team !== undefined && team !== '' ? team : undefined
103
- }
104
-
105
- function targetKey(t: ChatTarget): string {
106
- return t.kind === 'remote' ? `remote:${t.team}` : `${t.kind}:${t.id}`
107
- }
108
-
109
- export function createChatDrawer(deps: ChatDrawerDeps): ChatDrawer {
110
- let target: ChatTarget | null = null
111
- let last: StateBody | null = null
112
- let focusPending = false
113
- let infoOpen = false
114
- /** Transcript per key, kept across open/close; refresh forces one re-read. */
115
- const transcripts = new Map<string, Transcript>()
116
- /** Reading position per transcript key (restored on reopen). */
117
- const scrolls = new Map<string, number>()
118
- const peerLists = new Map<string, PeerListing>()
119
- /** Messages sent from this page, per routing team. */
120
- const outbox = new Map<string, Outgoing[]>()
121
- /** Task ids the ledger has shown as pending (a vanished one has left the ledger). */
122
- const seenPending = new Set<string>()
123
- /** Direction per task id, learned from pending rows — the fallback for an
124
- * older host whose dead rows carry none (gh#31 added it). An inbound row (a
125
- * peer's delivery to this session) shares this session's team; without a
126
- * direction it would read as our own message. */
127
- const directions = new Map<string, 'inbound' | 'outbound'>()
128
- /** One composer per routing team: a half-typed draft survives switching nodes. */
129
- const composers = new Map<string, Composer>()
130
-
131
- // ── static shell ──
132
- const root = el('aside', 'wb-chat')
133
- root.setAttribute('aria-label', '对话')
134
- const head = el('div', 'wb-chat-head')
135
- const back = iconButton('返回会话列表', '‹', 'wb-chat-back')
136
- const dot = el('span', 'wb-chat-dot')
137
- const titles = el('div', 'wb-chat-titles')
138
- const title = el('span', 'wb-chat-title')
139
- const sub = el('span', 'wb-chat-sub')
140
- titles.append(title, sub)
141
- const refresh = iconButton('刷新对话', '↻', 'wb-chat-refresh')
142
- const infoBtn = iconButton('会话详情', 'ⓘ', 'wb-chat-info-btn')
143
- infoBtn.setAttribute('aria-expanded', 'false')
144
- const closeBtn = iconButton('关闭对话', '✕', 'wb-chat-close')
145
- head.append(back, dot, titles, refresh, infoBtn, closeBtn)
146
- const info = el('div', 'wb-chat-info')
147
- info.hidden = true
148
- const thread = el('div', 'wb-chat-thread')
149
- thread.setAttribute('role', 'log')
150
- thread.setAttribute('aria-live', 'polite')
151
- thread.tabIndex = 0
152
- const notice = el('div', 'wb-chat-notice')
153
- const log = el('div', 'wb-chat-log')
154
- const sent = el('div', 'wb-chat-sent')
155
- thread.append(notice, log, sent)
156
- const foot = el('div', 'wb-chat-foot')
157
- root.append(head, info, thread, foot)
158
-
159
- back.addEventListener('click', () => {
160
- if (target?.kind !== 'peer') return
161
- target.picked = undefined
162
- paintFace()
163
- })
164
- refresh.addEventListener('click', () => {
165
- if (target === null) return
166
- const key = transcriptKey(target)
167
- if (key !== undefined) transcripts.delete(key)
168
- if (target.kind === 'peer' && target.picked === undefined && target.url !== undefined) peerLists.delete(`peer:${target.url}`)
169
- paintThread(true)
170
- })
171
- infoBtn.addEventListener('click', () => {
172
- infoOpen = !infoOpen
173
- info.hidden = !infoOpen
174
- infoBtn.setAttribute('aria-expanded', String(infoOpen))
175
- })
176
- closeBtn.addEventListener('click', () => close())
177
- thread.addEventListener('scroll', () => {
178
- const key = target !== null ? transcriptKey(target) : undefined
179
- if (key !== undefined) scrolls.set(key, thread.scrollTop)
180
- })
181
- // 0.6.17 chat-UI convention: Escape closes the drawer. Document-scoped (a
182
- // reading drawer often holds no focusable element), but it never steals
183
- // Escape from another surface's text field or the planning board's own
184
- // deselect gesture.
185
- document.addEventListener('keydown', ev => {
186
- if (ev.key !== 'Escape' || !root.classList.contains('is-open')) return
187
- const t = ev.target
188
- if (!(t instanceof HTMLElement)) return
189
- if (!root.contains(t)) {
190
- const typingElsewhere = t instanceof HTMLTextAreaElement || t instanceof HTMLInputElement || t.isContentEditable
191
- const onBoard = t.closest('.wb-stage-board') !== null
192
- if (typingElsewhere || onBoard) return
193
- }
194
- close()
195
- })
196
-
197
- /** Which transcript a target reads (undefined: listing or no reader wired). */
198
- function transcriptKey(t: ChatTarget): string | undefined {
199
- if (t.kind === 'session' || t.kind === 'log') return deps.sessionLog !== undefined ? `local:${t.id}` : undefined
200
- if (t.kind === 'remote') return deps.remoteSessionLog !== undefined ? `remote:${t.team}` : undefined
201
- if (t.picked !== undefined && t.url !== undefined && deps.remoteSessionLog !== undefined) return `peer:${t.url}:${t.picked}`
202
- return undefined
203
- }
204
-
205
- function loadTranscript(t: ChatTarget): Promise<Transcript> {
206
- if (t.kind === 'session' || t.kind === 'log') return deps.sessionLog?.(t.id) ?? Promise.resolve({ ok: false, error: 'sessionLog unavailable' })
207
- if (t.kind === 'remote') return deps.remoteSessionLog?.({ team: t.team }) ?? Promise.resolve({ ok: false, error: 'remoteSessionLog unavailable' })
208
- return deps.remoteSessionLog?.({ peer: t.url, id: t.picked }) ?? Promise.resolve({ ok: false, error: 'remoteSessionLog unavailable' })
209
- }
210
-
211
- // ── head + info ──
212
- function paintHead(): void {
213
- const t = target
214
- if (t === null) return
215
- const cold = t.kind === 'log' || t.kind === 'remote' || (t.kind === 'session' && t.live === false)
216
- dot.className = `wb-chat-dot ${t.kind === 'peer' ? 'is-peer' : cold ? 'is-cold' : 'is-live'}`
217
- dot.title = t.kind === 'peer' ? '对端主机' : t.kind === 'log' ? '只读日志' : t.kind === 'remote' ? '远端会话' : t.live === false ? '冷会话' : '在线'
218
- title.textContent = t.kind === 'peer' && t.picked !== undefined ? `…${t.picked.slice(-12)}` : t.title
219
- sub.textContent = t.kind === 'peer' && t.picked !== undefined ? t.title : t.sub
220
- back.hidden = !(t.kind === 'peer' && t.picked !== undefined)
221
- }
222
-
223
- function kv(label: string, value: string): void {
224
- const row = el('div', 'wb-kv')
225
- row.appendChild(el('span', 'wb-kv-k')).textContent = label
226
- row.appendChild(el('span', 'wb-kv-v')).textContent = value
227
- info.appendChild(row)
228
- }
229
-
230
- function paintInfo(): void {
231
- info.replaceChildren()
232
- const t = target
233
- if (t === null) return
234
- if (t.kind === 'session') {
235
- kv('会话', t.id)
236
- if (t.team !== undefined) kv('路由', t.team)
237
- kv('来源', '本机会话日志')
238
- } else if (t.kind === 'log') {
239
- kv('会话', t.id)
240
- kv('来源', '本机会话日志(不在网络目录)')
241
- } else if (t.kind === 'remote') {
242
- kv('路由', t.team)
243
- if (t.row?.origin !== undefined) kv('来源', t.row.origin)
244
- if (t.row?.via !== undefined) kv('经', t.row.via)
245
- if (t.row?.workspace !== undefined) kv('工作区', t.row.workspace)
246
- } else {
247
- kv('对端', t.url ?? t.sub)
248
- if (t.picked !== undefined) kv('会话', t.picked)
249
- kv('来源', '经本机代理读取对端 /a2a/log')
250
- }
251
- }
252
-
253
- /** The one-line context at the top of the thread: where these records come from. */
254
- function noticeText(t: ChatTarget): string {
255
- if (t.kind === 'log') return '只读日志 —— 此会话当前不在网络目录(可能已归档或未入网)。'
256
- if (t.kind === 'remote') {
257
- return deps.remoteSessionLog !== undefined
258
- ? '远端会话 —— 经本机代理从对端 /a2a/log 读取最近对话。'
259
- : '远端节点的对话记录在对端机器上;本页的代理读取面未启用。'
260
- }
261
- if (t.kind === 'peer') {
262
- if (t.url === undefined || deps.remoteSessionLog === undefined) return `对端主机节点(${t.sub})—— 会话记录存放在对端机器上,本页只读本机会话日志。`
263
- return t.picked === undefined ? '对端主机 —— 选择一个会话阅读(经本机代理读取对端 /a2a/log)。' : ''
264
- }
265
- return deps.sessionLog === undefined ? '本页未接入会话日志读取,只显示从这里发出的消息。' : ''
266
- }
267
-
268
- // ── thread ──
269
- function atBottom(): boolean {
270
- return thread.scrollHeight - thread.scrollTop - thread.clientHeight <= STICK_PX
271
- }
272
-
273
- /** Keep the reader where they were, or on the newest turn when they were there. */
274
- function settle(stick: boolean): void {
275
- const apply = (): void => {
276
- if (stick) thread.scrollTop = thread.scrollHeight
277
- }
278
- apply()
279
- // Scroll writes only stick on attached, laid-out elements.
280
- requestAnimationFrame(apply)
281
- }
282
-
283
- function emptyState(text: string, tone?: 'error'): HTMLElement {
284
- const n = el('div', `wb-chat-empty${tone === 'error' ? ' is-error' : ''}`)
285
- n.textContent = text
286
- return n
287
- }
288
-
289
- function bubble(role: string, text: string): HTMLElement {
290
- const mine = role === 'user'
291
- const b = el('div', `wb-msg ${mine ? 'wb-msg-user' : 'wb-msg-assistant'}`)
292
- const who = el('span', 'wb-msg-who')
293
- who.textContent = mine ? '问' : '答'
294
- const body = el('div', 'wb-msg-text')
295
- body.textContent = text
296
- b.append(who, body)
297
- return b
298
- }
299
-
300
- function paintLog(state: Transcript): void {
301
- log.replaceChildren()
302
- if (state === undefined) { log.appendChild(emptyState('读取中…')); return }
303
- if (state.ok !== true || state.messages === undefined) { log.appendChild(emptyState(`无法读取(${state.error ?? '未知错误'})`, 'error')); return }
304
- if (state.messages.length === 0) { log.appendChild(emptyState('暂无对话记录')); return }
305
- for (const m of state.messages) log.appendChild(bubble(m.role, m.text))
306
- }
307
-
308
- function paintPeerList(url: string, state: PeerListing): void {
309
- log.replaceChildren()
310
- if (state === undefined) { log.appendChild(emptyState('读取中…')); return }
311
- if (state.ok !== true || state.sessions === undefined) { log.appendChild(emptyState(`无法读取(${state.error ?? '未知错误'})`, 'error')); return }
312
- if (state.sessions.length === 0) { log.appendChild(emptyState('对端没有可读会话')); return }
313
- const list = el('div', 'wb-chat-sessions')
314
- const now = deps.now()
315
- for (const row of state.sessions) {
316
- const item = el('button', 'wb-chat-session')
317
- item.type = 'button'
318
- item.appendChild(el('span', 'wb-chat-session-id')).textContent = `…${row.id.slice(-12)}`
319
- const meta = [row.mtimeMs !== undefined ? ago(row.mtimeMs, now) : '', fmtSize(row.sizeBytes)].filter(Boolean).join(' · ')
320
- item.appendChild(el('span', 'wb-chat-session-meta')).textContent = meta
321
- item.title = row.id
322
- item.addEventListener('click', () => {
323
- if (target?.kind !== 'peer' || target.url !== url) return
324
- target.picked = row.id
325
- paintFace()
326
- })
327
- list.appendChild(item)
328
- }
329
- log.appendChild(list)
330
- }
331
-
332
- /** (Re)load what the thread shows; `force` bypasses the caches and re-lands on the newest turn. */
333
- function paintThread(force = false): void {
334
- const t = target
335
- if (t === null) return
336
- notice.textContent = noticeText(t)
337
- notice.hidden = notice.textContent === ''
338
- if (t.kind === 'peer' && t.picked === undefined) {
339
- if (t.url === undefined || deps.remoteSessionLog === undefined) { log.replaceChildren(); return }
340
- const url = t.url
341
- const key = `peer:${url}`
342
- if (!force && peerLists.has(key)) { paintPeerList(url, peerLists.get(key)); return }
343
- peerLists.set(key, undefined)
344
- paintPeerList(url, undefined)
345
- void deps.remoteSessionLog({ peer: url }).then(result => {
346
- peerLists.set(key, result)
347
- if (target?.kind === 'peer' && target.url === url && target.picked === undefined) paintPeerList(url, result)
348
- }, (error: unknown) => {
349
- const result = { ok: false, error: error instanceof Error ? error.message : String(error) }
350
- peerLists.set(key, result)
351
- if (target?.kind === 'peer' && target.url === url && target.picked === undefined) paintPeerList(url, result)
352
- })
353
- return
354
- }
355
- const key = transcriptKey(t)
356
- if (key === undefined) { log.replaceChildren(); paintSent(true); return }
357
- const restore = (): void => {
358
- const saved = scrolls.get(key)
359
- if (saved === undefined || force) settle(true)
360
- else {
361
- thread.scrollTop = saved
362
- requestAnimationFrame(() => { if (target !== null && transcriptKey(target) === key) thread.scrollTop = saved })
363
- }
364
- }
365
- if (!force && transcripts.has(key) && transcripts.get(key) !== undefined) {
366
- paintLog(transcripts.get(key))
367
- paintSent(true)
368
- restore()
369
- return
370
- }
371
- if (force) scrolls.delete(key)
372
- transcripts.set(key, undefined)
373
- paintLog(undefined)
374
- paintSent(true)
375
- void loadTranscript(t).then(result => {
376
- transcripts.set(key, result)
377
- if (result?.ok === true && result.messages !== undefined) deps.onTranscript?.(t, result.messages)
378
- if (target === null || transcriptKey(target) !== key) return
379
- paintLog(result)
380
- paintSent()
381
- restore()
382
- }, (error: unknown) => {
383
- const result = { ok: false, error: error instanceof Error ? error.message : String(error) }
384
- transcripts.set(key, result)
385
- if (target !== null && transcriptKey(target) === key) paintLog(result)
386
- })
387
- }
388
-
389
- // ── outgoing: this page's sends + the ledger's open rows for the same team ──
390
- function pendingFor(team: string): PendingRow[] {
391
- return (last?.tasks ?? []).filter(r => r.team === team)
392
- }
393
- function deadFor(team: string): DeadRow[] {
394
- return (last?.tasksDead ?? []).filter(r => r.team === team)
395
- }
396
-
397
- /** What the outgoing area shows, as data first: a poll that changes
398
- * nothing must not touch the DOM (or the reader's scroll position). */
399
- type Tone = 'wait' | 'ok' | 'warn' | 'danger' | 'muted'
400
- /** One ledger-area row. `side`: whose words it is — ours (sent to this
401
- * team), theirs (a peer's delivery to this session, receipt owed by us), or
402
- * a neutral ledger line when the direction cannot be known. */
403
- type SentRow = { at: number; side: 'mine' | 'theirs' | 'system'; text: string; status: { text: string; tone: Tone; hint?: string }; taskId?: string }
404
- let sentSig = ''
405
-
406
- function deadStatus(row: DeadRow): SentRow['status'] {
407
- // 'fire-forget' is a receiptless row expiring — expected bookkeeping,
408
- // not a failure (task-ledger sweep); only 'unconsumed' is a dead letter.
409
- if (row.reason === 'fire-forget') return { text: '已过期(免回执)', tone: 'muted', hint: '免回执消息到 24h TTL 归档,属正常记账' }
410
- return { text: '死信', tone: 'danger', hint: row.reason !== undefined && row.reason !== 'unspecified' ? `${row.reason}:超过 24h TTL 仍未结算,需在台账清账` : '超过 24h TTL 仍未结算,需在台账清账' }
411
- }
412
-
413
- function pendingStatus(row: PendingRow, now: number): SentRow['status'] {
414
- const inbound = row.direction === 'inbound'
415
- if (row.receiptExpected === false) return { text: inbound ? '来信 · 无需回执' : '已送达 · 无回执', tone: 'muted', hint: '免回执消息:不等回执,24h 后自动归档' }
416
- // One hour boundary with the 台账 tab (the host's settleable verdict,
417
- // browser clock only on older hosts). Past it, OUR send is 陈旧 (can be
418
- // nudged); a peer's delivery is a receipt WE owe — never 陈旧 (#30 P3).
419
- const late = overHour(row, now)
420
- const text = inbound ? (late ? '待本机回执 · 超 1 小时' : '待本机回执') : late ? '陈旧' : '待回执'
421
- // The row's own status only when it says more than 'pending'.
422
- const extra = row.status !== 'pending' ? ` · ${row.status}` : ''
423
- return { text: `${text}${extra}`, tone: late ? 'warn' : 'wait', hint: `${inbound ? '对方等待本机回执;' : ''}${inbound ? '收到' : '派发'}于 ${ago(row.startedAt, now)}` }
424
- }
425
-
426
- /** A send that left the ledger, by how it ended (gh#31). `endedBy` says
427
- * who ended it; an older host omits it, and then only the outcome is
428
- * known. The summary is the peer's free text only on a receipt-settled
429
- * row (a tooltip, never markup) — caller-ended and echo rows carry the
430
- * ledger's own markers, which are not shown. */
431
- function settledStatus(row: ArchivedRow, now: number): SentRow['status'] {
432
- const when = `结算于 ${ago(row.resolvedAt, now)}`
433
- // gh#68: a fire-forget send this host moved out of the ledger (移出 on
434
- // the 已过期(免回执) card, an abandon) is bookkeeping, not a failure — it
435
- // never waited on a receipt, so 已放弃 would contradict the button that
436
- // did it. A cancel stays 已撤回: it is a deliberate recall that also
437
- // tells the peer to stop (API / tool path; the workbench offers none).
438
- // A receipt that arrives after the move keeps its 回执迟到 flag.
439
- if (row.receiptExpected === false && row.endedBy === 'abandoned') {
440
- return row.late === true
441
- ? { text: '已移出(免回执) · 回执迟到', tone: 'warn', hint: `${when};免回执消息本机移出后对方回执才到,结局不改写` }
442
- : { text: '已移出(免回执)', tone: 'muted', hint: `${when};免回执消息,本机移出台账,不是失败` }
443
- }
444
- if (row.endedBy === 'abandoned' || row.endedBy === 'cancelled') {
445
- const act = row.endedBy === 'cancelled' ? '撤回' : '放弃'
446
- // The peer answered after we ended it: the verdict stays ours (late
447
- // receipts never rewrite it), but the reader should know it came.
448
- if (row.late === true) return { text: `已${act} · 回执迟到`, tone: 'warn', hint: `${when};本机${act}之后对方回执才到,结局不改写` }
449
- return { text: `已${act}`, tone: 'muted', hint: `${when};本机${act},未等到回执` }
450
- }
451
- if (row.endedBy === 'echo') return { text: '已结算 · 内容回显', tone: 'ok', hint: `${when};对方回了内容但未带正式回执,按内容回显结算` }
452
- // An older host without endedBy: 'abandoned' may be ours (abandon or
453
- // cancel) or the peer's receipt verdict — claim neither side.
454
- if (row.endedBy === undefined && row.outcome === 'abandoned') return { text: '已放弃(本机或对方)', tone: 'muted', hint: `${when};本机放弃或撤回,或对方回执报告放弃` }
455
- // Settled by the peer's receipt: its own verdict.
456
- const summary = row.summary !== undefined && row.summary !== '' ? ` · ${row.summary}` : ''
457
- if (row.outcome === 'abandoned') return { text: '已回执 · 对方放弃', tone: 'warn', hint: `${when};对方回执报告放弃${summary}` }
458
- const v = VERDICTS.find(x => x.key === row.outcome)
459
- const tone: Tone = v?.tone === 'completed' ? 'ok' : v?.tone === 'failed' ? 'danger' : v?.tone === 'rejected' ? 'warn' : 'muted'
460
- if (row.outcome === 'unclassified') return { text: '已回执', tone: 'ok', hint: `${when};回执未带结论${summary}` }
461
- return { text: `已回执 · ${v?.label ?? row.outcome}`, tone, hint: `${when}${summary}` }
462
- }
463
-
464
- /** Status of a send this page made, by task id. */
465
- function receiptOf(taskId: string, team: string, now: number): SentRow['status'] {
466
- const dead = deadFor(team).find(r => r.taskId === taskId)
467
- if (dead !== undefined) return deadStatus(dead)
468
- const pending = pendingFor(team).find(r => r.taskId === taskId)
469
- if (pending !== undefined) return pendingStatus(pending, now)
470
- // Gone from the ledger: the archive says how it ended — also for a send
471
- // that settled between two polls and was never seen pending.
472
- const settled = (last?.archived ?? []).find(r => r.taskId === taskId && r.team === team)
473
- if (settled !== undefined) return settledStatus(settled, now)
474
- // No archived row: an older host without the archive face, or a row past
475
- // its newest-64 window — say only that it left.
476
- if (seenPending.has(taskId)) return { text: '已离开台账', tone: 'muted', hint: '已结算、放弃或撤回之一;主机未给出这条的结局(旧主机,或已超出最近 64 条归档)' }
477
- return { text: '已派发', tone: 'muted', hint: '异步送达;回执到达后进台账' }
478
- }
479
-
480
- function sentRows(team: string): SentRow[] {
481
- const now = deps.now()
482
- const mine = outbox.get(team) ?? []
483
- const mineIds = new Set(mine.map(o => o.taskId).filter((id): id is string => id !== undefined))
484
- const brief = (b?: string): string => (b !== undefined && b !== '' ? b : '(无摘要)')
485
- // Ledger rows for this team that this page did not send (earlier session,
486
- // another tab, a reload): shown by their brief so nothing owed is hidden —
487
- // on the side of whoever said it.
488
- const owed: SentRow[] = pendingFor(team).filter(r => !mineIds.has(r.taskId)).map(r => ({
489
- at: r.startedAt, side: r.direction === 'inbound' ? 'theirs' : 'mine', text: brief(r.brief), status: pendingStatus(r, now), taskId: r.taskId,
490
- }))
491
- const dead: SentRow[] = deadFor(team).filter(r => !mineIds.has(r.taskId)).map(r => {
492
- const dir = r.direction ?? directions.get(r.taskId) ?? (r.peer?.startsWith('local:') === true ? 'inbound' : undefined)
493
- return { at: r.startedAt, side: dir === 'inbound' ? 'theirs' : dir === 'outbound' ? 'mine' : 'system', text: brief(r.brief), status: deadStatus(r), taskId: r.taskId }
494
- })
495
- const local: SentRow[] = mine.map(o => ({
496
- at: o.at,
497
- side: 'mine',
498
- text: o.text,
499
- status: o.state === 'sending' ? { text: '发送中…', tone: 'muted' }
500
- : o.state === 'failed' ? { text: `失败:${o.error ?? '未知原因'}`, tone: 'danger' }
501
- : o.taskId !== undefined ? receiptOf(o.taskId, team, now) : { text: '已派发', tone: 'muted' },
502
- taskId: o.taskId,
503
- }))
504
- return [...owed, ...dead, ...local].sort((a, b) => a.at - b.at)
505
- }
506
-
507
- function paintSent(force = false): void {
508
- const t = target
509
- const team = t !== null ? dispatchTeamOf(t) : undefined
510
- const rows = team !== undefined ? sentRows(team) : []
511
- const sig = JSON.stringify([team ?? '', ...rows.map(r => [r.taskId ?? '', r.side, r.text, r.status.text])])
512
- if (!force && sig === sentSig) return
513
- sentSig = sig
514
- const stick = atBottom()
515
- sent.replaceChildren()
516
- if (rows.length === 0) return
517
- const divider = el('div', 'wb-chat-divider')
518
- divider.textContent = '台账往来 · 异步送达,回执进台账'
519
- sent.appendChild(divider)
520
- for (const r of rows) {
521
- const cls = r.side === 'mine' ? 'wb-msg wb-msg-user wb-msg-out' : r.side === 'theirs' ? 'wb-msg wb-msg-assistant wb-msg-in' : 'wb-msg wb-msg-ledger'
522
- const b = el('div', cls)
523
- b.appendChild(el('span', 'wb-msg-who')).textContent = r.side === 'mine' ? '我' : r.side === 'theirs' ? '对方' : '台账记录'
524
- b.appendChild(el('div', 'wb-msg-text')).textContent = r.text
525
- const meta = el('div', 'wb-msg-meta')
526
- if (r.taskId !== undefined) meta.appendChild(el('span', 'wb-msg-task')).textContent = r.taskId
527
- const chip = el('span', `wb-msg-status is-${r.status.tone}`)
528
- chip.textContent = r.status.text
529
- if (r.status.hint !== undefined) chip.title = r.status.hint
530
- meta.appendChild(chip)
531
- b.appendChild(meta)
532
- sent.appendChild(b)
533
- }
534
- if (stick) settle(true)
535
- }
536
-
537
- // ── composer ──
538
- interface Composer { team: string; form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement; submit: HTMLButtonElement }
539
-
540
- function hint(c: Composer): void {
541
- const n = c.text.value.length
542
- c.note.classList.remove('is-error')
543
- c.note.textContent = n > MAX_CHARS * 0.9 ? `${n}/${MAX_CHARS}` : 'Enter 发送 · Shift+Enter 换行'
544
- if (n > MAX_CHARS) c.note.classList.add('is-error')
545
- c.submit.disabled = c.text.value.trim() === '' || n > MAX_CHARS
546
- }
547
-
548
- function grow(text: HTMLTextAreaElement): void {
549
- text.style.height = 'auto'
550
- text.style.height = `${Math.min(text.scrollHeight, 160)}px`
551
- }
552
-
553
- function buildComposer(team: string): Composer {
554
- const form = el('form', 'wb-dispatch-form wb-chat-composer')
555
- const text = document.createElement('textarea')
556
- text.className = 'wb-dispatch-text'
557
- text.rows = 1
558
- text.placeholder = '发一句话任务给这个节点…'
559
- text.setAttribute('aria-label', '发送消息')
560
- const submit = el('button', 'wb-dispatch-submit')
561
- submit.type = 'submit'
562
- submit.textContent = '发送'
563
- submit.setAttribute('aria-label', '发送')
564
- const note = el('div', 'wb-dispatch-note')
565
- note.setAttribute('aria-live', 'polite')
566
- const row = el('div', 'wb-chat-composer-row')
567
- row.append(text, submit)
568
- form.append(row, note)
569
- const c: Composer = { team, form, text, note, submit }
570
- text.addEventListener('input', () => { grow(text); hint(c) })
571
- // 0.6.17 chat-UI conventions: Enter sends, Shift+Enter is a soft newline,
572
- // IME composition is never hijacked.
573
- text.addEventListener('keydown', ev => {
574
- if (ev.key !== 'Enter' || ev.shiftKey || ev.ctrlKey || ev.altKey || ev.metaKey) return
575
- if (ev.isComposing) return
576
- ev.preventDefault()
577
- form.requestSubmit()
578
- })
579
- form.addEventListener('submit', ev => {
580
- ev.preventDefault()
581
- const message = text.value.trim()
582
- if (message === '' || message.length > MAX_CHARS || deps.dispatchQuick === undefined) return
583
- const item: Outgoing = { text: message, at: deps.now(), state: 'sending' }
584
- const list = outbox.get(team) ?? []
585
- list.push(item)
586
- outbox.set(team, list)
587
- // Sent: clear the composer like every chat surface and keep the caret
588
- // in it; a refused send puts the text back so nothing is lost.
589
- text.value = ''
590
- grow(text)
591
- hint(c)
592
- text.focus({ preventScroll: true })
593
- paintSent()
594
- settle(true)
595
- void deps.dispatchQuick(team, message).then(outcome => outcome, (error: unknown) => ({
596
- ok: false, error: error instanceof Error ? error.message : String(error),
597
- } as { ok: boolean; task_id?: string; error?: string })).then(outcome => {
598
- if (outcome.ok === true) {
599
- item.state = 'sent'
600
- item.taskId = outcome.task_id
601
- note.classList.remove('is-error')
602
- note.textContent = `已派发${outcome.task_id !== undefined ? ` · ${outcome.task_id}` : ''} · 回执将入台账`
603
- } else {
604
- item.state = 'failed'
605
- item.error = outcome.error ?? '未知原因'
606
- if (text.value === '') { text.value = message; grow(text) }
607
- note.classList.add('is-error')
608
- note.textContent = `失败:${item.error}`
609
- submit.disabled = text.value.trim() === ''
610
- }
611
- if (target !== null && dispatchTeamOf(target) === team) paintSent()
612
- })
613
- })
614
- hint(c)
615
- return c
616
- }
617
-
618
- function paintFoot(): void {
619
- const t = target
620
- foot.replaceChildren()
621
- if (t === null) return
622
- const team = dispatchTeamOf(t)
623
- if (team !== undefined && deps.dispatchQuick !== undefined) {
624
- let c = composers.get(team)
625
- if (c === undefined) { c = buildComposer(team); composers.set(team, c) }
626
- foot.appendChild(c.form)
627
- if (focusPending) {
628
- focusPending = false
629
- // preventScroll: focusing must not fight the thread's landing scroll.
630
- c.text.focus({ preventScroll: true })
631
- }
632
- return
633
- }
634
- focusPending = false
635
- const ro = el('div', 'wb-chat-readonly')
636
- ro.textContent = t.kind === 'log' ? '只读日志:此会话不在网络目录,不能从这里派发。'
637
- : t.kind === 'peer' ? '对端主机:选择会话阅读;要派发请打开它的远端会话节点。'
638
- : '此节点没有可派发的路由。'
639
- foot.appendChild(ro)
640
- }
641
-
642
- function paintFace(): void {
643
- paintHead()
644
- paintInfo()
645
- paintThread()
646
- paintFoot()
647
- }
648
-
649
- // ── surface ──
650
- function open(t: ChatTarget): void {
651
- const same = target !== null && targetKey(target) === targetKey(t)
652
- target = t
653
- if (!same) focusPending = true
654
- paintFace()
655
- root.classList.add('is-open')
656
- }
657
-
658
- function close(): void {
659
- target = null
660
- root.classList.remove('is-open')
661
- }
662
-
663
- function update(state: StateBody | null): void {
664
- last = state
665
- for (const r of state?.tasks ?? []) {
666
- seenPending.add(r.taskId)
667
- directions.set(r.taskId, r.direction === 'inbound' ? 'inbound' : 'outbound')
668
- }
669
- const t = target
670
- if (t === null) return
671
- // Title / liveness freshness for sessions; everything else is static per open.
672
- if (t.kind === 'session' && state !== null) {
673
- const row = (state.sessions ?? []).find(s => s.id === t.id)
674
- if (row !== undefined) {
675
- t.title = displayName(row)
676
- t.live = row.live
677
- }
678
- }
679
- paintHead()
680
- paintSent()
681
- }
682
-
683
- return { root, open, close, isOpen: () => target !== null, update }
684
- }
1
+ /**
2
+ * The workbench's right-side chat drawer: one conversation per picked node.
3
+ *
4
+ * Layout: a head (back · status dot · title/route · refresh · info · close),
5
+ * an optional info panel (forensics: ids, route, origin, where the records
6
+ * live), the full-height thread, and a composer pinned to the bottom.
7
+ *
8
+ * The thread shows the conversation by default (user ruling 2026-10-23,
9
+ * reversing b324460's folded-forensics default for this face): the session
10
+ * log's turns as bubbles, then the messages sent from this page — each one
11
+ * tracked against the ledger by task id (发送中 → 待回执 → 已结算 / 死信).
12
+ * Ledger rows already owed by the same team show even after a reload, so
13
+ * the drawer never hides that a message is still waiting for its receipt.
14
+ *
15
+ * The 3s poll never rebuilds the drawer: `update()` refreshes the head and
16
+ * the outgoing statuses in place, so the composer keeps focus and caret and
17
+ * the thread keeps its scroll without the old capture/restore workaround.
18
+ * Data is attacker-shaped (session titles, log text): textContent only.
19
+ * @module nexus-stage/wb-chat
20
+ */
21
+ import type { ArchivedRow, DeadRow, PendingRow, RemoteRow, StateBody } from './workbench'
22
+ import { overHour, VERDICTS } from './wb-ledger'
23
+ import { displayName } from './stage-name'
24
+
25
+ /** Chat target: a node picked on the board or in the directory. Session and
26
+ * log read the local transcript; remote rows and peers read through the
27
+ * /a2a/log proxy. Reading, not jumping. */
28
+ export type ChatTarget =
29
+ | { kind: 'session'; id: string; title: string; sub: string; team?: string; live?: boolean }
30
+ | { kind: 'log'; id: string; title: string; sub: string }
31
+ | { kind: 'peer'; id: string; title: string; sub: string; url?: string; picked?: string }
32
+ | { kind: 'remote'; team: string; title: string; sub: string; row?: RemoteRow }
33
+
34
+ type Message = { role: string; text: string }
35
+ type Transcript = { ok: boolean; messages?: Message[]; error?: string } | undefined
36
+ type PeerListing = { ok: boolean; sessions?: Array<{ id: string; mtimeMs?: number; sizeBytes: number }>; error?: string } | undefined
37
+
38
+ export interface ChatDrawerDeps {
39
+ now(): number
40
+ sessionLog?(id: string): Promise<{ ok: boolean; messages?: Message[]; error?: string }>
41
+ remoteSessionLog?(query: { team?: string; peer?: string; id?: string; limit?: number }): Promise<{ ok: boolean; messages?: Message[]; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; error?: string; via?: string }>
42
+ dispatchQuick?(team: string, message: string): Promise<{ ok: boolean; task_id?: string; reply?: string; error?: string }>
43
+ /** A transcript loaded for `target` (the directory learns 本机日志 titles from it). */
44
+ onTranscript?(target: ChatTarget, messages: ReadonlyArray<Message>): void
45
+ }
46
+
47
+ export interface ChatDrawer {
48
+ readonly root: HTMLElement
49
+ open(target: ChatTarget): void
50
+ close(): void
51
+ isOpen(): boolean
52
+ /** Poll facts: refreshes the head (title / live) and the outgoing statuses in place. */
53
+ update(state: StateBody | null): void
54
+ }
55
+
56
+ /** The composer's hard limit (dispatch route contract). */
57
+ const MAX_CHARS = 4000
58
+ /** Within this many px of the bottom, a repaint keeps the thread pinned there. */
59
+ const STICK_PX = 32
60
+
61
+ /** A message sent from this page, tracked until the ledger settles it. */
62
+ interface Outgoing {
63
+ readonly text: string
64
+ readonly at: number
65
+ state: 'sending' | 'sent' | 'failed'
66
+ taskId?: string
67
+ error?: string
68
+ }
69
+
70
+ function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] {
71
+ const node = document.createElement(tag)
72
+ if (className !== undefined) node.className = className
73
+ return node
74
+ }
75
+
76
+ function iconButton(label: string, glyph: string, className = ''): HTMLButtonElement {
77
+ const b = el('button', `wb-icon-btn wb-chat-act ${className}`.trim())
78
+ b.type = 'button'
79
+ b.setAttribute('aria-label', label)
80
+ b.title = label
81
+ b.textContent = glyph
82
+ return b
83
+ }
84
+
85
+ function ago(ms: number, now: number): string {
86
+ const s = Math.max(0, Math.round((now - ms) / 1000))
87
+ if (s < 60) return '刚刚'
88
+ const m = Math.round(s / 60)
89
+ if (m < 60) return `${m} 分钟前`
90
+ const h = Math.round(m / 60)
91
+ if (h < 48) return `${h} 小时前`
92
+ return `${Math.round(h / 24)} 天前`
93
+ }
94
+
95
+ function fmtSize(n: number): string {
96
+ return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} MB` : n >= 1_000 ? `${Math.round(n / 1_000)} KB` : `${n} B`
97
+ }
98
+
99
+ /** The routing team a target can be dispatched to (none for logs and peer hosts). */
100
+ function dispatchTeamOf(t: ChatTarget): string | undefined {
101
+ const team = t.kind === 'session' || t.kind === 'remote' ? t.team : undefined
102
+ return team !== undefined && team !== '' ? team : undefined
103
+ }
104
+
105
+ function targetKey(t: ChatTarget): string {
106
+ return t.kind === 'remote' ? `remote:${t.team}` : `${t.kind}:${t.id}`
107
+ }
108
+
109
+ export function createChatDrawer(deps: ChatDrawerDeps): ChatDrawer {
110
+ let target: ChatTarget | null = null
111
+ let last: StateBody | null = null
112
+ let focusPending = false
113
+ let infoOpen = false
114
+ /** Transcript per key, kept across open/close; refresh forces one re-read. */
115
+ const transcripts = new Map<string, Transcript>()
116
+ /** Reading position per transcript key (restored on reopen). */
117
+ const scrolls = new Map<string, number>()
118
+ const peerLists = new Map<string, PeerListing>()
119
+ /** Messages sent from this page, per routing team. */
120
+ const outbox = new Map<string, Outgoing[]>()
121
+ /** Task ids the ledger has shown as pending (a vanished one has left the ledger). */
122
+ const seenPending = new Set<string>()
123
+ /** Direction per task id, learned from pending rows — the fallback for an
124
+ * older host whose dead rows carry none (gh#31 added it). An inbound row (a
125
+ * peer's delivery to this session) shares this session's team; without a
126
+ * direction it would read as our own message. */
127
+ const directions = new Map<string, 'inbound' | 'outbound'>()
128
+ /** One composer per routing team: a half-typed draft survives switching nodes. */
129
+ const composers = new Map<string, Composer>()
130
+
131
+ // ── static shell ──
132
+ const root = el('aside', 'wb-chat')
133
+ root.setAttribute('aria-label', '对话')
134
+ const head = el('div', 'wb-chat-head')
135
+ const back = iconButton('返回会话列表', '‹', 'wb-chat-back')
136
+ const dot = el('span', 'wb-chat-dot')
137
+ const titles = el('div', 'wb-chat-titles')
138
+ const title = el('span', 'wb-chat-title')
139
+ const sub = el('span', 'wb-chat-sub')
140
+ titles.append(title, sub)
141
+ const refresh = iconButton('刷新对话', '↻', 'wb-chat-refresh')
142
+ const infoBtn = iconButton('会话详情', 'ⓘ', 'wb-chat-info-btn')
143
+ infoBtn.setAttribute('aria-expanded', 'false')
144
+ const closeBtn = iconButton('关闭对话', '✕', 'wb-chat-close')
145
+ head.append(back, dot, titles, refresh, infoBtn, closeBtn)
146
+ const info = el('div', 'wb-chat-info')
147
+ info.hidden = true
148
+ const thread = el('div', 'wb-chat-thread')
149
+ thread.setAttribute('role', 'log')
150
+ thread.setAttribute('aria-live', 'polite')
151
+ thread.tabIndex = 0
152
+ const notice = el('div', 'wb-chat-notice')
153
+ const log = el('div', 'wb-chat-log')
154
+ const sent = el('div', 'wb-chat-sent')
155
+ thread.append(notice, log, sent)
156
+ const foot = el('div', 'wb-chat-foot')
157
+ root.append(head, info, thread, foot)
158
+
159
+ back.addEventListener('click', () => {
160
+ if (target?.kind !== 'peer') return
161
+ target.picked = undefined
162
+ paintFace()
163
+ })
164
+ refresh.addEventListener('click', () => {
165
+ if (target === null) return
166
+ const key = transcriptKey(target)
167
+ if (key !== undefined) transcripts.delete(key)
168
+ if (target.kind === 'peer' && target.picked === undefined && target.url !== undefined) peerLists.delete(`peer:${target.url}`)
169
+ paintThread(true)
170
+ })
171
+ infoBtn.addEventListener('click', () => {
172
+ infoOpen = !infoOpen
173
+ info.hidden = !infoOpen
174
+ infoBtn.setAttribute('aria-expanded', String(infoOpen))
175
+ })
176
+ closeBtn.addEventListener('click', () => close())
177
+ thread.addEventListener('scroll', () => {
178
+ const key = target !== null ? transcriptKey(target) : undefined
179
+ if (key !== undefined) scrolls.set(key, thread.scrollTop)
180
+ })
181
+ // 0.6.17 chat-UI convention: Escape closes the drawer. Document-scoped (a
182
+ // reading drawer often holds no focusable element), but it never steals
183
+ // Escape from another surface's text field or the planning board's own
184
+ // deselect gesture.
185
+ document.addEventListener('keydown', ev => {
186
+ if (ev.key !== 'Escape' || !root.classList.contains('is-open')) return
187
+ const t = ev.target
188
+ if (!(t instanceof HTMLElement)) return
189
+ if (!root.contains(t)) {
190
+ const typingElsewhere = t instanceof HTMLTextAreaElement || t instanceof HTMLInputElement || t.isContentEditable
191
+ const onBoard = t.closest('.wb-stage-board') !== null
192
+ if (typingElsewhere || onBoard) return
193
+ }
194
+ close()
195
+ })
196
+
197
+ /** Which transcript a target reads (undefined: listing or no reader wired). */
198
+ function transcriptKey(t: ChatTarget): string | undefined {
199
+ if (t.kind === 'session' || t.kind === 'log') return deps.sessionLog !== undefined ? `local:${t.id}` : undefined
200
+ if (t.kind === 'remote') return deps.remoteSessionLog !== undefined ? `remote:${t.team}` : undefined
201
+ if (t.picked !== undefined && t.url !== undefined && deps.remoteSessionLog !== undefined) return `peer:${t.url}:${t.picked}`
202
+ return undefined
203
+ }
204
+
205
+ function loadTranscript(t: ChatTarget): Promise<Transcript> {
206
+ if (t.kind === 'session' || t.kind === 'log') return deps.sessionLog?.(t.id) ?? Promise.resolve({ ok: false, error: 'sessionLog unavailable' })
207
+ if (t.kind === 'remote') return deps.remoteSessionLog?.({ team: t.team }) ?? Promise.resolve({ ok: false, error: 'remoteSessionLog unavailable' })
208
+ return deps.remoteSessionLog?.({ peer: t.url, id: t.picked }) ?? Promise.resolve({ ok: false, error: 'remoteSessionLog unavailable' })
209
+ }
210
+
211
+ // ── head + info ──
212
+ function paintHead(): void {
213
+ const t = target
214
+ if (t === null) return
215
+ const cold = t.kind === 'log' || t.kind === 'remote' || (t.kind === 'session' && t.live === false)
216
+ dot.className = `wb-chat-dot ${t.kind === 'peer' ? 'is-peer' : cold ? 'is-cold' : 'is-live'}`
217
+ dot.title = t.kind === 'peer' ? '对端主机' : t.kind === 'log' ? '只读日志' : t.kind === 'remote' ? '远端会话' : t.live === false ? '冷会话' : '在线'
218
+ title.textContent = t.kind === 'peer' && t.picked !== undefined ? `…${t.picked.slice(-12)}` : t.title
219
+ sub.textContent = t.kind === 'peer' && t.picked !== undefined ? t.title : t.sub
220
+ back.hidden = !(t.kind === 'peer' && t.picked !== undefined)
221
+ }
222
+
223
+ function kv(label: string, value: string): void {
224
+ const row = el('div', 'wb-kv')
225
+ row.appendChild(el('span', 'wb-kv-k')).textContent = label
226
+ row.appendChild(el('span', 'wb-kv-v')).textContent = value
227
+ info.appendChild(row)
228
+ }
229
+
230
+ function paintInfo(): void {
231
+ info.replaceChildren()
232
+ const t = target
233
+ if (t === null) return
234
+ if (t.kind === 'session') {
235
+ kv('会话', t.id)
236
+ if (t.team !== undefined) kv('路由', t.team)
237
+ kv('来源', '本机会话日志')
238
+ } else if (t.kind === 'log') {
239
+ kv('会话', t.id)
240
+ kv('来源', '本机会话日志(不在网络目录)')
241
+ } else if (t.kind === 'remote') {
242
+ kv('路由', t.team)
243
+ if (t.row?.origin !== undefined) kv('来源', t.row.origin)
244
+ if (t.row?.via !== undefined) kv('经', t.row.via)
245
+ if (t.row?.workspace !== undefined) kv('工作区', t.row.workspace)
246
+ } else {
247
+ kv('对端', t.url ?? t.sub)
248
+ if (t.picked !== undefined) kv('会话', t.picked)
249
+ kv('来源', '经本机代理读取对端 /a2a/log')
250
+ }
251
+ }
252
+
253
+ /** The one-line context at the top of the thread: where these records come from. */
254
+ function noticeText(t: ChatTarget): string {
255
+ if (t.kind === 'log') return '只读日志 —— 此会话当前不在网络目录(可能已归档或未入网)。'
256
+ if (t.kind === 'remote') {
257
+ return deps.remoteSessionLog !== undefined
258
+ ? '远端会话 —— 经本机代理从对端 /a2a/log 读取最近对话。'
259
+ : '远端节点的对话记录在对端机器上;本页的代理读取面未启用。'
260
+ }
261
+ if (t.kind === 'peer') {
262
+ if (t.url === undefined || deps.remoteSessionLog === undefined) return `对端主机节点(${t.sub})—— 会话记录存放在对端机器上,本页只读本机会话日志。`
263
+ return t.picked === undefined ? '对端主机 —— 选择一个会话阅读(经本机代理读取对端 /a2a/log)。' : ''
264
+ }
265
+ return deps.sessionLog === undefined ? '本页未接入会话日志读取,只显示从这里发出的消息。' : ''
266
+ }
267
+
268
+ // ── thread ──
269
+ function atBottom(): boolean {
270
+ return thread.scrollHeight - thread.scrollTop - thread.clientHeight <= STICK_PX
271
+ }
272
+
273
+ /** Keep the reader where they were, or on the newest turn when they were there. */
274
+ function settle(stick: boolean): void {
275
+ const apply = (): void => {
276
+ if (stick) thread.scrollTop = thread.scrollHeight
277
+ }
278
+ apply()
279
+ // Scroll writes only stick on attached, laid-out elements.
280
+ requestAnimationFrame(apply)
281
+ }
282
+
283
+ function emptyState(text: string, tone?: 'error'): HTMLElement {
284
+ const n = el('div', `wb-chat-empty${tone === 'error' ? ' is-error' : ''}`)
285
+ n.textContent = text
286
+ return n
287
+ }
288
+
289
+ function bubble(role: string, text: string): HTMLElement {
290
+ const mine = role === 'user'
291
+ const b = el('div', `wb-msg ${mine ? 'wb-msg-user' : 'wb-msg-assistant'}`)
292
+ const who = el('span', 'wb-msg-who')
293
+ who.textContent = mine ? '问' : '答'
294
+ const body = el('div', 'wb-msg-text')
295
+ body.textContent = text
296
+ b.append(who, body)
297
+ return b
298
+ }
299
+
300
+ function paintLog(state: Transcript): void {
301
+ log.replaceChildren()
302
+ if (state === undefined) { log.appendChild(emptyState('读取中…')); return }
303
+ if (state.ok !== true || state.messages === undefined) { log.appendChild(emptyState(`无法读取(${state.error ?? '未知错误'})`, 'error')); return }
304
+ if (state.messages.length === 0) { log.appendChild(emptyState('暂无对话记录')); return }
305
+ for (const m of state.messages) log.appendChild(bubble(m.role, m.text))
306
+ }
307
+
308
+ function paintPeerList(url: string, state: PeerListing): void {
309
+ log.replaceChildren()
310
+ if (state === undefined) { log.appendChild(emptyState('读取中…')); return }
311
+ if (state.ok !== true || state.sessions === undefined) { log.appendChild(emptyState(`无法读取(${state.error ?? '未知错误'})`, 'error')); return }
312
+ if (state.sessions.length === 0) { log.appendChild(emptyState('对端没有可读会话')); return }
313
+ const list = el('div', 'wb-chat-sessions')
314
+ const now = deps.now()
315
+ for (const row of state.sessions) {
316
+ const item = el('button', 'wb-chat-session')
317
+ item.type = 'button'
318
+ item.appendChild(el('span', 'wb-chat-session-id')).textContent = `…${row.id.slice(-12)}`
319
+ const meta = [row.mtimeMs !== undefined ? ago(row.mtimeMs, now) : '', fmtSize(row.sizeBytes)].filter(Boolean).join(' · ')
320
+ item.appendChild(el('span', 'wb-chat-session-meta')).textContent = meta
321
+ item.title = row.id
322
+ item.addEventListener('click', () => {
323
+ if (target?.kind !== 'peer' || target.url !== url) return
324
+ target.picked = row.id
325
+ paintFace()
326
+ })
327
+ list.appendChild(item)
328
+ }
329
+ log.appendChild(list)
330
+ }
331
+
332
+ /** (Re)load what the thread shows; `force` bypasses the caches and re-lands on the newest turn. */
333
+ function paintThread(force = false): void {
334
+ const t = target
335
+ if (t === null) return
336
+ notice.textContent = noticeText(t)
337
+ notice.hidden = notice.textContent === ''
338
+ if (t.kind === 'peer' && t.picked === undefined) {
339
+ if (t.url === undefined || deps.remoteSessionLog === undefined) { log.replaceChildren(); return }
340
+ const url = t.url
341
+ const key = `peer:${url}`
342
+ if (!force && peerLists.has(key)) { paintPeerList(url, peerLists.get(key)); return }
343
+ peerLists.set(key, undefined)
344
+ paintPeerList(url, undefined)
345
+ void deps.remoteSessionLog({ peer: url }).then(result => {
346
+ peerLists.set(key, result)
347
+ if (target?.kind === 'peer' && target.url === url && target.picked === undefined) paintPeerList(url, result)
348
+ }, (error: unknown) => {
349
+ const result = { ok: false, error: error instanceof Error ? error.message : String(error) }
350
+ peerLists.set(key, result)
351
+ if (target?.kind === 'peer' && target.url === url && target.picked === undefined) paintPeerList(url, result)
352
+ })
353
+ return
354
+ }
355
+ const key = transcriptKey(t)
356
+ if (key === undefined) { log.replaceChildren(); paintSent(true); return }
357
+ const restore = (): void => {
358
+ const saved = scrolls.get(key)
359
+ if (saved === undefined || force) settle(true)
360
+ else {
361
+ thread.scrollTop = saved
362
+ requestAnimationFrame(() => { if (target !== null && transcriptKey(target) === key) thread.scrollTop = saved })
363
+ }
364
+ }
365
+ if (!force && transcripts.has(key) && transcripts.get(key) !== undefined) {
366
+ paintLog(transcripts.get(key))
367
+ paintSent(true)
368
+ restore()
369
+ return
370
+ }
371
+ if (force) scrolls.delete(key)
372
+ transcripts.set(key, undefined)
373
+ paintLog(undefined)
374
+ paintSent(true)
375
+ void loadTranscript(t).then(result => {
376
+ transcripts.set(key, result)
377
+ if (result?.ok === true && result.messages !== undefined) deps.onTranscript?.(t, result.messages)
378
+ if (target === null || transcriptKey(target) !== key) return
379
+ paintLog(result)
380
+ paintSent()
381
+ restore()
382
+ }, (error: unknown) => {
383
+ const result = { ok: false, error: error instanceof Error ? error.message : String(error) }
384
+ transcripts.set(key, result)
385
+ if (target !== null && transcriptKey(target) === key) paintLog(result)
386
+ })
387
+ }
388
+
389
+ // ── outgoing: this page's sends + the ledger's open rows for the same team ──
390
+ function pendingFor(team: string): PendingRow[] {
391
+ return (last?.tasks ?? []).filter(r => r.team === team)
392
+ }
393
+ function deadFor(team: string): DeadRow[] {
394
+ return (last?.tasksDead ?? []).filter(r => r.team === team)
395
+ }
396
+
397
+ /** What the outgoing area shows, as data first: a poll that changes
398
+ * nothing must not touch the DOM (or the reader's scroll position). */
399
+ type Tone = 'wait' | 'ok' | 'warn' | 'danger' | 'muted'
400
+ /** One ledger-area row. `side`: whose words it is — ours (sent to this
401
+ * team), theirs (a peer's delivery to this session, receipt owed by us), or
402
+ * a neutral ledger line when the direction cannot be known. */
403
+ type SentRow = { at: number; side: 'mine' | 'theirs' | 'system'; text: string; status: { text: string; tone: Tone; hint?: string }; taskId?: string }
404
+ let sentSig = ''
405
+
406
+ function deadStatus(row: DeadRow): SentRow['status'] {
407
+ // 'fire-forget' is a receiptless row expiring — expected bookkeeping,
408
+ // not a failure (task-ledger sweep); only 'unconsumed' is a dead letter.
409
+ if (row.reason === 'fire-forget') return { text: '已过期(免回执)', tone: 'muted', hint: '免回执消息到 24h TTL 归档,属正常记账' }
410
+ return { text: '死信', tone: 'danger', hint: row.reason !== undefined && row.reason !== 'unspecified' ? `${row.reason}:超过 24h TTL 仍未结算,需在台账清账` : '超过 24h TTL 仍未结算,需在台账清账' }
411
+ }
412
+
413
+ function pendingStatus(row: PendingRow, now: number): SentRow['status'] {
414
+ const inbound = row.direction === 'inbound'
415
+ if (row.receiptExpected === false) return { text: inbound ? '来信 · 无需回执' : '已送达 · 无回执', tone: 'muted', hint: '免回执消息:不等回执,24h 后自动归档' }
416
+ // One hour boundary with the 台账 tab (the host's settleable verdict,
417
+ // browser clock only on older hosts). Past it, OUR send is 陈旧 (can be
418
+ // nudged); a peer's delivery is a receipt WE owe — never 陈旧 (#30 P3).
419
+ const late = overHour(row, now)
420
+ const text = inbound ? (late ? '待本机回执 · 超 1 小时' : '待本机回执') : late ? '陈旧' : '待回执'
421
+ // The row's own status only when it says more than 'pending'.
422
+ const extra = row.status !== 'pending' ? ` · ${row.status}` : ''
423
+ return { text: `${text}${extra}`, tone: late ? 'warn' : 'wait', hint: `${inbound ? '对方等待本机回执;' : ''}${inbound ? '收到' : '派发'}于 ${ago(row.startedAt, now)}` }
424
+ }
425
+
426
+ /** A send that left the ledger, by how it ended (gh#31). `endedBy` says
427
+ * who ended it; an older host omits it, and then only the outcome is
428
+ * known. The summary is the peer's free text only on a receipt-settled
429
+ * row (a tooltip, never markup) — caller-ended and echo rows carry the
430
+ * ledger's own markers, which are not shown. */
431
+ function settledStatus(row: ArchivedRow, now: number): SentRow['status'] {
432
+ const when = `结算于 ${ago(row.resolvedAt, now)}`
433
+ // gh#68: a fire-forget send this host moved out of the ledger (移出 on
434
+ // the 已过期(免回执) card, an abandon) is bookkeeping, not a failure — it
435
+ // never waited on a receipt, so 已放弃 would contradict the button that
436
+ // did it. A cancel stays 已撤回: it is a deliberate recall that also
437
+ // tells the peer to stop (API / tool path; the workbench offers none).
438
+ // A receipt that arrives after the move keeps its 回执迟到 flag.
439
+ if (row.receiptExpected === false && row.endedBy === 'abandoned') {
440
+ return row.late === true
441
+ ? { text: '已移出(免回执) · 回执迟到', tone: 'warn', hint: `${when};免回执消息本机移出后对方回执才到,结局不改写` }
442
+ : { text: '已移出(免回执)', tone: 'muted', hint: `${when};免回执消息,本机移出台账,不是失败` }
443
+ }
444
+ if (row.endedBy === 'abandoned' || row.endedBy === 'cancelled') {
445
+ const act = row.endedBy === 'cancelled' ? '撤回' : '放弃'
446
+ // The peer answered after we ended it: the verdict stays ours (late
447
+ // receipts never rewrite it), but the reader should know it came.
448
+ if (row.late === true) return { text: `已${act} · 回执迟到`, tone: 'warn', hint: `${when};本机${act}之后对方回执才到,结局不改写` }
449
+ return { text: `已${act}`, tone: 'muted', hint: `${when};本机${act},未等到回执` }
450
+ }
451
+ if (row.endedBy === 'echo') return { text: '已结算 · 内容回显', tone: 'ok', hint: `${when};对方回了内容但未带正式回执,按内容回显结算` }
452
+ // An older host without endedBy: 'abandoned' may be ours (abandon or
453
+ // cancel) or the peer's receipt verdict — claim neither side.
454
+ if (row.endedBy === undefined && row.outcome === 'abandoned') return { text: '已放弃(本机或对方)', tone: 'muted', hint: `${when};本机放弃或撤回,或对方回执报告放弃` }
455
+ // Settled by the peer's receipt: its own verdict.
456
+ const summary = row.summary !== undefined && row.summary !== '' ? ` · ${row.summary}` : ''
457
+ if (row.outcome === 'abandoned') return { text: '已回执 · 对方放弃', tone: 'warn', hint: `${when};对方回执报告放弃${summary}` }
458
+ const v = VERDICTS.find(x => x.key === row.outcome)
459
+ const tone: Tone = v?.tone === 'completed' ? 'ok' : v?.tone === 'failed' ? 'danger' : v?.tone === 'rejected' ? 'warn' : 'muted'
460
+ if (row.outcome === 'unclassified') return { text: '已回执', tone: 'ok', hint: `${when};回执未带结论${summary}` }
461
+ return { text: `已回执 · ${v?.label ?? row.outcome}`, tone, hint: `${when}${summary}` }
462
+ }
463
+
464
+ /** Status of a send this page made, by task id. */
465
+ function receiptOf(taskId: string, team: string, now: number): SentRow['status'] {
466
+ const dead = deadFor(team).find(r => r.taskId === taskId)
467
+ if (dead !== undefined) return deadStatus(dead)
468
+ const pending = pendingFor(team).find(r => r.taskId === taskId)
469
+ if (pending !== undefined) return pendingStatus(pending, now)
470
+ // Gone from the ledger: the archive says how it ended — also for a send
471
+ // that settled between two polls and was never seen pending.
472
+ const settled = (last?.archived ?? []).find(r => r.taskId === taskId && r.team === team)
473
+ if (settled !== undefined) return settledStatus(settled, now)
474
+ // No archived row: an older host without the archive face, or a row past
475
+ // its newest-64 window — say only that it left.
476
+ if (seenPending.has(taskId)) return { text: '已离开台账', tone: 'muted', hint: '已结算、放弃或撤回之一;主机未给出这条的结局(旧主机,或已超出最近 64 条归档)' }
477
+ return { text: '已派发', tone: 'muted', hint: '异步送达;回执到达后进台账' }
478
+ }
479
+
480
+ function sentRows(team: string): SentRow[] {
481
+ const now = deps.now()
482
+ const mine = outbox.get(team) ?? []
483
+ const mineIds = new Set(mine.map(o => o.taskId).filter((id): id is string => id !== undefined))
484
+ const brief = (b?: string): string => (b !== undefined && b !== '' ? b : '(无摘要)')
485
+ // Ledger rows for this team that this page did not send (earlier session,
486
+ // another tab, a reload): shown by their brief so nothing owed is hidden —
487
+ // on the side of whoever said it.
488
+ const owed: SentRow[] = pendingFor(team).filter(r => !mineIds.has(r.taskId)).map(r => ({
489
+ at: r.startedAt, side: r.direction === 'inbound' ? 'theirs' : 'mine', text: brief(r.brief), status: pendingStatus(r, now), taskId: r.taskId,
490
+ }))
491
+ const dead: SentRow[] = deadFor(team).filter(r => !mineIds.has(r.taskId)).map(r => {
492
+ const dir = r.direction ?? directions.get(r.taskId) ?? (r.peer?.startsWith('local:') === true ? 'inbound' : undefined)
493
+ return { at: r.startedAt, side: dir === 'inbound' ? 'theirs' : dir === 'outbound' ? 'mine' : 'system', text: brief(r.brief), status: deadStatus(r), taskId: r.taskId }
494
+ })
495
+ const local: SentRow[] = mine.map(o => ({
496
+ at: o.at,
497
+ side: 'mine',
498
+ text: o.text,
499
+ status: o.state === 'sending' ? { text: '发送中…', tone: 'muted' }
500
+ : o.state === 'failed' ? { text: `失败:${o.error ?? '未知原因'}`, tone: 'danger' }
501
+ : o.taskId !== undefined ? receiptOf(o.taskId, team, now) : { text: '已派发', tone: 'muted' },
502
+ taskId: o.taskId,
503
+ }))
504
+ return [...owed, ...dead, ...local].sort((a, b) => a.at - b.at)
505
+ }
506
+
507
+ function paintSent(force = false): void {
508
+ const t = target
509
+ const team = t !== null ? dispatchTeamOf(t) : undefined
510
+ const rows = team !== undefined ? sentRows(team) : []
511
+ const sig = JSON.stringify([team ?? '', ...rows.map(r => [r.taskId ?? '', r.side, r.text, r.status.text])])
512
+ if (!force && sig === sentSig) return
513
+ sentSig = sig
514
+ const stick = atBottom()
515
+ sent.replaceChildren()
516
+ if (rows.length === 0) return
517
+ const divider = el('div', 'wb-chat-divider')
518
+ divider.textContent = '台账往来 · 异步送达,回执进台账'
519
+ sent.appendChild(divider)
520
+ for (const r of rows) {
521
+ const cls = r.side === 'mine' ? 'wb-msg wb-msg-user wb-msg-out' : r.side === 'theirs' ? 'wb-msg wb-msg-assistant wb-msg-in' : 'wb-msg wb-msg-ledger'
522
+ const b = el('div', cls)
523
+ b.appendChild(el('span', 'wb-msg-who')).textContent = r.side === 'mine' ? '我' : r.side === 'theirs' ? '对方' : '台账记录'
524
+ b.appendChild(el('div', 'wb-msg-text')).textContent = r.text
525
+ const meta = el('div', 'wb-msg-meta')
526
+ if (r.taskId !== undefined) meta.appendChild(el('span', 'wb-msg-task')).textContent = r.taskId
527
+ const chip = el('span', `wb-msg-status is-${r.status.tone}`)
528
+ chip.textContent = r.status.text
529
+ if (r.status.hint !== undefined) chip.title = r.status.hint
530
+ meta.appendChild(chip)
531
+ b.appendChild(meta)
532
+ sent.appendChild(b)
533
+ }
534
+ if (stick) settle(true)
535
+ }
536
+
537
+ // ── composer ──
538
+ interface Composer { team: string; form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement; submit: HTMLButtonElement }
539
+
540
+ function hint(c: Composer): void {
541
+ const n = c.text.value.length
542
+ c.note.classList.remove('is-error')
543
+ c.note.textContent = n > MAX_CHARS * 0.9 ? `${n}/${MAX_CHARS}` : 'Enter 发送 · Shift+Enter 换行'
544
+ if (n > MAX_CHARS) c.note.classList.add('is-error')
545
+ c.submit.disabled = c.text.value.trim() === '' || n > MAX_CHARS
546
+ }
547
+
548
+ function grow(text: HTMLTextAreaElement): void {
549
+ text.style.height = 'auto'
550
+ text.style.height = `${Math.min(text.scrollHeight, 160)}px`
551
+ }
552
+
553
+ function buildComposer(team: string): Composer {
554
+ const form = el('form', 'wb-dispatch-form wb-chat-composer')
555
+ const text = document.createElement('textarea')
556
+ text.className = 'wb-dispatch-text'
557
+ text.rows = 1
558
+ text.placeholder = '发一句话任务给这个节点…'
559
+ text.setAttribute('aria-label', '发送消息')
560
+ const submit = el('button', 'wb-dispatch-submit')
561
+ submit.type = 'submit'
562
+ submit.textContent = '发送'
563
+ submit.setAttribute('aria-label', '发送')
564
+ const note = el('div', 'wb-dispatch-note')
565
+ note.setAttribute('aria-live', 'polite')
566
+ const row = el('div', 'wb-chat-composer-row')
567
+ row.append(text, submit)
568
+ form.append(row, note)
569
+ const c: Composer = { team, form, text, note, submit }
570
+ text.addEventListener('input', () => { grow(text); hint(c) })
571
+ // 0.6.17 chat-UI conventions: Enter sends, Shift+Enter is a soft newline,
572
+ // IME composition is never hijacked.
573
+ text.addEventListener('keydown', ev => {
574
+ if (ev.key !== 'Enter' || ev.shiftKey || ev.ctrlKey || ev.altKey || ev.metaKey) return
575
+ if (ev.isComposing) return
576
+ ev.preventDefault()
577
+ form.requestSubmit()
578
+ })
579
+ form.addEventListener('submit', ev => {
580
+ ev.preventDefault()
581
+ const message = text.value.trim()
582
+ if (message === '' || message.length > MAX_CHARS || deps.dispatchQuick === undefined) return
583
+ const item: Outgoing = { text: message, at: deps.now(), state: 'sending' }
584
+ const list = outbox.get(team) ?? []
585
+ list.push(item)
586
+ outbox.set(team, list)
587
+ // Sent: clear the composer like every chat surface and keep the caret
588
+ // in it; a refused send puts the text back so nothing is lost.
589
+ text.value = ''
590
+ grow(text)
591
+ hint(c)
592
+ text.focus({ preventScroll: true })
593
+ paintSent()
594
+ settle(true)
595
+ void deps.dispatchQuick(team, message).then(outcome => outcome, (error: unknown) => ({
596
+ ok: false, error: error instanceof Error ? error.message : String(error),
597
+ } as { ok: boolean; task_id?: string; error?: string })).then(outcome => {
598
+ if (outcome.ok === true) {
599
+ item.state = 'sent'
600
+ item.taskId = outcome.task_id
601
+ note.classList.remove('is-error')
602
+ note.textContent = `已派发${outcome.task_id !== undefined ? ` · ${outcome.task_id}` : ''} · 回执将入台账`
603
+ } else {
604
+ item.state = 'failed'
605
+ item.error = outcome.error ?? '未知原因'
606
+ if (text.value === '') { text.value = message; grow(text) }
607
+ note.classList.add('is-error')
608
+ note.textContent = `失败:${item.error}`
609
+ submit.disabled = text.value.trim() === ''
610
+ }
611
+ if (target !== null && dispatchTeamOf(target) === team) paintSent()
612
+ })
613
+ })
614
+ hint(c)
615
+ return c
616
+ }
617
+
618
+ function paintFoot(): void {
619
+ const t = target
620
+ foot.replaceChildren()
621
+ if (t === null) return
622
+ const team = dispatchTeamOf(t)
623
+ if (team !== undefined && deps.dispatchQuick !== undefined) {
624
+ let c = composers.get(team)
625
+ if (c === undefined) { c = buildComposer(team); composers.set(team, c) }
626
+ foot.appendChild(c.form)
627
+ if (focusPending) {
628
+ focusPending = false
629
+ // preventScroll: focusing must not fight the thread's landing scroll.
630
+ c.text.focus({ preventScroll: true })
631
+ }
632
+ return
633
+ }
634
+ focusPending = false
635
+ const ro = el('div', 'wb-chat-readonly')
636
+ ro.textContent = t.kind === 'log' ? '只读日志:此会话不在网络目录,不能从这里派发。'
637
+ : t.kind === 'peer' ? '对端主机:选择会话阅读;要派发请打开它的远端会话节点。'
638
+ : '此节点没有可派发的路由。'
639
+ foot.appendChild(ro)
640
+ }
641
+
642
+ function paintFace(): void {
643
+ paintHead()
644
+ paintInfo()
645
+ paintThread()
646
+ paintFoot()
647
+ }
648
+
649
+ // ── surface ──
650
+ function open(t: ChatTarget): void {
651
+ const same = target !== null && targetKey(target) === targetKey(t)
652
+ target = t
653
+ if (!same) focusPending = true
654
+ paintFace()
655
+ root.classList.add('is-open')
656
+ }
657
+
658
+ function close(): void {
659
+ target = null
660
+ root.classList.remove('is-open')
661
+ }
662
+
663
+ function update(state: StateBody | null): void {
664
+ last = state
665
+ for (const r of state?.tasks ?? []) {
666
+ seenPending.add(r.taskId)
667
+ directions.set(r.taskId, r.direction === 'inbound' ? 'inbound' : 'outbound')
668
+ }
669
+ const t = target
670
+ if (t === null) return
671
+ // Title / liveness freshness for sessions; everything else is static per open.
672
+ if (t.kind === 'session' && state !== null) {
673
+ const row = (state.sessions ?? []).find(s => s.id === t.id)
674
+ if (row !== undefined) {
675
+ t.title = displayName(row)
676
+ t.live = row.live
677
+ }
678
+ }
679
+ paintHead()
680
+ paintSent()
681
+ }
682
+
683
+ return { root, open, close, isOpen: () => target !== null, update }
684
+ }