@nelsonlongxiang/dsh-open-a2a-net 0.5.14 → 0.6.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +243 -121
  2. package/README_zh.md +144 -84
  3. package/assets/nexusDist/assets/index-034h1cNc.js +3 -0
  4. package/assets/nexusDist/assets/index-PG9EieGX.css +1 -0
  5. package/assets/nexusDist/index.html +26 -0
  6. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -0
  7. package/assets/stageDist/assets/index-C-28k_o5.js +974 -0
  8. package/assets/stageDist/assets/interiors-CQGBjffT.png +0 -0
  9. package/assets/stageDist/assets/office-tileset-CHQT-uYL.png +0 -0
  10. package/assets/stageDist/index.html +13 -0
  11. package/lib/a2a-client.js +190 -13
  12. package/lib/canvas-store.js +218 -0
  13. package/lib/card.js +25 -0
  14. package/lib/client/A2aControl.js +45 -137
  15. package/lib/client/locales.js +16 -40
  16. package/lib/client.js +164 -467
  17. package/lib/client.js.map +1 -1
  18. package/lib/group-store.js +6 -0
  19. package/lib/idempotency-store.js +291 -0
  20. package/lib/index.js +4217 -259
  21. package/lib/joined-store.js +11 -0
  22. package/lib/layout-store.js +155 -0
  23. package/lib/peer-store.js +98 -13
  24. package/lib/receipt-ladder.js +35 -0
  25. package/lib/receipt.js +102 -0
  26. package/lib/self-suppress.js +62 -0
  27. package/lib/session-transcript.js +150 -0
  28. package/lib/skill/SKILL.md +77 -0
  29. package/lib/skill/references/discovery.md +38 -0
  30. package/lib/skill/references/membership.md +50 -0
  31. package/lib/skill/references/pitfalls.md +42 -0
  32. package/lib/skill/references/routing.md +43 -0
  33. package/lib/skill-registration.js +52 -0
  34. package/lib/stage-mount.js +32 -0
  35. package/lib/task-ledger.js +540 -0
  36. package/lib/team-store.js +122 -0
  37. package/lib/teams-bridge.js +18 -0
  38. package/lib/transport-caps.js +31 -0
  39. package/lib/types/a2a-client.d.ts +72 -1
  40. package/lib/types/canvas-store.d.ts +102 -0
  41. package/lib/types/client/A2aControl.d.ts +18 -28
  42. package/lib/types/client/locales.d.ts +1 -1
  43. package/lib/types/group-store.d.ts +6 -0
  44. package/lib/types/idempotency-store.d.ts +164 -0
  45. package/lib/types/index.d.ts +210 -6
  46. package/lib/types/joined-store.d.ts +9 -0
  47. package/lib/types/layout-store.d.ts +63 -0
  48. package/lib/types/peer-store.d.ts +45 -2
  49. package/lib/types/receipt-ladder.d.ts +25 -0
  50. package/lib/types/receipt.d.ts +51 -0
  51. package/lib/types/self-suppress.d.ts +41 -0
  52. package/lib/types/session-transcript.d.ts +37 -0
  53. package/lib/types/skill-registration.d.ts +3 -0
  54. package/lib/types/stage-mount.d.ts +29 -0
  55. package/lib/types/task-ledger.d.ts +308 -0
  56. package/lib/types/team-store.d.ts +55 -0
  57. package/lib/types/teams-bridge.d.ts +112 -0
  58. package/lib/types/transport-caps.d.ts +29 -0
  59. package/lib/types/types.d.ts +43 -0
  60. package/nexus-stage/package-lock.json +2262 -0
  61. package/nexus-stage/package.json +21 -0
  62. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
  63. package/nexus-stage/scripts/mockhost.py +253 -0
  64. package/nexus-stage/src/canvas-ops.ts +175 -0
  65. package/nexus-stage/src/canvas-wire.ts +156 -0
  66. package/nexus-stage/src/edges.ts +72 -0
  67. package/nexus-stage/src/fault.ts +56 -0
  68. package/nexus-stage/src/federation.ts +234 -0
  69. package/nexus-stage/src/layout-doc.ts +143 -0
  70. package/nexus-stage/src/layout-wire.ts +158 -0
  71. package/nexus-stage/src/main.ts +361 -0
  72. package/nexus-stage/src/planning-menus.ts +352 -0
  73. package/nexus-stage/src/planning-nodelist.ts +294 -0
  74. package/nexus-stage/src/planning-types.ts +111 -0
  75. package/nexus-stage/src/planning-view.ts +1313 -0
  76. package/nexus-stage/src/planning.css +280 -0
  77. package/nexus-stage/src/seat.ts +35 -0
  78. package/nexus-stage/src/stage-name.ts +13 -0
  79. package/nexus-stage/src/tokens.ts +32 -0
  80. package/nexus-stage/src/viewport.ts +83 -0
  81. package/nexus-stage/src/workbench.css +267 -0
  82. package/nexus-stage/src/workbench.ts +1530 -0
  83. package/nexus-stage/src/world.ts +509 -0
  84. package/nexus-stage/tsconfig.json +25 -0
  85. package/nexus-stage/vite.config.ts +16 -0
  86. package/package.json +83 -22
@@ -0,0 +1,1530 @@
1
+ /**
2
+ * The A2A workbench: one DOM client UI for human AI researchers and agents
3
+ * to OPERATE the network. Since 0.6.2 the 2D spatial planning board and the
4
+ * operational panels are MERGED into one responsive shell — the board is the
5
+ * stage (canvas mode, default while the host serves the canvas face) and the
6
+ * directory/ledger/activity/plan panels ride a collapsible side rail; when
7
+ * the canvas face is down the panels become the main stack (data mode).
8
+ * Narrow screens turn the rail into a drawer and the inspector into a
9
+ * bottom sheet (CSS media queries only — the DOM contract is stable).
10
+ *
11
+ * Discipline (inherited from the planning view it absorbed):
12
+ * - Pure DOM, no WebGL, no three, no framework: every row is a textContent
13
+ * write, so attacker-shaped labels stay text.
14
+ * - The workbench never fabricates capability: dead-letter rows act through
15
+ * the real abandon seam (deps), dispatch only DRAFTS a pasteable brief.
16
+ * - update() re-renders data regions wholesale but never touches the
17
+ * dispatch textarea (user input survives polls); rail collapse, section
18
+ * state, and the active mode are view state kept across updates.
19
+ * @module nexus-stage/workbench
20
+ */
21
+
22
+ /** One pending ledger row from /state `tasks`. */
23
+ export interface PendingRow {
24
+ readonly taskId: string
25
+ readonly team: string
26
+ readonly peer?: string
27
+ readonly startedAt: number
28
+ readonly status: string
29
+ readonly direction?: string
30
+ readonly receiptExpected?: boolean
31
+ readonly settleable?: boolean
32
+ /**
33
+ * First line of the dispatched message. A row without it is unreadable:
34
+ * `direct-8f3a1c2b · dsh · 3h` says nothing about whether to nudge,
35
+ * abandon, or go read the receipt — the operator's whole question is
36
+ * WHAT is owed. Absent on rows persisted before the field existed.
37
+ */
38
+ readonly brief?: string
39
+ }
40
+
41
+ /** One dead-lettered row from /state `tasksDead`. */
42
+ export interface DeadRow {
43
+ readonly taskId: string
44
+ readonly team: string
45
+ readonly peer?: string
46
+ readonly startedAt: number
47
+ readonly deadAt: number
48
+ readonly reason?: string
49
+ /** First line of the dispatched message (see {@link PendingRow.brief}). */
50
+ readonly brief?: string
51
+ }
52
+
53
+ /** One session row from /state `sessions` (live or cold joined). */
54
+ export interface SessionRow {
55
+ readonly id: string
56
+ readonly label: string
57
+ readonly team: string
58
+ readonly name?: string
59
+ readonly description?: string
60
+ readonly workspace?: string
61
+ readonly joined?: boolean
62
+ readonly live?: boolean
63
+ readonly teams?: readonly string[]
64
+ /** Canvas teams this session is a member of (0.6.6 roster annotation). */
65
+ readonly canvasTeams?: readonly string[]
66
+ /** @deprecated 0.6.6 — display-only taxonomy superseded by canvas teams. */
67
+ readonly group?: string
68
+ }
69
+
70
+ /** One remote team row from /state `remote`. */
71
+ export interface RemoteRow {
72
+ readonly team: string
73
+ readonly name?: string
74
+ readonly origin?: string
75
+ readonly via?: string
76
+ readonly workspace?: string
77
+ readonly legacy?: boolean
78
+ readonly teams?: readonly string[]
79
+ /** Canvas teams on this host that carry this remote handle as a member. */
80
+ readonly canvasTeams?: readonly string[]
81
+ }
82
+
83
+ /** One canvas member: a local bare session id or a zone-qualified remote
84
+ * handle (`zone/id8`) — remote members are routed by the directory, never
85
+ * woken locally (0.6.6 unified team model). */
86
+ export interface CanvasMemberRow {
87
+ readonly id: string
88
+ readonly team?: string
89
+ readonly joined?: boolean
90
+ readonly live?: boolean
91
+ readonly remote?: boolean
92
+ }
93
+
94
+ /** One canvas team from /state `canvas.teams`. */
95
+ export interface CanvasTeamRow {
96
+ readonly name: string
97
+ readonly team: string
98
+ readonly members: readonly CanvasMemberRow[]
99
+ }
100
+
101
+ /** One activity-ring entry. */
102
+ export interface ActivityRow {
103
+ readonly ts: number
104
+ readonly dir: 'in' | 'out'
105
+ readonly team: string
106
+ readonly peer: string
107
+ readonly ok: boolean
108
+ }
109
+
110
+ /** The /state subset the workbench consumes (additive fields ignored). */
111
+ export interface StateBody {
112
+ readonly version?: string
113
+ readonly sessions?: readonly SessionRow[]
114
+ readonly canvas?: { teams?: readonly CanvasTeamRow[] }
115
+ readonly peers?: ReadonlyArray<{ url: string; score?: number }>
116
+ readonly remote?: readonly RemoteRow[]
117
+ readonly activity?: readonly ActivityRow[]
118
+ readonly inFlight?: ReadonlyArray<{ team: string; peer: string; startedAt: number }>
119
+ readonly tasks?: readonly PendingRow[]
120
+ readonly tasksStaleCount?: number
121
+ readonly tasksDead?: readonly DeadRow[]
122
+ readonly archivedCount?: number
123
+ /** Settled-archive split by verdict ('completed' | 'declined' | … | 'abandoned' | 'unclassified'). */
124
+ readonly archivedSplit?: Readonly<Record<string, number>>
125
+ readonly idempotency?: {
126
+ window?: number
127
+ pending?: number
128
+ settled?: number
129
+ claimsFresh?: number
130
+ replays?: number
131
+ conflicts?: number
132
+ }
133
+ readonly reconcile?: {
134
+ state?: string
135
+ woken?: number
136
+ rows?: ReadonlyArray<{ id: string; error: string; attempts: number; needsRepair?: boolean }>
137
+ }
138
+ }
139
+
140
+ /** What the workbench can DO — the seams tests drive. */
141
+ export interface WorkbenchDeps {
142
+ now(): number
143
+ /** Abandon one dead-lettered task via the control route. True on success. */
144
+ abandon(taskId: string, reason: string): Promise<boolean>
145
+ /** Canvas team member change via the control route (0.6.6): `id` is a
146
+ * local session id or a zone-qualified remote handle. True on success. */
147
+ canvasOp(action: 'add-member' | 'remove-member', name: string, id: string): Promise<boolean>
148
+ /** Quick dispatch (0.6.9): POST /__dsh_a2a/dispatch as a human gesture —
149
+ * one message to a routing team, async delivery, receipt lands in the
150
+ * ledger. Optional — without it the 快派 form hides. */
151
+ dispatchQuick?(team: string, message: string): Promise<{ ok: boolean; task_id?: string; reply?: string; error?: string }>
152
+ /** Inline transcript (0.6.11): the tail of a LOCAL session's own
153
+ * conversation log, read-only. Optional — without it the block hides. */
154
+ sessionLog?(id: string): Promise<{ ok: boolean; messages?: Array<{ role: string; text: string }>; error?: string }>
155
+ /** 0.6.12: list every local session holding a log artifact (newest first) —
156
+ * the reading face is host-scoped, so archived and never-joined sessions
157
+ * appear here even when the network directory shows no local rows. */
158
+ sessionLogList?(): Promise<{ ok: boolean; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; error?: string }>
159
+ /** 0.6.15: remote transcript via the local proxy — ?team=<zone/id8>
160
+ * resolves the publishing peer through the directory, ?peer=<base-url>
161
+ * reads one tracked peer directly. Without id the peer answers its
162
+ * readable-session listing. Optional — without it remote/peer chat
163
+ * shows the honest where-the-records-live note. */
164
+ remoteSessionLog?(query: { team?: string; peer?: string; id?: string; limit?: number }): Promise<{ ok: boolean; messages?: Array<{ role: string; text: string }>; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; error?: string; via?: string }>
165
+ /** Copy text to the clipboard; drafts are pasteable task briefs. */
166
+ copy(text: string): void
167
+ // 0.6.10: openLocal/openRemote (0.6.7/0.6.8 jump affordances) removed by
168
+ // ruling — 禁止任何跳转,全部使用响应式布局. dispatchQuick is the only
169
+ // reach primitive and it stays on this page.
170
+ /** The spatial planning board. Optional: without it the shell stays in
171
+ * data mode permanently. `available()` mirrors the host's canvas face
172
+ * (state.canvas !== undefined) and gates canvas mode. */
173
+ board?: {
174
+ readonly root: HTMLElement
175
+ activate(): void
176
+ deactivate(): void
177
+ available(): boolean
178
+ }
179
+ }
180
+
181
+ /** Display name for one node: session title, then team, then label. */
182
+ function displayName(node: { name?: string; team?: string; label?: string }): string {
183
+ return node.name ?? node.team ?? node.label ?? ''
184
+ }
185
+
186
+ function ageText(ms: number, now: number): string {
187
+ const s = Math.max(0, Math.round((now - ms) / 1000))
188
+ if (s < 90) return `${s}s`
189
+ const m = Math.round(s / 60)
190
+ if (m < 90) return `${m}m`
191
+ const h = Math.round(m / 60)
192
+ if (h < 48) return `${h}h`
193
+ return `${Math.round(h / 24)}d`
194
+ }
195
+
196
+ function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] {
197
+ const node = document.createElement(tag)
198
+ if (className !== undefined) node.className = className
199
+ return node
200
+ }
201
+
202
+ /** One labelled counter in a pane's stat strip; `title` carries the definition
203
+ * (native tooltip — the definition lives with the number it explains). */
204
+ function statChip(label: string, value: number, hint: string, tone?: 'warn' | 'danger'): HTMLElement {
205
+ const chip = el('span', 'wb-stat')
206
+ if (tone !== undefined) chip.classList.add(`wb-stat-${tone}`)
207
+ chip.setAttribute('title', hint)
208
+ chip.appendChild(el('span', 'wb-stat-k')).textContent = label
209
+ chip.appendChild(el('span', 'wb-stat-v')).textContent = String(value)
210
+ return chip
211
+ }
212
+
213
+ /** One proportional bar: label · track · count. Cheap density for a histogram. */
214
+ function barRow(parent: HTMLElement, label: string, count: number, max: number): void {
215
+ const row = el('div', 'wb-bar-row')
216
+ row.appendChild(el('span', 'wb-bar-k')).textContent = label
217
+ const track = el('span', 'wb-bar-track')
218
+ const fill = el('span', 'wb-bar-fill')
219
+ fill.style.width = `${max > 0 ? Math.max(count > 0 ? 2 : 0, Math.round((count / max) * 100)) : 0}%`
220
+ track.appendChild(fill)
221
+ row.appendChild(track)
222
+ row.appendChild(el('span', 'wb-bar-v')).textContent = String(count)
223
+ parent.appendChild(row)
224
+ }
225
+
226
+ function textRow(parent: HTMLElement, label: string, value: string): void {
227
+ const row = el('div', 'wb-kv')
228
+ row.appendChild(el('span', 'wb-kv-k')).textContent = label
229
+ row.appendChild(el('span', 'wb-kv-v')).textContent = value
230
+ parent.appendChild(row)
231
+ }
232
+
233
+ type Selection =
234
+ | { kind: 'canvas'; row: CanvasTeamRow }
235
+ | undefined
236
+
237
+ /** Chat drawer target (0.6.13, remote 0.6.15): a node picked on the board or in the
238
+ * directory — session/log get the local transcript tail, remote rows and peers
239
+ * read through the /a2a/log proxy. Reading, not jumping. */
240
+ type ChatTarget =
241
+ | { kind: 'session'; id: string; title: string; sub: string; team?: string; live?: boolean }
242
+ | { kind: 'log'; id: string; title: string; sub: string }
243
+ | { kind: 'peer'; id: string; title: string; sub: string; url?: string; picked?: string }
244
+ | { kind: 'remote'; team: string; title: string; sub: string; row?: RemoteRow }
245
+
246
+ /** The four rail sections. */
247
+ export type WorkbenchSection = 'directory' | 'ledger' | 'activity' | 'plan'
248
+
249
+ /** Directory category filter (0.6.6): one chip per directory section group. */
250
+ type DirFilter = 'all' | 'local' | 'canvas' | 'peers' | 'remote'
251
+
252
+ const DIRECTORY_FILTERS: ReadonlyArray<{ key: DirFilter; label: string; hint: string }> = [
253
+ { key: 'all', label: '全部', hint: '不过滤:目录全部条目' },
254
+ { key: 'local', label: '本机', hint: '本机会话节点(现场 + 冷启动)' },
255
+ { key: 'canvas', label: '队', hint: '画布队(人类手工分配的组队)' },
256
+ { key: 'peers', label: 'Peers', hint: '对等节点(peer 池)' },
257
+ { key: 'remote', label: '远端', hint: '远端会话队(来自对端卡片)' },
258
+ ]
259
+
260
+ /** Lowercased haystack for the unified search: name/team/label/id/url/teams. */
261
+ function rowHaystack(...parts: ReadonlyArray<string | readonly string[] | undefined>): string {
262
+ const flat: string[] = []
263
+ for (const part of parts) {
264
+ if (part === undefined) continue
265
+ if (typeof part === 'string') {
266
+ if (part !== '') flat.push(part)
267
+ } else for (const entry of part) if (entry !== '') flat.push(entry)
268
+ }
269
+ return flat.join(' ').toLowerCase()
270
+ }
271
+
272
+ /** Legacy deep-link targets: 'board' focuses the stage, sections open a rail section. */
273
+ export type WorkbenchTab = 'board' | WorkbenchSection
274
+
275
+ /** Which face fills the stage. */
276
+ export type WorkbenchMode = 'canvas' | 'data'
277
+
278
+ export interface Workbench {
279
+ readonly root: HTMLElement
280
+ /** Feed one /state poll (or null while the first fetch is out). */
281
+ update(state: StateBody | null): void
282
+ /** Current stage mode: the board while the canvas face is up, else data. */
283
+ readonly mode: () => WorkbenchMode
284
+ /** Focus a target: 'board' converges on canvas mode (no-op while the
285
+ * canvas face is down); a section un-collapses its rail panel. */
286
+ selectTab(key: WorkbenchTab): void
287
+ /** A node was picked on the planning board (0.6.13): resolve it against
288
+ * the latest state and open the right-side chat drawer for it. */
289
+ openNodeChat(id: string): void
290
+ }
291
+
292
+ const SECTION_DEFS: ReadonlyArray<{ key: WorkbenchSection; label: string }> = [
293
+ { key: 'directory', label: '目录' },
294
+ { key: 'ledger', label: '台账' },
295
+ { key: 'activity', label: '活动' },
296
+ { key: 'plan', label: '规划' },
297
+ ]
298
+
299
+ export function createWorkbench(deps: WorkbenchDeps): Workbench {
300
+ const board = deps.board
301
+ let mode: WorkbenchMode = 'data'
302
+ let selection: Selection
303
+ let last: StateBody | null = null
304
+ const collapsed = new Set<WorkbenchSection>(['activity', 'plan'])
305
+ // Directory view state (survives polls): unified search + category filter.
306
+ let searchText = ''
307
+ let dirFilter: DirFilter = 'all'
308
+ // Canvas add-member form cache (static DOM re-mounted across polls; keyed
309
+ // by team name — same trick as draftForm so inputs survive the 3s poll).
310
+ let canvasForm: { name: string; form: HTMLFormElement; pick: HTMLSelectElement; manual: HTMLInputElement } | null = null
311
+ // Quick-dispatch form (0.6.9), cached per target routing team the same
312
+ // draftForm way: a textarea's draft must survive the 3s poll remount.
313
+ let dispatchForm: { team: string; form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } | null = null
314
+ // Chat drawer (0.6.13): the picked node + its own dispatch form cache
315
+ // (separate from the inspector's so both faces can hold different teams).
316
+ let chatTarget: ChatTarget | null = null
317
+ /** 0.6.17: one-shot composer focus on drawer open (consumed by renderChat). */
318
+ let chatFocusPending = false
319
+ let chatForm: { team: string; form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } | null = null
320
+ // 0.6.11: inline transcript cache — one fetch per session id, refreshed on
321
+ // demand. renderInspector rebuilds every poll, so the cache keeps the
322
+ // transcript block from refetching on each 3s tick.
323
+ const transcriptCache = new Map<string, { ok: boolean; messages?: Array<{ role: string; text: string }>; error?: string } | undefined>()
324
+ /** 0.6.17 chat-UI convention: reading position per transcript key — the
325
+ * 3s poll rebuilds the body, the scroll restores instead of jumping top. */
326
+ const transcriptScroll = new Map<string, number>()
327
+ /** Owner signal (2026-10-21): transcripts default to a collapsed detail —
328
+ * the seats' forensics face, not the owner's. Expansion persists per key. */
329
+ const expandedTranscripts = new Set<string>()
330
+ /** 0.6.15: peer-host session listings, cached per peer url. */
331
+ const peerListCache = new Map<string, { ok: boolean; sessions?: Array<{ id: string; sizeBytes: number }>; error?: string } | undefined>()
332
+ // 0.6.12: readable local logs (host-scope list, newest first). Refreshed
333
+ // opportunistically per poll; rows render in the directory's 本机日志 section.
334
+ let logRows: Array<{ id: string; mtimeMs: number; sizeBytes: number }> = []
335
+ let logRowsBusy = false
336
+
337
+ const root = el('div', 'wb')
338
+ root.setAttribute('data-testid', 'wb-root')
339
+
340
+ // ── Static skeleton: built once, update() only rewrites data regions. ──
341
+ const header = el('header', 'wb-header')
342
+ const menuBtn = el('button', 'wb-icon-btn wb-menu')
343
+ menuBtn.type = 'button'
344
+ menuBtn.dataset.testid = 'wb-menu'
345
+ menuBtn.setAttribute('aria-label', '打开目录抽屉')
346
+ menuBtn.textContent = '☰'
347
+ menuBtn.addEventListener('click', () => root.classList.toggle('is-rail-open'))
348
+ header.appendChild(menuBtn)
349
+ const title = el('div', 'wb-title')
350
+ title.appendChild(el('span', 'wb-title-main')).textContent = 'A2A 工作台'
351
+ const versionBadge = el('span', 'wb-badge')
352
+ title.appendChild(versionBadge)
353
+ header.appendChild(title)
354
+ const headerStats = el('div', 'wb-header-stats')
355
+ header.appendChild(headerStats)
356
+ const railToggle = el('button', 'wb-icon-btn wb-rail-toggle')
357
+ railToggle.type = 'button'
358
+ railToggle.dataset.testid = 'wb-rail-toggle'
359
+ railToggle.setAttribute('aria-label', '折叠/展开侧栏')
360
+ railToggle.textContent = '◧'
361
+ railToggle.addEventListener('click', () => {
362
+ root.classList.toggle('is-rail-collapsed')
363
+ // The stage width changed while the board fills it → re-sync its view
364
+ // size (idempotent; focus side effect is harmless in a shell toggle).
365
+ if (mode === 'canvas' && board !== undefined) board.activate()
366
+ })
367
+ header.appendChild(railToggle)
368
+
369
+ const body = el('div', 'wb-body')
370
+ const rail = el('aside', 'wb-rail')
371
+
372
+ // Rail sections (accordion). Panes are created once and re-parented
373
+ // between section bodies (canvas mode) and the stage stack (data mode).
374
+ const sectionBodies = new Map<WorkbenchSection, HTMLElement>()
375
+ const sectionCounts = new Map<WorkbenchSection, HTMLElement>()
376
+ for (const def of SECTION_DEFS) {
377
+ const sec = el('section', 'wb-sec')
378
+ sec.dataset.sec = def.key
379
+ const head = el('button', 'wb-sec-head')
380
+ head.type = 'button'
381
+ head.appendChild(el('span', 'wb-sec-label')).textContent = def.label
382
+ const count = el('span', 'wb-sec-count')
383
+ head.appendChild(count)
384
+ head.addEventListener('click', () => {
385
+ if (collapsed.has(def.key)) collapsed.delete(def.key)
386
+ else collapsed.add(def.key)
387
+ syncSections()
388
+ })
389
+ sec.appendChild(head)
390
+ const secBody = el('div', 'wb-sec-body')
391
+ sec.appendChild(secBody)
392
+ rail.appendChild(sec)
393
+ sectionBodies.set(def.key, secBody)
394
+ sectionCounts.set(def.key, count)
395
+ }
396
+
397
+ const directoryBody = el('div', 'wb-directory')
398
+ // Directory toolbar: unified search + category filter chips (0.6.6). Built
399
+ // once above the re-rendered list so the input keeps focus and its text
400
+ // across polls; filtering is pure frontend over /state.
401
+ const directoryTools = el('div', 'wb-dir-tools')
402
+ const searchInput = document.createElement('input')
403
+ searchInput.className = 'wb-search'
404
+ searchInput.type = 'search'
405
+ searchInput.placeholder = '搜索:名称 / 队 / 会话 / 节点'
406
+ searchInput.setAttribute('aria-label', '统一搜索目录')
407
+ searchInput.addEventListener('input', () => {
408
+ searchText = searchInput.value.trim().toLowerCase()
409
+ renderSidebar()
410
+ })
411
+ directoryTools.appendChild(searchInput)
412
+ const filterRow = el('div', 'wb-filters')
413
+ for (const def of DIRECTORY_FILTERS) {
414
+ const chip = el('button', 'wb-filter')
415
+ chip.type = 'button'
416
+ chip.textContent = def.label
417
+ chip.dataset.filter = def.key
418
+ chip.title = def.hint
419
+ chip.addEventListener('click', () => {
420
+ dirFilter = def.key
421
+ renderSidebar()
422
+ })
423
+ filterRow.appendChild(chip)
424
+ }
425
+ directoryTools.appendChild(filterRow)
426
+ sectionBodies.get('directory')!.appendChild(directoryTools)
427
+ sectionBodies.get('directory')!.appendChild(directoryBody)
428
+
429
+ const ledgerPane = el('section', 'wb-pane')
430
+ const activityPane = el('section', 'wb-pane')
431
+ const planPane = el('section', 'wb-pane')
432
+ const dataPanes: ReadonlyArray<[WorkbenchSection, HTMLElement]> = [
433
+ ['ledger', ledgerPane],
434
+ ['activity', activityPane],
435
+ ['plan', planPane],
436
+ ]
437
+
438
+ const stage = el('main', 'wb-stage')
439
+ const boardPane = el('section', 'wb-stage-board')
440
+ if (board !== undefined) {
441
+ boardPane.appendChild(board.root)
442
+ stage.appendChild(boardPane)
443
+ }
444
+ const dataStack = el('div', 'wb-data')
445
+
446
+ const inspector = el('aside', 'wb-inspector')
447
+ const inspectorHead = el('div', 'wb-inspector-head')
448
+ inspectorHead.appendChild(el('span', 'wb-inspector-title')).textContent = '详情'
449
+ const inspectorClose = el('button', 'wb-icon-btn')
450
+ inspectorClose.type = 'button'
451
+ inspectorClose.setAttribute('aria-label', '关闭详情')
452
+ inspectorClose.textContent = '✕'
453
+ inspectorClose.addEventListener('click', () => select(undefined))
454
+ inspectorHead.appendChild(inspectorClose)
455
+ inspector.appendChild(inspectorHead)
456
+ const inspectorBody = el('div', 'wb-inspector-body')
457
+ inspector.appendChild(inspectorBody)
458
+
459
+ // ── Node chat drawer (0.6.13) ──
460
+ // Clicking a node — on the planning board or a directory session/log/peer
461
+ // row — opens the conversation reading face as a right-side chat UI. The
462
+ // transcript tail is local-only; peers get the honest cross-host note.
463
+ const chat = el('aside', 'wb-chat')
464
+ const chatHead = el('div', 'wb-chat-head')
465
+ const chatDot = el('span', 'wb-chat-dot')
466
+ const chatTitle = el('span', 'wb-chat-title')
467
+ const chatSub = el('span', 'wb-chat-sub')
468
+ const chatClose = el('button', 'wb-icon-btn')
469
+ chatClose.type = 'button'
470
+ chatClose.setAttribute('aria-label', '关闭对话')
471
+ chatClose.textContent = '✕'
472
+ chatClose.addEventListener('click', () => closeChat())
473
+ chatHead.appendChild(chatDot)
474
+ chatHead.appendChild(chatTitle)
475
+ chatHead.appendChild(chatSub)
476
+ chatHead.appendChild(chatClose)
477
+ const chatBody = el('div', 'wb-chat-body')
478
+ chat.appendChild(chatHead)
479
+ chat.appendChild(chatBody)
480
+ // 0.6.17 chat-UI convention: Escape closes the drawer. Document-scoped
481
+ // (a reading drawer often holds no focusable element), but it never
482
+ // steals Escape from another surface's text field or from the planning
483
+ // board's own deselect gesture.
484
+ document.addEventListener('keydown', ev => {
485
+ if (ev.key !== 'Escape' || !chat.classList.contains('is-open')) return
486
+ const target = ev.target
487
+ if (!(target instanceof HTMLElement)) return
488
+ const insideChat = chat.contains(target)
489
+ if (!insideChat) {
490
+ const typingElsewhere = target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement || target.isContentEditable
491
+ const onBoard = target.closest('.wb-stage-board') !== null
492
+ if (typingElsewhere || onBoard) return
493
+ }
494
+ closeChat()
495
+ })
496
+
497
+ stage.appendChild(dataStack)
498
+ body.appendChild(rail)
499
+ body.appendChild(stage)
500
+ body.appendChild(inspector)
501
+ body.appendChild(chat)
502
+ root.appendChild(header)
503
+ root.appendChild(body)
504
+
505
+ function syncSections(): void {
506
+ for (const def of SECTION_DEFS) {
507
+ const sec = sectionBodies.get(def.key)!.parentElement!
508
+ sec.classList.toggle('is-collapsed', collapsed.has(def.key))
509
+ }
510
+ }
511
+
512
+ /** Move the data panes to their mode home: rail sections while the board
513
+ * owns the stage, the stage stack when the board is out. */
514
+ function placePanes(m: WorkbenchMode): void {
515
+ for (const [key, pane] of dataPanes) {
516
+ const home = m === 'canvas' ? sectionBodies.get(key)! : dataStack
517
+ if (pane.parentElement !== home) home.appendChild(pane)
518
+ }
519
+ }
520
+
521
+ function syncMode(): void {
522
+ const canvasUp = board !== undefined && board.available()
523
+ const next: WorkbenchMode = canvasUp ? 'canvas' : 'data'
524
+ root.dataset.mode = next
525
+ boardPane.classList.toggle('is-hidden', next !== 'canvas')
526
+ dataStack.classList.toggle('is-hidden', next === 'canvas')
527
+ placePanes(next)
528
+ if (next !== mode) {
529
+ if (board !== undefined) {
530
+ if (next === 'canvas') board.activate()
531
+ else board.deactivate()
532
+ }
533
+ mode = next
534
+ }
535
+ }
536
+
537
+ function clear(node: HTMLElement): HTMLElement {
538
+ while (node.firstChild !== null) node.removeChild(node.firstChild)
539
+ return node
540
+ }
541
+
542
+ function select(next: Selection): void {
543
+ selection = next
544
+ // Canvas add-member form is cached per team name (draftForm trick):
545
+ // drop it whenever the selection moves off its team.
546
+ if (next?.kind !== 'canvas' || canvasForm?.name !== next.row.name) canvasForm = null
547
+ // Same for the quick-dispatch form: it is cached per target team
548
+ // (0.6.15: remote dispatch moved to the chat drawer's own form).
549
+ if (next?.kind !== 'canvas') dispatchForm = null
550
+ renderInspector()
551
+ // Narrow screens: picking from the drawer closes it so the stage shows.
552
+ root.classList.remove('is-rail-open')
553
+ }
554
+
555
+ // ── Directory section ──
556
+ function renderSidebar(): void {
557
+ const box = clear(directoryBody)
558
+ // Filter chips reflect the live view state (built once, toggled here).
559
+ for (let i = 0; i < filterRow.children.length; i++) {
560
+ const node = filterRow.children[i] as HTMLElement
561
+ node.classList.toggle('is-active', node.dataset.filter === dirFilter)
562
+ }
563
+ if (last === null) {
564
+ box.appendChild(el('div', 'wb-muted')).textContent = '载入中…'
565
+ sectionCounts.get('directory')!.textContent = ''
566
+ return
567
+ }
568
+ const state = last
569
+ const filtering = searchText !== '' || dirFilter !== 'all'
570
+ /** One directory row factory with the shared search+filter gate. */
571
+ const admit = (cat: DirFilter, haystack: string): boolean =>
572
+ (dirFilter === 'all' || dirFilter === cat) && (searchText === '' || haystack.includes(searchText))
573
+ /** Canvas-team badge for roster annotation (▤ + short name). */
574
+ const badges = (item: HTMLElement, teams: readonly string[] | undefined): void => {
575
+ if (teams === undefined || teams.length === 0) return
576
+ const chip = el('span', 'wb-item-badge')
577
+ const names = teams.map(t => t.slice(t.lastIndexOf('/') + 1))
578
+ chip.textContent = `▤ ${names.slice(0, 3).join(' ')}${names.length > 3 ? ` +${names.length - 3}` : ''}`
579
+ chip.title = `画布队:${teams.join(' · ')}`
580
+ item.appendChild(chip)
581
+ }
582
+ const section = (label: string, count: number): HTMLElement => {
583
+ const head = el('div', 'wb-side-head')
584
+ head.textContent = `${label} · ${count}`
585
+ box.appendChild(head)
586
+ return box
587
+ }
588
+ const sessions = state.sessions ?? []
589
+ const live = sessions.filter(row => row.live === true && admit('local', rowHaystack(displayName(row), row.team, row.label, row.id, row.canvasTeams)))
590
+ const cold = sessions.filter(row => row.live !== true && admit('local', rowHaystack(displayName(row), row.team, row.label, row.id, row.canvasTeams)))
591
+ sectionCounts.get('directory')!.textContent = `${live.length}/${cold.length}${logRows.length > 0 ? ` · 档 ${logRows.length}` : ''}`
592
+ if (live.length > 0) {
593
+ section('本机 · 现场', live.length)
594
+ for (const row of live) {
595
+ const item = el('button', 'wb-item')
596
+ item.type = 'button'
597
+ item.appendChild(el('span', 'wb-dot wb-dot-live')).textContent = '●'
598
+ item.appendChild(el('span', 'wb-item-label')).textContent = displayName(row)
599
+ badges(item, row.canvasTeams)
600
+ item.addEventListener('click', () => openChat({ kind: 'session', id: row.id, title: displayName(row), sub: row.team, team: row.team, live: row.live }))
601
+ box.appendChild(item)
602
+ }
603
+ }
604
+ if (cold.length > 0) {
605
+ section('本机 · 冷启动', cold.length)
606
+ for (const row of cold) {
607
+ const item = el('button', 'wb-item')
608
+ item.type = 'button'
609
+ item.appendChild(el('span', 'wb-dot wb-dot-cold')).textContent = '○'
610
+ item.appendChild(el('span', 'wb-item-label')).textContent = displayName(row)
611
+ badges(item, row.canvasTeams)
612
+ item.addEventListener('click', () => openChat({ kind: 'session', id: row.id, title: displayName(row), sub: row.team, team: row.team, live: row.live }))
613
+ box.appendChild(item)
614
+ }
615
+ }
616
+ const canvases = (state.canvas?.teams ?? []).filter(row =>
617
+ admit('canvas', rowHaystack(row.name, row.team, row.members.map(member => member.team ?? member.id))))
618
+ if (canvases.length > 0) {
619
+ section('Canvas 队', canvases.length)
620
+ for (const row of canvases) {
621
+ const item = el('button', 'wb-item')
622
+ item.type = 'button'
623
+ item.appendChild(el('span', 'wb-dot wb-dot-canvas')).textContent = '▤'
624
+ const remote = row.members.filter(member => member.remote === true).length
625
+ item.appendChild(el('span', 'wb-item-label')).textContent =
626
+ `${row.name} · ${row.members.length}${remote > 0 ? `(远端 ${remote})` : ''}`
627
+ item.addEventListener('click', () => select({ kind: 'canvas', row }))
628
+ box.appendChild(item)
629
+ }
630
+ }
631
+ const peers = (state.peers ?? []).filter(row => admit('peers', rowHaystack(row.url)))
632
+ if (peers.length > 0) {
633
+ section('Peers', peers.length)
634
+ for (const row of peers) {
635
+ const item = el('button', 'wb-item')
636
+ item.type = 'button'
637
+ item.appendChild(el('span', 'wb-dot wb-dot-peer')).textContent = '⇄'
638
+ item.appendChild(el('span', 'wb-item-label')).textContent =
639
+ row.url.replace(/^https?:\/\//, '') + (row.score !== undefined ? ` · ${row.score}` : '')
640
+ item.addEventListener('click', () => {
641
+ const host = row.url.replace(/^https?:\/\//, '').replace(/\/.*$/, '')
642
+ openChat({ kind: 'peer', id: `peer-${host}`, title: row.url, sub: host, url: row.url })
643
+ })
644
+ box.appendChild(item)
645
+ }
646
+ }
647
+ const remote = (state.remote ?? []).filter(row => admit('remote', rowHaystack(displayName(row), row.team, row.origin, row.workspace, row.canvasTeams)))
648
+ if (remote.length > 0) {
649
+ section('远端节点', remote.length)
650
+ for (const row of remote) {
651
+ const item = el('button', 'wb-item')
652
+ item.type = 'button'
653
+ item.appendChild(el('span', 'wb-dot wb-dot-remote')).textContent = '◇'
654
+ item.appendChild(el('span', 'wb-item-label')).textContent = displayName(row)
655
+ badges(item, row.canvasTeams)
656
+ // 0.6.15: remote rows open the chat drawer — the transcript now
657
+ // reads through the proxy instead of the where-the-records-live note.
658
+ item.addEventListener('click', () => openChat({ kind: 'remote', team: row.team, title: displayName(row), sub: row.team, row }))
659
+ box.appendChild(item)
660
+ }
661
+ }
662
+ // 0.6.12: 本机日志 — every local session artifact EXCEPT ids already shown
663
+ // as network rows (those open the same transcript from their own cards).
664
+ // This is the reachability fix for hosts whose cold rows are all archived:
665
+ // reading is host-scoped, the network directory is not the reading gate.
666
+ const networkIds = new Set(sessions.map(row => row.id))
667
+ const fmtSize = (n: number): string => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} MB` : n >= 1_000 ? `${Math.round(n / 1_000)} KB` : `${n} B`
668
+ const logs = logRows.filter(row => !networkIds.has(row.id) && admit('local', rowHaystack(row.id)))
669
+ if (logs.length > 0) {
670
+ section('本机日志', logs.length)
671
+ for (const row of logs) {
672
+ const item = el('button', 'wb-item')
673
+ item.type = 'button'
674
+ item.appendChild(el('span', 'wb-dot')).textContent = '❐'
675
+ const label = el('span', 'wb-item-label')
676
+ label.textContent = `…${row.id.slice(-12)} · ${fmtSize(row.sizeBytes)}`
677
+ item.title = `${row.id}\n${fmtSize(row.sizeBytes)} · ${new Date(row.mtimeMs).toLocaleString()}`
678
+ item.appendChild(label)
679
+ item.addEventListener('click', () => openChat({ kind: 'log', id: row.id, title: `…${row.id.slice(-12)}`, sub: '档案' }))
680
+ box.appendChild(item)
681
+ }
682
+ }
683
+ if (filtering && live.length + cold.length + canvases.length + peers.length + remote.length + logs.length === 0) {
684
+ box.appendChild(el('div', 'wb-muted')).textContent = '无匹配条目'
685
+ }
686
+ }
687
+
688
+ // ── Ledger pane ──
689
+ /** Age buckets for debt: what the ledger is actually made of, at a glance.
690
+ * Boundaries mirror the C-ruling split (1h = settleable→conversational). */
691
+ const AGE_BUCKETS: ReadonlyArray<{ label: string; maxMs: number }> = [
692
+ { label: '<10m', maxMs: 10 * 60_000 },
693
+ { label: '10m-1h', maxMs: 60 * 60_000 },
694
+ { label: '1-6h', maxMs: 6 * 60 * 60_000 },
695
+ { label: '6-24h', maxMs: 24 * 60 * 60_000 },
696
+ { label: '>24h', maxMs: Number.POSITIVE_INFINITY },
697
+ ]
698
+
699
+ /** Group pending rows by age bucket; returns buckets in age order with counts. */
700
+ function ageHistogram(rows: readonly PendingRow[], now: number): number[] {
701
+ const counts = AGE_BUCKETS.map(() => 0)
702
+ for (const row of rows) {
703
+ const age = now - row.startedAt
704
+ const index = AGE_BUCKETS.findIndex(bucket => age < bucket.maxMs)
705
+ counts[index === -1 ? counts.length - 1 : index]! += 1
706
+ }
707
+ return counts
708
+ }
709
+
710
+ /** Top teams by pending-row count, most loaded first (bounded to 5). */
711
+ function topTeams(rows: readonly PendingRow[]): Array<[string, number]> {
712
+ const counts = new Map<string, number>()
713
+ for (const row of rows) counts.set(row.team, (counts.get(row.team) ?? 0) + 1)
714
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5)
715
+ }
716
+
717
+ function renderLedger(): void {
718
+ const pane = clear(ledgerPane)
719
+ if (last === null) return
720
+ const now = deps.now()
721
+ const pending = last.tasks ?? []
722
+ const dead = last.tasksDead ?? []
723
+ const stale = last.tasksStaleCount ?? 0
724
+ const archived = last.archivedCount ?? 0
725
+ sectionCounts.get('ledger')!.textContent = `待 ${pending.length} · 死 ${dead.length}`
726
+
727
+ // Stat strip: the four headline numbers, each carrying its definition.
728
+ const stats = el('div', 'wb-stats')
729
+ stats.appendChild(statChip('待回执', pending.length,
730
+ '等待回执的任务(pending)。其中「陈旧」= 超过 1 小时仍未回执,或本就无回执债(fire-and-forget)'))
731
+ stats.appendChild(statChip('陈旧', stale,
732
+ '陈旧尾:≥1h 无回执的旧账 + receiptExpected:false 的免回执行。它们到 24h TTL 自动转死信,不算可催办债务',
733
+ stale > 0 ? 'warn' : undefined))
734
+ stats.appendChild(statChip('死信', dead.length,
735
+ '超过 24h TTL 仍未结算,或目标早已离开。需清账(abandon)才能移出',
736
+ dead.length > 0 ? 'danger' : undefined))
737
+ stats.appendChild(statChip('已归档', archived,
738
+ '已结算并留档的任务(回执已关联)。用于审计,不再占待回执名额'))
739
+ pane.appendChild(stats)
740
+
741
+ // Archive verdict split: how those settled rows actually ended.
742
+ const split = last.archivedSplit
743
+ if (archived > 0 && split !== undefined) {
744
+ const entries = Object.entries(split).sort((a, b) => b[1] - a[1])
745
+ if (entries.length > 0) {
746
+ const line = el('div', 'wb-pane-head')
747
+ line.textContent = `归档结局 · ${entries.map(([key, count]) => `${key} ${count}`).join(' · ')}`
748
+ pane.appendChild(line)
749
+ }
750
+ }
751
+
752
+ // Forensics (moved off the header): the plumbing counters a seat reads
753
+ // when something looks off — muted, never the owner's first line.
754
+ const idem = last.idempotency
755
+ const reconcileState = last.reconcile?.state
756
+ if (idem !== undefined || reconcileState !== undefined) {
757
+ const parts: string[] = []
758
+ if (idem !== undefined) parts.push(`幂等 claims ${idem.claimsFresh ?? 0} · replays ${idem.replays ?? 0} · conflicts ${idem.conflicts ?? 0}`)
759
+ if (reconcileState !== undefined) parts.push(`reconcile ${reconcileState}`)
760
+ const line = el('div', 'wb-pane-head wb-forensics')
761
+ line.textContent = `取证 · ${parts.join(' · ')}`
762
+ pane.appendChild(line)
763
+ }
764
+
765
+ if (pending.length > 0) {
766
+ // Age histogram + team load: WHERE the debt sits and how old it is.
767
+ const counts = ageHistogram(pending, now)
768
+ const max = Math.max(...counts, 1)
769
+ const hist = el('div', 'wb-hist')
770
+ for (let i = 0; i < AGE_BUCKETS.length; i += 1) {
771
+ barRow(hist, AGE_BUCKETS[i]!.label, counts[i]!, max)
772
+ }
773
+ pane.appendChild(hist)
774
+ const teams = topTeams(pending)
775
+ const teamLine = el('div', 'wb-pane-head')
776
+ teamLine.textContent = `债务队 Top${teams.length} · ${teams.map(([team, count]) => `${team} ${count}`).join(' · ')}`
777
+ pane.appendChild(teamLine)
778
+ }
779
+
780
+ const table = el('div', 'wb-table')
781
+ for (const row of pending) {
782
+ // Owner signal (2026-10-21): the ledger is a RESULTS face — the brief
783
+ // is the primary copy, ids/teams/ages are muted forensics underneath.
784
+ // Exception-driven: only overdue (>1h) rows raise their voice; normal
785
+ // rows stay quiet so the debt that needs eyes stands out.
786
+ const overdue = now - row.startedAt > 60 * 60_000
787
+ const line = el('div', `wb-row wb-row-result${overdue ? ' wb-row-alert' : ''}`)
788
+ const briefText = row.brief ?? '(无摘要——派发未带 brief)'
789
+ const brief = el('span', 'wb-row-head')
790
+ brief.textContent = briefText
791
+ brief.title = briefText
792
+ line.appendChild(brief)
793
+ const badges: string[] = []
794
+ if (row.settleable === true) badges.push('可结算')
795
+ if (row.receiptExpected === false) badges.push('无回执债')
796
+ if (row.direction === 'inbound') badges.push('入站')
797
+ const meta = el('span', 'wb-row-meta-line')
798
+ meta.textContent = `${row.taskId} · ${row.team} · ${ageText(row.startedAt, now)}${badges.length > 0 ? ` · ${badges.join(' ')}` : ''}`
799
+ line.appendChild(meta)
800
+ table.appendChild(line)
801
+ }
802
+ pane.appendChild(table)
803
+ if (dead.length > 0) {
804
+ const deadHead = el('div', 'wb-pane-subhead')
805
+ deadHead.textContent = '死信(可清账)'
806
+ pane.appendChild(deadHead)
807
+ const deadTable = el('div', 'wb-table wb-table-dead')
808
+ for (const row of dead) {
809
+ // Same results-first shape, always exception-colored: dead letters
810
+ // ARE the alarm.
811
+ const line = el('div', 'wb-row wb-row-result wb-row-alert')
812
+ const deadBriefText = row.brief ?? '(无摘要)'
813
+ const deadBrief = el('span', 'wb-row-head')
814
+ deadBrief.textContent = deadBriefText
815
+ deadBrief.title = deadBriefText
816
+ line.appendChild(deadBrief)
817
+ const meta = el('span', 'wb-row-meta-line')
818
+ meta.textContent = `${row.taskId} · ${row.team} · ${ageText(row.deadAt, now)}${row.reason !== undefined && row.reason !== '' ? ` · ${row.reason}` : ''}`
819
+ line.appendChild(meta)
820
+ const btn = el('button', 'wb-btn')
821
+ btn.type = 'button'
822
+ btn.textContent = '清账'
823
+ btn.addEventListener('click', () => {
824
+ btn.disabled = true
825
+ void deps.abandon(row.taskId, 'workbench dead-letter cleanup').then(ok => {
826
+ btn.disabled = false
827
+ btn.textContent = ok ? '已清' : '失败'
828
+ })
829
+ })
830
+ line.appendChild(btn)
831
+ deadTable.appendChild(line)
832
+ }
833
+ pane.appendChild(deadTable)
834
+ }
835
+ }
836
+
837
+ // ── Activity pane ──
838
+ function renderActivity(): void {
839
+ const pane = clear(activityPane)
840
+ if (last === null) return
841
+ const now = deps.now()
842
+ const flights = last.inFlight ?? []
843
+ sectionCounts.get('activity')!.textContent = `${flights.length}+${last.activity?.length ?? 0}`
844
+ const head = el('div', 'wb-pane-head')
845
+ head.textContent = `在途 ${flights.length} · 活动 ${last.activity?.length ?? 0}`
846
+ pane.appendChild(head)
847
+ if (flights.length > 0) {
848
+ const sub = el('div', 'wb-pane-subhead')
849
+ sub.textContent = '在途路由'
850
+ pane.appendChild(sub)
851
+ for (const row of flights) {
852
+ const line = el('div', 'wb-row')
853
+ line.appendChild(el('span', 'wb-row-dir')).textContent = '→'
854
+ line.appendChild(el('span', 'wb-row-team')).textContent = row.team
855
+ line.appendChild(el('span', 'wb-row-meta')).textContent = `${row.peer} · ${ageText(row.startedAt, now)}`
856
+ pane.appendChild(line)
857
+ }
858
+ }
859
+ const ringHead = el('div', 'wb-pane-subhead')
860
+ ringHead.textContent = '活动环(最新在前)'
861
+ pane.appendChild(ringHead)
862
+ const ring = last.activity ?? []
863
+ if (ring.length === 0) {
864
+ pane.appendChild(el('div', 'wb-muted')).textContent = '暂无活动'
865
+ return
866
+ }
867
+ for (const row of ring) {
868
+ const line = el('div', 'wb-row')
869
+ const dir = el('span', 'wb-row-dir')
870
+ dir.textContent = row.dir === 'out' ? '→' : '←'
871
+ if (row.ok !== true) dir.classList.add('is-fail')
872
+ line.appendChild(dir)
873
+ line.appendChild(el('span', 'wb-row-team')).textContent = row.team
874
+ line.appendChild(el('span', 'wb-row-meta')).textContent = `${row.peer} · ${ageText(row.ts, now)}`
875
+ line.appendChild(el('span', 'wb-row-badges')).textContent = row.ok === true ? '' : '失败'
876
+ pane.appendChild(line)
877
+ }
878
+ }
879
+
880
+ // ── Plan pane: derived actions + dispatch drafting ──
881
+ function planActions(state: StateBody): Array<{ severity: 'high' | 'normal'; text: string; brief?: string }> {
882
+ const now = deps.now()
883
+ const actions: Array<{ severity: 'high' | 'normal'; text: string; brief?: string }> = []
884
+ const dead = state.tasksDead ?? []
885
+ if (dead.length > 0) {
886
+ actions.push({ severity: 'high', text: `清账 ${dead.length} 条死信(目标早已离开或死信化,无回执可期)` })
887
+ }
888
+ for (const row of state.tasks ?? []) {
889
+ const age = now - row.startedAt
890
+ if (age > 60 * 60_000 && row.settleable === true) {
891
+ // Quote the original ask: a nudge that only repeats a task id makes
892
+ // the target re-derive WHAT was owed, and the target is exactly the
893
+ // session that already failed to answer once.
894
+ const subject = row.brief === undefined || row.brief === '' ? '' : `「${row.brief}」`
895
+ actions.push({
896
+ severity: 'normal',
897
+ text: `催办 ${row.taskId} → ${row.team}(等待 ${ageText(row.startedAt, now)} 无回执)${subject}`,
898
+ brief: `a2a_route 催收回执:task ${row.taskId}(team ${row.team})${subject === '' ? '' : `原任务「${row.brief}」`}已派发 ${ageText(row.startedAt, now)},请回报结果或回执。`,
899
+ })
900
+ }
901
+ }
902
+ for (const row of state.sessions ?? []) {
903
+ if (row.live !== true) {
904
+ actions.push({ severity: 'normal', text: `冷会话 ${displayName(row)}:在 GUI 打开该会话即可唤醒加入面` })
905
+ }
906
+ }
907
+ for (const row of state.reconcile?.rows ?? []) {
908
+ if (row.needsRepair === true) {
909
+ actions.push({ severity: 'high', text: `reconcile 需修复:${row.id} ${row.error}` })
910
+ }
911
+ }
912
+ const conflicts = state.idempotency?.conflicts ?? 0
913
+ if (conflicts > 0) {
914
+ actions.push({ severity: 'high', text: `幂等冲突 ${conflicts} 例:人工核查 UNIQUE-ization 流量` })
915
+ }
916
+ return actions
917
+ }
918
+
919
+ function renderPlan(): void {
920
+ const pane = clear(planPane)
921
+ if (last === null) return
922
+ const actions = planActions(last)
923
+ sectionCounts.get('plan')!.textContent = `${actions.length}`
924
+ const head = el('div', 'wb-pane-head')
925
+ head.textContent = actions.length === 0 ? '网络健康——无待办行动' : `待办行动 ${actions.length}`
926
+ pane.appendChild(head)
927
+ for (const action of actions) {
928
+ const line = el('div', `wb-action${action.severity === 'high' ? ' wb-action-high' : ''}`)
929
+ line.appendChild(el('span', 'wb-action-text')).textContent = action.text
930
+ if (action.brief !== undefined) {
931
+ const btn = el('button', 'wb-btn')
932
+ btn.type = 'button'
933
+ btn.textContent = '复制催办'
934
+ btn.addEventListener('click', () => deps.copy(action.brief ?? ''))
935
+ line.appendChild(btn)
936
+ }
937
+ pane.appendChild(line)
938
+ }
939
+ // Dispatch drafting — static inner form so textarea survives updates.
940
+ const draftHead = el('div', 'wb-pane-subhead')
941
+ draftHead.textContent = '派单起草(复制后在任一会话粘贴执行)'
942
+ pane.appendChild(draftHead)
943
+ pane.appendChild(draftForm)
944
+ }
945
+
946
+ // Dispatch draft form: created ONCE; renderPlan re-attaches the same node.
947
+ const draftForm = el('div', 'wb-draft')
948
+ const draftTarget = document.createElement('select')
949
+ draftTarget.className = 'wb-select'
950
+ const draftInput = document.createElement('textarea')
951
+ draftInput.className = 'wb-textarea'
952
+ draftInput.rows = 3
953
+ draftInput.placeholder = '任务正文:目标、验收、期限…'
954
+ const draftBtn = el('button', 'wb-btn wb-btn-primary')
955
+ draftBtn.type = 'button'
956
+ draftBtn.textContent = '复制任务书'
957
+ draftBtn.addEventListener('click', () => {
958
+ const target = draftTarget.value
959
+ const message = draftInput.value.trim()
960
+ if (target === '' || message === '') return
961
+ deps.copy(`a2a_route 派单 → ${target}:\n${message}`)
962
+ })
963
+ draftForm.appendChild(draftTarget)
964
+ draftForm.appendChild(draftInput)
965
+ draftForm.appendChild(draftBtn)
966
+
967
+ function renderDraftTargets(): void {
968
+ const previous = draftTarget.value
969
+ clear(draftTarget)
970
+ const empty = document.createElement('option')
971
+ empty.value = ''
972
+ empty.textContent = '选择目标队…'
973
+ draftTarget.appendChild(empty)
974
+ if (last === null) return
975
+ const state = last
976
+ const targets = new Map<string, string>()
977
+ for (const row of state.sessions ?? []) {
978
+ if (row.team !== undefined && row.team !== '') targets.set(row.team, `${displayName(row)}(会话队)`)
979
+ }
980
+ for (const row of state.canvas?.teams ?? []) targets.set(row.team, `${row.name}(canvas 队)`)
981
+ for (const row of state.remote ?? []) targets.set(row.team, `${displayName(row)}(远端)`)
982
+ for (const [team, label] of targets) {
983
+ const opt = document.createElement('option')
984
+ opt.value = team
985
+ opt.textContent = `${label} · ${team}`
986
+ draftTarget.appendChild(opt)
987
+ }
988
+ if (previous !== '' && targets.has(previous)) draftTarget.value = previous
989
+ }
990
+
991
+ // ── Inspector (overlay card over the stage; bottom sheet on narrow) ──
992
+ function renderInspector(): void {
993
+ const box = clear(inspectorBody)
994
+ inspector.classList.toggle('is-open', selection !== undefined)
995
+ if (selection === undefined) return
996
+ const sel = selection
997
+ const card = el('div', 'wb-card')
998
+ // 0.6.13: session/log/peer faces moved to the chat drawer; 0.6.15 took
999
+ // the remote rows with them (their metadata rides the chat head) — the
1000
+ // inspector keeps the canvas team card.
1001
+ if (sel.kind === 'canvas') {
1002
+ // Re-resolve the team from the freshest state: membership changes here
1003
+ // must be visible on the next render even though the click-time row is
1004
+ // a stale snapshot.
1005
+ const fresh = (last?.canvas?.teams ?? []).find(t => t.name === sel.row.name) ?? sel.row
1006
+ textRow(card, 'Canvas 队', fresh.name)
1007
+ textRow(card, '路由队', fresh.team)
1008
+ const head = el('div', 'wb-side-head')
1009
+ head.textContent = `成员 · ${fresh.members.length}`
1010
+ card.appendChild(head)
1011
+ if (fresh.members.length === 0) {
1012
+ const empty = el('div', 'wb-muted')
1013
+ empty.textContent = '空队——用下方表单添加成员'
1014
+ card.appendChild(empty)
1015
+ }
1016
+ for (const member of fresh.members) {
1017
+ const row = el('div', 'wb-member-row')
1018
+ const dot = member.remote === true ? '◇ 远端' : member.live === true ? '● 现场' : member.joined === true ? '○ 冷启' : '· 未入网'
1019
+ row.appendChild(el('span', 'wb-member-dot')).textContent = dot
1020
+ const label = el('span', 'wb-member-label')
1021
+ const session = (last?.sessions ?? []).find(s => s.id === member.id)
1022
+ label.textContent = member.remote === true ? member.id : (session !== undefined ? displayName(session) : member.id)
1023
+ label.title = member.team ?? member.id
1024
+ row.appendChild(label)
1025
+ const remove = el('button', 'wb-member-remove')
1026
+ remove.type = 'button'
1027
+ remove.textContent = '移除'
1028
+ remove.addEventListener('click', async () => {
1029
+ remove.disabled = true
1030
+ if (!(await deps.canvasOp('remove-member', fresh.name, member.id))) remove.disabled = false
1031
+ })
1032
+ row.appendChild(remove)
1033
+ card.appendChild(row)
1034
+ }
1035
+ // Add-member form: static DOM re-mounted per poll (draftForm trick) —
1036
+ // rebuilt only when the inspected team changes.
1037
+ if (canvasForm?.name !== fresh.name) {
1038
+ const form = el('form', 'wb-member-add')
1039
+ const pick = document.createElement('select')
1040
+ pick.className = 'wb-member-pick'
1041
+ pick.setAttribute('aria-label', '选择成员')
1042
+ const manual = document.createElement('input')
1043
+ manual.type = 'text'
1044
+ manual.className = 'wb-member-manual'
1045
+ manual.placeholder = '或输入远端句柄 zone/id8'
1046
+ manual.setAttribute('aria-label', '远端成员句柄')
1047
+ const submit = el('button', 'wb-member-submit')
1048
+ submit.type = 'submit'
1049
+ submit.textContent = '添加'
1050
+ form.appendChild(pick)
1051
+ form.appendChild(manual)
1052
+ form.appendChild(submit)
1053
+ form.addEventListener('submit', async ev => {
1054
+ ev.preventDefault()
1055
+ const handle = manual.value.trim()
1056
+ const id = handle !== '' ? handle : pick.value
1057
+ if (id === '') return
1058
+ submit.disabled = true
1059
+ const ok = await deps.canvasOp('add-member', fresh.name, id)
1060
+ submit.disabled = false
1061
+ if (ok) manual.value = ''
1062
+ })
1063
+ canvasForm = { name: fresh.name, form, pick, manual }
1064
+ }
1065
+ // Refresh the picker's options from the freshest state, preserving the
1066
+ // current choice (sessions/remote rows churn across polls).
1067
+ const pick = canvasForm.pick
1068
+ const keep = pick.value
1069
+ clear(pick)
1070
+ pick.appendChild(new Option('本机会话(现场/冷启动)…', '', true, true))
1071
+ const memberIds = new Set(fresh.members.map(member => member.id))
1072
+ for (const session of last?.sessions ?? []) {
1073
+ if (memberIds.has(session.id)) continue
1074
+ pick.appendChild(new Option(`${displayName(session)} — 本机`, session.id))
1075
+ }
1076
+ let remoteLabeled = false
1077
+ for (const remote of last?.remote ?? []) {
1078
+ if (memberIds.has(remote.team)) continue
1079
+ if (!remoteLabeled) {
1080
+ pick.appendChild(new Option('远端会话队(目录卡片)…', '', true, false))
1081
+ remoteLabeled = true
1082
+ }
1083
+ pick.appendChild(new Option(`${displayName(remote)} — 远端`, remote.team))
1084
+ }
1085
+ if (keep !== '' && Array.from(pick.options).some(option => option.value === keep)) pick.value = keep
1086
+ card.appendChild(canvasForm.form)
1087
+ const hint = el('div', 'wb-hint')
1088
+ hint.textContent =
1089
+ '成员由人类在此手工分配(agent 不可自组队)。远端成员走对端目录路由,不做本机冷唤醒;地址由目录在派发时重解析。'
1090
+ card.appendChild(hint)
1091
+ // Quick dispatch targets the canvas ROUTING team (0.6.9): local tiers
1092
+ // first, then the remote members in order — the seam's own priority.
1093
+ if (deps.dispatchQuick !== undefined) card.appendChild(dispatchCard(fresh.team))
1094
+ }
1095
+ box.appendChild(card)
1096
+ }
1097
+
1098
+ // ── Quick dispatch (0.6.9) ──
1099
+ // One human-typed message to a routing team, no session opened. The form
1100
+ // is cached per target team (draftForm trick) so the textarea draft and
1101
+ // the result note survive the 3s poll remount.
1102
+ function buildDispatchForm(target: string): { form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } {
1103
+ const form = el('form', 'wb-dispatch-form')
1104
+ const text = document.createElement('textarea')
1105
+ text.className = 'wb-dispatch-text'
1106
+ text.placeholder = '一句话任务…(≤4000 字符,异步送达,回执进台账)'
1107
+ text.setAttribute('aria-label', '快派消息')
1108
+ const submit = el('button', 'wb-dispatch-submit')
1109
+ submit.type = 'submit'
1110
+ submit.textContent = '发送'
1111
+ const note = el('div', 'wb-dispatch-note')
1112
+ form.appendChild(text)
1113
+ form.appendChild(submit)
1114
+ form.appendChild(note)
1115
+ // 0.6.17 chat-UI conventions (deepseek-harness composer): Enter sends,
1116
+ // Shift+Enter is a soft newline, IME composition is never hijacked.
1117
+ text.addEventListener('keydown', ev => {
1118
+ if (ev.key !== 'Enter' || ev.shiftKey || ev.ctrlKey || ev.altKey || ev.metaKey) return
1119
+ if (ev.isComposing) return
1120
+ ev.preventDefault()
1121
+ if (!submit.disabled) form.requestSubmit()
1122
+ })
1123
+ form.addEventListener('submit', async ev => {
1124
+ ev.preventDefault()
1125
+ const message = text.value.trim()
1126
+ if (message === '' || submit.disabled) return
1127
+ submit.disabled = true
1128
+ note.textContent = '派发中…'
1129
+ let outcome: { ok: boolean; task_id?: string; reply?: string; error?: string }
1130
+ try {
1131
+ outcome = await (deps.dispatchQuick?.(target, message) ?? Promise.resolve({ ok: false, error: 'dispatch unavailable' }))
1132
+ } catch (error) {
1133
+ outcome = { ok: false, error: error instanceof Error ? error.message : String(error) }
1134
+ }
1135
+ submit.disabled = false
1136
+ if (outcome.ok === true) {
1137
+ // Sent: clear the composer like every chat surface and hand the
1138
+ // caret back — the submit round-trip must not strand focus.
1139
+ text.value = ''
1140
+ }
1141
+ text.focus({ preventScroll: true })
1142
+ note.textContent = outcome.ok === true
1143
+ ? `已派发${outcome.task_id !== undefined ? ` · ${outcome.task_id}` : ''} · 回执将入台账(待回执)`
1144
+ : `失败:${outcome.error ?? '未知原因'}`
1145
+ })
1146
+ return { form, text, note }
1147
+ }
1148
+
1149
+ function dispatchCard(target: string): HTMLElement {
1150
+ const wrap = el('div', 'wb-dispatch')
1151
+ const head = el('div', 'wb-side-head')
1152
+ head.textContent = '快派'
1153
+ wrap.appendChild(head)
1154
+ if (dispatchForm?.team !== target) dispatchForm = { team: target, ...buildDispatchForm(target) }
1155
+ wrap.appendChild(dispatchForm.form)
1156
+ return wrap
1157
+ }
1158
+
1159
+ // ── Inline transcript (0.6.11, remote 0.6.15) ──
1160
+ // The tail of a session's conversation log, read-only, on this page —
1161
+ // local sessions read the host log face; remote sessions read through
1162
+ // the proxy. Cached per cache key across the 3s poll remounts; the
1163
+ // refresh button forces one re-read. Reading — not jumping — is the
1164
+ // interaction.
1165
+ type TranscriptState = { ok: boolean; messages?: Array<{ role: string; text: string }>; error?: string } | undefined
1166
+
1167
+ function transcriptBlock(cacheKey: string, load: () => Promise<TranscriptState>): HTMLElement {
1168
+ const block = el('div', 'wb-transcript')
1169
+ const head = el('div', 'wb-side-head')
1170
+ // Owner signal (2026-10-21): log reading is the SEAT's forensics face —
1171
+ // it defaults to a collapsed detail (展开 once per transcript; the
1172
+ // expansion persists across the 3s poll remounts). The owner reads
1173
+ // results in the ledger; seats click 展开 when they need the record.
1174
+ const expand = el('button', 'wb-transcript-refresh')
1175
+ expand.type = 'button'
1176
+ const paintExpand = (): void => {
1177
+ expand.textContent = expandedTranscripts.has(cacheKey) ? '收起取证' : '展开取证'
1178
+ }
1179
+ expand.addEventListener('click', () => {
1180
+ if (expandedTranscripts.has(cacheKey)) expandedTranscripts.delete(cacheKey)
1181
+ else expandedTranscripts.add(cacheKey)
1182
+ paintExpand()
1183
+ body.classList.toggle('is-folded', !expandedTranscripts.has(cacheKey))
1184
+ if (expandedTranscripts.has(cacheKey)) settle()
1185
+ })
1186
+ paintExpand()
1187
+ head.appendChild(expand)
1188
+ head.appendChild(el('span', 'wb-transcript-title')).textContent = '最近对话'
1189
+ const refresh = el('button', 'wb-transcript-refresh')
1190
+ refresh.type = 'button'
1191
+ refresh.textContent = '刷新'
1192
+ head.appendChild(refresh)
1193
+ block.appendChild(head)
1194
+ const body = el('div', 'wb-transcript-body')
1195
+ if (!expandedTranscripts.has(cacheKey)) body.classList.add('is-folded')
1196
+ block.appendChild(body)
1197
+ // 0.6.17 chat-UI conventions: the reading position survives the 3s poll
1198
+ // remount (scroll captured on scroll, restored after every paint), and a
1199
+ // FIRST successful load lands on the newest turn (bottom) like every
1200
+ // chat surface. Refresh explicitly re-lands on the bottom.
1201
+ let landed = transcriptScroll.has(cacheKey)
1202
+ let landOnBottom = !landed
1203
+ const settle = (): void => {
1204
+ if (landOnBottom) {
1205
+ body.scrollTop = body.scrollHeight
1206
+ landOnBottom = false
1207
+ landed = true
1208
+ } else if (transcriptScroll.has(cacheKey)) {
1209
+ body.scrollTop = transcriptScroll.get(cacheKey) ?? 0
1210
+ }
1211
+ }
1212
+ body.addEventListener('scroll', () => {
1213
+ if (body.scrollTop > 0 || landed) transcriptScroll.set(cacheKey, body.scrollTop)
1214
+ })
1215
+ const paint = (state: TranscriptState): void => {
1216
+ body.replaceChildren()
1217
+ if (state === undefined) {
1218
+ body.appendChild(el('div', 'wb-muted')).textContent = '读取中…'
1219
+ return
1220
+ }
1221
+ if (state.ok !== true || state.messages === undefined) {
1222
+ body.appendChild(el('div', 'wb-muted')).textContent = `无法读取(${state.error ?? '未知错误'})`
1223
+ return
1224
+ }
1225
+ if (state.messages.length === 0) {
1226
+ body.appendChild(el('div', 'wb-muted')).textContent = '暂无对话记录'
1227
+ return
1228
+ }
1229
+ for (const message of state.messages) {
1230
+ const row = el('div', `wb-transcript-row wb-transcript-${message.role === 'user' ? 'user' : 'assistant'}`)
1231
+ row.appendChild(el('span', 'wb-transcript-role')).textContent = message.role === 'user' ? '问' : '答'
1232
+ row.appendChild(el('span', 'wb-transcript-text')).textContent = message.text
1233
+ body.appendChild(row)
1234
+ }
1235
+ // Scroll writes only stick on ATTACHED elements: the synchronous
1236
+ // cache-hit path paints before the block joins the DOM, so the
1237
+ // settle must run on the next frame, after append + layout.
1238
+ requestAnimationFrame(() => {
1239
+ if (body.isConnected) settle()
1240
+ })
1241
+ }
1242
+ const run = (force: boolean): void => {
1243
+ if (force) landOnBottom = true
1244
+ if (!force && transcriptCache.has(cacheKey)) {
1245
+ paint(transcriptCache.get(cacheKey))
1246
+ return
1247
+ }
1248
+ transcriptCache.set(cacheKey, undefined)
1249
+ paint(undefined)
1250
+ void load().then(result => {
1251
+ transcriptCache.set(cacheKey, result)
1252
+ // The poll may have remounted the drawer since; only the live
1253
+ // body repaints, the cache carries the result to the next mount.
1254
+ if (body.isConnected) paint(result)
1255
+ })
1256
+ }
1257
+ refresh.addEventListener('click', () => run(true))
1258
+ run(false)
1259
+ return block
1260
+ }
1261
+
1262
+ function localTranscript(id: string): HTMLElement {
1263
+ return transcriptBlock(`local:${id}`, async () => await deps.sessionLog?.(id) ?? { ok: false, error: 'sessionLog unavailable' })
1264
+ }
1265
+
1266
+ function remoteTranscript(cacheKey: string, query: { team?: string; peer?: string; id?: string }): HTMLElement {
1267
+ return transcriptBlock(cacheKey, async () => await deps.remoteSessionLog?.(query) ?? { ok: false, error: 'remoteSessionLog unavailable' })
1268
+ }
1269
+
1270
+ /** Peer-host session listing (0.6.15): the list face of /a2a/log through
1271
+ * the proxy — recent readable sessions on that peer, click to drill. */
1272
+ function peerLogList(url: string): HTMLElement {
1273
+ const block = el('div', 'wb-transcript')
1274
+ const head = el('div', 'wb-side-head')
1275
+ head.textContent = '对端最近会话'
1276
+ block.appendChild(head)
1277
+ const body = el('div', 'wb-transcript-body')
1278
+ block.appendChild(body)
1279
+ const cacheKey = `peerlist:${url}`
1280
+ const paint = (state: { ok: boolean; sessions?: Array<{ id: string; sizeBytes: number }>; error?: string } | undefined): void => {
1281
+ body.replaceChildren()
1282
+ if (state === undefined) {
1283
+ body.appendChild(el('div', 'wb-muted')).textContent = '读取中…'
1284
+ return
1285
+ }
1286
+ if (state.ok !== true || state.sessions === undefined) {
1287
+ body.appendChild(el('div', 'wb-muted')).textContent = `无法读取(${state.error ?? '未知错误'})`
1288
+ return
1289
+ }
1290
+ if (state.sessions.length === 0) {
1291
+ body.appendChild(el('div', 'wb-muted')).textContent = '对端没有可读会话'
1292
+ return
1293
+ }
1294
+ const fmtSize = (n: number): string => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} MB` : n >= 1_000 ? `${Math.round(n / 1_000)} KB` : `${n} B`
1295
+ for (const row of state.sessions) {
1296
+ const item = el('button', 'wb-member-row')
1297
+ item.type = 'button'
1298
+ item.appendChild(el('span', 'wb-chat-dot is-cold')).textContent = '❐'
1299
+ item.appendChild(el('span', 'wb-item-label')).textContent = `…${row.id.slice(-12)} · ${fmtSize(row.sizeBytes)}`
1300
+ item.addEventListener('click', () => {
1301
+ if (chatTarget?.kind === 'peer') chatTarget.picked = row.id
1302
+ renderChat()
1303
+ })
1304
+ body.appendChild(item)
1305
+ }
1306
+ }
1307
+ if (peerListCache.has(cacheKey)) paint(peerListCache.get(cacheKey))
1308
+ else {
1309
+ paint(undefined)
1310
+ void deps.remoteSessionLog?.({ peer: url }).then(result => {
1311
+ peerListCache.set(cacheKey, result)
1312
+ if (body.isConnected) paint(result)
1313
+ })
1314
+ }
1315
+ return block
1316
+ }
1317
+
1318
+ // ── Node chat drawer (0.6.13) ──
1319
+ // The right-side reading face. The transcript block is cached per id, so
1320
+ // re-rendering across the 3s poll is cheap; the dispatch form has its own
1321
+ // cache slot so the inspector's team and the chat's can coexist.
1322
+ function closeChat(): void {
1323
+ chatTarget = null
1324
+ chatForm = null
1325
+ chat.classList.remove('is-open')
1326
+ }
1327
+
1328
+ function openChat(target: ChatTarget): void {
1329
+ chatTarget = target
1330
+ // Keep the chat form only when it belongs to the same session team.
1331
+ const team = target.kind === 'session' || target.kind === 'remote' ? target.team : undefined
1332
+ if (chatForm !== null && chatForm.team !== team) chatForm = null
1333
+ // 0.6.17: focus the composer ONCE per open — never on poll remounts,
1334
+ // which would steal focus every 3s (the focus bug the owner hit).
1335
+ chatFocusPending = true
1336
+ renderChat()
1337
+ chat.classList.add('is-open')
1338
+ root.classList.remove('is-rail-open')
1339
+ }
1340
+
1341
+ /** 0.6.17 focus law: the 3s poll rebuilds the drawer, and MOVING a
1342
+ * focused node (re-append) blurs it in every browser — the composer
1343
+ * would lose the caret mid-typing. Capture focus + selection around the
1344
+ * face render and restore it on the same node after (cached nodes come
1345
+ * back; throwaway nodes just lose focus as before). */
1346
+ function renderChat(): void {
1347
+ const activeBefore = document.activeElement
1348
+ const focusWasInside = activeBefore instanceof HTMLElement && chatBody.contains(activeBefore)
1349
+ const selection = activeBefore instanceof HTMLTextAreaElement || activeBefore instanceof HTMLInputElement
1350
+ ? { start: activeBefore.selectionStart, end: activeBefore.selectionEnd }
1351
+ : undefined
1352
+ renderChatFace()
1353
+ if (!focusWasInside || !(activeBefore instanceof HTMLElement) || !chatBody.contains(activeBefore)) return
1354
+ activeBefore.focus({ preventScroll: true })
1355
+ if (selection !== undefined && activeBefore instanceof HTMLTextAreaElement) {
1356
+ activeBefore.setSelectionRange(selection.start, selection.end)
1357
+ }
1358
+ }
1359
+
1360
+ function renderChatFace(): void {
1361
+ const t = chatTarget
1362
+ if (t === null) return
1363
+ chatDot.className = `wb-chat-dot ${t.kind === 'peer' ? 'is-peer' : t.kind === 'log' || t.kind === 'remote' || t.live === false ? 'is-cold' : 'is-live'}`
1364
+ chatTitle.textContent = t.title
1365
+ chatSub.textContent = t.sub
1366
+ clear(chatBody)
1367
+ if (t.kind === 'peer') {
1368
+ // 0.6.15: the peer host is readable through the proxy — list its
1369
+ // recent sessions, then drill into one. Without the proxy dep the
1370
+ // honest where-the-records-live note stays.
1371
+ const url = t.url ?? (t.title.startsWith('http') ? t.title : '')
1372
+ if (url === '' || deps.remoteSessionLog === undefined) {
1373
+ const note = el('div', 'wb-chat-note')
1374
+ note.textContent = `对端主机节点(${t.sub})—— 会话记录存放在对端机器上,本页只读本机会话日志。`
1375
+ chatBody.appendChild(note)
1376
+ return
1377
+ }
1378
+ const note = el('div', 'wb-chat-note')
1379
+ note.textContent = '对端主机节点 —— 以下会话列表经本机代理从对端 /a2a/log 读取。'
1380
+ chatBody.appendChild(note)
1381
+ if (t.picked !== undefined) {
1382
+ const back = el('button', 'wb-transcript-refresh')
1383
+ back.type = 'button'
1384
+ back.textContent = '← 返回会话列表'
1385
+ back.addEventListener('click', () => {
1386
+ if (chatTarget?.kind === 'peer') chatTarget.picked = undefined
1387
+ renderChat()
1388
+ })
1389
+ chatBody.appendChild(back)
1390
+ chatBody.appendChild(remoteTranscript(`peer:${url}:${t.picked}`, { peer: url, id: t.picked }))
1391
+ } else {
1392
+ chatBody.appendChild(peerLogList(url))
1393
+ }
1394
+ return
1395
+ }
1396
+ if (t.kind === 'remote') {
1397
+ // Card metadata the old inspector face carried (origin/via/workspace),
1398
+ // kept readable in the drawer head area.
1399
+ const meta = el('div')
1400
+ if (t.row?.origin !== undefined) textRow(meta, '来源', t.row.origin)
1401
+ if (t.row?.via !== undefined) textRow(meta, '经', t.row.via)
1402
+ if (t.row?.workspace !== undefined) textRow(meta, '工作区', t.row.workspace)
1403
+ if (meta.childElementCount > 0) chatBody.appendChild(meta)
1404
+ if (deps.remoteSessionLog !== undefined) {
1405
+ const note = el('div', 'wb-chat-note')
1406
+ note.textContent = '远端会话节点 —— 经本机代理从对端 /a2a/log 读取最近对话。'
1407
+ chatBody.appendChild(note)
1408
+ chatBody.appendChild(remoteTranscript(`remote:${t.team}`, { team: t.team }))
1409
+ } else {
1410
+ const note = el('div', 'wb-chat-note')
1411
+ note.textContent = '远端节点的对话记录在对端机器上;本页的代理读取面未启用。'
1412
+ chatBody.appendChild(note)
1413
+ }
1414
+ } else {
1415
+ if (t.kind === 'log') {
1416
+ const note = el('div', 'wb-chat-note')
1417
+ note.textContent = '只读日志 —— 此会话当前不在网络目录(可能已归档或未入网)。'
1418
+ chatBody.appendChild(note)
1419
+ }
1420
+ if (deps.sessionLog !== undefined) chatBody.appendChild(localTranscript(t.id))
1421
+ }
1422
+ const dispatchTeam = t.kind === 'session' || t.kind === 'remote' ? t.team : undefined
1423
+ if (dispatchTeam !== undefined && dispatchTeam !== '' && deps.dispatchQuick !== undefined) {
1424
+ const wrap = el('div', 'wb-dispatch')
1425
+ const head = el('div', 'wb-side-head')
1426
+ head.textContent = '发送'
1427
+ wrap.appendChild(head)
1428
+ if (chatForm === null) chatForm = { team: dispatchTeam, ...buildDispatchForm(dispatchTeam) }
1429
+ wrap.appendChild(chatForm.form)
1430
+ chatBody.appendChild(wrap)
1431
+ if (chatFocusPending) {
1432
+ chatFocusPending = false
1433
+ // preventScroll: focusing must not fight the transcript's own
1434
+ // landing scroll (bottom on first read).
1435
+ chatForm.text.focus({ preventScroll: true })
1436
+ }
1437
+ } else {
1438
+ chatFocusPending = false
1439
+ }
1440
+ }
1441
+
1442
+ /** Board picks (0.6.13): resolve a node id against the latest state. */
1443
+ function openNodeChat(id: string): void {
1444
+ if (last === null) return
1445
+ const session = (last.sessions ?? []).find(row => row.id === id)
1446
+ if (session !== undefined) {
1447
+ openChat({ kind: 'session', id, title: displayName(session), sub: session.team, team: session.team, live: session.live })
1448
+ return
1449
+ }
1450
+ if (id.startsWith('peer-')) {
1451
+ const host = id.slice('peer-'.length)
1452
+ const peer = (last.peers ?? []).find(row => row.url.includes(host))
1453
+ openChat({ kind: 'peer', id, title: peer !== undefined ? peer.url : host, sub: host, url: peer?.url })
1454
+ return
1455
+ }
1456
+ const log = logRows.find(row => row.id === id)
1457
+ if (log !== undefined) openChat({ kind: 'log', id, title: `…${id.slice(-12)}`, sub: '档案' })
1458
+ }
1459
+
1460
+ // ── Header ──
1461
+ // Results first (owner signal, 0.6.24): the header carries the verdict —
1462
+ // exceptions only, one pill each; a healthy host says so quietly. The
1463
+ // forensic counters that used to sit here (idempotency claims/replays,
1464
+ // reconcile state) read on the ledger's 取证 line instead.
1465
+ function renderHeader(): void {
1466
+ versionBadge.textContent = last?.version !== undefined ? `v${last.version}` : 'v?'
1467
+ const box = clear(headerStats)
1468
+ if (last === null) return
1469
+ const alerts: Array<{ text: string; tone: 'warn' | 'danger'; title: string }> = []
1470
+ const dead = last.tasksDead?.length ?? 0
1471
+ if (dead > 0) alerts.push({ text: `死信 ${dead}`, tone: 'danger', title: '超过 24h TTL 仍未结算的任务,需在台账清账' })
1472
+ const stale = last.tasksStaleCount ?? 0
1473
+ if (stale > 0) alerts.push({ text: `陈旧 ${stale}`, tone: 'warn', title: '≥1h 无回执的旧账(含免回执行),到 24h TTL 自动转死信' })
1474
+ const conflicts = last.idempotency?.conflicts ?? 0
1475
+ if (conflicts > 0) alerts.push({ text: `幂等冲突 ${conflicts}`, tone: 'danger', title: '同一幂等键对应了不同载荷,需人工核查' })
1476
+ const repair = (last.reconcile?.rows ?? []).filter(row => row.needsRepair === true).length
1477
+ if (repair > 0) alerts.push({ text: `需修复 ${repair}`, tone: 'danger', title: 'reconcile 无法自动恢复的会话,见行动计划' })
1478
+ if (alerts.length === 0) {
1479
+ box.appendChild(el('span', 'wb-header-ok')).textContent = '● 无异常'
1480
+ return
1481
+ }
1482
+ for (const a of alerts) {
1483
+ const pill = el('span', `wb-alert wb-alert-${a.tone}`)
1484
+ pill.textContent = a.text
1485
+ pill.title = a.title
1486
+ box.appendChild(pill)
1487
+ }
1488
+ }
1489
+
1490
+ function update(state: StateBody | null): void {
1491
+ last = state
1492
+ // 0.6.12: refresh the readable-log list opportunistically (single flight;
1493
+ // the sidebar re-renders when rows land, mid-poll remount is idempotent).
1494
+ if (deps.sessionLogList !== undefined && !logRowsBusy) {
1495
+ logRowsBusy = true
1496
+ deps.sessionLogList().then(result => {
1497
+ logRows = result.ok && result.sessions !== undefined ? result.sessions : []
1498
+ }).catch(() => {}).finally(() => {
1499
+ logRowsBusy = false
1500
+ renderSidebar()
1501
+ })
1502
+ }
1503
+ renderHeader()
1504
+ renderSidebar()
1505
+ renderDraftTargets()
1506
+ renderLedger()
1507
+ // 0.6.13: an open chat drawer re-renders against the fresh state (title
1508
+ // freshness + the transcript block's per-id cache answers instantly).
1509
+ if (chatTarget !== null) renderChat()
1510
+ renderActivity()
1511
+ renderPlan()
1512
+ renderInspector()
1513
+ // Mode is availability-derived every poll: canvas face down → data mode,
1514
+ // the board deactivates and the panes return to the stage stack.
1515
+ syncMode()
1516
+ syncSections()
1517
+ }
1518
+
1519
+ function selectTab(key: WorkbenchTab): void {
1520
+ if (key === 'board') {
1521
+ syncMode()
1522
+ return
1523
+ }
1524
+ collapsed.delete(key)
1525
+ syncSections()
1526
+ }
1527
+
1528
+ update(null)
1529
+ return { root, update, mode: () => mode, selectTab, openNodeChat }
1530
+ }