@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.
- package/README.md +2 -1
- package/README_zh.md +2 -1
- package/assets/nexusDist/assets/index-CaA76j0F.js +9 -0
- package/assets/nexusDist/index.html +1 -1
- package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
- package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
- package/assets/stageDist/index.html +12 -12
- package/cli/a2a.mjs +24 -4
- package/lib/canvas-store.js +249 -17
- package/lib/client.js +62 -72
- package/lib/client.js.map +1 -1
- package/lib/config.js +1 -0
- package/lib/faces/admin.js +103 -63
- package/lib/faces/wire.js +3 -1
- package/lib/index.js +116 -22
- package/lib/origin-claim.js +4 -0
- package/lib/skill/SKILL.md +117 -117
- package/lib/skill/references/collaboration.md +428 -428
- package/lib/skill/references/discovery.md +78 -78
- package/lib/skill/references/intake.md +107 -107
- package/lib/skill/references/membership.md +73 -73
- package/lib/skill/references/pitfalls.md +51 -51
- package/lib/skill/references/router.md +96 -96
- package/lib/skill/references/routing.md +65 -65
- package/lib/types/canvas-store.d.ts +48 -1
- package/lib/types/config.d.ts +2 -0
- package/lib/types/faces/admin.d.ts +2 -0
- package/lib/types/origin-claim.d.ts +2 -0
- package/nexus-stage/package.json +21 -21
- package/nexus-stage/scripts/mockhost.py +261 -261
- package/nexus-stage/src/canvas-ops.ts +191 -191
- package/nexus-stage/src/canvas-wire.ts +164 -164
- package/nexus-stage/src/cli-home-picker.ts +42 -42
- package/nexus-stage/src/fault.ts +56 -56
- package/nexus-stage/src/federation-overlay.ts +103 -103
- package/nexus-stage/src/federation.ts +241 -241
- package/nexus-stage/src/layout-doc.ts +145 -145
- package/nexus-stage/src/main.ts +520 -513
- package/nexus-stage/src/planning-card-text.ts +73 -73
- package/nexus-stage/src/planning-card.ts +63 -63
- package/nexus-stage/src/planning-edges.ts +26 -26
- package/nexus-stage/src/planning-fit.ts +45 -45
- package/nexus-stage/src/planning-frame-hue.ts +13 -13
- package/nexus-stage/src/planning-legend.ts +39 -39
- package/nexus-stage/src/planning-member-handles.ts +61 -61
- package/nexus-stage/src/planning-menus.ts +422 -422
- package/nexus-stage/src/planning-mirror.ts +75 -75
- package/nexus-stage/src/planning-nodelist.ts +294 -294
- package/nexus-stage/src/planning-overlap.ts +159 -159
- package/nexus-stage/src/planning-reachability.ts +28 -28
- package/nexus-stage/src/planning-relayout.ts +121 -121
- package/nexus-stage/src/planning-roster.ts +15 -15
- package/nexus-stage/src/planning-search.ts +168 -168
- package/nexus-stage/src/planning-types.ts +115 -115
- package/nexus-stage/src/planning-undo.ts +68 -68
- package/nexus-stage/src/planning-view.ts +1499 -1499
- package/nexus-stage/src/planning.css +340 -340
- package/nexus-stage/src/seat-name-editor.ts +31 -31
- package/nexus-stage/src/session-drag.ts +64 -64
- package/nexus-stage/src/viewport.ts +106 -106
- package/nexus-stage/src/wb-chat.ts +684 -684
- package/nexus-stage/src/wb-directory.ts +237 -237
- package/nexus-stage/src/wb-ledger.ts +279 -279
- package/nexus-stage/src/wb-names.ts +43 -43
- package/nexus-stage/src/wb-rail.ts +147 -147
- package/nexus-stage/src/workbench.css +388 -388
- package/nexus-stage/src/workbench.ts +1294 -1272
- package/nexus-stage/src/world.ts +509 -509
- package/nexus-stage/tsconfig.json +24 -24
- package/nexus-stage/vite.config.ts +15 -15
- package/package.json +1 -1
- 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
|
+
}
|