@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 0.6.42
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 +1 -1
- package/README_zh.md +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/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/index.js +57 -9
- 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/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 +513 -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 +1272 -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
|
@@ -1,1272 +1,1272 @@
|
|
|
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
|
-
import { createChatDrawer, type ChatTarget } from './wb-chat'
|
|
22
|
-
import { createSeatNameEditor } from './seat-name-editor'
|
|
23
|
-
import { createCliHomePicker, type CliHomeOptions } from './cli-home-picker'
|
|
24
|
-
import { createRailTabs } from './wb-rail'
|
|
25
|
-
import { DIRECTORY_FILTERS, learnLogTitle, loadLogTitles, renderDirectory, type DirFilter } from './wb-directory'
|
|
26
|
-
import { ledgerFacts, renderLedger as paintLedger } from './wb-ledger'
|
|
27
|
-
import { nameInto, teamLabel } from './wb-names'
|
|
28
|
-
|
|
29
|
-
/** One pending ledger row from /state `tasks`. */
|
|
30
|
-
export interface PendingRow {
|
|
31
|
-
readonly taskId: string
|
|
32
|
-
readonly team: string
|
|
33
|
-
readonly peer?: string
|
|
34
|
-
readonly startedAt: number
|
|
35
|
-
readonly status: string
|
|
36
|
-
readonly direction?: string
|
|
37
|
-
readonly receiptExpected?: boolean
|
|
38
|
-
readonly settleable?: boolean
|
|
39
|
-
/**
|
|
40
|
-
* First line of the dispatched message. A row without it is unreadable:
|
|
41
|
-
* `direct-8f3a1c2b · dsh · 3h` says nothing about whether to nudge,
|
|
42
|
-
* abandon, or go read the receipt — the operator's whole question is
|
|
43
|
-
* WHAT is owed. Absent on rows persisted before the field existed.
|
|
44
|
-
*/
|
|
45
|
-
readonly brief?: string
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** One dead-lettered row from /state `tasksDead`. */
|
|
49
|
-
export interface DeadRow {
|
|
50
|
-
readonly taskId: string
|
|
51
|
-
readonly team: string
|
|
52
|
-
readonly peer?: string
|
|
53
|
-
readonly startedAt: number
|
|
54
|
-
readonly deadAt: number
|
|
55
|
-
readonly reason?: string
|
|
56
|
-
/** First line of the dispatched message (see {@link PendingRow.brief}). */
|
|
57
|
-
readonly brief?: string
|
|
58
|
-
/** gh#31: which side of the wire this row served (absent on old hosts). */
|
|
59
|
-
readonly direction?: 'inbound' | 'outbound'
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** gh#31: one archived row from /state `archived` (newest first) — the
|
|
63
|
-
* per-task outcome for a task that left the ledger. Fields past `summary`
|
|
64
|
-
* are absent on hosts that predate them. */
|
|
65
|
-
export interface ArchivedRow {
|
|
66
|
-
readonly taskId: string
|
|
67
|
-
readonly team: string
|
|
68
|
-
readonly resolvedAt: number
|
|
69
|
-
readonly outcome: string
|
|
70
|
-
readonly summary?: string
|
|
71
|
-
readonly peer?: string
|
|
72
|
-
readonly startedAt?: number
|
|
73
|
-
/** How the row left the book: a receipt, a content echo, or the caller
|
|
74
|
-
* (abandon / cancel — both archive with outcome 'abandoned'). */
|
|
75
|
-
readonly endedBy?: 'receipt' | 'echo' | 'abandoned' | 'cancelled'
|
|
76
|
-
/** A receipt arrived after the caller had already ended the row. */
|
|
77
|
-
readonly late?: boolean
|
|
78
|
-
/** gh#68: present (false) only when the row never expected a receipt — a
|
|
79
|
-
* fire-forget send; absent on receipt-expected rows and older hosts. */
|
|
80
|
-
readonly receiptExpected?: false
|
|
81
|
-
readonly direction?: 'inbound' | 'outbound'
|
|
82
|
-
readonly brief?: string
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** gh#41: one tracked peer's published canvas organization (full-card
|
|
86
|
-
* canvasOrg) — the board renders REMOTE team frames from these rows. */
|
|
87
|
-
export interface PeerOrgRow {
|
|
88
|
-
readonly url: string
|
|
89
|
-
readonly unreachable?: boolean
|
|
90
|
-
readonly org: ReadonlyArray<{ name: string; team: string; members: readonly string[] }>
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** One session row from /state `sessions` (live or cold joined). */
|
|
94
|
-
export interface SessionRow {
|
|
95
|
-
readonly id: string
|
|
96
|
-
readonly label: string
|
|
97
|
-
readonly team: string
|
|
98
|
-
readonly name?: string
|
|
99
|
-
readonly seatName?: string
|
|
100
|
-
readonly description?: string
|
|
101
|
-
readonly workspace?: string
|
|
102
|
-
readonly joined?: boolean
|
|
103
|
-
readonly live?: boolean
|
|
104
|
-
/** Final scoped assembly lacks at least one registered node section (#174). */
|
|
105
|
-
readonly nodePromptMissing?: boolean
|
|
106
|
-
readonly teams?: readonly string[]
|
|
107
|
-
/** Canvas teams this session is a member of (0.6.6 roster annotation). */
|
|
108
|
-
readonly canvasTeams?: readonly string[]
|
|
109
|
-
/** @deprecated 0.6.6 — display-only taxonomy superseded by canvas teams. */
|
|
110
|
-
readonly group?: string
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** One remote team row from /state `remote`. */
|
|
114
|
-
export interface RemoteRow {
|
|
115
|
-
readonly team: string
|
|
116
|
-
readonly unreachable?: boolean
|
|
117
|
-
readonly name?: string
|
|
118
|
-
readonly origin?: string
|
|
119
|
-
readonly via?: string
|
|
120
|
-
readonly workspace?: string
|
|
121
|
-
readonly legacy?: boolean
|
|
122
|
-
readonly teams?: readonly string[]
|
|
123
|
-
/** Canvas teams on this host that carry this remote handle as a member. */
|
|
124
|
-
readonly canvasTeams?: readonly string[]
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** One canvas member: a local bare session id or a zone-qualified remote
|
|
128
|
-
* handle (`zone/id8`) — remote members are routed by the directory, never
|
|
129
|
-
* woken locally (0.6.6 unified team model). */
|
|
130
|
-
export interface CanvasMemberRow {
|
|
131
|
-
readonly id: string
|
|
132
|
-
readonly team?: string
|
|
133
|
-
readonly joined?: boolean
|
|
134
|
-
readonly live?: boolean
|
|
135
|
-
readonly remote?: boolean
|
|
136
|
-
/** Present only for CLI refs: null means the linked home is ambiguous or unknown. */
|
|
137
|
-
readonly cliHome?: string | null
|
|
138
|
-
readonly cliHeld?: boolean
|
|
139
|
-
readonly cliHomeCleared?: boolean
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** One canvas team from /state `canvas.teams`. */
|
|
143
|
-
export interface CanvasTeamRow {
|
|
144
|
-
readonly name: string
|
|
145
|
-
readonly team: string
|
|
146
|
-
readonly members: readonly CanvasMemberRow[]
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** One activity-ring entry. */
|
|
150
|
-
export interface ActivityRow {
|
|
151
|
-
readonly ts: number
|
|
152
|
-
readonly dir: 'in' | 'out' | 'fwd'
|
|
153
|
-
readonly team: string
|
|
154
|
-
readonly peer: string
|
|
155
|
-
readonly ok: boolean
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** The /state subset the workbench consumes (additive fields ignored). */
|
|
159
|
-
export interface StateBody {
|
|
160
|
-
readonly zone?: string
|
|
161
|
-
readonly version?: string
|
|
162
|
-
readonly sessions?: readonly SessionRow[]
|
|
163
|
-
readonly canvas?: { teams?: readonly CanvasTeamRow[] }
|
|
164
|
-
/** gh#20 W-A/W-C: the peer row carries the link badge source, the host
|
|
165
|
-
* rail's label/version/last-contact facts (absent on older hosts). */
|
|
166
|
-
readonly peers?: ReadonlyArray<{
|
|
167
|
-
url: string
|
|
168
|
-
score?: number
|
|
169
|
-
link?: 'linked' | 'stranger'
|
|
170
|
-
verified?: boolean
|
|
171
|
-
name?: string
|
|
172
|
-
version?: string
|
|
173
|
-
lastContactAt?: number
|
|
174
|
-
/** gh#24: whether the last contact attempt succeeded. */
|
|
175
|
-
lastContactOk?: boolean
|
|
176
|
-
}>
|
|
177
|
-
readonly remote?: readonly RemoteRow[]
|
|
178
|
-
readonly activity?: readonly ActivityRow[]
|
|
179
|
-
readonly inFlight?: ReadonlyArray<{ team: string; peer: string; startedAt: number }>
|
|
180
|
-
readonly tasks?: readonly PendingRow[]
|
|
181
|
-
readonly tasksStaleCount?: number
|
|
182
|
-
readonly tasksDead?: readonly DeadRow[]
|
|
183
|
-
/** gh#31: per-task outcomes for rows that left the ledger (bounded). */
|
|
184
|
-
readonly archived?: readonly ArchivedRow[]
|
|
185
|
-
/** gh#41 fleet planning: each tracked peer's published canvas org. */
|
|
186
|
-
readonly peerOrg?: readonly PeerOrgRow[]
|
|
187
|
-
readonly registry?: { teams?: ReadonlyArray<{ team: string; members: readonly string[]; live?: number }> }
|
|
188
|
-
readonly archivedCount?: number
|
|
189
|
-
/** Settled-archive split by verdict (the receipt outcome vocabulary — 'completed' | 'rejected' | 'failed' | … | 'abandoned' — or 'unclassified'). */
|
|
190
|
-
readonly archivedSplit?: Readonly<Record<string, number>>
|
|
191
|
-
readonly idempotency?: {
|
|
192
|
-
window?: number
|
|
193
|
-
pending?: number
|
|
194
|
-
settled?: number
|
|
195
|
-
claimsFresh?: number
|
|
196
|
-
replays?: number
|
|
197
|
-
conflicts?: number
|
|
198
|
-
}
|
|
199
|
-
readonly reconcile?: {
|
|
200
|
-
state?: string
|
|
201
|
-
woken?: number
|
|
202
|
-
rows?: ReadonlyArray<{ id: string; error: string; attempts: number; needsRepair?: boolean }>
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** What the workbench can DO — the seams tests drive. */
|
|
207
|
-
export interface WorkbenchDeps {
|
|
208
|
-
now(): number
|
|
209
|
-
/** gh#41 fleet planning: each tracked peer's published canvas org. */
|
|
210
|
-
peerOrg?: readonly PeerOrgRow[]
|
|
211
|
-
/** gh#34: read the owner three-key panel state (GET /__dsh_a2a/owner-settings). */
|
|
212
|
-
ownerSettingsRead?(): Promise<{ ok: boolean; seatNames?: Record<string, string>; teamJoinAllowlist?: string[]; teamScopeInfo?: boolean; announce?: boolean; announceRestartPending?: boolean; error?: string }>
|
|
213
|
-
/** gh#34: write owner panel changes; true on success (POST the same face). */
|
|
214
|
-
ownerSettingsWrite?(changes: { seatNames?: Record<string, string>; teamJoinAllowlist?: string[]; teamScopeInfo?: boolean; announce?: boolean }): Promise<{ ok: boolean; restartNeeded?: boolean; error?: string }>
|
|
215
|
-
/** gh#41 fleet planning: regulate a REMOTE peer's canvas team (this host
|
|
216
|
-
* signs the envelope and forwards; the peer's link gate authorizes). */
|
|
217
|
-
fleetCanvasOp?(peer: string, op: 'create' | 'add-member' | 'remove-member' | 'remove', name: string, id?: string): Promise<{ ok: boolean; members?: string[]; error?: string }>
|
|
218
|
-
/** Abandon one dead-lettered task via the control route. True on success. */
|
|
219
|
-
abandon(taskId: string, reason: string): Promise<boolean>
|
|
220
|
-
/** Canvas team member change via the control route (0.6.6): `id` is a
|
|
221
|
-
* local session id or a zone-qualified remote handle. True on success. */
|
|
222
|
-
canvasOp(action: 'add-member' | 'remove-member', name: string, id: string, home?: string): Promise<boolean>
|
|
223
|
-
cliHomeOptions?(name: string, id: string): Promise<CliHomeOptions>
|
|
224
|
-
/** Quick dispatch (0.6.9): POST /__dsh_a2a/dispatch as a human gesture —
|
|
225
|
-
* one message to a routing team, async delivery, receipt lands in the
|
|
226
|
-
* ledger. Optional — without it the 快派 form hides. */
|
|
227
|
-
dispatchQuick?(team: string, message: string): Promise<{ ok: boolean; task_id?: string; reply?: string; error?: string }>
|
|
228
|
-
/** Inline transcript (0.6.11): the tail of a LOCAL session's own
|
|
229
|
-
* conversation log, read-only. Optional — without it the block hides. */
|
|
230
|
-
sessionLog?(id: string): Promise<{ ok: boolean; messages?: Array<{ role: string; text: string }>; error?: string }>
|
|
231
|
-
/** 0.6.12: list every local session holding a log artifact (newest first) —
|
|
232
|
-
* the reading face is host-scoped, so archived and never-joined sessions
|
|
233
|
-
* appear here even when the network directory shows no local rows. */
|
|
234
|
-
sessionLogList?(): Promise<{ ok: boolean; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; count?: number; error?: string }>
|
|
235
|
-
/** 0.6.15: remote transcript via the local proxy — ?team=<zone/id8>
|
|
236
|
-
* resolves the publishing peer through the directory, ?peer=<base-url>
|
|
237
|
-
* reads one tracked peer directly. Without id the peer answers its
|
|
238
|
-
* readable-session listing. Optional — without it remote/peer chat
|
|
239
|
-
* shows the honest where-the-records-live note. */
|
|
240
|
-
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 }>
|
|
241
|
-
/** Copy text to the clipboard; drafts are pasteable task briefs. */
|
|
242
|
-
copy(text: string): void
|
|
243
|
-
// 0.6.10: openLocal/openRemote (0.6.7/0.6.8 jump affordances) removed by
|
|
244
|
-
// ruling — 禁止任何跳转,全部使用响应式布局. dispatchQuick is the only
|
|
245
|
-
// reach primitive and it stays on this page.
|
|
246
|
-
/** The spatial planning board. Optional: without it the shell stays in
|
|
247
|
-
* data mode permanently. `available()` mirrors the host's canvas face
|
|
248
|
-
* (state.canvas !== undefined) and gates canvas mode. */
|
|
249
|
-
board?: {
|
|
250
|
-
readonly root: HTMLElement
|
|
251
|
-
activate(): void
|
|
252
|
-
deactivate(): void
|
|
253
|
-
available(): boolean
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/** Display name for one node: session title, then team, then label. */
|
|
258
|
-
function displayName(node: { name?: string; team?: string; label?: string }): string {
|
|
259
|
-
return node.name ?? node.team ?? node.label ?? ''
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function ageText(ms: number, now: number): string {
|
|
263
|
-
const s = Math.max(0, Math.round((now - ms) / 1000))
|
|
264
|
-
if (s < 90) return `${s}s`
|
|
265
|
-
const m = Math.round(s / 60)
|
|
266
|
-
if (m < 90) return `${m}m`
|
|
267
|
-
const h = Math.round(m / 60)
|
|
268
|
-
if (h < 48) return `${h}h`
|
|
269
|
-
return `${Math.round(h / 24)}d`
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] {
|
|
273
|
-
const node = document.createElement(tag)
|
|
274
|
-
if (className !== undefined) node.className = className
|
|
275
|
-
return node
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function textRow(parent: HTMLElement, label: string, value: string): void {
|
|
279
|
-
const row = el('div', 'wb-kv')
|
|
280
|
-
row.appendChild(el('span', 'wb-kv-k')).textContent = label
|
|
281
|
-
row.appendChild(el('span', 'wb-kv-v')).textContent = value
|
|
282
|
-
parent.appendChild(row)
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
type Selection =
|
|
286
|
-
| { kind: 'canvas'; row: CanvasTeamRow }
|
|
287
|
-
| undefined
|
|
288
|
-
|
|
289
|
-
/** The rail sections. */
|
|
290
|
-
export type WorkbenchSection = 'directory' | 'hosts' | 'ledger' | 'activity' | 'plan'
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
/** Legacy deep-link targets: 'board' focuses the stage, sections open a rail section. */
|
|
294
|
-
export type WorkbenchTab = 'board' | WorkbenchSection
|
|
295
|
-
|
|
296
|
-
/** Which face fills the stage. */
|
|
297
|
-
export type WorkbenchMode = 'canvas' | 'data'
|
|
298
|
-
|
|
299
|
-
export interface Workbench {
|
|
300
|
-
readonly root: HTMLElement
|
|
301
|
-
/** Feed one /state poll (or null while the first fetch is out). */
|
|
302
|
-
update(state: StateBody | null): void
|
|
303
|
-
/** Current stage mode: the board while the canvas face is up, else data. */
|
|
304
|
-
readonly mode: () => WorkbenchMode
|
|
305
|
-
/** Focus a target: 'board' converges on canvas mode (no-op while the
|
|
306
|
-
* canvas face is down); a section un-collapses its rail panel. */
|
|
307
|
-
selectTab(key: WorkbenchTab): void
|
|
308
|
-
/** A node was picked on the planning board (0.6.13): resolve it against
|
|
309
|
-
* the latest state and open the right-side chat drawer for it. */
|
|
310
|
-
openNodeChat(id: string): void
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** Bare host:port of a peer url (workbench-local twin of federation's
|
|
314
|
-
* helper — this module keeps a zero-import posture). */
|
|
315
|
-
function peerHostOf(url: string): string {
|
|
316
|
-
try {
|
|
317
|
-
return new URL(url).host
|
|
318
|
-
} catch {
|
|
319
|
-
return url
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/** Rail tabs (2026-10-23): 主机 moved up next to 目录 — host health was the
|
|
324
|
-
* last accordion section and sat below the fold. */
|
|
325
|
-
const SECTION_DEFS: ReadonlyArray<{ key: WorkbenchSection; label: string }> = [
|
|
326
|
-
{ key: 'directory', label: '目录' },
|
|
327
|
-
{ key: 'hosts', label: '主机' },
|
|
328
|
-
{ key: 'ledger', label: '台账' },
|
|
329
|
-
{ key: 'activity', label: '活动' },
|
|
330
|
-
{ key: 'plan', label: '规划' },
|
|
331
|
-
]
|
|
332
|
-
|
|
333
|
-
export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
334
|
-
const board = deps.board
|
|
335
|
-
let mode: WorkbenchMode = 'data'
|
|
336
|
-
let selection: Selection
|
|
337
|
-
let last: StateBody | null = null
|
|
338
|
-
// Directory view state (survives polls): unified search + category filter.
|
|
339
|
-
let searchText = ''
|
|
340
|
-
let dirFilter: DirFilter = 'all'
|
|
341
|
-
// Canvas add-member form cache (static DOM re-mounted across polls; keyed
|
|
342
|
-
// by team name — same trick as draftForm so inputs survive the 3s poll).
|
|
343
|
-
let canvasForm: { name: string; form: HTMLFormElement; pick: HTMLSelectElement; manual: HTMLInputElement } | null = null
|
|
344
|
-
const cliHomeForms = new Map<string, HTMLFormElement>()
|
|
345
|
-
// Quick-dispatch form (0.6.9), cached per target routing team the same
|
|
346
|
-
// draftForm way: a textarea's draft must survive the 3s poll remount.
|
|
347
|
-
let dispatchForm: { team: string; form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } | null = null
|
|
348
|
-
// 0.6.12: readable local logs (host-scope list, newest first). Refreshed
|
|
349
|
-
// opportunistically per poll; rows render in the directory's 本机日志 section.
|
|
350
|
-
let logRows: Array<{ id: string; mtimeMs: number; sizeBytes: number }> = []
|
|
351
|
-
/** How many logs the host holds (the list face caps at 50); from the listing's count. */
|
|
352
|
-
let logTotal: number | undefined
|
|
353
|
-
/** Learned 本机日志 titles: the first user line of a transcript, captured
|
|
354
|
-
* when the chat drawer loads it (the list face carries no titles). */
|
|
355
|
-
const logTitles = loadLogTitles()
|
|
356
|
-
let logRowsBusy = false
|
|
357
|
-
|
|
358
|
-
const root = el('div', 'wb')
|
|
359
|
-
root.setAttribute('data-testid', 'wb-root')
|
|
360
|
-
|
|
361
|
-
// ── Static skeleton: built once, update() only rewrites data regions. ──
|
|
362
|
-
const header = el('header', 'wb-header')
|
|
363
|
-
const menuBtn = el('button', 'wb-icon-btn wb-menu')
|
|
364
|
-
menuBtn.type = 'button'
|
|
365
|
-
menuBtn.dataset.testid = 'wb-menu'
|
|
366
|
-
menuBtn.setAttribute('aria-label', '打开目录抽屉')
|
|
367
|
-
menuBtn.textContent = '☰'
|
|
368
|
-
menuBtn.addEventListener('click', () => root.classList.toggle('is-rail-open'))
|
|
369
|
-
header.appendChild(menuBtn)
|
|
370
|
-
const title = el('div', 'wb-title')
|
|
371
|
-
title.appendChild(el('span', 'wb-title-main')).textContent = 'A2A 工作台'
|
|
372
|
-
const versionBadge = el('span', 'wb-badge')
|
|
373
|
-
title.appendChild(versionBadge)
|
|
374
|
-
header.appendChild(title)
|
|
375
|
-
// gh#34 owner three-key panel: the header gear opens the inspector panel
|
|
376
|
-
// (teamJoinAllowlist / teamScopeInfo / announce) — 远程建联三件不再翻配置文件。
|
|
377
|
-
const ownerBtn = el('button', 'wb-icon-btn wb-owner-btn')
|
|
378
|
-
ownerBtn.type = 'button'
|
|
379
|
-
ownerBtn.dataset.testid = 'wb-owner-btn'
|
|
380
|
-
ownerBtn.setAttribute('aria-label', '主人设置')
|
|
381
|
-
ownerBtn.title = '主人设置:席位名 / 入网白名单 / 可见性 / 发布'
|
|
382
|
-
ownerBtn.textContent = '⚙'
|
|
383
|
-
ownerBtn.addEventListener('click', () => { ownerPanelOpen = !ownerPanelOpen; renderOwnerPanel() })
|
|
384
|
-
header.appendChild(ownerBtn)
|
|
385
|
-
const headerStats = el('div', 'wb-header-stats')
|
|
386
|
-
header.appendChild(headerStats)
|
|
387
|
-
const railToggle = el('button', 'wb-icon-btn wb-rail-toggle')
|
|
388
|
-
railToggle.type = 'button'
|
|
389
|
-
railToggle.dataset.testid = 'wb-rail-toggle'
|
|
390
|
-
railToggle.setAttribute('aria-label', '折叠/展开侧栏')
|
|
391
|
-
railToggle.textContent = '◧'
|
|
392
|
-
railToggle.addEventListener('click', () => {
|
|
393
|
-
root.classList.toggle('is-rail-collapsed')
|
|
394
|
-
// The stage width changed while the board fills it → re-sync its view
|
|
395
|
-
// size (idempotent; focus side effect is harmless in a shell toggle).
|
|
396
|
-
if (mode === 'canvas' && board !== undefined) board.activate()
|
|
397
|
-
})
|
|
398
|
-
header.appendChild(railToggle)
|
|
399
|
-
|
|
400
|
-
const body = el('div', 'wb-body')
|
|
401
|
-
// Rail tabs (2026-10-23, wb-rail): one panel per pane, each with its own
|
|
402
|
-
// scroll. Panes are created once and re-parented between tab panels
|
|
403
|
-
// (canvas mode) and the stage stack (data mode).
|
|
404
|
-
const railTabs = createRailTabs(SECTION_DEFS)
|
|
405
|
-
const rail = railTabs.root
|
|
406
|
-
|
|
407
|
-
const directoryBody = el('div', 'wb-directory')
|
|
408
|
-
// Directory toolbar: unified search + category filter chips (0.6.6). Built
|
|
409
|
-
// once above the re-rendered list so the input keeps focus and its text
|
|
410
|
-
// across polls; filtering is pure frontend over /state.
|
|
411
|
-
const directoryTools = el('div', 'wb-dir-tools')
|
|
412
|
-
const searchInput = document.createElement('input')
|
|
413
|
-
searchInput.className = 'wb-search'
|
|
414
|
-
searchInput.type = 'search'
|
|
415
|
-
searchInput.placeholder = '搜索:名称 / 队 / 会话 / 节点'
|
|
416
|
-
searchInput.setAttribute('aria-label', '统一搜索目录')
|
|
417
|
-
searchInput.addEventListener('input', () => {
|
|
418
|
-
searchText = searchInput.value.trim().toLowerCase()
|
|
419
|
-
renderSidebar()
|
|
420
|
-
})
|
|
421
|
-
directoryTools.appendChild(searchInput)
|
|
422
|
-
const filterRow = el('div', 'wb-filters')
|
|
423
|
-
for (const def of DIRECTORY_FILTERS) {
|
|
424
|
-
const chip = el('button', 'wb-filter')
|
|
425
|
-
chip.type = 'button'
|
|
426
|
-
chip.textContent = def.label
|
|
427
|
-
chip.dataset.filter = def.key
|
|
428
|
-
chip.title = def.hint
|
|
429
|
-
chip.addEventListener('click', () => {
|
|
430
|
-
dirFilter = def.key
|
|
431
|
-
renderSidebar()
|
|
432
|
-
})
|
|
433
|
-
filterRow.appendChild(chip)
|
|
434
|
-
}
|
|
435
|
-
directoryTools.appendChild(filterRow)
|
|
436
|
-
railTabs.panel('directory').appendChild(directoryTools)
|
|
437
|
-
railTabs.panel('directory').appendChild(directoryBody)
|
|
438
|
-
|
|
439
|
-
const ledgerPane = el('section', 'wb-pane')
|
|
440
|
-
const activityPane = el('section', 'wb-pane')
|
|
441
|
-
const planPane = el('section', 'wb-pane')
|
|
442
|
-
// gh#20 W-C: the host rail pane (one row per tracked peer).
|
|
443
|
-
const hostsPane = el('section', 'wb-pane')
|
|
444
|
-
const dataPanes: ReadonlyArray<[WorkbenchSection, HTMLElement]> = [
|
|
445
|
-
['ledger', ledgerPane],
|
|
446
|
-
['activity', activityPane],
|
|
447
|
-
['plan', planPane],
|
|
448
|
-
['hosts', hostsPane],
|
|
449
|
-
]
|
|
450
|
-
|
|
451
|
-
const stage = el('main', 'wb-stage')
|
|
452
|
-
const boardPane = el('section', 'wb-stage-board')
|
|
453
|
-
if (board !== undefined) {
|
|
454
|
-
boardPane.appendChild(board.root)
|
|
455
|
-
stage.appendChild(boardPane)
|
|
456
|
-
}
|
|
457
|
-
const dataStack = el('div', 'wb-data')
|
|
458
|
-
|
|
459
|
-
const inspector = el('aside', 'wb-inspector')
|
|
460
|
-
const inspectorHead = el('div', 'wb-inspector-head')
|
|
461
|
-
inspectorHead.appendChild(el('span', 'wb-inspector-title')).textContent = '详情'
|
|
462
|
-
const inspectorClose = el('button', 'wb-icon-btn')
|
|
463
|
-
inspectorClose.type = 'button'
|
|
464
|
-
inspectorClose.setAttribute('aria-label', '关闭详情')
|
|
465
|
-
inspectorClose.textContent = '✕'
|
|
466
|
-
inspectorClose.addEventListener('click', () => select(undefined))
|
|
467
|
-
inspectorHead.appendChild(inspectorClose)
|
|
468
|
-
inspector.appendChild(inspectorHead)
|
|
469
|
-
const inspectorBody = el('div', 'wb-inspector-body')
|
|
470
|
-
inspector.appendChild(inspectorBody)
|
|
471
|
-
|
|
472
|
-
// ── Node chat drawer (0.6.13; rewritten 2026-10-23 as wb-chat) ──
|
|
473
|
-
// Clicking a node — on the planning board or a directory row — opens the
|
|
474
|
-
// conversation as a right-side chat: thread + composer + receipt tracking.
|
|
475
|
-
const chat = createChatDrawer({
|
|
476
|
-
now: deps.now,
|
|
477
|
-
sessionLog: deps.sessionLog,
|
|
478
|
-
remoteSessionLog: deps.remoteSessionLog,
|
|
479
|
-
dispatchQuick: deps.dispatchQuick,
|
|
480
|
-
onTranscript: (target, messages) => {
|
|
481
|
-
if (target.kind !== 'log' && target.kind !== 'session') return
|
|
482
|
-
if (learnLogTitle(logTitles, target.id, messages)) renderSidebar()
|
|
483
|
-
},
|
|
484
|
-
})
|
|
485
|
-
|
|
486
|
-
stage.appendChild(dataStack)
|
|
487
|
-
body.appendChild(rail)
|
|
488
|
-
body.appendChild(stage)
|
|
489
|
-
body.appendChild(inspector)
|
|
490
|
-
body.appendChild(chat.root)
|
|
491
|
-
root.appendChild(header)
|
|
492
|
-
root.appendChild(body)
|
|
493
|
-
|
|
494
|
-
/** Move the data panes to their mode home: rail sections while the board
|
|
495
|
-
* owns the stage, the stage stack when the board is out. */
|
|
496
|
-
function placePanes(m: WorkbenchMode): void {
|
|
497
|
-
for (const [key, pane] of dataPanes) {
|
|
498
|
-
const home = m === 'canvas' ? railTabs.panel(key) : dataStack
|
|
499
|
-
// Data mode: the pane lives on the stage, so its tab steps aside.
|
|
500
|
-
railTabs.setHidden(key, m !== 'canvas')
|
|
501
|
-
if (pane.parentElement !== home) home.appendChild(pane)
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
function syncMode(): void {
|
|
506
|
-
const canvasUp = board !== undefined && board.available()
|
|
507
|
-
const next: WorkbenchMode = canvasUp ? 'canvas' : 'data'
|
|
508
|
-
root.dataset.mode = next
|
|
509
|
-
boardPane.classList.toggle('is-hidden', next !== 'canvas')
|
|
510
|
-
dataStack.classList.toggle('is-hidden', next === 'canvas')
|
|
511
|
-
placePanes(next)
|
|
512
|
-
if (next !== mode) {
|
|
513
|
-
if (board !== undefined) {
|
|
514
|
-
if (next === 'canvas') board.activate()
|
|
515
|
-
else board.deactivate()
|
|
516
|
-
}
|
|
517
|
-
mode = next
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
function clear(node: HTMLElement): HTMLElement {
|
|
522
|
-
while (node.firstChild !== null) node.removeChild(node.firstChild)
|
|
523
|
-
return node
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
function select(next: Selection): void {
|
|
527
|
-
selection = next
|
|
528
|
-
// Canvas add-member form is cached per team name (draftForm trick):
|
|
529
|
-
// drop it whenever the selection moves off its team.
|
|
530
|
-
if (next?.kind !== 'canvas' || canvasForm?.name !== next.row.name) canvasForm = null
|
|
531
|
-
// Same for the quick-dispatch form: it is cached per target team
|
|
532
|
-
// (0.6.15: remote dispatch moved to the chat drawer's own form).
|
|
533
|
-
if (next?.kind !== 'canvas') dispatchForm = null
|
|
534
|
-
renderInspector()
|
|
535
|
-
// Narrow screens: picking from the drawer closes it so the stage shows.
|
|
536
|
-
root.classList.remove('is-rail-open')
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// ── Directory section ──
|
|
540
|
-
function renderSidebar(): void {
|
|
541
|
-
// Filter chips reflect the live view state (built once, toggled here).
|
|
542
|
-
for (let i = 0; i < filterRow.children.length; i++) {
|
|
543
|
-
const node = filterRow.children[i] as HTMLElement
|
|
544
|
-
node.classList.toggle('is-active', node.dataset.filter === dirFilter)
|
|
545
|
-
}
|
|
546
|
-
if (last === null) {
|
|
547
|
-
directoryBody.replaceChildren(el('div', 'wb-muted'))
|
|
548
|
-
directoryBody.firstElementChild!.textContent = '载入中…'
|
|
549
|
-
railTabs.setBadge('directory', '')
|
|
550
|
-
return
|
|
551
|
-
}
|
|
552
|
-
const badge = renderDirectory(directoryBody, {
|
|
553
|
-
state: last,
|
|
554
|
-
logs: logRows,
|
|
555
|
-
logTotal,
|
|
556
|
-
titles: logTitles,
|
|
557
|
-
search: searchText,
|
|
558
|
-
filter: dirFilter,
|
|
559
|
-
now: deps.now(),
|
|
560
|
-
openChat,
|
|
561
|
-
selectCanvas: (row) => select({ kind: 'canvas', row }),
|
|
562
|
-
})
|
|
563
|
-
railTabs.setBadge('directory', badge)
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
/**
|
|
567
|
-
* gh#20 W-C: the host rail — one row per tracked peer host. Each row is a
|
|
568
|
-
* HOST card: operator-facing label (the peer's published card name, host
|
|
569
|
-
* fallback), the W-A tri-state badge (link class + verified mark), a
|
|
570
|
-
* health lamp driven by the last-contact recency (green ≤60s, amber
|
|
571
|
-
* ≤5min, gray older/never — tooltip carries the exact timestamp), and the
|
|
572
|
-
* peer's published version chip. Clicking drills down: the peer chat
|
|
573
|
-
* drawer (remote log entry) rides the existing face.
|
|
574
|
-
*/
|
|
575
|
-
function renderHosts(): void {
|
|
576
|
-
const pane = clear(hostsPane)
|
|
577
|
-
if (last === null) {
|
|
578
|
-
railTabs.setBadge('hosts', '')
|
|
579
|
-
return
|
|
580
|
-
}
|
|
581
|
-
const now = deps.now()
|
|
582
|
-
const peers = [...(last.peers ?? [])].sort((a, b) => {
|
|
583
|
-
const rank = (row: { link?: string }): number => row.link === 'linked' ? 0 : 1
|
|
584
|
-
return rank(a) - rank(b) || (b.score ?? 0) - (a.score ?? 0)
|
|
585
|
-
})
|
|
586
|
-
railTabs.setBadge('hosts', peers.length > 0 ? `${peers.filter(row => row.link === 'linked').length}/${peers.length}` : '')
|
|
587
|
-
if (peers.length === 0) {
|
|
588
|
-
pane.appendChild(el('div', 'wb-nodelist-empty')).textContent = '暂无 tracked peer——配 peers 种子或等 referral 到达'
|
|
589
|
-
return
|
|
590
|
-
}
|
|
591
|
-
for (const row of peers) {
|
|
592
|
-
const host = peerHostOf(row.url)
|
|
593
|
-
const lineEl = el('div', 'wb-host-row')
|
|
594
|
-
// Health lamp (gh#24 semantics): recency alone lied — a FAILED fresh
|
|
595
|
-
// contact glowed green. Tiers by age: ≤60s live / ≤5min warm /
|
|
596
|
-
// beyond cold, overridden by the OUTCOME: a recent failure (≤5min)
|
|
597
|
-
// is red regardless of the live/warm band, and a never-contacted
|
|
598
|
-
// peer is the hollow unknown state.
|
|
599
|
-
const lamp = el('span', 'wb-host-lamp')
|
|
600
|
-
const last = row.lastContactAt
|
|
601
|
-
const age = last === undefined ? undefined : now - last
|
|
602
|
-
const ok = row.lastContactOk
|
|
603
|
-
if (age === undefined) lamp.classList.add('is-unknown')
|
|
604
|
-
else if (ok === false && age <= 5 * 60_000) lamp.classList.add('is-fail')
|
|
605
|
-
else if (age <= 60_000) lamp.classList.add('is-live')
|
|
606
|
-
else if (age <= 5 * 60_000) lamp.classList.add('is-warm')
|
|
607
|
-
else lamp.classList.add('is-cold')
|
|
608
|
-
lamp.title = last === undefined
|
|
609
|
-
? '尚无接触记录'
|
|
610
|
-
: `最近接触 ${ageText(last, now)}${ok === false ? '(失败)' : ok === true ? '(成功)' : ''}`
|
|
611
|
-
lineEl.appendChild(lamp)
|
|
612
|
-
// Label: the peer's published name, host fallback.
|
|
613
|
-
lineEl.appendChild(el('span', 'wb-host-name')).textContent = row.name !== undefined && row.name !== '' ? row.name : host
|
|
614
|
-
// W-A badge: link class + verified mark.
|
|
615
|
-
const badge = el('span', `wb-host-badge ${row.link === 'linked' ? 'is-linked' : 'is-stranger'}`)
|
|
616
|
-
badge.textContent = `${row.link === 'linked' ? 'linked' : 'stranger'} ${row.verified === true ? '✓' : '✗'}`
|
|
617
|
-
// The tooltip follows the verified mark (gh#24): a linked ✗ must not
|
|
618
|
-
// claim a valid signature.
|
|
619
|
-
badge.title = row.link === 'linked'
|
|
620
|
-
? `已建立运营者链接(互种或 canvas 引用);${row.verified === true ? '验签有效' : '尚未持有验签通过的卡片'}`
|
|
621
|
-
: '无运营者关系(验签 ≠ 关系)'
|
|
622
|
-
lineEl.appendChild(badge)
|
|
623
|
-
// Version chip from the warm card.
|
|
624
|
-
if (row.version !== undefined && row.version !== '') {
|
|
625
|
-
lineEl.appendChild(el('span', 'wb-host-ver')).textContent = row.version
|
|
626
|
-
}
|
|
627
|
-
lineEl.appendChild(el('span', 'wb-host-url mono')).textContent = host
|
|
628
|
-
lineEl.setAttribute('role', 'button')
|
|
629
|
-
lineEl.tabIndex = 0
|
|
630
|
-
lineEl.title = `${row.url} · score ${String(row.score ?? 0)}`
|
|
631
|
-
const drill = (): void => { openChat({ kind: 'peer', id: `peer-${host}`, title: row.name !== undefined && row.name !== '' ? row.name : host, sub: host, url: row.url }) }
|
|
632
|
-
lineEl.addEventListener('click', drill)
|
|
633
|
-
lineEl.addEventListener('keydown', ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); drill() } })
|
|
634
|
-
pane.appendChild(lineEl)
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
// ── Ledger pane (wb-ledger) ──
|
|
639
|
-
function renderLedger(): void {
|
|
640
|
-
if (last === null) { ledgerPane.replaceChildren(); railTabs.setBadge('ledger', ''); return }
|
|
641
|
-
const state = last
|
|
642
|
-
const resolve = (team: string): ChatTarget | undefined => {
|
|
643
|
-
const session = (state.sessions ?? []).find(row => row.team === team)
|
|
644
|
-
if (session !== undefined) return { kind: 'session', id: session.id, title: displayName(session), sub: session.team, team: session.team, live: session.live }
|
|
645
|
-
const remote = (state.remote ?? []).find(row => row.team === team)
|
|
646
|
-
if (remote !== undefined) return { kind: 'remote', team: remote.team, title: displayName(remote), sub: remote.team, row: remote }
|
|
647
|
-
return undefined
|
|
648
|
-
}
|
|
649
|
-
const out = paintLedger(ledgerPane, {
|
|
650
|
-
state,
|
|
651
|
-
now: deps.now(),
|
|
652
|
-
abandon: (taskId, reason) => deps.abandon(taskId, reason),
|
|
653
|
-
canOpenTeam: (team) => resolve(team) !== undefined,
|
|
654
|
-
openTeam: (team) => {
|
|
655
|
-
const target = resolve(team)
|
|
656
|
-
if (target === undefined) return false
|
|
657
|
-
openChat(target)
|
|
658
|
-
return true
|
|
659
|
-
},
|
|
660
|
-
})
|
|
661
|
-
railTabs.setBadge('ledger', out.badge, out.tone)
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// ── Activity pane ──
|
|
665
|
-
/** A team cell: the name when known (wb-names), else the id. */
|
|
666
|
-
function teamCell(line: HTMLElement, state: StateBody, team: string): void {
|
|
667
|
-
nameInto(line.appendChild(el('span', 'wb-row-team')), state, team)
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
function renderActivity(): void {
|
|
671
|
-
const pane = clear(activityPane)
|
|
672
|
-
if (last === null) return
|
|
673
|
-
const now = deps.now()
|
|
674
|
-
const flights = last.inFlight ?? []
|
|
675
|
-
railTabs.setBadge('activity', flights.length > 0 ? String(flights.length) : '', flights.length > 0 ? 'accent' : undefined)
|
|
676
|
-
const head = el('div', 'wb-pane-head')
|
|
677
|
-
head.textContent = `在途 ${flights.length} · 活动 ${last.activity?.length ?? 0}`
|
|
678
|
-
pane.appendChild(head)
|
|
679
|
-
if (flights.length > 0) {
|
|
680
|
-
const sub = el('div', 'wb-pane-subhead')
|
|
681
|
-
sub.textContent = '在途路由'
|
|
682
|
-
pane.appendChild(sub)
|
|
683
|
-
for (const row of flights) {
|
|
684
|
-
const line = el('div', 'wb-row')
|
|
685
|
-
line.appendChild(el('span', 'wb-row-dir')).textContent = '→'
|
|
686
|
-
teamCell(line, last, row.team)
|
|
687
|
-
line.appendChild(el('span', 'wb-row-meta')).textContent = `${row.peer} · ${ageText(row.startedAt, now)}`
|
|
688
|
-
pane.appendChild(line)
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
const ringHead = el('div', 'wb-pane-subhead')
|
|
692
|
-
ringHead.textContent = '活动环(最新在前)'
|
|
693
|
-
pane.appendChild(ringHead)
|
|
694
|
-
const ring = last.activity ?? []
|
|
695
|
-
if (ring.length === 0) {
|
|
696
|
-
pane.appendChild(el('div', 'wb-muted')).textContent = '暂无活动'
|
|
697
|
-
return
|
|
698
|
-
}
|
|
699
|
-
for (const row of ring) {
|
|
700
|
-
const line = el('div', 'wb-row')
|
|
701
|
-
const dir = el('span', 'wb-row-dir')
|
|
702
|
-
dir.textContent = row.dir === 'fwd' ? '⇢' : row.dir === 'out' ? '→' : '←'
|
|
703
|
-
if (row.ok !== true) dir.classList.add('is-fail')
|
|
704
|
-
line.appendChild(dir)
|
|
705
|
-
teamCell(line, last, row.team)
|
|
706
|
-
line.appendChild(el('span', 'wb-row-meta')).textContent = `${row.peer} · ${ageText(row.ts, now)}`
|
|
707
|
-
line.appendChild(el('span', 'wb-row-badges')).textContent = row.ok === true ? '' : '失败'
|
|
708
|
-
pane.appendChild(line)
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
// ── Plan pane: derived actions + dispatch drafting ──
|
|
713
|
-
function planActions(state: StateBody): Array<{ severity: 'high' | 'normal'; text: string; brief?: string }> {
|
|
714
|
-
const now = deps.now()
|
|
715
|
-
const actions: Array<{ severity: 'high' | 'normal'; text: string; brief?: string }> = []
|
|
716
|
-
// One definition with the 台账 tab: fire-forget expiries are bookkeeping,
|
|
717
|
-
// not dead letters (review L1), and only OUR stale sends are nudgeable.
|
|
718
|
-
const facts = ledgerFacts(state, now)
|
|
719
|
-
if (facts.deadLetters.length > 0) {
|
|
720
|
-
actions.push({ severity: 'high', text: `清账 ${facts.deadLetters.length} 条死信(目标早已离开或死信化,无回执可期)` })
|
|
721
|
-
}
|
|
722
|
-
// (The old `age > 1h && settleable === true` never fired against a real
|
|
723
|
-
// host — the server's settleable means receipt-expected AND age < 1h.)
|
|
724
|
-
for (const row of facts.stale) {
|
|
725
|
-
// Quote the original ask: a nudge that only repeats a task id makes
|
|
726
|
-
// the target re-derive WHAT was owed, and the target is exactly the
|
|
727
|
-
// session that already failed to answer once.
|
|
728
|
-
const subject = row.brief === undefined || row.brief === '' ? '' : `「${row.brief}」`
|
|
729
|
-
actions.push({
|
|
730
|
-
severity: 'normal',
|
|
731
|
-
text: `催办 ${row.taskId} → ${teamLabel(state, row.team)}(等待 ${ageText(row.startedAt, now)} 无回执)${subject}`,
|
|
732
|
-
brief: `a2a_route 催收回执:task ${row.taskId}(team ${row.team})${subject === '' ? '' : `原任务「${row.brief}」`}已派发 ${ageText(row.startedAt, now)},请回报结果或回执。`,
|
|
733
|
-
})
|
|
734
|
-
}
|
|
735
|
-
// Cold sessions are one action, not one per session: they share the same
|
|
736
|
-
// remedy (open them in the GUI), and a list of identical lines buried the
|
|
737
|
-
// actions that differ.
|
|
738
|
-
const cold = (state.sessions ?? []).filter(row => row.live !== true).map(row => displayName(row))
|
|
739
|
-
if (cold.length > 0) {
|
|
740
|
-
const names = cold.slice(0, 3).join('、') + (cold.length > 3 ? ` 等 ${cold.length} 个` : '')
|
|
741
|
-
actions.push({ severity: 'normal', text: `冷会话 ${cold.length} 个(${names}):在 GUI 打开即可唤醒加入面` })
|
|
742
|
-
}
|
|
743
|
-
for (const row of state.reconcile?.rows ?? []) {
|
|
744
|
-
if (row.needsRepair === true) {
|
|
745
|
-
// Name the session; the id stays for the operator who greps logs.
|
|
746
|
-
const s = (state.sessions ?? []).find(x => x.id === row.id)
|
|
747
|
-
const who = s !== undefined ? `${displayName(s)}(${row.id})` : row.id
|
|
748
|
-
actions.push({ severity: 'high', text: `reconcile 需修复:${who} ${row.error}` })
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
const conflicts = state.idempotency?.conflicts ?? 0
|
|
752
|
-
if (conflicts > 0) {
|
|
753
|
-
actions.push({ severity: 'high', text: `幂等冲突 ${conflicts} 例:人工核查 UNIQUE-ization 流量` })
|
|
754
|
-
}
|
|
755
|
-
return actions
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
function renderPlan(): void {
|
|
759
|
-
const pane = clear(planPane)
|
|
760
|
-
if (last === null) return
|
|
761
|
-
const actions = planActions(last)
|
|
762
|
-
railTabs.setBadge('plan', actions.length > 0 ? String(actions.length) : '', actions.some(a => a.severity === 'high') ? 'danger' : undefined)
|
|
763
|
-
const head = el('div', 'wb-pane-head')
|
|
764
|
-
head.textContent = actions.length === 0 ? '网络健康——无待办行动' : `待办行动 ${actions.length}`
|
|
765
|
-
pane.appendChild(head)
|
|
766
|
-
for (const action of actions) {
|
|
767
|
-
const line = el('div', `wb-action${action.severity === 'high' ? ' wb-action-high' : ''}`)
|
|
768
|
-
line.appendChild(el('span', 'wb-action-text')).textContent = action.text
|
|
769
|
-
if (action.brief !== undefined) {
|
|
770
|
-
const btn = el('button', 'wb-btn')
|
|
771
|
-
btn.type = 'button'
|
|
772
|
-
btn.textContent = '复制催办'
|
|
773
|
-
btn.addEventListener('click', () => deps.copy(action.brief ?? ''))
|
|
774
|
-
line.appendChild(btn)
|
|
775
|
-
}
|
|
776
|
-
pane.appendChild(line)
|
|
777
|
-
}
|
|
778
|
-
// Dispatch drafting — static inner form so textarea survives updates.
|
|
779
|
-
const draftHead = el('div', 'wb-pane-subhead')
|
|
780
|
-
draftHead.textContent = '派单起草(复制后在任一会话粘贴执行)'
|
|
781
|
-
pane.appendChild(draftHead)
|
|
782
|
-
pane.appendChild(draftForm)
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
// Dispatch draft form: created ONCE; renderPlan re-attaches the same node.
|
|
786
|
-
const draftForm = el('div', 'wb-draft')
|
|
787
|
-
const draftTarget = document.createElement('select')
|
|
788
|
-
draftTarget.className = 'wb-select'
|
|
789
|
-
const draftInput = document.createElement('textarea')
|
|
790
|
-
draftInput.className = 'wb-textarea'
|
|
791
|
-
draftInput.rows = 3
|
|
792
|
-
draftInput.placeholder = '任务正文:目标、验收、期限…'
|
|
793
|
-
const draftBtn = el('button', 'wb-btn wb-btn-primary')
|
|
794
|
-
draftBtn.type = 'button'
|
|
795
|
-
draftBtn.textContent = '复制任务书'
|
|
796
|
-
draftBtn.addEventListener('click', () => {
|
|
797
|
-
const target = draftTarget.value
|
|
798
|
-
const message = draftInput.value.trim()
|
|
799
|
-
if (target === '' || message === '') return
|
|
800
|
-
deps.copy(`a2a_route 派单 → ${target}:\n${message}`)
|
|
801
|
-
})
|
|
802
|
-
draftForm.appendChild(draftTarget)
|
|
803
|
-
draftForm.appendChild(draftInput)
|
|
804
|
-
draftForm.appendChild(draftBtn)
|
|
805
|
-
|
|
806
|
-
function renderDraftTargets(): void {
|
|
807
|
-
const previous = draftTarget.value
|
|
808
|
-
clear(draftTarget)
|
|
809
|
-
const empty = document.createElement('option')
|
|
810
|
-
empty.value = ''
|
|
811
|
-
empty.textContent = '选择目标队…'
|
|
812
|
-
draftTarget.appendChild(empty)
|
|
813
|
-
if (last === null) return
|
|
814
|
-
const state = last
|
|
815
|
-
const targets = new Map<string, string>()
|
|
816
|
-
for (const row of state.sessions ?? []) {
|
|
817
|
-
if (row.team !== undefined && row.team !== '') targets.set(row.team, `${displayName(row)}(会话队)`)
|
|
818
|
-
}
|
|
819
|
-
for (const row of state.canvas?.teams ?? []) targets.set(row.team, `${row.name}(canvas 队)`)
|
|
820
|
-
for (const row of state.remote ?? []) targets.set(row.team, `${displayName(row)}(远端)`)
|
|
821
|
-
for (const [team, label] of targets) {
|
|
822
|
-
const opt = document.createElement('option')
|
|
823
|
-
opt.value = team
|
|
824
|
-
opt.textContent = `${label} · ${team}`
|
|
825
|
-
draftTarget.appendChild(opt)
|
|
826
|
-
}
|
|
827
|
-
if (previous !== '' && targets.has(previous)) draftTarget.value = previous
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
// ── Inspector (overlay card over the stage; bottom sheet on narrow) ──
|
|
831
|
-
// gh#34 owner panel state + render (the gear toggles; the inspector hosts).
|
|
832
|
-
let seatEditor: HTMLElement | undefined
|
|
833
|
-
let ownerPanelOpen = false
|
|
834
|
-
let ownerState: { seatNames: Record<string, string>; teamJoinAllowlist: string[]; teamScopeInfo: boolean; announce: boolean; announceRestartPending: boolean } | null = null
|
|
835
|
-
function renderOwnerPanel(): void {
|
|
836
|
-
renderInspector()
|
|
837
|
-
if (!ownerPanelOpen) return
|
|
838
|
-
void deps.ownerSettingsRead?.().then(result => {
|
|
839
|
-
if (!ownerPanelOpen) return
|
|
840
|
-
if (result.ok !== true) { ownerState = null; renderInspector(); return }
|
|
841
|
-
seatEditor = undefined
|
|
842
|
-
ownerState = {
|
|
843
|
-
seatNames: result.seatNames ?? {},
|
|
844
|
-
teamJoinAllowlist: result.teamJoinAllowlist ?? [],
|
|
845
|
-
teamScopeInfo: result.teamScopeInfo !== false,
|
|
846
|
-
announce: result.announce === true,
|
|
847
|
-
announceRestartPending: result.announceRestartPending === true,
|
|
848
|
-
}
|
|
849
|
-
renderInspector()
|
|
850
|
-
})
|
|
851
|
-
}
|
|
852
|
-
function renderOwnerPanelInto(box: HTMLElement): void {
|
|
853
|
-
const card = el('div', 'wb-card')
|
|
854
|
-
const head = el('div', 'wb-inspector-head')
|
|
855
|
-
head.appendChild(el('span', 'wb-inspector-title')).textContent = '主人设置'
|
|
856
|
-
const closeBtn = el('button', 'wb-icon-btn')
|
|
857
|
-
closeBtn.type = 'button'
|
|
858
|
-
closeBtn.setAttribute('aria-label', '关闭主人设置')
|
|
859
|
-
closeBtn.textContent = '✕'
|
|
860
|
-
closeBtn.addEventListener('click', () => { ownerPanelOpen = false; renderInspector() })
|
|
861
|
-
head.appendChild(closeBtn)
|
|
862
|
-
card.appendChild(head)
|
|
863
|
-
if (deps.ownerSettingsRead === undefined) {
|
|
864
|
-
const note = el('div', 'wb-hint')
|
|
865
|
-
note.textContent = '此宿主未提供主人设置面(旧版本)——升级后可用。'
|
|
866
|
-
card.appendChild(note)
|
|
867
|
-
box.appendChild(card)
|
|
868
|
-
return
|
|
869
|
-
}
|
|
870
|
-
if (ownerState === null) {
|
|
871
|
-
const note = el('div', 'wb-hint')
|
|
872
|
-
note.textContent = '读取中…'
|
|
873
|
-
card.appendChild(note)
|
|
874
|
-
box.appendChild(card)
|
|
875
|
-
return
|
|
876
|
-
}
|
|
877
|
-
const st = ownerState
|
|
878
|
-
seatEditor ??= createSeatNameEditor(last?.sessions ?? [], st.seatNames, async seatNames => {
|
|
879
|
-
const result = await deps.ownerSettingsWrite?.({ seatNames })
|
|
880
|
-
if (result?.ok) { st.seatNames = seatNames; return { ok: true } }
|
|
881
|
-
return { ok: false, error: result?.error ?? '保存失败' }
|
|
882
|
-
})
|
|
883
|
-
card.appendChild(seatEditor)
|
|
884
|
-
// ① 入网白名单(精确团队名增删)
|
|
885
|
-
const alHead = el('div', 'wb-side-head')
|
|
886
|
-
alHead.textContent = '入网白名单(精确团队名)'
|
|
887
|
-
card.appendChild(alHead)
|
|
888
|
-
for (const name of st.teamJoinAllowlist) {
|
|
889
|
-
const row = el('div', 'wb-member-row')
|
|
890
|
-
row.appendChild(el('span', 'wb-member-label')).textContent = name
|
|
891
|
-
const del = el('button', 'wb-member-remove')
|
|
892
|
-
del.type = 'button'
|
|
893
|
-
del.textContent = '移除'
|
|
894
|
-
del.addEventListener('click', () => {
|
|
895
|
-
void deps.ownerSettingsWrite?.({ teamJoinAllowlist: st.teamJoinAllowlist.filter(n => n !== name) }).then(() => renderOwnerPanel())
|
|
896
|
-
})
|
|
897
|
-
row.appendChild(del)
|
|
898
|
-
card.appendChild(row)
|
|
899
|
-
}
|
|
900
|
-
const addForm = el('div', 'wb-member-add')
|
|
901
|
-
const input = el('input', 'wb-member-manual')
|
|
902
|
-
input.type = 'text'
|
|
903
|
-
input.placeholder = 'dsh/团队名(精确,无通配)'
|
|
904
|
-
input.setAttribute('aria-label', '新增白名单团队名')
|
|
905
|
-
const addBtn = el('button', 'wb-member-submit')
|
|
906
|
-
addBtn.type = 'button'
|
|
907
|
-
addBtn.textContent = '加入'
|
|
908
|
-
const doAdd = (): void => {
|
|
909
|
-
const v = input.value.trim()
|
|
910
|
-
if (v === '' || st.teamJoinAllowlist.includes(v)) return
|
|
911
|
-
void deps.ownerSettingsWrite?.({ teamJoinAllowlist: [...st.teamJoinAllowlist, v] }).then(() => renderOwnerPanel())
|
|
912
|
-
}
|
|
913
|
-
addBtn.addEventListener('click', doAdd)
|
|
914
|
-
input.addEventListener('keydown', ev => { if (ev.key === 'Enter') doAdd() })
|
|
915
|
-
addForm.append(input, addBtn)
|
|
916
|
-
card.appendChild(addForm)
|
|
917
|
-
// ② 可见性开关 + 精度固化文案
|
|
918
|
-
const tsHead = el('div', 'wb-side-head')
|
|
919
|
-
tsHead.textContent = '目录可见性(teamScopeInfo)'
|
|
920
|
-
card.appendChild(tsHead)
|
|
921
|
-
const tsRow = el('div', 'wb-member-row')
|
|
922
|
-
const tsBtn = el('button', 'wb-member-submit')
|
|
923
|
-
tsBtn.type = 'button'
|
|
924
|
-
tsBtn.textContent = st.teamScopeInfo ? '已开:仅显示已加入团队(点击放开为全舰队可见)' : '已关:全舰队可见(点击收窄为已加入团队)'
|
|
925
|
-
tsBtn.addEventListener('click', () => {
|
|
926
|
-
void deps.ownerSettingsWrite?.({ teamScopeInfo: !st.teamScopeInfo }).then(() => renderOwnerPanel())
|
|
927
|
-
})
|
|
928
|
-
tsRow.appendChild(tsBtn)
|
|
929
|
-
card.appendChild(tsRow)
|
|
930
|
-
const tsNote = el('div', 'wb-hint')
|
|
931
|
-
tsNote.textContent = '精度说明:teamScopeInfo 只管「目录显示范围」。关上它不会解锁任何通信准入——会话入网(S3)与目标级路由(F5)规则原样生效。'
|
|
932
|
-
card.appendChild(tsNote)
|
|
933
|
-
// ③ 发布开关(重启生效)
|
|
934
|
-
const anHead = el('div', 'wb-side-head')
|
|
935
|
-
anHead.textContent = '节点发布(announce)'
|
|
936
|
-
card.appendChild(anHead)
|
|
937
|
-
const anRow = el('div', 'wb-member-row')
|
|
938
|
-
const anBtn = el('button', 'wb-member-submit')
|
|
939
|
-
anBtn.type = 'button'
|
|
940
|
-
anBtn.textContent = st.announce ? '已开:本节点发布签名卡(点击关闭)' : '已关:本节点不发布(点击开启——重启宿主后生效)'
|
|
941
|
-
anBtn.addEventListener('click', () => {
|
|
942
|
-
void deps.ownerSettingsWrite?.({ announce: !st.announce }).then(() => renderOwnerPanel())
|
|
943
|
-
})
|
|
944
|
-
anRow.appendChild(anBtn)
|
|
945
|
-
card.appendChild(anRow)
|
|
946
|
-
if (st.announceRestartPending) {
|
|
947
|
-
const anNote = el('div', 'wb-hint')
|
|
948
|
-
anNote.textContent = '有 announce 变更待生效:重启宿主后按新配置发布。'
|
|
949
|
-
card.appendChild(anNote)
|
|
950
|
-
}
|
|
951
|
-
box.appendChild(card)
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
function renderInspector(): void {
|
|
955
|
-
const box = clear(inspectorBody)
|
|
956
|
-
// gh#34: the owner panel owns the inspector when open (selection cards
|
|
957
|
-
// yield; the gear closes it back).
|
|
958
|
-
if (ownerPanelOpen) { inspector.classList.add('is-open'); renderOwnerPanelInto(box); return }
|
|
959
|
-
inspector.classList.toggle('is-open', selection !== undefined)
|
|
960
|
-
if (selection === undefined) return
|
|
961
|
-
const sel = selection
|
|
962
|
-
const card = el('div', 'wb-card')
|
|
963
|
-
// 0.6.13: session/log/peer faces moved to the chat drawer; 0.6.15 took
|
|
964
|
-
// the remote rows with them (their metadata rides the chat head) — the
|
|
965
|
-
// inspector keeps the canvas team card.
|
|
966
|
-
if (sel.kind === 'canvas') {
|
|
967
|
-
// Re-resolve the team from the freshest state: membership changes here
|
|
968
|
-
// must be visible on the next render even though the click-time row is
|
|
969
|
-
// a stale snapshot.
|
|
970
|
-
const fresh = (last?.canvas?.teams ?? []).find(t => t.name === sel.row.name) ?? sel.row
|
|
971
|
-
textRow(card, 'Canvas 队', fresh.name)
|
|
972
|
-
textRow(card, '路由队', fresh.team)
|
|
973
|
-
const head = el('div', 'wb-side-head')
|
|
974
|
-
head.textContent = `成员 · ${fresh.members.length}`
|
|
975
|
-
card.appendChild(head)
|
|
976
|
-
if (fresh.members.length === 0) {
|
|
977
|
-
const empty = el('div', 'wb-muted')
|
|
978
|
-
empty.textContent = '空队——用下方表单添加成员'
|
|
979
|
-
card.appendChild(empty)
|
|
980
|
-
}
|
|
981
|
-
for (const member of fresh.members) {
|
|
982
|
-
const row = el('div', 'wb-member-row')
|
|
983
|
-
const dot = member.cliHeld === true ? '本机持有,无需指定' : member.remote === true ? (member.cliHome === null ? '◇ 远端 · home 未确定' : '◇ 远端') : member.live === true ? '● 现场' : member.joined === true ? '○ 冷启' : '· 未入网'
|
|
984
|
-
row.appendChild(el('span', 'wb-member-dot')).textContent = dot
|
|
985
|
-
const label = el('span', 'wb-member-label')
|
|
986
|
-
const session = (last?.sessions ?? []).find(s => s.id === member.id)
|
|
987
|
-
label.textContent = member.remote === true ? member.id : (session !== undefined ? displayName(session) : member.id)
|
|
988
|
-
label.title = member.cliHeld === true ? '本机持有,无需指定' : member.cliHome !== undefined ? (member.cliHome ?? 'home 未确定') : (member.team ?? member.id)
|
|
989
|
-
row.appendChild(label)
|
|
990
|
-
const remove = el('button', 'wb-member-remove')
|
|
991
|
-
remove.type = 'button'
|
|
992
|
-
remove.textContent = '移除'
|
|
993
|
-
remove.addEventListener('click', async () => {
|
|
994
|
-
remove.disabled = true
|
|
995
|
-
if (!(await deps.canvasOp('remove-member', fresh.name, member.id))) remove.disabled = false
|
|
996
|
-
})
|
|
997
|
-
row.appendChild(remove)
|
|
998
|
-
card.appendChild(row)
|
|
999
|
-
if (member.cliHomeCleared === true) {
|
|
1000
|
-
const notice = el('div', 'wb-hint')
|
|
1001
|
-
notice.textContent = '本机落户已清除旧 home 钉;户口离线后也不会恢复旧钉。'
|
|
1002
|
-
card.appendChild(notice)
|
|
1003
|
-
}
|
|
1004
|
-
if (member.cliHome === null && member.cliHeld !== true && deps.cliHomeOptions !== undefined) {
|
|
1005
|
-
const key = JSON.stringify([fresh.name, member.id])
|
|
1006
|
-
let form = cliHomeForms.get(key)
|
|
1007
|
-
if (form === undefined) {
|
|
1008
|
-
form = createCliHomePicker(() => deps.cliHomeOptions!(fresh.name, member.id), home => deps.canvasOp('add-member', fresh.name, member.id, home))
|
|
1009
|
-
cliHomeForms.set(key, form)
|
|
1010
|
-
}
|
|
1011
|
-
card.appendChild(form)
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
// Add-member form: static DOM re-mounted per poll (draftForm trick) —
|
|
1015
|
-
// rebuilt only when the inspected team changes.
|
|
1016
|
-
if (canvasForm?.name !== fresh.name) {
|
|
1017
|
-
const form = el('form', 'wb-member-add')
|
|
1018
|
-
const pick = document.createElement('select')
|
|
1019
|
-
pick.className = 'wb-member-pick'
|
|
1020
|
-
pick.setAttribute('aria-label', '选择成员')
|
|
1021
|
-
const manual = document.createElement('input')
|
|
1022
|
-
manual.type = 'text'
|
|
1023
|
-
manual.className = 'wb-member-manual'
|
|
1024
|
-
manual.placeholder = '或输入远端句柄 zone/id8'
|
|
1025
|
-
manual.setAttribute('aria-label', '远端成员句柄')
|
|
1026
|
-
const submit = el('button', 'wb-member-submit')
|
|
1027
|
-
submit.type = 'submit'
|
|
1028
|
-
submit.textContent = '添加'
|
|
1029
|
-
form.appendChild(pick)
|
|
1030
|
-
form.appendChild(manual)
|
|
1031
|
-
form.appendChild(submit)
|
|
1032
|
-
form.addEventListener('submit', async ev => {
|
|
1033
|
-
ev.preventDefault()
|
|
1034
|
-
const handle = manual.value.trim()
|
|
1035
|
-
const id = handle !== '' ? handle : pick.value
|
|
1036
|
-
if (id === '') return
|
|
1037
|
-
submit.disabled = true
|
|
1038
|
-
const ok = await deps.canvasOp('add-member', fresh.name, id)
|
|
1039
|
-
submit.disabled = false
|
|
1040
|
-
if (ok) manual.value = ''
|
|
1041
|
-
})
|
|
1042
|
-
canvasForm = { name: fresh.name, form, pick, manual }
|
|
1043
|
-
}
|
|
1044
|
-
// Refresh the picker's options from the freshest state, preserving the
|
|
1045
|
-
// current choice (sessions/remote rows churn across polls).
|
|
1046
|
-
const pick = canvasForm.pick
|
|
1047
|
-
const keep = pick.value
|
|
1048
|
-
clear(pick)
|
|
1049
|
-
pick.appendChild(new Option('本机会话(现场/冷启动)…', '', true, true))
|
|
1050
|
-
const memberIds = new Set(fresh.members.map(member => member.id))
|
|
1051
|
-
for (const session of last?.sessions ?? []) {
|
|
1052
|
-
if (memberIds.has(session.id)) continue
|
|
1053
|
-
pick.appendChild(new Option(`${displayName(session)} — 本机`, session.id))
|
|
1054
|
-
}
|
|
1055
|
-
let remoteLabeled = false
|
|
1056
|
-
for (const remote of last?.remote ?? []) {
|
|
1057
|
-
// gh#37: only zone/id8 HANDLES are addable members — a bare zone row
|
|
1058
|
-
// (the peer's own team card, e.g. 'dsh') fails the backend shape
|
|
1059
|
-
// gate and the old picker offered it anyway: the owner clicked 远端,
|
|
1060
|
-
// got refused, and read the feature as broken. Rows without a '/'
|
|
1061
|
-
// are skipped; when the directory carries no handles at all the
|
|
1062
|
-
// hint explains why (unlinked peers serve the minimal card, which
|
|
1063
|
-
// carries no session teams) instead of offering dead options.
|
|
1064
|
-
if (!remote.team.includes('/')) continue
|
|
1065
|
-
if (memberIds.has(remote.team)) continue
|
|
1066
|
-
if (!remoteLabeled) {
|
|
1067
|
-
pick.appendChild(new Option('远端会话队(目录卡片)…', '', true, false))
|
|
1068
|
-
remoteLabeled = true
|
|
1069
|
-
}
|
|
1070
|
-
pick.appendChild(new Option(`${displayName(remote)} — 远端`, remote.team))
|
|
1071
|
-
}
|
|
1072
|
-
if (!remoteLabeled) {
|
|
1073
|
-
pick.appendChild(new Option('(暂无可拉入的远端会话队——见下方说明)', '', true, false))
|
|
1074
|
-
}
|
|
1075
|
-
if (keep !== '' && Array.from(pick.options).some(option => option.value === keep)) pick.value = keep
|
|
1076
|
-
card.appendChild(canvasForm.form)
|
|
1077
|
-
const hint = el('div', 'wb-hint')
|
|
1078
|
-
hint.textContent =
|
|
1079
|
-
'成员由人类在此手工分配(agent 不可自组队)。远端成员走对端目录路由,不做本机冷唤醒;地址由目录在派发时重解析。远端下拉只列 zone/id8 会话句柄——若为空,说明目录里还没有已链接对端的会话队(未链接的对端只给最小卡);此时可在输入框手输句柄(如 dsh/xxxxxxxx)先把人拉进来,句柄本身即链接手势。'
|
|
1080
|
-
card.appendChild(hint)
|
|
1081
|
-
// Quick dispatch targets the canvas ROUTING team (0.6.9): local tiers
|
|
1082
|
-
// first, then the remote members in order — the seam's own priority.
|
|
1083
|
-
if (deps.dispatchQuick !== undefined) card.appendChild(dispatchCard(fresh.team))
|
|
1084
|
-
}
|
|
1085
|
-
box.appendChild(card)
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
// ── Quick dispatch (0.6.9) ──
|
|
1089
|
-
// One human-typed message to a routing team, no session opened. The form
|
|
1090
|
-
// is cached per target team (draftForm trick) so the textarea draft and
|
|
1091
|
-
// the result note survive the 3s poll remount.
|
|
1092
|
-
function buildDispatchForm(target: string): { form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } {
|
|
1093
|
-
const form = el('form', 'wb-dispatch-form')
|
|
1094
|
-
const text = document.createElement('textarea')
|
|
1095
|
-
text.className = 'wb-dispatch-text'
|
|
1096
|
-
text.placeholder = '一句话任务…(≤4000 字符,异步送达,回执进台账)'
|
|
1097
|
-
text.setAttribute('aria-label', '快派消息')
|
|
1098
|
-
const submit = el('button', 'wb-dispatch-submit')
|
|
1099
|
-
submit.type = 'submit'
|
|
1100
|
-
submit.textContent = '发送'
|
|
1101
|
-
const note = el('div', 'wb-dispatch-note')
|
|
1102
|
-
form.appendChild(text)
|
|
1103
|
-
form.appendChild(submit)
|
|
1104
|
-
form.appendChild(note)
|
|
1105
|
-
// 0.6.17 chat-UI conventions (deepseek-harness composer): Enter sends,
|
|
1106
|
-
// Shift+Enter is a soft newline, IME composition is never hijacked.
|
|
1107
|
-
text.addEventListener('keydown', ev => {
|
|
1108
|
-
if (ev.key !== 'Enter' || ev.shiftKey || ev.ctrlKey || ev.altKey || ev.metaKey) return
|
|
1109
|
-
if (ev.isComposing) return
|
|
1110
|
-
ev.preventDefault()
|
|
1111
|
-
if (!submit.disabled) form.requestSubmit()
|
|
1112
|
-
})
|
|
1113
|
-
form.addEventListener('submit', async ev => {
|
|
1114
|
-
ev.preventDefault()
|
|
1115
|
-
const message = text.value.trim()
|
|
1116
|
-
if (message === '' || submit.disabled) return
|
|
1117
|
-
submit.disabled = true
|
|
1118
|
-
note.textContent = '派发中…'
|
|
1119
|
-
let outcome: { ok: boolean; task_id?: string; reply?: string; error?: string }
|
|
1120
|
-
try {
|
|
1121
|
-
outcome = await (deps.dispatchQuick?.(target, message) ?? Promise.resolve({ ok: false, error: 'dispatch unavailable' }))
|
|
1122
|
-
} catch (error) {
|
|
1123
|
-
outcome = { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
1124
|
-
}
|
|
1125
|
-
submit.disabled = false
|
|
1126
|
-
if (outcome.ok === true) {
|
|
1127
|
-
// Sent: clear the composer like every chat surface and hand the
|
|
1128
|
-
// caret back — the submit round-trip must not strand focus.
|
|
1129
|
-
text.value = ''
|
|
1130
|
-
}
|
|
1131
|
-
text.focus({ preventScroll: true })
|
|
1132
|
-
note.textContent = outcome.ok === true
|
|
1133
|
-
? `已派发${outcome.task_id !== undefined ? ` · ${outcome.task_id}` : ''} · 回执将入台账(待回执)`
|
|
1134
|
-
: `失败:${outcome.error ?? '未知原因'}`
|
|
1135
|
-
})
|
|
1136
|
-
return { form, text, note }
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
function dispatchCard(target: string): HTMLElement {
|
|
1140
|
-
const wrap = el('div', 'wb-dispatch')
|
|
1141
|
-
const head = el('div', 'wb-side-head')
|
|
1142
|
-
head.textContent = '快派'
|
|
1143
|
-
wrap.appendChild(head)
|
|
1144
|
-
if (dispatchForm?.team !== target) dispatchForm = { team: target, ...buildDispatchForm(target) }
|
|
1145
|
-
wrap.appendChild(dispatchForm.form)
|
|
1146
|
-
return wrap
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
function openChat(target: ChatTarget): void {
|
|
1150
|
-
chat.open(target)
|
|
1151
|
-
root.classList.remove('is-rail-open')
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
/** Board picks (0.6.13): resolve a node id against the latest state. */
|
|
1155
|
-
function openNodeChat(id: string): void {
|
|
1156
|
-
if (last === null) return
|
|
1157
|
-
const session = (last.sessions ?? []).find(row => row.id === id)
|
|
1158
|
-
if (session !== undefined) {
|
|
1159
|
-
openChat({ kind: 'session', id, title: displayName(session), sub: session.team, team: session.team, live: session.live })
|
|
1160
|
-
return
|
|
1161
|
-
}
|
|
1162
|
-
if (id.startsWith('peer-')) {
|
|
1163
|
-
const host = id.slice('peer-'.length)
|
|
1164
|
-
const peer = (last.peers ?? []).find(row => row.url.includes(host))
|
|
1165
|
-
openChat({ kind: 'peer', id, title: peer !== undefined ? peer.url : host, sub: host, url: peer?.url })
|
|
1166
|
-
return
|
|
1167
|
-
}
|
|
1168
|
-
const log = logRows.find(row => row.id === id)
|
|
1169
|
-
if (log !== undefined) openChat({ kind: 'log', id, title: `…${id.slice(-12)}`, sub: '档案' })
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
// ── Header ──
|
|
1173
|
-
// Results first (owner signal, 0.6.24): the header carries the verdict —
|
|
1174
|
-
// exceptions only, one pill each; a healthy host says so quietly. The
|
|
1175
|
-
// forensic counters that used to sit here (idempotency claims/replays,
|
|
1176
|
-
// reconcile state) read on the ledger's 取证 line instead.
|
|
1177
|
-
function renderHeader(): void {
|
|
1178
|
-
// An unknown version says nothing: hide the badge rather than print 'v?'.
|
|
1179
|
-
versionBadge.textContent = last?.version !== undefined ? `v${last.version}` : ''
|
|
1180
|
-
versionBadge.hidden = last?.version === undefined
|
|
1181
|
-
const box = clear(headerStats)
|
|
1182
|
-
if (last === null) return
|
|
1183
|
-
// Each pill is a way in: it opens the tab (and ledger group) that holds
|
|
1184
|
-
// the rows behind its count.
|
|
1185
|
-
const alerts: Array<{ text: string; tone: 'warn' | 'danger'; title: string; tab: 'ledger' | 'plan' | 'activity'; group?: string }> = []
|
|
1186
|
-
// Same definitions as the 台账 tab (wb-ledger ledgerFacts): fire-forget
|
|
1187
|
-
// expiries are not dead letters, receiptless rows are never stale.
|
|
1188
|
-
const facts = ledgerFacts(last, deps.now())
|
|
1189
|
-
const dead = facts.deadLetters.length
|
|
1190
|
-
if (dead > 0) alerts.push({ text: `死信 ${dead}`, tone: 'danger', title: '超过 24h TTL 仍未结算的任务(unconsumed),需在台账清账', tab: 'ledger', group: '死信' })
|
|
1191
|
-
const stale = facts.stale.length
|
|
1192
|
-
if (stale > 0) alerts.push({ text: `陈旧 ${stale}`, tone: 'warn', title: '我方派发、要回执、已等待 ≥1h 仍未回执(可催办;免回执行不算)', tab: 'ledger', group: '陈旧' })
|
|
1193
|
-
const owed = facts.owedOverdue.length
|
|
1194
|
-
if (owed > 0) alerts.push({ text: `欠回执 ${owed}`, tone: 'warn', title: '对方投递给本机、已等待本机回执超过 1 小时', tab: 'ledger', group: '待本机回执' })
|
|
1195
|
-
const conflicts = last.idempotency?.conflicts ?? 0
|
|
1196
|
-
if (conflicts > 0) alerts.push({ text: `幂等冲突 ${conflicts}`, tone: 'danger', title: '同一幂等键对应了不同载荷,需人工核查', tab: 'ledger', group: '取证' })
|
|
1197
|
-
const repair = (last.reconcile?.rows ?? []).filter(row => row.needsRepair === true).length
|
|
1198
|
-
if (repair > 0) alerts.push({ text: `需修复 ${repair}`, tone: 'danger', title: 'reconcile 无法自动恢复的会话,见行动计划', tab: 'plan' })
|
|
1199
|
-
if (alerts.length === 0) {
|
|
1200
|
-
box.appendChild(el('span', 'wb-header-ok')).textContent = '● 无异常'
|
|
1201
|
-
return
|
|
1202
|
-
}
|
|
1203
|
-
for (const a of alerts) {
|
|
1204
|
-
const pill = el('button', `wb-alert wb-alert-${a.tone}`)
|
|
1205
|
-
pill.type = 'button'
|
|
1206
|
-
pill.textContent = a.text
|
|
1207
|
-
pill.title = `${a.title}(点击查看)`
|
|
1208
|
-
pill.addEventListener('click', () => openAlert(a.tab, a.group))
|
|
1209
|
-
box.appendChild(pill)
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
/** Open the pane behind a header pill. With the board up the panes are
|
|
1214
|
-
* rail tabs (on a narrow screen the rail slides in first); without it they
|
|
1215
|
-
* sit on the stage. A ledger group (or the 取证 fold) scrolls into view. */
|
|
1216
|
-
function openAlert(tab: 'ledger' | 'plan' | 'activity', group?: string): void {
|
|
1217
|
-
const pane = dataPanes.find(([k]) => k === tab)![1]
|
|
1218
|
-
if (mode === 'canvas') {
|
|
1219
|
-
root.classList.add('is-rail-open')
|
|
1220
|
-
railTabs.select(tab, { focus: false })
|
|
1221
|
-
}
|
|
1222
|
-
const target = group === undefined ? pane
|
|
1223
|
-
: group === '取证' ? pane.querySelector<HTMLDetailsElement>('details.wb-led-forensics')
|
|
1224
|
-
: Array.from(pane.querySelectorAll<HTMLElement>('.wb-led-group')).find(g => g.dataset.group === group) ?? null
|
|
1225
|
-
if (target === null) return
|
|
1226
|
-
if (target instanceof HTMLDetailsElement) target.open = true
|
|
1227
|
-
target.scrollIntoView?.({ block: 'start', behavior: 'smooth' })
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
function update(state: StateBody | null): void {
|
|
1231
|
-
const unknown = new Set((state?.canvas?.teams ?? []).flatMap(team => team.members.filter(member => member.cliHome === null && member.cliHeld !== true).map(member => JSON.stringify([team.name, member.id]))))
|
|
1232
|
-
for (const key of cliHomeForms.keys()) if (!unknown.has(key)) cliHomeForms.delete(key)
|
|
1233
|
-
last = state
|
|
1234
|
-
// 0.6.12: refresh the readable-log list opportunistically (single flight;
|
|
1235
|
-
// the sidebar re-renders when rows land, mid-poll remount is idempotent).
|
|
1236
|
-
if (deps.sessionLogList !== undefined && !logRowsBusy) {
|
|
1237
|
-
logRowsBusy = true
|
|
1238
|
-
deps.sessionLogList().then(result => {
|
|
1239
|
-
logRows = result.ok && result.sessions !== undefined ? result.sessions : []
|
|
1240
|
-
logTotal = result.ok ? result.count : undefined
|
|
1241
|
-
}).catch(() => {}).finally(() => {
|
|
1242
|
-
logRowsBusy = false
|
|
1243
|
-
renderSidebar()
|
|
1244
|
-
})
|
|
1245
|
-
}
|
|
1246
|
-
renderHeader()
|
|
1247
|
-
renderSidebar()
|
|
1248
|
-
renderDraftTargets()
|
|
1249
|
-
renderHosts()
|
|
1250
|
-
renderLedger()
|
|
1251
|
-
// The open chat drawer refreshes in place (head + receipt statuses); its
|
|
1252
|
-
// thread and composer are never rebuilt by the poll.
|
|
1253
|
-
chat.update(state)
|
|
1254
|
-
renderActivity()
|
|
1255
|
-
renderPlan()
|
|
1256
|
-
renderInspector()
|
|
1257
|
-
// Mode is availability-derived every poll: canvas face down → data mode,
|
|
1258
|
-
// the board deactivates and the panes return to the stage stack.
|
|
1259
|
-
syncMode()
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
function selectTab(key: WorkbenchTab): void {
|
|
1263
|
-
if (key === 'board') {
|
|
1264
|
-
syncMode()
|
|
1265
|
-
return
|
|
1266
|
-
}
|
|
1267
|
-
railTabs.select(key)
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
update(null)
|
|
1271
|
-
return { root, update, mode: () => mode, selectTab, openNodeChat }
|
|
1272
|
-
}
|
|
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
|
+
import { createChatDrawer, type ChatTarget } from './wb-chat'
|
|
22
|
+
import { createSeatNameEditor } from './seat-name-editor'
|
|
23
|
+
import { createCliHomePicker, type CliHomeOptions } from './cli-home-picker'
|
|
24
|
+
import { createRailTabs } from './wb-rail'
|
|
25
|
+
import { DIRECTORY_FILTERS, learnLogTitle, loadLogTitles, renderDirectory, type DirFilter } from './wb-directory'
|
|
26
|
+
import { ledgerFacts, renderLedger as paintLedger } from './wb-ledger'
|
|
27
|
+
import { nameInto, teamLabel } from './wb-names'
|
|
28
|
+
|
|
29
|
+
/** One pending ledger row from /state `tasks`. */
|
|
30
|
+
export interface PendingRow {
|
|
31
|
+
readonly taskId: string
|
|
32
|
+
readonly team: string
|
|
33
|
+
readonly peer?: string
|
|
34
|
+
readonly startedAt: number
|
|
35
|
+
readonly status: string
|
|
36
|
+
readonly direction?: string
|
|
37
|
+
readonly receiptExpected?: boolean
|
|
38
|
+
readonly settleable?: boolean
|
|
39
|
+
/**
|
|
40
|
+
* First line of the dispatched message. A row without it is unreadable:
|
|
41
|
+
* `direct-8f3a1c2b · dsh · 3h` says nothing about whether to nudge,
|
|
42
|
+
* abandon, or go read the receipt — the operator's whole question is
|
|
43
|
+
* WHAT is owed. Absent on rows persisted before the field existed.
|
|
44
|
+
*/
|
|
45
|
+
readonly brief?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One dead-lettered row from /state `tasksDead`. */
|
|
49
|
+
export interface DeadRow {
|
|
50
|
+
readonly taskId: string
|
|
51
|
+
readonly team: string
|
|
52
|
+
readonly peer?: string
|
|
53
|
+
readonly startedAt: number
|
|
54
|
+
readonly deadAt: number
|
|
55
|
+
readonly reason?: string
|
|
56
|
+
/** First line of the dispatched message (see {@link PendingRow.brief}). */
|
|
57
|
+
readonly brief?: string
|
|
58
|
+
/** gh#31: which side of the wire this row served (absent on old hosts). */
|
|
59
|
+
readonly direction?: 'inbound' | 'outbound'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** gh#31: one archived row from /state `archived` (newest first) — the
|
|
63
|
+
* per-task outcome for a task that left the ledger. Fields past `summary`
|
|
64
|
+
* are absent on hosts that predate them. */
|
|
65
|
+
export interface ArchivedRow {
|
|
66
|
+
readonly taskId: string
|
|
67
|
+
readonly team: string
|
|
68
|
+
readonly resolvedAt: number
|
|
69
|
+
readonly outcome: string
|
|
70
|
+
readonly summary?: string
|
|
71
|
+
readonly peer?: string
|
|
72
|
+
readonly startedAt?: number
|
|
73
|
+
/** How the row left the book: a receipt, a content echo, or the caller
|
|
74
|
+
* (abandon / cancel — both archive with outcome 'abandoned'). */
|
|
75
|
+
readonly endedBy?: 'receipt' | 'echo' | 'abandoned' | 'cancelled'
|
|
76
|
+
/** A receipt arrived after the caller had already ended the row. */
|
|
77
|
+
readonly late?: boolean
|
|
78
|
+
/** gh#68: present (false) only when the row never expected a receipt — a
|
|
79
|
+
* fire-forget send; absent on receipt-expected rows and older hosts. */
|
|
80
|
+
readonly receiptExpected?: false
|
|
81
|
+
readonly direction?: 'inbound' | 'outbound'
|
|
82
|
+
readonly brief?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** gh#41: one tracked peer's published canvas organization (full-card
|
|
86
|
+
* canvasOrg) — the board renders REMOTE team frames from these rows. */
|
|
87
|
+
export interface PeerOrgRow {
|
|
88
|
+
readonly url: string
|
|
89
|
+
readonly unreachable?: boolean
|
|
90
|
+
readonly org: ReadonlyArray<{ name: string; team: string; members: readonly string[] }>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One session row from /state `sessions` (live or cold joined). */
|
|
94
|
+
export interface SessionRow {
|
|
95
|
+
readonly id: string
|
|
96
|
+
readonly label: string
|
|
97
|
+
readonly team: string
|
|
98
|
+
readonly name?: string
|
|
99
|
+
readonly seatName?: string
|
|
100
|
+
readonly description?: string
|
|
101
|
+
readonly workspace?: string
|
|
102
|
+
readonly joined?: boolean
|
|
103
|
+
readonly live?: boolean
|
|
104
|
+
/** Final scoped assembly lacks at least one registered node section (#174). */
|
|
105
|
+
readonly nodePromptMissing?: boolean
|
|
106
|
+
readonly teams?: readonly string[]
|
|
107
|
+
/** Canvas teams this session is a member of (0.6.6 roster annotation). */
|
|
108
|
+
readonly canvasTeams?: readonly string[]
|
|
109
|
+
/** @deprecated 0.6.6 — display-only taxonomy superseded by canvas teams. */
|
|
110
|
+
readonly group?: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One remote team row from /state `remote`. */
|
|
114
|
+
export interface RemoteRow {
|
|
115
|
+
readonly team: string
|
|
116
|
+
readonly unreachable?: boolean
|
|
117
|
+
readonly name?: string
|
|
118
|
+
readonly origin?: string
|
|
119
|
+
readonly via?: string
|
|
120
|
+
readonly workspace?: string
|
|
121
|
+
readonly legacy?: boolean
|
|
122
|
+
readonly teams?: readonly string[]
|
|
123
|
+
/** Canvas teams on this host that carry this remote handle as a member. */
|
|
124
|
+
readonly canvasTeams?: readonly string[]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One canvas member: a local bare session id or a zone-qualified remote
|
|
128
|
+
* handle (`zone/id8`) — remote members are routed by the directory, never
|
|
129
|
+
* woken locally (0.6.6 unified team model). */
|
|
130
|
+
export interface CanvasMemberRow {
|
|
131
|
+
readonly id: string
|
|
132
|
+
readonly team?: string
|
|
133
|
+
readonly joined?: boolean
|
|
134
|
+
readonly live?: boolean
|
|
135
|
+
readonly remote?: boolean
|
|
136
|
+
/** Present only for CLI refs: null means the linked home is ambiguous or unknown. */
|
|
137
|
+
readonly cliHome?: string | null
|
|
138
|
+
readonly cliHeld?: boolean
|
|
139
|
+
readonly cliHomeCleared?: boolean
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One canvas team from /state `canvas.teams`. */
|
|
143
|
+
export interface CanvasTeamRow {
|
|
144
|
+
readonly name: string
|
|
145
|
+
readonly team: string
|
|
146
|
+
readonly members: readonly CanvasMemberRow[]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One activity-ring entry. */
|
|
150
|
+
export interface ActivityRow {
|
|
151
|
+
readonly ts: number
|
|
152
|
+
readonly dir: 'in' | 'out' | 'fwd'
|
|
153
|
+
readonly team: string
|
|
154
|
+
readonly peer: string
|
|
155
|
+
readonly ok: boolean
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The /state subset the workbench consumes (additive fields ignored). */
|
|
159
|
+
export interface StateBody {
|
|
160
|
+
readonly zone?: string
|
|
161
|
+
readonly version?: string
|
|
162
|
+
readonly sessions?: readonly SessionRow[]
|
|
163
|
+
readonly canvas?: { teams?: readonly CanvasTeamRow[] }
|
|
164
|
+
/** gh#20 W-A/W-C: the peer row carries the link badge source, the host
|
|
165
|
+
* rail's label/version/last-contact facts (absent on older hosts). */
|
|
166
|
+
readonly peers?: ReadonlyArray<{
|
|
167
|
+
url: string
|
|
168
|
+
score?: number
|
|
169
|
+
link?: 'linked' | 'stranger'
|
|
170
|
+
verified?: boolean
|
|
171
|
+
name?: string
|
|
172
|
+
version?: string
|
|
173
|
+
lastContactAt?: number
|
|
174
|
+
/** gh#24: whether the last contact attempt succeeded. */
|
|
175
|
+
lastContactOk?: boolean
|
|
176
|
+
}>
|
|
177
|
+
readonly remote?: readonly RemoteRow[]
|
|
178
|
+
readonly activity?: readonly ActivityRow[]
|
|
179
|
+
readonly inFlight?: ReadonlyArray<{ team: string; peer: string; startedAt: number }>
|
|
180
|
+
readonly tasks?: readonly PendingRow[]
|
|
181
|
+
readonly tasksStaleCount?: number
|
|
182
|
+
readonly tasksDead?: readonly DeadRow[]
|
|
183
|
+
/** gh#31: per-task outcomes for rows that left the ledger (bounded). */
|
|
184
|
+
readonly archived?: readonly ArchivedRow[]
|
|
185
|
+
/** gh#41 fleet planning: each tracked peer's published canvas org. */
|
|
186
|
+
readonly peerOrg?: readonly PeerOrgRow[]
|
|
187
|
+
readonly registry?: { teams?: ReadonlyArray<{ team: string; members: readonly string[]; live?: number }> }
|
|
188
|
+
readonly archivedCount?: number
|
|
189
|
+
/** Settled-archive split by verdict (the receipt outcome vocabulary — 'completed' | 'rejected' | 'failed' | … | 'abandoned' — or 'unclassified'). */
|
|
190
|
+
readonly archivedSplit?: Readonly<Record<string, number>>
|
|
191
|
+
readonly idempotency?: {
|
|
192
|
+
window?: number
|
|
193
|
+
pending?: number
|
|
194
|
+
settled?: number
|
|
195
|
+
claimsFresh?: number
|
|
196
|
+
replays?: number
|
|
197
|
+
conflicts?: number
|
|
198
|
+
}
|
|
199
|
+
readonly reconcile?: {
|
|
200
|
+
state?: string
|
|
201
|
+
woken?: number
|
|
202
|
+
rows?: ReadonlyArray<{ id: string; error: string; attempts: number; needsRepair?: boolean }>
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** What the workbench can DO — the seams tests drive. */
|
|
207
|
+
export interface WorkbenchDeps {
|
|
208
|
+
now(): number
|
|
209
|
+
/** gh#41 fleet planning: each tracked peer's published canvas org. */
|
|
210
|
+
peerOrg?: readonly PeerOrgRow[]
|
|
211
|
+
/** gh#34: read the owner three-key panel state (GET /__dsh_a2a/owner-settings). */
|
|
212
|
+
ownerSettingsRead?(): Promise<{ ok: boolean; seatNames?: Record<string, string>; teamJoinAllowlist?: string[]; teamScopeInfo?: boolean; announce?: boolean; announceRestartPending?: boolean; error?: string }>
|
|
213
|
+
/** gh#34: write owner panel changes; true on success (POST the same face). */
|
|
214
|
+
ownerSettingsWrite?(changes: { seatNames?: Record<string, string>; teamJoinAllowlist?: string[]; teamScopeInfo?: boolean; announce?: boolean }): Promise<{ ok: boolean; restartNeeded?: boolean; error?: string }>
|
|
215
|
+
/** gh#41 fleet planning: regulate a REMOTE peer's canvas team (this host
|
|
216
|
+
* signs the envelope and forwards; the peer's link gate authorizes). */
|
|
217
|
+
fleetCanvasOp?(peer: string, op: 'create' | 'add-member' | 'remove-member' | 'remove', name: string, id?: string): Promise<{ ok: boolean; members?: string[]; error?: string }>
|
|
218
|
+
/** Abandon one dead-lettered task via the control route. True on success. */
|
|
219
|
+
abandon(taskId: string, reason: string): Promise<boolean>
|
|
220
|
+
/** Canvas team member change via the control route (0.6.6): `id` is a
|
|
221
|
+
* local session id or a zone-qualified remote handle. True on success. */
|
|
222
|
+
canvasOp(action: 'add-member' | 'remove-member', name: string, id: string, home?: string): Promise<boolean>
|
|
223
|
+
cliHomeOptions?(name: string, id: string): Promise<CliHomeOptions>
|
|
224
|
+
/** Quick dispatch (0.6.9): POST /__dsh_a2a/dispatch as a human gesture —
|
|
225
|
+
* one message to a routing team, async delivery, receipt lands in the
|
|
226
|
+
* ledger. Optional — without it the 快派 form hides. */
|
|
227
|
+
dispatchQuick?(team: string, message: string): Promise<{ ok: boolean; task_id?: string; reply?: string; error?: string }>
|
|
228
|
+
/** Inline transcript (0.6.11): the tail of a LOCAL session's own
|
|
229
|
+
* conversation log, read-only. Optional — without it the block hides. */
|
|
230
|
+
sessionLog?(id: string): Promise<{ ok: boolean; messages?: Array<{ role: string; text: string }>; error?: string }>
|
|
231
|
+
/** 0.6.12: list every local session holding a log artifact (newest first) —
|
|
232
|
+
* the reading face is host-scoped, so archived and never-joined sessions
|
|
233
|
+
* appear here even when the network directory shows no local rows. */
|
|
234
|
+
sessionLogList?(): Promise<{ ok: boolean; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; count?: number; error?: string }>
|
|
235
|
+
/** 0.6.15: remote transcript via the local proxy — ?team=<zone/id8>
|
|
236
|
+
* resolves the publishing peer through the directory, ?peer=<base-url>
|
|
237
|
+
* reads one tracked peer directly. Without id the peer answers its
|
|
238
|
+
* readable-session listing. Optional — without it remote/peer chat
|
|
239
|
+
* shows the honest where-the-records-live note. */
|
|
240
|
+
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 }>
|
|
241
|
+
/** Copy text to the clipboard; drafts are pasteable task briefs. */
|
|
242
|
+
copy(text: string): void
|
|
243
|
+
// 0.6.10: openLocal/openRemote (0.6.7/0.6.8 jump affordances) removed by
|
|
244
|
+
// ruling — 禁止任何跳转,全部使用响应式布局. dispatchQuick is the only
|
|
245
|
+
// reach primitive and it stays on this page.
|
|
246
|
+
/** The spatial planning board. Optional: without it the shell stays in
|
|
247
|
+
* data mode permanently. `available()` mirrors the host's canvas face
|
|
248
|
+
* (state.canvas !== undefined) and gates canvas mode. */
|
|
249
|
+
board?: {
|
|
250
|
+
readonly root: HTMLElement
|
|
251
|
+
activate(): void
|
|
252
|
+
deactivate(): void
|
|
253
|
+
available(): boolean
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Display name for one node: session title, then team, then label. */
|
|
258
|
+
function displayName(node: { name?: string; team?: string; label?: string }): string {
|
|
259
|
+
return node.name ?? node.team ?? node.label ?? ''
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function ageText(ms: number, now: number): string {
|
|
263
|
+
const s = Math.max(0, Math.round((now - ms) / 1000))
|
|
264
|
+
if (s < 90) return `${s}s`
|
|
265
|
+
const m = Math.round(s / 60)
|
|
266
|
+
if (m < 90) return `${m}m`
|
|
267
|
+
const h = Math.round(m / 60)
|
|
268
|
+
if (h < 48) return `${h}h`
|
|
269
|
+
return `${Math.round(h / 24)}d`
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] {
|
|
273
|
+
const node = document.createElement(tag)
|
|
274
|
+
if (className !== undefined) node.className = className
|
|
275
|
+
return node
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function textRow(parent: HTMLElement, label: string, value: string): void {
|
|
279
|
+
const row = el('div', 'wb-kv')
|
|
280
|
+
row.appendChild(el('span', 'wb-kv-k')).textContent = label
|
|
281
|
+
row.appendChild(el('span', 'wb-kv-v')).textContent = value
|
|
282
|
+
parent.appendChild(row)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
type Selection =
|
|
286
|
+
| { kind: 'canvas'; row: CanvasTeamRow }
|
|
287
|
+
| undefined
|
|
288
|
+
|
|
289
|
+
/** The rail sections. */
|
|
290
|
+
export type WorkbenchSection = 'directory' | 'hosts' | 'ledger' | 'activity' | 'plan'
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
/** Legacy deep-link targets: 'board' focuses the stage, sections open a rail section. */
|
|
294
|
+
export type WorkbenchTab = 'board' | WorkbenchSection
|
|
295
|
+
|
|
296
|
+
/** Which face fills the stage. */
|
|
297
|
+
export type WorkbenchMode = 'canvas' | 'data'
|
|
298
|
+
|
|
299
|
+
export interface Workbench {
|
|
300
|
+
readonly root: HTMLElement
|
|
301
|
+
/** Feed one /state poll (or null while the first fetch is out). */
|
|
302
|
+
update(state: StateBody | null): void
|
|
303
|
+
/** Current stage mode: the board while the canvas face is up, else data. */
|
|
304
|
+
readonly mode: () => WorkbenchMode
|
|
305
|
+
/** Focus a target: 'board' converges on canvas mode (no-op while the
|
|
306
|
+
* canvas face is down); a section un-collapses its rail panel. */
|
|
307
|
+
selectTab(key: WorkbenchTab): void
|
|
308
|
+
/** A node was picked on the planning board (0.6.13): resolve it against
|
|
309
|
+
* the latest state and open the right-side chat drawer for it. */
|
|
310
|
+
openNodeChat(id: string): void
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Bare host:port of a peer url (workbench-local twin of federation's
|
|
314
|
+
* helper — this module keeps a zero-import posture). */
|
|
315
|
+
function peerHostOf(url: string): string {
|
|
316
|
+
try {
|
|
317
|
+
return new URL(url).host
|
|
318
|
+
} catch {
|
|
319
|
+
return url
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Rail tabs (2026-10-23): 主机 moved up next to 目录 — host health was the
|
|
324
|
+
* last accordion section and sat below the fold. */
|
|
325
|
+
const SECTION_DEFS: ReadonlyArray<{ key: WorkbenchSection; label: string }> = [
|
|
326
|
+
{ key: 'directory', label: '目录' },
|
|
327
|
+
{ key: 'hosts', label: '主机' },
|
|
328
|
+
{ key: 'ledger', label: '台账' },
|
|
329
|
+
{ key: 'activity', label: '活动' },
|
|
330
|
+
{ key: 'plan', label: '规划' },
|
|
331
|
+
]
|
|
332
|
+
|
|
333
|
+
export function createWorkbench(deps: WorkbenchDeps): Workbench {
|
|
334
|
+
const board = deps.board
|
|
335
|
+
let mode: WorkbenchMode = 'data'
|
|
336
|
+
let selection: Selection
|
|
337
|
+
let last: StateBody | null = null
|
|
338
|
+
// Directory view state (survives polls): unified search + category filter.
|
|
339
|
+
let searchText = ''
|
|
340
|
+
let dirFilter: DirFilter = 'all'
|
|
341
|
+
// Canvas add-member form cache (static DOM re-mounted across polls; keyed
|
|
342
|
+
// by team name — same trick as draftForm so inputs survive the 3s poll).
|
|
343
|
+
let canvasForm: { name: string; form: HTMLFormElement; pick: HTMLSelectElement; manual: HTMLInputElement } | null = null
|
|
344
|
+
const cliHomeForms = new Map<string, HTMLFormElement>()
|
|
345
|
+
// Quick-dispatch form (0.6.9), cached per target routing team the same
|
|
346
|
+
// draftForm way: a textarea's draft must survive the 3s poll remount.
|
|
347
|
+
let dispatchForm: { team: string; form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } | null = null
|
|
348
|
+
// 0.6.12: readable local logs (host-scope list, newest first). Refreshed
|
|
349
|
+
// opportunistically per poll; rows render in the directory's 本机日志 section.
|
|
350
|
+
let logRows: Array<{ id: string; mtimeMs: number; sizeBytes: number }> = []
|
|
351
|
+
/** How many logs the host holds (the list face caps at 50); from the listing's count. */
|
|
352
|
+
let logTotal: number | undefined
|
|
353
|
+
/** Learned 本机日志 titles: the first user line of a transcript, captured
|
|
354
|
+
* when the chat drawer loads it (the list face carries no titles). */
|
|
355
|
+
const logTitles = loadLogTitles()
|
|
356
|
+
let logRowsBusy = false
|
|
357
|
+
|
|
358
|
+
const root = el('div', 'wb')
|
|
359
|
+
root.setAttribute('data-testid', 'wb-root')
|
|
360
|
+
|
|
361
|
+
// ── Static skeleton: built once, update() only rewrites data regions. ──
|
|
362
|
+
const header = el('header', 'wb-header')
|
|
363
|
+
const menuBtn = el('button', 'wb-icon-btn wb-menu')
|
|
364
|
+
menuBtn.type = 'button'
|
|
365
|
+
menuBtn.dataset.testid = 'wb-menu'
|
|
366
|
+
menuBtn.setAttribute('aria-label', '打开目录抽屉')
|
|
367
|
+
menuBtn.textContent = '☰'
|
|
368
|
+
menuBtn.addEventListener('click', () => root.classList.toggle('is-rail-open'))
|
|
369
|
+
header.appendChild(menuBtn)
|
|
370
|
+
const title = el('div', 'wb-title')
|
|
371
|
+
title.appendChild(el('span', 'wb-title-main')).textContent = 'A2A 工作台'
|
|
372
|
+
const versionBadge = el('span', 'wb-badge')
|
|
373
|
+
title.appendChild(versionBadge)
|
|
374
|
+
header.appendChild(title)
|
|
375
|
+
// gh#34 owner three-key panel: the header gear opens the inspector panel
|
|
376
|
+
// (teamJoinAllowlist / teamScopeInfo / announce) — 远程建联三件不再翻配置文件。
|
|
377
|
+
const ownerBtn = el('button', 'wb-icon-btn wb-owner-btn')
|
|
378
|
+
ownerBtn.type = 'button'
|
|
379
|
+
ownerBtn.dataset.testid = 'wb-owner-btn'
|
|
380
|
+
ownerBtn.setAttribute('aria-label', '主人设置')
|
|
381
|
+
ownerBtn.title = '主人设置:席位名 / 入网白名单 / 可见性 / 发布'
|
|
382
|
+
ownerBtn.textContent = '⚙'
|
|
383
|
+
ownerBtn.addEventListener('click', () => { ownerPanelOpen = !ownerPanelOpen; renderOwnerPanel() })
|
|
384
|
+
header.appendChild(ownerBtn)
|
|
385
|
+
const headerStats = el('div', 'wb-header-stats')
|
|
386
|
+
header.appendChild(headerStats)
|
|
387
|
+
const railToggle = el('button', 'wb-icon-btn wb-rail-toggle')
|
|
388
|
+
railToggle.type = 'button'
|
|
389
|
+
railToggle.dataset.testid = 'wb-rail-toggle'
|
|
390
|
+
railToggle.setAttribute('aria-label', '折叠/展开侧栏')
|
|
391
|
+
railToggle.textContent = '◧'
|
|
392
|
+
railToggle.addEventListener('click', () => {
|
|
393
|
+
root.classList.toggle('is-rail-collapsed')
|
|
394
|
+
// The stage width changed while the board fills it → re-sync its view
|
|
395
|
+
// size (idempotent; focus side effect is harmless in a shell toggle).
|
|
396
|
+
if (mode === 'canvas' && board !== undefined) board.activate()
|
|
397
|
+
})
|
|
398
|
+
header.appendChild(railToggle)
|
|
399
|
+
|
|
400
|
+
const body = el('div', 'wb-body')
|
|
401
|
+
// Rail tabs (2026-10-23, wb-rail): one panel per pane, each with its own
|
|
402
|
+
// scroll. Panes are created once and re-parented between tab panels
|
|
403
|
+
// (canvas mode) and the stage stack (data mode).
|
|
404
|
+
const railTabs = createRailTabs(SECTION_DEFS)
|
|
405
|
+
const rail = railTabs.root
|
|
406
|
+
|
|
407
|
+
const directoryBody = el('div', 'wb-directory')
|
|
408
|
+
// Directory toolbar: unified search + category filter chips (0.6.6). Built
|
|
409
|
+
// once above the re-rendered list so the input keeps focus and its text
|
|
410
|
+
// across polls; filtering is pure frontend over /state.
|
|
411
|
+
const directoryTools = el('div', 'wb-dir-tools')
|
|
412
|
+
const searchInput = document.createElement('input')
|
|
413
|
+
searchInput.className = 'wb-search'
|
|
414
|
+
searchInput.type = 'search'
|
|
415
|
+
searchInput.placeholder = '搜索:名称 / 队 / 会话 / 节点'
|
|
416
|
+
searchInput.setAttribute('aria-label', '统一搜索目录')
|
|
417
|
+
searchInput.addEventListener('input', () => {
|
|
418
|
+
searchText = searchInput.value.trim().toLowerCase()
|
|
419
|
+
renderSidebar()
|
|
420
|
+
})
|
|
421
|
+
directoryTools.appendChild(searchInput)
|
|
422
|
+
const filterRow = el('div', 'wb-filters')
|
|
423
|
+
for (const def of DIRECTORY_FILTERS) {
|
|
424
|
+
const chip = el('button', 'wb-filter')
|
|
425
|
+
chip.type = 'button'
|
|
426
|
+
chip.textContent = def.label
|
|
427
|
+
chip.dataset.filter = def.key
|
|
428
|
+
chip.title = def.hint
|
|
429
|
+
chip.addEventListener('click', () => {
|
|
430
|
+
dirFilter = def.key
|
|
431
|
+
renderSidebar()
|
|
432
|
+
})
|
|
433
|
+
filterRow.appendChild(chip)
|
|
434
|
+
}
|
|
435
|
+
directoryTools.appendChild(filterRow)
|
|
436
|
+
railTabs.panel('directory').appendChild(directoryTools)
|
|
437
|
+
railTabs.panel('directory').appendChild(directoryBody)
|
|
438
|
+
|
|
439
|
+
const ledgerPane = el('section', 'wb-pane')
|
|
440
|
+
const activityPane = el('section', 'wb-pane')
|
|
441
|
+
const planPane = el('section', 'wb-pane')
|
|
442
|
+
// gh#20 W-C: the host rail pane (one row per tracked peer).
|
|
443
|
+
const hostsPane = el('section', 'wb-pane')
|
|
444
|
+
const dataPanes: ReadonlyArray<[WorkbenchSection, HTMLElement]> = [
|
|
445
|
+
['ledger', ledgerPane],
|
|
446
|
+
['activity', activityPane],
|
|
447
|
+
['plan', planPane],
|
|
448
|
+
['hosts', hostsPane],
|
|
449
|
+
]
|
|
450
|
+
|
|
451
|
+
const stage = el('main', 'wb-stage')
|
|
452
|
+
const boardPane = el('section', 'wb-stage-board')
|
|
453
|
+
if (board !== undefined) {
|
|
454
|
+
boardPane.appendChild(board.root)
|
|
455
|
+
stage.appendChild(boardPane)
|
|
456
|
+
}
|
|
457
|
+
const dataStack = el('div', 'wb-data')
|
|
458
|
+
|
|
459
|
+
const inspector = el('aside', 'wb-inspector')
|
|
460
|
+
const inspectorHead = el('div', 'wb-inspector-head')
|
|
461
|
+
inspectorHead.appendChild(el('span', 'wb-inspector-title')).textContent = '详情'
|
|
462
|
+
const inspectorClose = el('button', 'wb-icon-btn')
|
|
463
|
+
inspectorClose.type = 'button'
|
|
464
|
+
inspectorClose.setAttribute('aria-label', '关闭详情')
|
|
465
|
+
inspectorClose.textContent = '✕'
|
|
466
|
+
inspectorClose.addEventListener('click', () => select(undefined))
|
|
467
|
+
inspectorHead.appendChild(inspectorClose)
|
|
468
|
+
inspector.appendChild(inspectorHead)
|
|
469
|
+
const inspectorBody = el('div', 'wb-inspector-body')
|
|
470
|
+
inspector.appendChild(inspectorBody)
|
|
471
|
+
|
|
472
|
+
// ── Node chat drawer (0.6.13; rewritten 2026-10-23 as wb-chat) ──
|
|
473
|
+
// Clicking a node — on the planning board or a directory row — opens the
|
|
474
|
+
// conversation as a right-side chat: thread + composer + receipt tracking.
|
|
475
|
+
const chat = createChatDrawer({
|
|
476
|
+
now: deps.now,
|
|
477
|
+
sessionLog: deps.sessionLog,
|
|
478
|
+
remoteSessionLog: deps.remoteSessionLog,
|
|
479
|
+
dispatchQuick: deps.dispatchQuick,
|
|
480
|
+
onTranscript: (target, messages) => {
|
|
481
|
+
if (target.kind !== 'log' && target.kind !== 'session') return
|
|
482
|
+
if (learnLogTitle(logTitles, target.id, messages)) renderSidebar()
|
|
483
|
+
},
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
stage.appendChild(dataStack)
|
|
487
|
+
body.appendChild(rail)
|
|
488
|
+
body.appendChild(stage)
|
|
489
|
+
body.appendChild(inspector)
|
|
490
|
+
body.appendChild(chat.root)
|
|
491
|
+
root.appendChild(header)
|
|
492
|
+
root.appendChild(body)
|
|
493
|
+
|
|
494
|
+
/** Move the data panes to their mode home: rail sections while the board
|
|
495
|
+
* owns the stage, the stage stack when the board is out. */
|
|
496
|
+
function placePanes(m: WorkbenchMode): void {
|
|
497
|
+
for (const [key, pane] of dataPanes) {
|
|
498
|
+
const home = m === 'canvas' ? railTabs.panel(key) : dataStack
|
|
499
|
+
// Data mode: the pane lives on the stage, so its tab steps aside.
|
|
500
|
+
railTabs.setHidden(key, m !== 'canvas')
|
|
501
|
+
if (pane.parentElement !== home) home.appendChild(pane)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function syncMode(): void {
|
|
506
|
+
const canvasUp = board !== undefined && board.available()
|
|
507
|
+
const next: WorkbenchMode = canvasUp ? 'canvas' : 'data'
|
|
508
|
+
root.dataset.mode = next
|
|
509
|
+
boardPane.classList.toggle('is-hidden', next !== 'canvas')
|
|
510
|
+
dataStack.classList.toggle('is-hidden', next === 'canvas')
|
|
511
|
+
placePanes(next)
|
|
512
|
+
if (next !== mode) {
|
|
513
|
+
if (board !== undefined) {
|
|
514
|
+
if (next === 'canvas') board.activate()
|
|
515
|
+
else board.deactivate()
|
|
516
|
+
}
|
|
517
|
+
mode = next
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function clear(node: HTMLElement): HTMLElement {
|
|
522
|
+
while (node.firstChild !== null) node.removeChild(node.firstChild)
|
|
523
|
+
return node
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function select(next: Selection): void {
|
|
527
|
+
selection = next
|
|
528
|
+
// Canvas add-member form is cached per team name (draftForm trick):
|
|
529
|
+
// drop it whenever the selection moves off its team.
|
|
530
|
+
if (next?.kind !== 'canvas' || canvasForm?.name !== next.row.name) canvasForm = null
|
|
531
|
+
// Same for the quick-dispatch form: it is cached per target team
|
|
532
|
+
// (0.6.15: remote dispatch moved to the chat drawer's own form).
|
|
533
|
+
if (next?.kind !== 'canvas') dispatchForm = null
|
|
534
|
+
renderInspector()
|
|
535
|
+
// Narrow screens: picking from the drawer closes it so the stage shows.
|
|
536
|
+
root.classList.remove('is-rail-open')
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ── Directory section ──
|
|
540
|
+
function renderSidebar(): void {
|
|
541
|
+
// Filter chips reflect the live view state (built once, toggled here).
|
|
542
|
+
for (let i = 0; i < filterRow.children.length; i++) {
|
|
543
|
+
const node = filterRow.children[i] as HTMLElement
|
|
544
|
+
node.classList.toggle('is-active', node.dataset.filter === dirFilter)
|
|
545
|
+
}
|
|
546
|
+
if (last === null) {
|
|
547
|
+
directoryBody.replaceChildren(el('div', 'wb-muted'))
|
|
548
|
+
directoryBody.firstElementChild!.textContent = '载入中…'
|
|
549
|
+
railTabs.setBadge('directory', '')
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
const badge = renderDirectory(directoryBody, {
|
|
553
|
+
state: last,
|
|
554
|
+
logs: logRows,
|
|
555
|
+
logTotal,
|
|
556
|
+
titles: logTitles,
|
|
557
|
+
search: searchText,
|
|
558
|
+
filter: dirFilter,
|
|
559
|
+
now: deps.now(),
|
|
560
|
+
openChat,
|
|
561
|
+
selectCanvas: (row) => select({ kind: 'canvas', row }),
|
|
562
|
+
})
|
|
563
|
+
railTabs.setBadge('directory', badge)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* gh#20 W-C: the host rail — one row per tracked peer host. Each row is a
|
|
568
|
+
* HOST card: operator-facing label (the peer's published card name, host
|
|
569
|
+
* fallback), the W-A tri-state badge (link class + verified mark), a
|
|
570
|
+
* health lamp driven by the last-contact recency (green ≤60s, amber
|
|
571
|
+
* ≤5min, gray older/never — tooltip carries the exact timestamp), and the
|
|
572
|
+
* peer's published version chip. Clicking drills down: the peer chat
|
|
573
|
+
* drawer (remote log entry) rides the existing face.
|
|
574
|
+
*/
|
|
575
|
+
function renderHosts(): void {
|
|
576
|
+
const pane = clear(hostsPane)
|
|
577
|
+
if (last === null) {
|
|
578
|
+
railTabs.setBadge('hosts', '')
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
const now = deps.now()
|
|
582
|
+
const peers = [...(last.peers ?? [])].sort((a, b) => {
|
|
583
|
+
const rank = (row: { link?: string }): number => row.link === 'linked' ? 0 : 1
|
|
584
|
+
return rank(a) - rank(b) || (b.score ?? 0) - (a.score ?? 0)
|
|
585
|
+
})
|
|
586
|
+
railTabs.setBadge('hosts', peers.length > 0 ? `${peers.filter(row => row.link === 'linked').length}/${peers.length}` : '')
|
|
587
|
+
if (peers.length === 0) {
|
|
588
|
+
pane.appendChild(el('div', 'wb-nodelist-empty')).textContent = '暂无 tracked peer——配 peers 种子或等 referral 到达'
|
|
589
|
+
return
|
|
590
|
+
}
|
|
591
|
+
for (const row of peers) {
|
|
592
|
+
const host = peerHostOf(row.url)
|
|
593
|
+
const lineEl = el('div', 'wb-host-row')
|
|
594
|
+
// Health lamp (gh#24 semantics): recency alone lied — a FAILED fresh
|
|
595
|
+
// contact glowed green. Tiers by age: ≤60s live / ≤5min warm /
|
|
596
|
+
// beyond cold, overridden by the OUTCOME: a recent failure (≤5min)
|
|
597
|
+
// is red regardless of the live/warm band, and a never-contacted
|
|
598
|
+
// peer is the hollow unknown state.
|
|
599
|
+
const lamp = el('span', 'wb-host-lamp')
|
|
600
|
+
const last = row.lastContactAt
|
|
601
|
+
const age = last === undefined ? undefined : now - last
|
|
602
|
+
const ok = row.lastContactOk
|
|
603
|
+
if (age === undefined) lamp.classList.add('is-unknown')
|
|
604
|
+
else if (ok === false && age <= 5 * 60_000) lamp.classList.add('is-fail')
|
|
605
|
+
else if (age <= 60_000) lamp.classList.add('is-live')
|
|
606
|
+
else if (age <= 5 * 60_000) lamp.classList.add('is-warm')
|
|
607
|
+
else lamp.classList.add('is-cold')
|
|
608
|
+
lamp.title = last === undefined
|
|
609
|
+
? '尚无接触记录'
|
|
610
|
+
: `最近接触 ${ageText(last, now)}${ok === false ? '(失败)' : ok === true ? '(成功)' : ''}`
|
|
611
|
+
lineEl.appendChild(lamp)
|
|
612
|
+
// Label: the peer's published name, host fallback.
|
|
613
|
+
lineEl.appendChild(el('span', 'wb-host-name')).textContent = row.name !== undefined && row.name !== '' ? row.name : host
|
|
614
|
+
// W-A badge: link class + verified mark.
|
|
615
|
+
const badge = el('span', `wb-host-badge ${row.link === 'linked' ? 'is-linked' : 'is-stranger'}`)
|
|
616
|
+
badge.textContent = `${row.link === 'linked' ? 'linked' : 'stranger'} ${row.verified === true ? '✓' : '✗'}`
|
|
617
|
+
// The tooltip follows the verified mark (gh#24): a linked ✗ must not
|
|
618
|
+
// claim a valid signature.
|
|
619
|
+
badge.title = row.link === 'linked'
|
|
620
|
+
? `已建立运营者链接(互种或 canvas 引用);${row.verified === true ? '验签有效' : '尚未持有验签通过的卡片'}`
|
|
621
|
+
: '无运营者关系(验签 ≠ 关系)'
|
|
622
|
+
lineEl.appendChild(badge)
|
|
623
|
+
// Version chip from the warm card.
|
|
624
|
+
if (row.version !== undefined && row.version !== '') {
|
|
625
|
+
lineEl.appendChild(el('span', 'wb-host-ver')).textContent = row.version
|
|
626
|
+
}
|
|
627
|
+
lineEl.appendChild(el('span', 'wb-host-url mono')).textContent = host
|
|
628
|
+
lineEl.setAttribute('role', 'button')
|
|
629
|
+
lineEl.tabIndex = 0
|
|
630
|
+
lineEl.title = `${row.url} · score ${String(row.score ?? 0)}`
|
|
631
|
+
const drill = (): void => { openChat({ kind: 'peer', id: `peer-${host}`, title: row.name !== undefined && row.name !== '' ? row.name : host, sub: host, url: row.url }) }
|
|
632
|
+
lineEl.addEventListener('click', drill)
|
|
633
|
+
lineEl.addEventListener('keydown', ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); drill() } })
|
|
634
|
+
pane.appendChild(lineEl)
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ── Ledger pane (wb-ledger) ──
|
|
639
|
+
function renderLedger(): void {
|
|
640
|
+
if (last === null) { ledgerPane.replaceChildren(); railTabs.setBadge('ledger', ''); return }
|
|
641
|
+
const state = last
|
|
642
|
+
const resolve = (team: string): ChatTarget | undefined => {
|
|
643
|
+
const session = (state.sessions ?? []).find(row => row.team === team)
|
|
644
|
+
if (session !== undefined) return { kind: 'session', id: session.id, title: displayName(session), sub: session.team, team: session.team, live: session.live }
|
|
645
|
+
const remote = (state.remote ?? []).find(row => row.team === team)
|
|
646
|
+
if (remote !== undefined) return { kind: 'remote', team: remote.team, title: displayName(remote), sub: remote.team, row: remote }
|
|
647
|
+
return undefined
|
|
648
|
+
}
|
|
649
|
+
const out = paintLedger(ledgerPane, {
|
|
650
|
+
state,
|
|
651
|
+
now: deps.now(),
|
|
652
|
+
abandon: (taskId, reason) => deps.abandon(taskId, reason),
|
|
653
|
+
canOpenTeam: (team) => resolve(team) !== undefined,
|
|
654
|
+
openTeam: (team) => {
|
|
655
|
+
const target = resolve(team)
|
|
656
|
+
if (target === undefined) return false
|
|
657
|
+
openChat(target)
|
|
658
|
+
return true
|
|
659
|
+
},
|
|
660
|
+
})
|
|
661
|
+
railTabs.setBadge('ledger', out.badge, out.tone)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ── Activity pane ──
|
|
665
|
+
/** A team cell: the name when known (wb-names), else the id. */
|
|
666
|
+
function teamCell(line: HTMLElement, state: StateBody, team: string): void {
|
|
667
|
+
nameInto(line.appendChild(el('span', 'wb-row-team')), state, team)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function renderActivity(): void {
|
|
671
|
+
const pane = clear(activityPane)
|
|
672
|
+
if (last === null) return
|
|
673
|
+
const now = deps.now()
|
|
674
|
+
const flights = last.inFlight ?? []
|
|
675
|
+
railTabs.setBadge('activity', flights.length > 0 ? String(flights.length) : '', flights.length > 0 ? 'accent' : undefined)
|
|
676
|
+
const head = el('div', 'wb-pane-head')
|
|
677
|
+
head.textContent = `在途 ${flights.length} · 活动 ${last.activity?.length ?? 0}`
|
|
678
|
+
pane.appendChild(head)
|
|
679
|
+
if (flights.length > 0) {
|
|
680
|
+
const sub = el('div', 'wb-pane-subhead')
|
|
681
|
+
sub.textContent = '在途路由'
|
|
682
|
+
pane.appendChild(sub)
|
|
683
|
+
for (const row of flights) {
|
|
684
|
+
const line = el('div', 'wb-row')
|
|
685
|
+
line.appendChild(el('span', 'wb-row-dir')).textContent = '→'
|
|
686
|
+
teamCell(line, last, row.team)
|
|
687
|
+
line.appendChild(el('span', 'wb-row-meta')).textContent = `${row.peer} · ${ageText(row.startedAt, now)}`
|
|
688
|
+
pane.appendChild(line)
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
const ringHead = el('div', 'wb-pane-subhead')
|
|
692
|
+
ringHead.textContent = '活动环(最新在前)'
|
|
693
|
+
pane.appendChild(ringHead)
|
|
694
|
+
const ring = last.activity ?? []
|
|
695
|
+
if (ring.length === 0) {
|
|
696
|
+
pane.appendChild(el('div', 'wb-muted')).textContent = '暂无活动'
|
|
697
|
+
return
|
|
698
|
+
}
|
|
699
|
+
for (const row of ring) {
|
|
700
|
+
const line = el('div', 'wb-row')
|
|
701
|
+
const dir = el('span', 'wb-row-dir')
|
|
702
|
+
dir.textContent = row.dir === 'fwd' ? '⇢' : row.dir === 'out' ? '→' : '←'
|
|
703
|
+
if (row.ok !== true) dir.classList.add('is-fail')
|
|
704
|
+
line.appendChild(dir)
|
|
705
|
+
teamCell(line, last, row.team)
|
|
706
|
+
line.appendChild(el('span', 'wb-row-meta')).textContent = `${row.peer} · ${ageText(row.ts, now)}`
|
|
707
|
+
line.appendChild(el('span', 'wb-row-badges')).textContent = row.ok === true ? '' : '失败'
|
|
708
|
+
pane.appendChild(line)
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ── Plan pane: derived actions + dispatch drafting ──
|
|
713
|
+
function planActions(state: StateBody): Array<{ severity: 'high' | 'normal'; text: string; brief?: string }> {
|
|
714
|
+
const now = deps.now()
|
|
715
|
+
const actions: Array<{ severity: 'high' | 'normal'; text: string; brief?: string }> = []
|
|
716
|
+
// One definition with the 台账 tab: fire-forget expiries are bookkeeping,
|
|
717
|
+
// not dead letters (review L1), and only OUR stale sends are nudgeable.
|
|
718
|
+
const facts = ledgerFacts(state, now)
|
|
719
|
+
if (facts.deadLetters.length > 0) {
|
|
720
|
+
actions.push({ severity: 'high', text: `清账 ${facts.deadLetters.length} 条死信(目标早已离开或死信化,无回执可期)` })
|
|
721
|
+
}
|
|
722
|
+
// (The old `age > 1h && settleable === true` never fired against a real
|
|
723
|
+
// host — the server's settleable means receipt-expected AND age < 1h.)
|
|
724
|
+
for (const row of facts.stale) {
|
|
725
|
+
// Quote the original ask: a nudge that only repeats a task id makes
|
|
726
|
+
// the target re-derive WHAT was owed, and the target is exactly the
|
|
727
|
+
// session that already failed to answer once.
|
|
728
|
+
const subject = row.brief === undefined || row.brief === '' ? '' : `「${row.brief}」`
|
|
729
|
+
actions.push({
|
|
730
|
+
severity: 'normal',
|
|
731
|
+
text: `催办 ${row.taskId} → ${teamLabel(state, row.team)}(等待 ${ageText(row.startedAt, now)} 无回执)${subject}`,
|
|
732
|
+
brief: `a2a_route 催收回执:task ${row.taskId}(team ${row.team})${subject === '' ? '' : `原任务「${row.brief}」`}已派发 ${ageText(row.startedAt, now)},请回报结果或回执。`,
|
|
733
|
+
})
|
|
734
|
+
}
|
|
735
|
+
// Cold sessions are one action, not one per session: they share the same
|
|
736
|
+
// remedy (open them in the GUI), and a list of identical lines buried the
|
|
737
|
+
// actions that differ.
|
|
738
|
+
const cold = (state.sessions ?? []).filter(row => row.live !== true).map(row => displayName(row))
|
|
739
|
+
if (cold.length > 0) {
|
|
740
|
+
const names = cold.slice(0, 3).join('、') + (cold.length > 3 ? ` 等 ${cold.length} 个` : '')
|
|
741
|
+
actions.push({ severity: 'normal', text: `冷会话 ${cold.length} 个(${names}):在 GUI 打开即可唤醒加入面` })
|
|
742
|
+
}
|
|
743
|
+
for (const row of state.reconcile?.rows ?? []) {
|
|
744
|
+
if (row.needsRepair === true) {
|
|
745
|
+
// Name the session; the id stays for the operator who greps logs.
|
|
746
|
+
const s = (state.sessions ?? []).find(x => x.id === row.id)
|
|
747
|
+
const who = s !== undefined ? `${displayName(s)}(${row.id})` : row.id
|
|
748
|
+
actions.push({ severity: 'high', text: `reconcile 需修复:${who} ${row.error}` })
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
const conflicts = state.idempotency?.conflicts ?? 0
|
|
752
|
+
if (conflicts > 0) {
|
|
753
|
+
actions.push({ severity: 'high', text: `幂等冲突 ${conflicts} 例:人工核查 UNIQUE-ization 流量` })
|
|
754
|
+
}
|
|
755
|
+
return actions
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function renderPlan(): void {
|
|
759
|
+
const pane = clear(planPane)
|
|
760
|
+
if (last === null) return
|
|
761
|
+
const actions = planActions(last)
|
|
762
|
+
railTabs.setBadge('plan', actions.length > 0 ? String(actions.length) : '', actions.some(a => a.severity === 'high') ? 'danger' : undefined)
|
|
763
|
+
const head = el('div', 'wb-pane-head')
|
|
764
|
+
head.textContent = actions.length === 0 ? '网络健康——无待办行动' : `待办行动 ${actions.length}`
|
|
765
|
+
pane.appendChild(head)
|
|
766
|
+
for (const action of actions) {
|
|
767
|
+
const line = el('div', `wb-action${action.severity === 'high' ? ' wb-action-high' : ''}`)
|
|
768
|
+
line.appendChild(el('span', 'wb-action-text')).textContent = action.text
|
|
769
|
+
if (action.brief !== undefined) {
|
|
770
|
+
const btn = el('button', 'wb-btn')
|
|
771
|
+
btn.type = 'button'
|
|
772
|
+
btn.textContent = '复制催办'
|
|
773
|
+
btn.addEventListener('click', () => deps.copy(action.brief ?? ''))
|
|
774
|
+
line.appendChild(btn)
|
|
775
|
+
}
|
|
776
|
+
pane.appendChild(line)
|
|
777
|
+
}
|
|
778
|
+
// Dispatch drafting — static inner form so textarea survives updates.
|
|
779
|
+
const draftHead = el('div', 'wb-pane-subhead')
|
|
780
|
+
draftHead.textContent = '派单起草(复制后在任一会话粘贴执行)'
|
|
781
|
+
pane.appendChild(draftHead)
|
|
782
|
+
pane.appendChild(draftForm)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Dispatch draft form: created ONCE; renderPlan re-attaches the same node.
|
|
786
|
+
const draftForm = el('div', 'wb-draft')
|
|
787
|
+
const draftTarget = document.createElement('select')
|
|
788
|
+
draftTarget.className = 'wb-select'
|
|
789
|
+
const draftInput = document.createElement('textarea')
|
|
790
|
+
draftInput.className = 'wb-textarea'
|
|
791
|
+
draftInput.rows = 3
|
|
792
|
+
draftInput.placeholder = '任务正文:目标、验收、期限…'
|
|
793
|
+
const draftBtn = el('button', 'wb-btn wb-btn-primary')
|
|
794
|
+
draftBtn.type = 'button'
|
|
795
|
+
draftBtn.textContent = '复制任务书'
|
|
796
|
+
draftBtn.addEventListener('click', () => {
|
|
797
|
+
const target = draftTarget.value
|
|
798
|
+
const message = draftInput.value.trim()
|
|
799
|
+
if (target === '' || message === '') return
|
|
800
|
+
deps.copy(`a2a_route 派单 → ${target}:\n${message}`)
|
|
801
|
+
})
|
|
802
|
+
draftForm.appendChild(draftTarget)
|
|
803
|
+
draftForm.appendChild(draftInput)
|
|
804
|
+
draftForm.appendChild(draftBtn)
|
|
805
|
+
|
|
806
|
+
function renderDraftTargets(): void {
|
|
807
|
+
const previous = draftTarget.value
|
|
808
|
+
clear(draftTarget)
|
|
809
|
+
const empty = document.createElement('option')
|
|
810
|
+
empty.value = ''
|
|
811
|
+
empty.textContent = '选择目标队…'
|
|
812
|
+
draftTarget.appendChild(empty)
|
|
813
|
+
if (last === null) return
|
|
814
|
+
const state = last
|
|
815
|
+
const targets = new Map<string, string>()
|
|
816
|
+
for (const row of state.sessions ?? []) {
|
|
817
|
+
if (row.team !== undefined && row.team !== '') targets.set(row.team, `${displayName(row)}(会话队)`)
|
|
818
|
+
}
|
|
819
|
+
for (const row of state.canvas?.teams ?? []) targets.set(row.team, `${row.name}(canvas 队)`)
|
|
820
|
+
for (const row of state.remote ?? []) targets.set(row.team, `${displayName(row)}(远端)`)
|
|
821
|
+
for (const [team, label] of targets) {
|
|
822
|
+
const opt = document.createElement('option')
|
|
823
|
+
opt.value = team
|
|
824
|
+
opt.textContent = `${label} · ${team}`
|
|
825
|
+
draftTarget.appendChild(opt)
|
|
826
|
+
}
|
|
827
|
+
if (previous !== '' && targets.has(previous)) draftTarget.value = previous
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// ── Inspector (overlay card over the stage; bottom sheet on narrow) ──
|
|
831
|
+
// gh#34 owner panel state + render (the gear toggles; the inspector hosts).
|
|
832
|
+
let seatEditor: HTMLElement | undefined
|
|
833
|
+
let ownerPanelOpen = false
|
|
834
|
+
let ownerState: { seatNames: Record<string, string>; teamJoinAllowlist: string[]; teamScopeInfo: boolean; announce: boolean; announceRestartPending: boolean } | null = null
|
|
835
|
+
function renderOwnerPanel(): void {
|
|
836
|
+
renderInspector()
|
|
837
|
+
if (!ownerPanelOpen) return
|
|
838
|
+
void deps.ownerSettingsRead?.().then(result => {
|
|
839
|
+
if (!ownerPanelOpen) return
|
|
840
|
+
if (result.ok !== true) { ownerState = null; renderInspector(); return }
|
|
841
|
+
seatEditor = undefined
|
|
842
|
+
ownerState = {
|
|
843
|
+
seatNames: result.seatNames ?? {},
|
|
844
|
+
teamJoinAllowlist: result.teamJoinAllowlist ?? [],
|
|
845
|
+
teamScopeInfo: result.teamScopeInfo !== false,
|
|
846
|
+
announce: result.announce === true,
|
|
847
|
+
announceRestartPending: result.announceRestartPending === true,
|
|
848
|
+
}
|
|
849
|
+
renderInspector()
|
|
850
|
+
})
|
|
851
|
+
}
|
|
852
|
+
function renderOwnerPanelInto(box: HTMLElement): void {
|
|
853
|
+
const card = el('div', 'wb-card')
|
|
854
|
+
const head = el('div', 'wb-inspector-head')
|
|
855
|
+
head.appendChild(el('span', 'wb-inspector-title')).textContent = '主人设置'
|
|
856
|
+
const closeBtn = el('button', 'wb-icon-btn')
|
|
857
|
+
closeBtn.type = 'button'
|
|
858
|
+
closeBtn.setAttribute('aria-label', '关闭主人设置')
|
|
859
|
+
closeBtn.textContent = '✕'
|
|
860
|
+
closeBtn.addEventListener('click', () => { ownerPanelOpen = false; renderInspector() })
|
|
861
|
+
head.appendChild(closeBtn)
|
|
862
|
+
card.appendChild(head)
|
|
863
|
+
if (deps.ownerSettingsRead === undefined) {
|
|
864
|
+
const note = el('div', 'wb-hint')
|
|
865
|
+
note.textContent = '此宿主未提供主人设置面(旧版本)——升级后可用。'
|
|
866
|
+
card.appendChild(note)
|
|
867
|
+
box.appendChild(card)
|
|
868
|
+
return
|
|
869
|
+
}
|
|
870
|
+
if (ownerState === null) {
|
|
871
|
+
const note = el('div', 'wb-hint')
|
|
872
|
+
note.textContent = '读取中…'
|
|
873
|
+
card.appendChild(note)
|
|
874
|
+
box.appendChild(card)
|
|
875
|
+
return
|
|
876
|
+
}
|
|
877
|
+
const st = ownerState
|
|
878
|
+
seatEditor ??= createSeatNameEditor(last?.sessions ?? [], st.seatNames, async seatNames => {
|
|
879
|
+
const result = await deps.ownerSettingsWrite?.({ seatNames })
|
|
880
|
+
if (result?.ok) { st.seatNames = seatNames; return { ok: true } }
|
|
881
|
+
return { ok: false, error: result?.error ?? '保存失败' }
|
|
882
|
+
})
|
|
883
|
+
card.appendChild(seatEditor)
|
|
884
|
+
// ① 入网白名单(精确团队名增删)
|
|
885
|
+
const alHead = el('div', 'wb-side-head')
|
|
886
|
+
alHead.textContent = '入网白名单(精确团队名)'
|
|
887
|
+
card.appendChild(alHead)
|
|
888
|
+
for (const name of st.teamJoinAllowlist) {
|
|
889
|
+
const row = el('div', 'wb-member-row')
|
|
890
|
+
row.appendChild(el('span', 'wb-member-label')).textContent = name
|
|
891
|
+
const del = el('button', 'wb-member-remove')
|
|
892
|
+
del.type = 'button'
|
|
893
|
+
del.textContent = '移除'
|
|
894
|
+
del.addEventListener('click', () => {
|
|
895
|
+
void deps.ownerSettingsWrite?.({ teamJoinAllowlist: st.teamJoinAllowlist.filter(n => n !== name) }).then(() => renderOwnerPanel())
|
|
896
|
+
})
|
|
897
|
+
row.appendChild(del)
|
|
898
|
+
card.appendChild(row)
|
|
899
|
+
}
|
|
900
|
+
const addForm = el('div', 'wb-member-add')
|
|
901
|
+
const input = el('input', 'wb-member-manual')
|
|
902
|
+
input.type = 'text'
|
|
903
|
+
input.placeholder = 'dsh/团队名(精确,无通配)'
|
|
904
|
+
input.setAttribute('aria-label', '新增白名单团队名')
|
|
905
|
+
const addBtn = el('button', 'wb-member-submit')
|
|
906
|
+
addBtn.type = 'button'
|
|
907
|
+
addBtn.textContent = '加入'
|
|
908
|
+
const doAdd = (): void => {
|
|
909
|
+
const v = input.value.trim()
|
|
910
|
+
if (v === '' || st.teamJoinAllowlist.includes(v)) return
|
|
911
|
+
void deps.ownerSettingsWrite?.({ teamJoinAllowlist: [...st.teamJoinAllowlist, v] }).then(() => renderOwnerPanel())
|
|
912
|
+
}
|
|
913
|
+
addBtn.addEventListener('click', doAdd)
|
|
914
|
+
input.addEventListener('keydown', ev => { if (ev.key === 'Enter') doAdd() })
|
|
915
|
+
addForm.append(input, addBtn)
|
|
916
|
+
card.appendChild(addForm)
|
|
917
|
+
// ② 可见性开关 + 精度固化文案
|
|
918
|
+
const tsHead = el('div', 'wb-side-head')
|
|
919
|
+
tsHead.textContent = '目录可见性(teamScopeInfo)'
|
|
920
|
+
card.appendChild(tsHead)
|
|
921
|
+
const tsRow = el('div', 'wb-member-row')
|
|
922
|
+
const tsBtn = el('button', 'wb-member-submit')
|
|
923
|
+
tsBtn.type = 'button'
|
|
924
|
+
tsBtn.textContent = st.teamScopeInfo ? '已开:仅显示已加入团队(点击放开为全舰队可见)' : '已关:全舰队可见(点击收窄为已加入团队)'
|
|
925
|
+
tsBtn.addEventListener('click', () => {
|
|
926
|
+
void deps.ownerSettingsWrite?.({ teamScopeInfo: !st.teamScopeInfo }).then(() => renderOwnerPanel())
|
|
927
|
+
})
|
|
928
|
+
tsRow.appendChild(tsBtn)
|
|
929
|
+
card.appendChild(tsRow)
|
|
930
|
+
const tsNote = el('div', 'wb-hint')
|
|
931
|
+
tsNote.textContent = '精度说明:teamScopeInfo 只管「目录显示范围」。关上它不会解锁任何通信准入——会话入网(S3)与目标级路由(F5)规则原样生效。'
|
|
932
|
+
card.appendChild(tsNote)
|
|
933
|
+
// ③ 发布开关(重启生效)
|
|
934
|
+
const anHead = el('div', 'wb-side-head')
|
|
935
|
+
anHead.textContent = '节点发布(announce)'
|
|
936
|
+
card.appendChild(anHead)
|
|
937
|
+
const anRow = el('div', 'wb-member-row')
|
|
938
|
+
const anBtn = el('button', 'wb-member-submit')
|
|
939
|
+
anBtn.type = 'button'
|
|
940
|
+
anBtn.textContent = st.announce ? '已开:本节点发布签名卡(点击关闭)' : '已关:本节点不发布(点击开启——重启宿主后生效)'
|
|
941
|
+
anBtn.addEventListener('click', () => {
|
|
942
|
+
void deps.ownerSettingsWrite?.({ announce: !st.announce }).then(() => renderOwnerPanel())
|
|
943
|
+
})
|
|
944
|
+
anRow.appendChild(anBtn)
|
|
945
|
+
card.appendChild(anRow)
|
|
946
|
+
if (st.announceRestartPending) {
|
|
947
|
+
const anNote = el('div', 'wb-hint')
|
|
948
|
+
anNote.textContent = '有 announce 变更待生效:重启宿主后按新配置发布。'
|
|
949
|
+
card.appendChild(anNote)
|
|
950
|
+
}
|
|
951
|
+
box.appendChild(card)
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function renderInspector(): void {
|
|
955
|
+
const box = clear(inspectorBody)
|
|
956
|
+
// gh#34: the owner panel owns the inspector when open (selection cards
|
|
957
|
+
// yield; the gear closes it back).
|
|
958
|
+
if (ownerPanelOpen) { inspector.classList.add('is-open'); renderOwnerPanelInto(box); return }
|
|
959
|
+
inspector.classList.toggle('is-open', selection !== undefined)
|
|
960
|
+
if (selection === undefined) return
|
|
961
|
+
const sel = selection
|
|
962
|
+
const card = el('div', 'wb-card')
|
|
963
|
+
// 0.6.13: session/log/peer faces moved to the chat drawer; 0.6.15 took
|
|
964
|
+
// the remote rows with them (their metadata rides the chat head) — the
|
|
965
|
+
// inspector keeps the canvas team card.
|
|
966
|
+
if (sel.kind === 'canvas') {
|
|
967
|
+
// Re-resolve the team from the freshest state: membership changes here
|
|
968
|
+
// must be visible on the next render even though the click-time row is
|
|
969
|
+
// a stale snapshot.
|
|
970
|
+
const fresh = (last?.canvas?.teams ?? []).find(t => t.name === sel.row.name) ?? sel.row
|
|
971
|
+
textRow(card, 'Canvas 队', fresh.name)
|
|
972
|
+
textRow(card, '路由队', fresh.team)
|
|
973
|
+
const head = el('div', 'wb-side-head')
|
|
974
|
+
head.textContent = `成员 · ${fresh.members.length}`
|
|
975
|
+
card.appendChild(head)
|
|
976
|
+
if (fresh.members.length === 0) {
|
|
977
|
+
const empty = el('div', 'wb-muted')
|
|
978
|
+
empty.textContent = '空队——用下方表单添加成员'
|
|
979
|
+
card.appendChild(empty)
|
|
980
|
+
}
|
|
981
|
+
for (const member of fresh.members) {
|
|
982
|
+
const row = el('div', 'wb-member-row')
|
|
983
|
+
const dot = member.cliHeld === true ? '本机持有,无需指定' : member.remote === true ? (member.cliHome === null ? '◇ 远端 · home 未确定' : '◇ 远端') : member.live === true ? '● 现场' : member.joined === true ? '○ 冷启' : '· 未入网'
|
|
984
|
+
row.appendChild(el('span', 'wb-member-dot')).textContent = dot
|
|
985
|
+
const label = el('span', 'wb-member-label')
|
|
986
|
+
const session = (last?.sessions ?? []).find(s => s.id === member.id)
|
|
987
|
+
label.textContent = member.remote === true ? member.id : (session !== undefined ? displayName(session) : member.id)
|
|
988
|
+
label.title = member.cliHeld === true ? '本机持有,无需指定' : member.cliHome !== undefined ? (member.cliHome ?? 'home 未确定') : (member.team ?? member.id)
|
|
989
|
+
row.appendChild(label)
|
|
990
|
+
const remove = el('button', 'wb-member-remove')
|
|
991
|
+
remove.type = 'button'
|
|
992
|
+
remove.textContent = '移除'
|
|
993
|
+
remove.addEventListener('click', async () => {
|
|
994
|
+
remove.disabled = true
|
|
995
|
+
if (!(await deps.canvasOp('remove-member', fresh.name, member.id))) remove.disabled = false
|
|
996
|
+
})
|
|
997
|
+
row.appendChild(remove)
|
|
998
|
+
card.appendChild(row)
|
|
999
|
+
if (member.cliHomeCleared === true) {
|
|
1000
|
+
const notice = el('div', 'wb-hint')
|
|
1001
|
+
notice.textContent = '本机落户已清除旧 home 钉;户口离线后也不会恢复旧钉。'
|
|
1002
|
+
card.appendChild(notice)
|
|
1003
|
+
}
|
|
1004
|
+
if (member.cliHome === null && member.cliHeld !== true && deps.cliHomeOptions !== undefined) {
|
|
1005
|
+
const key = JSON.stringify([fresh.name, member.id])
|
|
1006
|
+
let form = cliHomeForms.get(key)
|
|
1007
|
+
if (form === undefined) {
|
|
1008
|
+
form = createCliHomePicker(() => deps.cliHomeOptions!(fresh.name, member.id), home => deps.canvasOp('add-member', fresh.name, member.id, home))
|
|
1009
|
+
cliHomeForms.set(key, form)
|
|
1010
|
+
}
|
|
1011
|
+
card.appendChild(form)
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
// Add-member form: static DOM re-mounted per poll (draftForm trick) —
|
|
1015
|
+
// rebuilt only when the inspected team changes.
|
|
1016
|
+
if (canvasForm?.name !== fresh.name) {
|
|
1017
|
+
const form = el('form', 'wb-member-add')
|
|
1018
|
+
const pick = document.createElement('select')
|
|
1019
|
+
pick.className = 'wb-member-pick'
|
|
1020
|
+
pick.setAttribute('aria-label', '选择成员')
|
|
1021
|
+
const manual = document.createElement('input')
|
|
1022
|
+
manual.type = 'text'
|
|
1023
|
+
manual.className = 'wb-member-manual'
|
|
1024
|
+
manual.placeholder = '或输入远端句柄 zone/id8'
|
|
1025
|
+
manual.setAttribute('aria-label', '远端成员句柄')
|
|
1026
|
+
const submit = el('button', 'wb-member-submit')
|
|
1027
|
+
submit.type = 'submit'
|
|
1028
|
+
submit.textContent = '添加'
|
|
1029
|
+
form.appendChild(pick)
|
|
1030
|
+
form.appendChild(manual)
|
|
1031
|
+
form.appendChild(submit)
|
|
1032
|
+
form.addEventListener('submit', async ev => {
|
|
1033
|
+
ev.preventDefault()
|
|
1034
|
+
const handle = manual.value.trim()
|
|
1035
|
+
const id = handle !== '' ? handle : pick.value
|
|
1036
|
+
if (id === '') return
|
|
1037
|
+
submit.disabled = true
|
|
1038
|
+
const ok = await deps.canvasOp('add-member', fresh.name, id)
|
|
1039
|
+
submit.disabled = false
|
|
1040
|
+
if (ok) manual.value = ''
|
|
1041
|
+
})
|
|
1042
|
+
canvasForm = { name: fresh.name, form, pick, manual }
|
|
1043
|
+
}
|
|
1044
|
+
// Refresh the picker's options from the freshest state, preserving the
|
|
1045
|
+
// current choice (sessions/remote rows churn across polls).
|
|
1046
|
+
const pick = canvasForm.pick
|
|
1047
|
+
const keep = pick.value
|
|
1048
|
+
clear(pick)
|
|
1049
|
+
pick.appendChild(new Option('本机会话(现场/冷启动)…', '', true, true))
|
|
1050
|
+
const memberIds = new Set(fresh.members.map(member => member.id))
|
|
1051
|
+
for (const session of last?.sessions ?? []) {
|
|
1052
|
+
if (memberIds.has(session.id)) continue
|
|
1053
|
+
pick.appendChild(new Option(`${displayName(session)} — 本机`, session.id))
|
|
1054
|
+
}
|
|
1055
|
+
let remoteLabeled = false
|
|
1056
|
+
for (const remote of last?.remote ?? []) {
|
|
1057
|
+
// gh#37: only zone/id8 HANDLES are addable members — a bare zone row
|
|
1058
|
+
// (the peer's own team card, e.g. 'dsh') fails the backend shape
|
|
1059
|
+
// gate and the old picker offered it anyway: the owner clicked 远端,
|
|
1060
|
+
// got refused, and read the feature as broken. Rows without a '/'
|
|
1061
|
+
// are skipped; when the directory carries no handles at all the
|
|
1062
|
+
// hint explains why (unlinked peers serve the minimal card, which
|
|
1063
|
+
// carries no session teams) instead of offering dead options.
|
|
1064
|
+
if (!remote.team.includes('/')) continue
|
|
1065
|
+
if (memberIds.has(remote.team)) continue
|
|
1066
|
+
if (!remoteLabeled) {
|
|
1067
|
+
pick.appendChild(new Option('远端会话队(目录卡片)…', '', true, false))
|
|
1068
|
+
remoteLabeled = true
|
|
1069
|
+
}
|
|
1070
|
+
pick.appendChild(new Option(`${displayName(remote)} — 远端`, remote.team))
|
|
1071
|
+
}
|
|
1072
|
+
if (!remoteLabeled) {
|
|
1073
|
+
pick.appendChild(new Option('(暂无可拉入的远端会话队——见下方说明)', '', true, false))
|
|
1074
|
+
}
|
|
1075
|
+
if (keep !== '' && Array.from(pick.options).some(option => option.value === keep)) pick.value = keep
|
|
1076
|
+
card.appendChild(canvasForm.form)
|
|
1077
|
+
const hint = el('div', 'wb-hint')
|
|
1078
|
+
hint.textContent =
|
|
1079
|
+
'成员由人类在此手工分配(agent 不可自组队)。远端成员走对端目录路由,不做本机冷唤醒;地址由目录在派发时重解析。远端下拉只列 zone/id8 会话句柄——若为空,说明目录里还没有已链接对端的会话队(未链接的对端只给最小卡);此时可在输入框手输句柄(如 dsh/xxxxxxxx)先把人拉进来,句柄本身即链接手势。'
|
|
1080
|
+
card.appendChild(hint)
|
|
1081
|
+
// Quick dispatch targets the canvas ROUTING team (0.6.9): local tiers
|
|
1082
|
+
// first, then the remote members in order — the seam's own priority.
|
|
1083
|
+
if (deps.dispatchQuick !== undefined) card.appendChild(dispatchCard(fresh.team))
|
|
1084
|
+
}
|
|
1085
|
+
box.appendChild(card)
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// ── Quick dispatch (0.6.9) ──
|
|
1089
|
+
// One human-typed message to a routing team, no session opened. The form
|
|
1090
|
+
// is cached per target team (draftForm trick) so the textarea draft and
|
|
1091
|
+
// the result note survive the 3s poll remount.
|
|
1092
|
+
function buildDispatchForm(target: string): { form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } {
|
|
1093
|
+
const form = el('form', 'wb-dispatch-form')
|
|
1094
|
+
const text = document.createElement('textarea')
|
|
1095
|
+
text.className = 'wb-dispatch-text'
|
|
1096
|
+
text.placeholder = '一句话任务…(≤4000 字符,异步送达,回执进台账)'
|
|
1097
|
+
text.setAttribute('aria-label', '快派消息')
|
|
1098
|
+
const submit = el('button', 'wb-dispatch-submit')
|
|
1099
|
+
submit.type = 'submit'
|
|
1100
|
+
submit.textContent = '发送'
|
|
1101
|
+
const note = el('div', 'wb-dispatch-note')
|
|
1102
|
+
form.appendChild(text)
|
|
1103
|
+
form.appendChild(submit)
|
|
1104
|
+
form.appendChild(note)
|
|
1105
|
+
// 0.6.17 chat-UI conventions (deepseek-harness composer): Enter sends,
|
|
1106
|
+
// Shift+Enter is a soft newline, IME composition is never hijacked.
|
|
1107
|
+
text.addEventListener('keydown', ev => {
|
|
1108
|
+
if (ev.key !== 'Enter' || ev.shiftKey || ev.ctrlKey || ev.altKey || ev.metaKey) return
|
|
1109
|
+
if (ev.isComposing) return
|
|
1110
|
+
ev.preventDefault()
|
|
1111
|
+
if (!submit.disabled) form.requestSubmit()
|
|
1112
|
+
})
|
|
1113
|
+
form.addEventListener('submit', async ev => {
|
|
1114
|
+
ev.preventDefault()
|
|
1115
|
+
const message = text.value.trim()
|
|
1116
|
+
if (message === '' || submit.disabled) return
|
|
1117
|
+
submit.disabled = true
|
|
1118
|
+
note.textContent = '派发中…'
|
|
1119
|
+
let outcome: { ok: boolean; task_id?: string; reply?: string; error?: string }
|
|
1120
|
+
try {
|
|
1121
|
+
outcome = await (deps.dispatchQuick?.(target, message) ?? Promise.resolve({ ok: false, error: 'dispatch unavailable' }))
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
outcome = { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
1124
|
+
}
|
|
1125
|
+
submit.disabled = false
|
|
1126
|
+
if (outcome.ok === true) {
|
|
1127
|
+
// Sent: clear the composer like every chat surface and hand the
|
|
1128
|
+
// caret back — the submit round-trip must not strand focus.
|
|
1129
|
+
text.value = ''
|
|
1130
|
+
}
|
|
1131
|
+
text.focus({ preventScroll: true })
|
|
1132
|
+
note.textContent = outcome.ok === true
|
|
1133
|
+
? `已派发${outcome.task_id !== undefined ? ` · ${outcome.task_id}` : ''} · 回执将入台账(待回执)`
|
|
1134
|
+
: `失败:${outcome.error ?? '未知原因'}`
|
|
1135
|
+
})
|
|
1136
|
+
return { form, text, note }
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function dispatchCard(target: string): HTMLElement {
|
|
1140
|
+
const wrap = el('div', 'wb-dispatch')
|
|
1141
|
+
const head = el('div', 'wb-side-head')
|
|
1142
|
+
head.textContent = '快派'
|
|
1143
|
+
wrap.appendChild(head)
|
|
1144
|
+
if (dispatchForm?.team !== target) dispatchForm = { team: target, ...buildDispatchForm(target) }
|
|
1145
|
+
wrap.appendChild(dispatchForm.form)
|
|
1146
|
+
return wrap
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function openChat(target: ChatTarget): void {
|
|
1150
|
+
chat.open(target)
|
|
1151
|
+
root.classList.remove('is-rail-open')
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** Board picks (0.6.13): resolve a node id against the latest state. */
|
|
1155
|
+
function openNodeChat(id: string): void {
|
|
1156
|
+
if (last === null) return
|
|
1157
|
+
const session = (last.sessions ?? []).find(row => row.id === id)
|
|
1158
|
+
if (session !== undefined) {
|
|
1159
|
+
openChat({ kind: 'session', id, title: displayName(session), sub: session.team, team: session.team, live: session.live })
|
|
1160
|
+
return
|
|
1161
|
+
}
|
|
1162
|
+
if (id.startsWith('peer-')) {
|
|
1163
|
+
const host = id.slice('peer-'.length)
|
|
1164
|
+
const peer = (last.peers ?? []).find(row => row.url.includes(host))
|
|
1165
|
+
openChat({ kind: 'peer', id, title: peer !== undefined ? peer.url : host, sub: host, url: peer?.url })
|
|
1166
|
+
return
|
|
1167
|
+
}
|
|
1168
|
+
const log = logRows.find(row => row.id === id)
|
|
1169
|
+
if (log !== undefined) openChat({ kind: 'log', id, title: `…${id.slice(-12)}`, sub: '档案' })
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// ── Header ──
|
|
1173
|
+
// Results first (owner signal, 0.6.24): the header carries the verdict —
|
|
1174
|
+
// exceptions only, one pill each; a healthy host says so quietly. The
|
|
1175
|
+
// forensic counters that used to sit here (idempotency claims/replays,
|
|
1176
|
+
// reconcile state) read on the ledger's 取证 line instead.
|
|
1177
|
+
function renderHeader(): void {
|
|
1178
|
+
// An unknown version says nothing: hide the badge rather than print 'v?'.
|
|
1179
|
+
versionBadge.textContent = last?.version !== undefined ? `v${last.version}` : ''
|
|
1180
|
+
versionBadge.hidden = last?.version === undefined
|
|
1181
|
+
const box = clear(headerStats)
|
|
1182
|
+
if (last === null) return
|
|
1183
|
+
// Each pill is a way in: it opens the tab (and ledger group) that holds
|
|
1184
|
+
// the rows behind its count.
|
|
1185
|
+
const alerts: Array<{ text: string; tone: 'warn' | 'danger'; title: string; tab: 'ledger' | 'plan' | 'activity'; group?: string }> = []
|
|
1186
|
+
// Same definitions as the 台账 tab (wb-ledger ledgerFacts): fire-forget
|
|
1187
|
+
// expiries are not dead letters, receiptless rows are never stale.
|
|
1188
|
+
const facts = ledgerFacts(last, deps.now())
|
|
1189
|
+
const dead = facts.deadLetters.length
|
|
1190
|
+
if (dead > 0) alerts.push({ text: `死信 ${dead}`, tone: 'danger', title: '超过 24h TTL 仍未结算的任务(unconsumed),需在台账清账', tab: 'ledger', group: '死信' })
|
|
1191
|
+
const stale = facts.stale.length
|
|
1192
|
+
if (stale > 0) alerts.push({ text: `陈旧 ${stale}`, tone: 'warn', title: '我方派发、要回执、已等待 ≥1h 仍未回执(可催办;免回执行不算)', tab: 'ledger', group: '陈旧' })
|
|
1193
|
+
const owed = facts.owedOverdue.length
|
|
1194
|
+
if (owed > 0) alerts.push({ text: `欠回执 ${owed}`, tone: 'warn', title: '对方投递给本机、已等待本机回执超过 1 小时', tab: 'ledger', group: '待本机回执' })
|
|
1195
|
+
const conflicts = last.idempotency?.conflicts ?? 0
|
|
1196
|
+
if (conflicts > 0) alerts.push({ text: `幂等冲突 ${conflicts}`, tone: 'danger', title: '同一幂等键对应了不同载荷,需人工核查', tab: 'ledger', group: '取证' })
|
|
1197
|
+
const repair = (last.reconcile?.rows ?? []).filter(row => row.needsRepair === true).length
|
|
1198
|
+
if (repair > 0) alerts.push({ text: `需修复 ${repair}`, tone: 'danger', title: 'reconcile 无法自动恢复的会话,见行动计划', tab: 'plan' })
|
|
1199
|
+
if (alerts.length === 0) {
|
|
1200
|
+
box.appendChild(el('span', 'wb-header-ok')).textContent = '● 无异常'
|
|
1201
|
+
return
|
|
1202
|
+
}
|
|
1203
|
+
for (const a of alerts) {
|
|
1204
|
+
const pill = el('button', `wb-alert wb-alert-${a.tone}`)
|
|
1205
|
+
pill.type = 'button'
|
|
1206
|
+
pill.textContent = a.text
|
|
1207
|
+
pill.title = `${a.title}(点击查看)`
|
|
1208
|
+
pill.addEventListener('click', () => openAlert(a.tab, a.group))
|
|
1209
|
+
box.appendChild(pill)
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/** Open the pane behind a header pill. With the board up the panes are
|
|
1214
|
+
* rail tabs (on a narrow screen the rail slides in first); without it they
|
|
1215
|
+
* sit on the stage. A ledger group (or the 取证 fold) scrolls into view. */
|
|
1216
|
+
function openAlert(tab: 'ledger' | 'plan' | 'activity', group?: string): void {
|
|
1217
|
+
const pane = dataPanes.find(([k]) => k === tab)![1]
|
|
1218
|
+
if (mode === 'canvas') {
|
|
1219
|
+
root.classList.add('is-rail-open')
|
|
1220
|
+
railTabs.select(tab, { focus: false })
|
|
1221
|
+
}
|
|
1222
|
+
const target = group === undefined ? pane
|
|
1223
|
+
: group === '取证' ? pane.querySelector<HTMLDetailsElement>('details.wb-led-forensics')
|
|
1224
|
+
: Array.from(pane.querySelectorAll<HTMLElement>('.wb-led-group')).find(g => g.dataset.group === group) ?? null
|
|
1225
|
+
if (target === null) return
|
|
1226
|
+
if (target instanceof HTMLDetailsElement) target.open = true
|
|
1227
|
+
target.scrollIntoView?.({ block: 'start', behavior: 'smooth' })
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function update(state: StateBody | null): void {
|
|
1231
|
+
const unknown = new Set((state?.canvas?.teams ?? []).flatMap(team => team.members.filter(member => member.cliHome === null && member.cliHeld !== true).map(member => JSON.stringify([team.name, member.id]))))
|
|
1232
|
+
for (const key of cliHomeForms.keys()) if (!unknown.has(key)) cliHomeForms.delete(key)
|
|
1233
|
+
last = state
|
|
1234
|
+
// 0.6.12: refresh the readable-log list opportunistically (single flight;
|
|
1235
|
+
// the sidebar re-renders when rows land, mid-poll remount is idempotent).
|
|
1236
|
+
if (deps.sessionLogList !== undefined && !logRowsBusy) {
|
|
1237
|
+
logRowsBusy = true
|
|
1238
|
+
deps.sessionLogList().then(result => {
|
|
1239
|
+
logRows = result.ok && result.sessions !== undefined ? result.sessions : []
|
|
1240
|
+
logTotal = result.ok ? result.count : undefined
|
|
1241
|
+
}).catch(() => {}).finally(() => {
|
|
1242
|
+
logRowsBusy = false
|
|
1243
|
+
renderSidebar()
|
|
1244
|
+
})
|
|
1245
|
+
}
|
|
1246
|
+
renderHeader()
|
|
1247
|
+
renderSidebar()
|
|
1248
|
+
renderDraftTargets()
|
|
1249
|
+
renderHosts()
|
|
1250
|
+
renderLedger()
|
|
1251
|
+
// The open chat drawer refreshes in place (head + receipt statuses); its
|
|
1252
|
+
// thread and composer are never rebuilt by the poll.
|
|
1253
|
+
chat.update(state)
|
|
1254
|
+
renderActivity()
|
|
1255
|
+
renderPlan()
|
|
1256
|
+
renderInspector()
|
|
1257
|
+
// Mode is availability-derived every poll: canvas face down → data mode,
|
|
1258
|
+
// the board deactivates and the panes return to the stage stack.
|
|
1259
|
+
syncMode()
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function selectTab(key: WorkbenchTab): void {
|
|
1263
|
+
if (key === 'board') {
|
|
1264
|
+
syncMode()
|
|
1265
|
+
return
|
|
1266
|
+
}
|
|
1267
|
+
railTabs.select(key)
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
update(null)
|
|
1271
|
+
return { root, update, mode: () => mode, selectTab, openNodeChat }
|
|
1272
|
+
}
|