@nelsonlongxiang/dsh-open-a2a-net 0.6.25 → 0.6.27

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