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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The 目录 tab (2026-10-23 rewrite): unified search + category chips over
3
+ * four groups — 本机会话 (live first, then cold), 画布队, 远端节点, 本机日志.
4
+ * Peer hosts moved to the 主机 tab (the directory listed them twice).
5
+ *
6
+ * 本机日志 rows used to read `…arket-review · 48 KB`: the id's last 12
7
+ * characters and a size. They now group by day (今天 / 昨天 / 更早), carry a
8
+ * relative time, and show a learned title — the first user line of the
9
+ * transcript, captured when the log is opened in the chat drawer (the list
10
+ * face itself returns only id / mtime / size). When the host caps the list,
11
+ * the group says how many it is showing out of how many.
12
+ * Rows are attacker-shaped data: textContent only.
13
+ * @module nexus-stage/wb-directory
14
+ */
15
+ import type { CanvasTeamRow, StateBody } from './workbench'
16
+ import type { ChatTarget } from './wb-chat'
17
+ import { displayName } from './stage-name'
18
+
19
+ export type DirFilter = 'all' | 'local' | 'canvas' | 'remote' | 'logs'
20
+
21
+ export const DIRECTORY_FILTERS: ReadonlyArray<{ key: DirFilter; label: string; hint: string }> = [
22
+ { key: 'all', label: '全部', hint: '不过滤:目录全部条目' },
23
+ { key: 'local', label: '本机', hint: '本机会话节点(现场 + 冷启动)' },
24
+ { key: 'canvas', label: '队', hint: '画布队(人类手工分配的组队)' },
25
+ { key: 'remote', label: '远端', hint: '远端会话队(来自对端卡片)' },
26
+ { key: 'logs', label: '日志', hint: '本机日志:不在网络目录里的会话档案' },
27
+ ]
28
+
29
+ export interface LogRow { readonly id: string; readonly mtimeMs: number; readonly sizeBytes: number }
30
+
31
+ export interface DirectoryContext {
32
+ readonly state: StateBody
33
+ readonly logs: ReadonlyArray<LogRow>
34
+ /** How many logs the host holds (the list face is capped); undefined when unknown. */
35
+ readonly logTotal?: number
36
+ /** Learned log titles by session id. */
37
+ readonly titles: ReadonlyMap<string, string>
38
+ /** Lowercased search text ('' = none). */
39
+ readonly search: string
40
+ readonly filter: DirFilter
41
+ readonly now: number
42
+ openChat(target: ChatTarget): void
43
+ selectCanvas(row: CanvasTeamRow): void
44
+ }
45
+
46
+ function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] {
47
+ const node = document.createElement(tag)
48
+ if (className !== undefined) node.className = className
49
+ return node
50
+ }
51
+
52
+ /** Lowercased haystack for the unified search. */
53
+ function haystack(...parts: ReadonlyArray<string | readonly string[] | undefined>): string {
54
+ const flat: string[] = []
55
+ for (const p of parts) {
56
+ if (p === undefined) continue
57
+ if (typeof p === 'string') flat.push(p)
58
+ else flat.push(...p)
59
+ }
60
+ return flat.join(' ').toLowerCase()
61
+ }
62
+
63
+ function ago(ms: number, now: number): string {
64
+ const s = Math.max(0, Math.round((now - ms) / 1000))
65
+ if (s < 60) return '刚刚'
66
+ const m = Math.round(s / 60)
67
+ if (m < 60) return `${m} 分钟前`
68
+ const h = Math.round(m / 60)
69
+ if (h < 48) return `${h} 小时前`
70
+ return `${Math.round(h / 24)} 天前`
71
+ }
72
+
73
+ function fmtSize(n: number): string {
74
+ return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} MB` : n >= 1_000 ? `${Math.round(n / 1_000)} KB` : `${n} B`
75
+ }
76
+
77
+ /** Local-calendar day bucket relative to `now`. */
78
+ function dayBucket(ms: number, now: number): '今天' | '昨天' | '更早' {
79
+ const startOfToday = new Date(now)
80
+ startOfToday.setHours(0, 0, 0, 0)
81
+ const t0 = startOfToday.getTime()
82
+ if (ms >= t0) return '今天'
83
+ if (ms >= t0 - 86_400_000) return '昨天'
84
+ return '更早'
85
+ }
86
+
87
+ /** Render the directory into `box`; returns the tab badge text. */
88
+ export function renderDirectory(box: HTMLElement, ctx: DirectoryContext): string {
89
+ box.replaceChildren()
90
+ const { state, search, filter } = ctx
91
+ const filtering = search !== '' || filter !== 'all'
92
+ const admit = (cat: DirFilter, hay: string): boolean => (filter === 'all' || filter === cat) && (search === '' || hay.includes(search))
93
+
94
+ const group = (label: string, count: number, extra?: string): HTMLElement => {
95
+ const g = el('div', 'wb-dir-group')
96
+ const head = el('div', 'wb-dir-head')
97
+ head.appendChild(el('span', 'wb-dir-head-label')).textContent = `${label} · ${count}`
98
+ if (extra !== undefined) head.appendChild(el('span', 'wb-dir-head-note')).textContent = extra
99
+ g.appendChild(head)
100
+ box.appendChild(g)
101
+ return g
102
+ }
103
+ const row = (parent: HTMLElement, dotClass: string, glyph: string, label: string, sub: string, onClick: () => void, tip?: string): HTMLButtonElement => {
104
+ const item = el('button', 'wb-item')
105
+ item.type = 'button'
106
+ item.appendChild(el('span', `wb-dot ${dotClass}`)).textContent = glyph
107
+ const text = el('span', 'wb-item-text')
108
+ text.appendChild(el('span', 'wb-item-label')).textContent = label
109
+ if (sub !== '') text.appendChild(el('span', 'wb-item-sub')).textContent = sub
110
+ item.appendChild(text)
111
+ if (tip !== undefined) item.title = tip
112
+ item.addEventListener('click', onClick)
113
+ parent.appendChild(item)
114
+ return item
115
+ }
116
+ const badges = (item: HTMLElement, teams: readonly string[] | undefined): void => {
117
+ if (teams === undefined || teams.length === 0) return
118
+ const chip = el('span', 'wb-item-badge')
119
+ const names = teams.map(t => t.slice(t.lastIndexOf('/') + 1))
120
+ chip.textContent = `▤ ${names.slice(0, 2).join(' ')}${names.length > 2 ? ` +${names.length - 2}` : ''}`
121
+ chip.title = `画布队:${teams.join(' · ')}`
122
+ item.appendChild(chip)
123
+ }
124
+
125
+ // ── 本机会话: live first, then cold ──
126
+ const sessions = (state.sessions ?? []).filter(s => admit('local', haystack(displayName(s), s.team, s.label, s.id, s.canvasTeams)))
127
+ const live = sessions.filter(s => s.live === true)
128
+ const cold = sessions.filter(s => s.live !== true)
129
+ if (sessions.length > 0) {
130
+ const g = group('本机会话', sessions.length, cold.length > 0 ? `${live.length} 在线 · ${cold.length} 冷` : undefined)
131
+ for (const s of [...live, ...cold]) {
132
+ const isLive = s.live === true
133
+ const item = row(g, isLive ? 'wb-dot-live' : 'wb-dot-cold', isLive ? '●' : '○', displayName(s), isLive ? s.team : `冷 · ${s.team}`,
134
+ () => ctx.openChat({ kind: 'session', id: s.id, title: displayName(s), sub: s.team, team: s.team, live: s.live }), s.id)
135
+ badges(item, s.canvasTeams)
136
+ }
137
+ }
138
+
139
+ // ── 画布队 ──
140
+ const canvases = (state.canvas?.teams ?? []).filter(t => admit('canvas', haystack(t.name, t.team, t.members.map(m => m.team ?? m.id))))
141
+ if (canvases.length > 0) {
142
+ const g = group('画布队', canvases.length)
143
+ for (const t of canvases) {
144
+ const remote = t.members.filter(m => m.remote === true).length
145
+ row(g, 'wb-dot-canvas', '▤', t.name, `${t.members.length} 成员${remote > 0 ? ` · ${remote} 远端` : ''}`, () => ctx.selectCanvas(t), t.team)
146
+ }
147
+ }
148
+
149
+ // ── 远端节点 ──
150
+ const remote = (state.remote ?? []).filter(r => admit('remote', haystack(displayName(r), r.team, r.origin, r.workspace, r.canvasTeams)))
151
+ if (remote.length > 0) {
152
+ const g = group('远端节点', remote.length)
153
+ for (const r of remote) {
154
+ const item = row(g, 'wb-dot-remote', '◇', displayName(r), r.origin !== undefined && r.origin !== '' ? r.origin : r.team,
155
+ () => ctx.openChat({ kind: 'remote', team: r.team, title: displayName(r), sub: r.team, row: r }), r.team)
156
+ badges(item, r.canvasTeams)
157
+ }
158
+ }
159
+
160
+ // ── 本机日志: every local log artifact not already shown as a network row ──
161
+ // (0.6.12 reachability: reading is host-scoped, the network directory is
162
+ // not the reading gate), newest first, grouped by day.
163
+ const networkIds = new Set((state.sessions ?? []).map(s => s.id))
164
+ const logs = ctx.logs.filter(l => !networkIds.has(l.id) && admit('logs', haystack(l.id, ctx.titles.get(l.id))))
165
+ if (logs.length > 0) {
166
+ const capped = ctx.logTotal !== undefined && ctx.logTotal > ctx.logs.length
167
+ const g = group('本机日志', logs.length, capped ? `显示 ${ctx.logs.length} / 共 ${ctx.logTotal}` : undefined)
168
+ let bucket = ''
169
+ for (const l of [...logs].sort((a, b) => b.mtimeMs - a.mtimeMs)) {
170
+ const b = dayBucket(l.mtimeMs, ctx.now)
171
+ if (b !== bucket) {
172
+ bucket = b
173
+ g.appendChild(el('div', 'wb-dir-day')).textContent = b
174
+ }
175
+ const title = ctx.titles.get(l.id)
176
+ const item = row(g, 'wb-dot-log', '❐', title ?? `会话 …${l.id.slice(-12)}`, `${ago(l.mtimeMs, ctx.now)} · ${fmtSize(l.sizeBytes)}`,
177
+ () => ctx.openChat({ kind: 'log', id: l.id, title: title ?? `…${l.id.slice(-12)}`, sub: '本机日志' }),
178
+ `${l.id}\n${new Date(l.mtimeMs).toLocaleString()} · ${fmtSize(l.sizeBytes)}`)
179
+ if (title === undefined) item.classList.add('is-untitled')
180
+ }
181
+ }
182
+
183
+ if (sessions.length + canvases.length + remote.length + logs.length === 0) {
184
+ box.appendChild(el('div', 'wb-dir-empty')).textContent = filtering ? '无匹配条目' : '目录为空'
185
+ }
186
+ const allSessions = state.sessions ?? []
187
+ const liveAll = allSessions.filter(s => s.live === true).length
188
+ return `${liveAll}/${allSessions.length}`
189
+ }
190
+
191
+ // ── learned log titles (per-viewer convenience, browser-local) ──
192
+ const TITLE_KEY = 'dsh-a2a.logtitles'
193
+ const TITLE_CAP = 300
194
+ const TITLE_MAX = 60
195
+
196
+ /** Load the learned titles; storage can be absent or throw — an empty map then. */
197
+ export function loadLogTitles(): Map<string, string> {
198
+ try {
199
+ const raw = window.localStorage.getItem(TITLE_KEY)
200
+ const parsed = raw === null ? null : JSON.parse(raw) as unknown
201
+ if (parsed !== null && typeof parsed === 'object') {
202
+ return new Map(Object.entries(parsed as Record<string, unknown>).filter((e): e is [string, string] => typeof e[1] === 'string'))
203
+ }
204
+ } catch { /* fall through */ }
205
+ return new Map()
206
+ }
207
+
208
+ /** Learn a title from a transcript (first user line, else first line); true when it changed. */
209
+ export function learnLogTitle(titles: Map<string, string>, id: string, messages: ReadonlyArray<{ role: string; text: string }>): boolean {
210
+ const source = messages.find(m => m.role === 'user' && m.text.trim() !== '') ?? messages.find(m => m.text.trim() !== '')
211
+ if (source === undefined) return false
212
+ const line = source.text.trim().split('\n')[0]!.trim()
213
+ const title = line.length > TITLE_MAX ? `${line.slice(0, TITLE_MAX - 1)}…` : line
214
+ if (titles.get(id) === title) return false
215
+ titles.delete(id)
216
+ titles.set(id, title)
217
+ while (titles.size > TITLE_CAP) titles.delete(titles.keys().next().value as string)
218
+ try {
219
+ window.localStorage.setItem(TITLE_KEY, JSON.stringify(Object.fromEntries(titles)))
220
+ } catch { /* per-viewer convenience only */ }
221
+ return true
222
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * The 台账 tab (2026-10-23 redesign, user ruling — supersedes the b324460
3
+ * layout while keeping its intent: results first, color only for what needs
4
+ * a hand).
5
+ *
6
+ * Top: four stat tiles (待回执 · 陈旧 · 死信 · 已归档), each carrying its
7
+ * definition; the archive verdict split as one proportional bar. Below: the
8
+ * rows grouped by what to do with them —
9
+ * 死信 red, 'unconsumed' (or unspecified) dead rows, with 清账;
10
+ * 陈旧 amber, our receipt-expected sends ≥1h old — 可催办;
11
+ * 待本机回执 a peer's delivery to this host whose receipt WE owe
12
+ * (amber from 1h, its own overdue count); never "可催办";
13
+ * 等待回执 quiet, our sends still within their hour;
14
+ * 免回执 muted, receiptExpected:false rows (no receipt will ever come);
15
+ * 已过期(免回执) muted, 'fire-forget' expiries — expected bookkeeping
16
+ * (task-ledger sweep), not dead letters; 清账 still tidies them.
17
+ * The stale and dead counts come from ledgerFacts(), shared with the header
18
+ * verdict so the two never disagree (PR #30 review L1/L3).
19
+ * Each card leads with the brief, then a muted meta line (direction, team,
20
+ * age, task id) and chips (可结算 / 入站). A card whose team resolves to a
21
+ * session or remote node opens that conversation. Forensic counters
22
+ * (idempotency, reconcile) fold into a 取证 details element.
23
+ * Rows are attacker-shaped data: textContent only.
24
+ * @module nexus-stage/wb-ledger
25
+ */
26
+ import type { DeadRow, PendingRow, StateBody } from './workbench'
27
+ import type { RailTone } from './wb-rail'
28
+
29
+ export interface LedgerContext {
30
+ readonly state: StateBody
31
+ readonly now: number
32
+ abandon(taskId: string, reason: string): Promise<boolean>
33
+ /** Open the conversation for a routing team; false when no node resolves. */
34
+ openTeam(team: string): boolean
35
+ /** Whether openTeam would resolve (cards only become clickable when it does). */
36
+ canOpenTeam(team: string): boolean
37
+ }
38
+
39
+ const HOUR = 60 * 60_000
40
+
41
+ /** Inbound rows carry this host's own team; the sender is in `peer` as
42
+ * `local:<sender>`. Rows say their direction (dead rows too since gh#31);
43
+ * on an older host whose dead rows omit it, the peer prefix is the tell
44
+ * (outbound local sends read 'local', remote ones a URL). */
45
+ function isInbound(row: { direction?: string; peer?: string }): boolean {
46
+ return row.direction === 'inbound' || (row.direction === undefined && row.peer?.startsWith('local:') === true)
47
+ }
48
+
49
+ function senderOf(row: { peer?: string }): string | undefined {
50
+ return row.peer?.startsWith('local:') === true ? row.peer.slice('local:'.length) : undefined
51
+ }
52
+
53
+ /** Past its hour, by the host's clock when the row carries the server's
54
+ * settleable verdict (receiptExpected && age < 1h, computed host-side), else
55
+ * by ours — a workbench opened from another machine with a skewed clock then
56
+ * still draws the boundary where the server does (at most one poll late). */
57
+ function overHour(r: PendingRow, now: number): boolean {
58
+ return r.settleable !== undefined ? r.settleable === false : now - r.startedAt >= HOUR
59
+ }
60
+
61
+ /** The ledger's action counts, one definition for the 台账 tab and the header:
62
+ * stale = OUR receipt-expected sends past their hour (receiptless rows are
63
+ * never stale — no receipt is coming); owedOverdue = peers' deliveries whose
64
+ * receipt WE have owed for over an hour (their own count, never mixed into
65
+ * stale); dead letters = dead rows that are not a 'fire-forget' expiry. */
66
+ export function ledgerFacts(state: StateBody, now: number): { stale: PendingRow[]; owedOverdue: PendingRow[]; deadLetters: DeadRow[]; expired: DeadRow[] } {
67
+ const expected = (state.tasks ?? []).filter(r => r.receiptExpected !== false)
68
+ const stale = expected.filter(r => !isInbound(r) && overHour(r, now))
69
+ const owedOverdue = expected.filter(r => isInbound(r) && overHour(r, now))
70
+ const dead = state.tasksDead ?? []
71
+ return { stale, owedOverdue, deadLetters: dead.filter(r => r.reason !== 'fire-forget'), expired: dead.filter(r => r.reason === 'fire-forget') }
72
+ }
73
+
74
+ function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] {
75
+ const node = document.createElement(tag)
76
+ if (className !== undefined) node.className = className
77
+ return node
78
+ }
79
+
80
+ function ageText(ms: number, now: number): string {
81
+ const s = Math.max(0, Math.round((now - ms) / 1000))
82
+ if (s < 90) return `${s}s`
83
+ const m = Math.round(s / 60)
84
+ if (m < 90) return `${m}m`
85
+ const h = Math.round(m / 60)
86
+ if (h < 48) return `${h}h`
87
+ return `${Math.round(h / 24)}d`
88
+ }
89
+
90
+ function tile(parent: HTMLElement, label: string, value: number, hint: string, tone?: 'warn' | 'danger'): void {
91
+ const t = el('div', `wb-led-tile${tone !== undefined ? ` is-${tone}` : ''}`)
92
+ t.title = hint
93
+ t.appendChild(el('span', 'wb-led-tile-v')).textContent = String(value)
94
+ t.appendChild(el('span', 'wb-led-tile-k')).textContent = label
95
+ parent.appendChild(t)
96
+ }
97
+
98
+ /** The archive's verdict vocabulary, in a fixed order: the receipt
99
+ * envelope's controlled outcomes (src/receipt.ts RECEIPT_OUTCOMES) plus the
100
+ * projection's 'unclassified' (a receipt that rode no envelope outcome).
101
+ * 'abandoned' also covers a caller's cancel — the ledger archives both the
102
+ * same way. Each verdict has a token-driven tone class, shared by the split
103
+ * bar here and the chat drawer's settled status. */
104
+ export const VERDICTS: ReadonlyArray<{ key: string; label: string; tone: 'completed' | 'rejected' | 'failed' | 'abandoned' | 'unclassified' }> = [
105
+ { key: 'completed', label: '完成', tone: 'completed' },
106
+ { key: 'sync_completed', label: '同步完成', tone: 'completed' },
107
+ { key: 'rejected', label: '拒绝', tone: 'rejected' },
108
+ { key: 'failed', label: '失败', tone: 'failed' },
109
+ { key: 'expired', label: '过期', tone: 'abandoned' },
110
+ { key: 'abandoned', label: '放弃', tone: 'abandoned' },
111
+ { key: 'unknown', label: '结论不明', tone: 'unclassified' },
112
+ { key: 'unclassified', label: '未分类', tone: 'unclassified' },
113
+ ]
114
+
115
+ function splitBar(parent: HTMLElement, split: Readonly<Record<string, number>>, total: number): void {
116
+ const wrap = el('div', 'wb-led-split')
117
+ const bar = el('div', 'wb-led-split-bar')
118
+ bar.setAttribute('role', 'img')
119
+ const legend = el('div', 'wb-led-split-legend')
120
+ const known = new Set(VERDICTS.map(v => v.key))
121
+ const entries: Array<[string, string, number]> = VERDICTS.map(v => [v.tone, v.label, split[v.key] ?? 0])
122
+ for (const [k, n] of Object.entries(split)) if (!known.has(k)) entries.push(['other', k, n])
123
+ const parts: string[] = []
124
+ for (const [tone, label, n] of entries) {
125
+ if (n <= 0) continue
126
+ const seg = el('span', `wb-led-seg is-${tone}`)
127
+ seg.style.width = `${Math.max(2, (n / total) * 100)}%`
128
+ bar.appendChild(seg)
129
+ const item = el('span', 'wb-led-split-item')
130
+ item.appendChild(el('i', `wb-led-swatch is-${tone}`))
131
+ item.appendChild(document.createTextNode(`${label} ${n}`))
132
+ legend.appendChild(item)
133
+ parts.push(`${label} ${n}`)
134
+ }
135
+ bar.setAttribute('aria-label', `归档结局:${parts.join(',')}`)
136
+ wrap.append(bar, legend)
137
+ parent.appendChild(wrap)
138
+ }
139
+
140
+ type Card = { brief?: string; team: string; sender?: string; taskId: string; atText: string; inbound: boolean; chips: string[]; tone: 'danger' | 'warn' | 'muted' | 'quiet'; dead?: DeadRow }
141
+
142
+ export function renderLedger(pane: HTMLElement, ctx: LedgerContext): { badge: string; tone?: RailTone } {
143
+ pane.replaceChildren()
144
+ const { state, now } = ctx
145
+ const pending = state.tasks ?? []
146
+ const dead = state.tasksDead ?? []
147
+ const archived = state.archivedCount ?? 0
148
+ const facts = ledgerFacts(state, now)
149
+ const receiptless = pending.filter(r => r.receiptExpected === false)
150
+ const expected = pending.filter(r => r.receiptExpected !== false)
151
+ const inbound = expected.filter(r => isInbound(r))
152
+ const outbound = expected.filter(r => !isInbound(r))
153
+ const stale = facts.stale
154
+ const young = outbound.filter(r => !overHour(r, now))
155
+ const staleCount = facts.stale.length
156
+
157
+ const tiles = el('div', 'wb-led-tiles')
158
+ tile(tiles, '待回执', pending.length, '等待回执的任务(pending),含陈旧与免回执行')
159
+ tile(tiles, '陈旧', staleCount, '我方派发、要回执、已等待 ≥1h 仍未回执(可催办);本机欠对方的入站行在「待本机回执」组另计,免回执行不算陈旧', staleCount > 0 ? 'warn' : undefined)
160
+ tile(tiles, '死信', facts.deadLetters.length, '超过 24h TTL 仍未结算(unconsumed);需清账(abandon)才能移出。免回执到期另列,不算死信', facts.deadLetters.length > 0 ? 'danger' : undefined)
161
+ tile(tiles, '已归档', archived, '已离开台账并留档的任务(结算、拒绝或放弃),用于审计')
162
+ pane.appendChild(tiles)
163
+ if (archived > 0 && state.archivedSplit !== undefined) splitBar(pane, state.archivedSplit, archived)
164
+
165
+ const card = (parent: HTMLElement, c: Card): void => {
166
+ const node = el('div', `wb-led-card is-${c.tone}`)
167
+ const head = el('div', 'wb-led-card-head')
168
+ head.appendChild(el('span', `wb-led-dir${c.inbound ? ' is-in' : ''}`)).textContent = c.inbound ? '↙' : '↗'
169
+ const brief = el('span', 'wb-led-brief')
170
+ brief.textContent = c.brief !== undefined && c.brief !== '' ? c.brief : '(无摘要——派发未带 brief)'
171
+ if (c.brief === undefined || c.brief === '') brief.classList.add('is-missing')
172
+ head.appendChild(brief)
173
+ node.appendChild(head)
174
+ const meta = el('div', 'wb-led-meta')
175
+ meta.appendChild(el('span', 'wb-led-team')).textContent = c.inbound ? `来自 ${c.sender ?? '对端'}` : `发往 ${c.team}`
176
+ if (c.inbound) meta.appendChild(el('span', 'wb-led-to')).textContent = `至 ${c.team}`
177
+ meta.appendChild(el('span')).textContent = c.atText
178
+ meta.appendChild(el('span', 'wb-led-task')).textContent = c.taskId
179
+ for (const chip of c.chips) meta.appendChild(el('span', 'wb-led-chip')).textContent = chip
180
+ node.appendChild(meta)
181
+ node.title = `${c.taskId}\n${c.team}`
182
+ if (c.dead !== undefined) {
183
+ const row = c.dead
184
+ const btn = el('button', 'wb-btn wb-led-abandon')
185
+ btn.type = 'button'
186
+ btn.textContent = '清账'
187
+ btn.title = '从台账移出这条死信(abandon)'
188
+ btn.addEventListener('click', ev => {
189
+ ev.stopPropagation()
190
+ btn.disabled = true
191
+ void ctx.abandon(row.taskId, 'workbench dead-letter cleanup').then(ok => {
192
+ btn.disabled = false
193
+ btn.textContent = ok ? '已清' : '失败'
194
+ })
195
+ })
196
+ node.appendChild(btn)
197
+ }
198
+ if (ctx.canOpenTeam(c.team)) {
199
+ node.classList.add('is-link')
200
+ node.setAttribute('role', 'button')
201
+ node.tabIndex = 0
202
+ node.setAttribute('aria-label', `${brief.textContent},打开 ${c.team} 的对话`)
203
+ const go = (): void => { ctx.openTeam(c.team) }
204
+ node.addEventListener('click', go)
205
+ node.addEventListener('keydown', ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); go() } })
206
+ }
207
+ parent.appendChild(node)
208
+ }
209
+ const section = (label: string, rows: number, tone: string, hint: string): HTMLElement | null => {
210
+ if (rows === 0) return null
211
+ const g = el('div', `wb-led-group is-${tone}`)
212
+ const head = el('div', 'wb-led-group-head')
213
+ head.appendChild(el('span')).textContent = `${label} · ${rows}`
214
+ head.appendChild(el('span', 'wb-led-group-hint')).textContent = hint
215
+ g.appendChild(head)
216
+ pane.appendChild(g)
217
+ return g
218
+ }
219
+ const pendingCard = (r: PendingRow, tone: Card['tone']): Card => {
220
+ const inb = isInbound(r)
221
+ return {
222
+ brief: r.brief, team: r.team, sender: senderOf(r), taskId: r.taskId, atText: `${ageText(r.startedAt, now)} 前${inb ? '收到' : '派发'}`, inbound: inb, tone,
223
+ chips: [...(r.settleable === true ? ['可结算'] : []), ...(r.receiptExpected === false ? ['无回执债'] : []), ...(inb ? ['入站'] : []), ...(r.status !== 'pending' ? [r.status] : [])],
224
+ }
225
+ }
226
+ const deadCard = (r: DeadRow, tone: Card['tone'], label: string): Card => ({
227
+ brief: r.brief, team: r.team, sender: senderOf(r), taskId: r.taskId, atText: `${ageText(r.deadAt, now)} 前${label}`, inbound: isInbound(r), tone, dead: r,
228
+ chips: r.reason !== undefined && r.reason !== '' && r.reason !== 'unspecified' && r.reason !== 'fire-forget' ? [r.reason] : [],
229
+ })
230
+
231
+ const gDead = section('死信', facts.deadLetters.length, 'danger', '需清账')
232
+ if (gDead !== null) for (const r of [...facts.deadLetters].sort((a, b) => b.deadAt - a.deadAt)) card(gDead, deadCard(r, 'danger', '死信'))
233
+ const gStale = section('陈旧', stale.length, 'warn', '我方派发 ≥1 小时仍无回执,可催办')
234
+ if (gStale !== null) for (const r of [...stale].sort((a, b) => a.startedAt - b.startedAt)) card(gStale, pendingCard(r, 'warn'))
235
+ const overdue = facts.owedOverdue.length
236
+ const gOwe = section('待本机回执', inbound.length, overdue > 0 ? 'warn' : 'quiet', overdue > 0 ? `对方在等本机回执 · ${overdue} 条超过 1 小时` : '对方在等本机回执')
237
+ if (gOwe !== null) for (const r of [...inbound].sort((a, b) => a.startedAt - b.startedAt)) card(gOwe, pendingCard(r, overHour(r, now) ? 'warn' : 'quiet'))
238
+ const gWait = section('等待回执', young.length, 'quiet', '1 小时内')
239
+ if (gWait !== null) for (const r of [...young].sort((a, b) => b.startedAt - a.startedAt)) card(gWait, pendingCard(r, 'quiet'))
240
+ const gFree = section('免回执', receiptless.length, 'muted', '不等回执,24h 后自动归档')
241
+ if (gFree !== null) for (const r of receiptless) card(gFree, pendingCard(r, 'muted'))
242
+ const gExpired = section('已过期(免回执)', facts.expired.length, 'muted', '正常记账,不是失败')
243
+ if (gExpired !== null) for (const r of [...facts.expired].sort((a, b) => b.deadAt - a.deadAt)) card(gExpired, deadCard(r, 'muted', '过期'))
244
+
245
+ if (pending.length + dead.length === 0) {
246
+ pane.appendChild(el('div', 'wb-led-empty')).textContent = '台账为空——没有待回执的任务'
247
+ }
248
+
249
+ // Forensics: the plumbing counters a seat reads when something looks off.
250
+ const idem = state.idempotency
251
+ const reconcileState = state.reconcile?.state
252
+ if (idem !== undefined || reconcileState !== undefined) {
253
+ const details = el('details', 'wb-led-forensics wb-forensics')
254
+ details.appendChild(el('summary')).textContent = '取证'
255
+ const body = el('div', 'wb-led-forensics-body')
256
+ if (idem !== undefined) body.appendChild(el('div')).textContent = `幂等 claims ${idem.claimsFresh ?? 0} · replays ${idem.replays ?? 0} · conflicts ${idem.conflicts ?? 0}`
257
+ if (reconcileState !== undefined) body.appendChild(el('div')).textContent = `reconcile ${reconcileState}`
258
+ details.appendChild(body)
259
+ pane.appendChild(details)
260
+ }
261
+
262
+ // Owed = receipts still expected + dead letters to clean; receiptless rows
263
+ // and fire-forget expiries owe nothing.
264
+ const owed = expected.length + facts.deadLetters.length
265
+ return { badge: owed > 0 ? String(owed) : '', tone: facts.deadLetters.length > 0 ? 'danger' : staleCount > 0 || overdue > 0 ? 'warn' : undefined }
266
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The workbench's left rail as tabs (2026-10-23 rewrite): one tab per pane
3
+ * — 目录 · 主机 · 台账 · 活动 · 规划 — each panel full-height with its own
4
+ * scroll, replacing the single accordion scroll where the host rail sat
5
+ * last and the ledger pushed everything below the fold.
6
+ *
7
+ * WAI-ARIA tabs: role=tablist / tab / tabpanel, roving tabindex, ←/→ and
8
+ * Home/End move between tabs and activate them. Badges carry each pane's
9
+ * count, colored only when there is something to act on (danger / warn).
10
+ * The selected tab is a per-viewer convenience kept in localStorage (every
11
+ * access guarded; the rail works the same without it).
12
+ * @module nexus-stage/wb-rail
13
+ */
14
+
15
+ export type RailTone = 'danger' | 'warn' | 'accent'
16
+
17
+ export interface RailTabDef<K extends string> {
18
+ readonly key: K
19
+ readonly label: string
20
+ }
21
+
22
+ export interface RailTabs<K extends string> {
23
+ readonly root: HTMLElement
24
+ /** The panel a pane mounts into for tab `key`. */
25
+ panel(key: K): HTMLElement
26
+ select(key: K, opts?: { focus?: boolean }): void
27
+ selected(): K
28
+ setBadge(key: K, text: string, tone?: RailTone): void
29
+ /** Hide a tab (data mode moves its pane to the stage); a hidden selected tab falls back to the first visible one. */
30
+ setHidden(key: K, hidden: boolean): void
31
+ }
32
+
33
+ const STORE_KEY = 'dsh-a2a.rail.tab'
34
+
35
+ function readStored(): string | null {
36
+ try {
37
+ return window.localStorage.getItem(STORE_KEY)
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ function writeStored(key: string): void {
44
+ try {
45
+ window.localStorage.setItem(STORE_KEY, key)
46
+ } catch { /* per-viewer convenience only */ }
47
+ }
48
+
49
+ export function createRailTabs<K extends string>(defs: ReadonlyArray<RailTabDef<K>>, opts: { onChange?(key: K): void } = {}): RailTabs<K> {
50
+ const root = document.createElement('aside')
51
+ root.className = 'wb-rail'
52
+ const list = document.createElement('div')
53
+ list.className = 'wb-tabs'
54
+ list.setAttribute('role', 'tablist')
55
+ list.setAttribute('aria-label', '工作台侧栏')
56
+ const panels = document.createElement('div')
57
+ panels.className = 'wb-tabpanels'
58
+ root.append(list, panels)
59
+
60
+ const tabs = new Map<K, HTMLButtonElement>()
61
+ const panelEls = new Map<K, HTMLElement>()
62
+ const badges = new Map<K, HTMLElement>()
63
+ const hidden = new Set<K>()
64
+ for (const def of defs) {
65
+ const tab = document.createElement('button')
66
+ tab.type = 'button'
67
+ tab.className = 'wb-tab'
68
+ tab.id = `wb-tab-${def.key}`
69
+ tab.dataset.sec = def.key
70
+ tab.setAttribute('role', 'tab')
71
+ tab.setAttribute('aria-controls', `wb-panel-${def.key}`)
72
+ const label = document.createElement('span')
73
+ label.className = 'wb-tab-label'
74
+ label.textContent = def.label
75
+ const badge = document.createElement('span')
76
+ badge.className = 'wb-tab-badge'
77
+ tab.append(label, badge)
78
+ tab.addEventListener('click', () => select(def.key))
79
+ list.appendChild(tab)
80
+ const panel = document.createElement('section')
81
+ panel.className = 'wb-tabpanel'
82
+ panel.id = `wb-panel-${def.key}`
83
+ panel.dataset.sec = def.key
84
+ panel.setAttribute('role', 'tabpanel')
85
+ panel.setAttribute('aria-labelledby', tab.id)
86
+ panel.tabIndex = -1
87
+ panels.appendChild(panel)
88
+ tabs.set(def.key, tab)
89
+ panelEls.set(def.key, panel)
90
+ badges.set(def.key, badge)
91
+ }
92
+
93
+ const visible = (): K[] => defs.map(d => d.key).filter(k => !hidden.has(k))
94
+ const stored = readStored()
95
+ let current: K = defs.find(d => d.key === stored)?.key ?? defs[0]!.key
96
+
97
+ list.addEventListener('keydown', ev => {
98
+ const keys = visible()
99
+ const at = keys.indexOf(current)
100
+ let next: K | undefined
101
+ if (ev.key === 'ArrowRight') next = keys[(at + 1) % keys.length]
102
+ else if (ev.key === 'ArrowLeft') next = keys[(at - 1 + keys.length) % keys.length]
103
+ else if (ev.key === 'Home') next = keys[0]
104
+ else if (ev.key === 'End') next = keys[keys.length - 1]
105
+ if (next === undefined) return
106
+ ev.preventDefault()
107
+ select(next, { focus: true })
108
+ })
109
+
110
+ function paint(): void {
111
+ for (const def of defs) {
112
+ const on = def.key === current
113
+ const tab = tabs.get(def.key)!
114
+ tab.setAttribute('aria-selected', String(on))
115
+ tab.tabIndex = on ? 0 : -1
116
+ tab.classList.toggle('is-active', on)
117
+ tab.hidden = hidden.has(def.key)
118
+ panelEls.get(def.key)!.hidden = !on
119
+ }
120
+ }
121
+
122
+ function select(key: K, o: { focus?: boolean } = {}): void {
123
+ if (hidden.has(key)) return
124
+ const changed = key !== current
125
+ current = key
126
+ writeStored(key)
127
+ paint()
128
+ if (o.focus === true) tabs.get(key)!.focus()
129
+ if (changed) opts.onChange?.(key)
130
+ }
131
+
132
+ function setBadge(key: K, text: string, tone?: RailTone): void {
133
+ const b = badges.get(key)!
134
+ b.textContent = text
135
+ b.className = `wb-tab-badge${tone !== undefined ? ` is-${tone}` : ''}`
136
+ }
137
+
138
+ function setHidden(key: K, h: boolean): void {
139
+ if (h) hidden.add(key)
140
+ else hidden.delete(key)
141
+ if (hidden.has(current)) current = visible()[0] ?? defs[0]!.key
142
+ paint()
143
+ }
144
+
145
+ paint()
146
+ return { root, panel: (k) => panelEls.get(k)!, select, selected: () => current, setBadge, setHidden }
147
+ }