dsh-hitl 0.1.0

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/client.js ADDED
@@ -0,0 +1,1918 @@
1
+ /**
2
+ * dsh-hitl browser half: the decision panel.
3
+ *
4
+ * One self-contained classic script, because a profile-installed `dsh.client`
5
+ * bundle is served verbatim and cannot be bundled: React comes from the browser
6
+ * module table, everything else — the markdown renderer, the split diff, the
7
+ * countdown, the markup and the CSS — is written here against the host's theme
8
+ * tokens. Harness client packages are deliberately not imported.
9
+ *
10
+ * Data flow: the downlink is a Server-Sent-Events stream on this plugin's own
11
+ * route, the uplink is one JSON POST per decision. A pending request becomes a
12
+ * session-scoped pending interaction (`ctx.uiSession.registerPendingInteraction`)
13
+ * so the shipped composer chain seats the panel exactly where the shipped
14
+ * approval and question panels sit.
15
+ */
16
+
17
+ window.__ModuleLoader__.load({
18
+ id: 'dsh-hitl',
19
+ factory(require) {
20
+ const React = require('react')
21
+ const h = React.createElement
22
+
23
+ /** Locale namespace owned by this plugin. */
24
+ const NS = 'hitl'
25
+ /** Discriminator of the pending-interaction values this half publishes. */
26
+ const KIND = 'hitl'
27
+ /** Pending-interaction precedence: a blocked tool call outranks approval and question panels. */
28
+ const PRECEDENCE = 1000000
29
+ /** Composer-chain priority: the shipped entries use 0 and 1, so a decision outranks them. */
30
+ const CHAIN_PRIORITY = 5
31
+ /** Largest line count either diff side may have before the panel stops aligning. */
32
+ const DIFF_LINE_LIMIT = 2000
33
+ /** How long the panel waits for the host's settled frame before closing itself. */
34
+ const SETTLE_GRACE_MS = 8000
35
+ /** How long the frame-level notice waits before claiming a composer seat is really absent. */
36
+ const NOTICE_DELAY_MS = 600
37
+ /** How long a decision the human did not make stays on screen, saying what happened. */
38
+ const LINGER_MS = 5000
39
+ /** Outcomes worth reading: a timeout is the one the human never chose. */
40
+ const LINGER_OUTCOMES = ['timeout']
41
+ /** How often a seated panel re-tests whether the human can still reach it. */
42
+ const OBSCURE_POLL_MS = 1000
43
+ /** How far below a seated panel's top edge the reachability probe samples. */
44
+ const OBSCURE_SAMPLE_INSET_PX = 40
45
+
46
+ const zh = {
47
+ 'title.default': '请确认 {tool} 的执行方案',
48
+ 'label.countdown': '剩余 {seconds}s',
49
+ 'label.countdownHeld': '已暂停(交互中)· 剩余 {seconds}s',
50
+ 'label.timeout': '已超时,等待宿主处理',
51
+ 'label.offline': '决策通道未连接,正在重试',
52
+ 'label.offlineNoToken': '当前页面未注入 HITL 通道,无法决策',
53
+ 'label.queued': '另有 {count} 项待决策',
54
+ 'label.editable': '可编辑',
55
+ 'label.readonly': '只读',
56
+ 'label.truncated': '内容已截断',
57
+ 'label.diffChanged': '变更 {path}',
58
+ 'label.diffPlain': '变更内容',
59
+ 'label.empty': '(空)',
60
+ 'button.approve': '同意',
61
+ 'button.modify': '修改',
62
+ 'button.reject': '拒绝',
63
+ 'button.rejecting': '拒绝…',
64
+ 'button.submitting': '提交中…',
65
+ 'button.retry': '重试',
66
+ 'feedback.placeholder': '可补充拒绝原因,它会随拒绝一起交给 Agent',
67
+ 'feedback.required': '请填写拒绝原因',
68
+ 'feedback.title': '拒绝原因(可选)',
69
+ 'modify.note': '你将把修改后的内容交给 Agent:本次调用不会执行。',
70
+ 'markdown.edit': '编辑',
71
+ 'markdown.preview': '预览',
72
+ 'markdown.split': '并排',
73
+ 'markdown.empty': '(空文档)',
74
+ 'error.submit': '提交失败:{message}',
75
+ 'error.diffTooLarge': '内容过大,已改为原文对照',
76
+ 'settled.byUser': '已提交',
77
+ 'settled.byTimeout': '已按超时策略处理',
78
+ 'settled.byHost': '请求已撤回',
79
+ 'aria.dialog': '{tool} 的人工决策',
80
+ 'aria.field': '{title} 的提案内容',
81
+ 'aria.collapse': '收起待决策提案',
82
+ 'aria.expand': '展开待决策提案',
83
+ 'button.collapse': '收起',
84
+ 'button.expand': '展开',
85
+ 'label.foreign': '来自子代理会话 {session}',
86
+ 'button.gotoSession': '前往该会话',
87
+ 'aria.notice': '等待人工决策的工具调用',
88
+ 'notice.title': '{count} 条工具调用等待人工决策',
89
+ 'notice.more': '另有 {count} 条',
90
+ 'notice.own': '本会话',
91
+ 'notice.subagent': '子代理会话 {session}',
92
+ 'notice.hint': '完整面板(修改、带原因的拒绝)在该会话的输入框上',
93
+ }
94
+
95
+ const en = {
96
+ 'title.default': 'Confirm how {tool} runs',
97
+ 'label.countdown': '{seconds}s left',
98
+ 'label.countdownHeld': 'paused while you edit · {seconds}s left',
99
+ 'label.timeout': 'timed out; the host is settling it',
100
+ 'label.offline': 'decision channel offline, retrying',
101
+ 'label.offlineNoToken': 'this page carries no HITL channel, so no decision can be sent',
102
+ 'label.queued': '{count} more waiting',
103
+ 'label.editable': 'editable',
104
+ 'label.readonly': 'read-only',
105
+ 'label.truncated': 'truncated',
106
+ 'label.diffChanged': 'change {path}',
107
+ 'label.diffPlain': 'change',
108
+ 'label.empty': '(empty)',
109
+ 'button.approve': 'Approve',
110
+ 'button.modify': 'Modify',
111
+ 'button.reject': 'Reject',
112
+ 'button.rejecting': 'Reject…',
113
+ 'button.submitting': 'Sending…',
114
+ 'button.retry': 'Retry',
115
+ 'feedback.placeholder': 'Optional reason; it reaches the agent with your rejection',
116
+ 'feedback.required': 'A reason is required',
117
+ 'feedback.title': 'Rejection reason (optional)',
118
+ 'modify.note': 'Your edited text goes to the agent: this call will not run.',
119
+ 'markdown.edit': 'Edit',
120
+ 'markdown.preview': 'Preview',
121
+ 'markdown.split': 'Side by side',
122
+ 'markdown.empty': '(empty document)',
123
+ 'error.submit': 'could not submit: {message}',
124
+ 'error.diffTooLarge': 'too large to align; showing both sides',
125
+ 'settled.byUser': 'submitted',
126
+ 'settled.byTimeout': 'settled by the timeout policy',
127
+ 'settled.byHost': 'withdrawn',
128
+ 'aria.dialog': 'human decision for {tool}',
129
+ 'aria.field': 'proposal for {title}',
130
+ 'aria.collapse': 'Hide the proposal',
131
+ 'aria.expand': 'Show the proposal',
132
+ 'button.collapse': 'Hide',
133
+ 'button.expand': 'Show',
134
+ 'label.foreign': 'from subagent Session {session}',
135
+ 'button.gotoSession': 'Open that Session',
136
+ 'aria.notice': 'Tool calls waiting for a human decision',
137
+ 'notice.title': '{count} tool calls waiting for a human decision',
138
+ 'notice.more': '{count} more',
139
+ 'notice.own': 'this Session',
140
+ 'notice.subagent': 'subagent Session {session}',
141
+ 'notice.hint': 'The full panel (modify, reject with a reason) is on that Session\u2019s composer.',
142
+ }
143
+
144
+ // ── pure helpers ─────────────────────────────────────────────────────────
145
+
146
+ /** Escape one text run for HTML output; every tag the renderer emits is its own. */
147
+ function escapeHtml(text) {
148
+ return String(text)
149
+ .replaceAll('&', '&')
150
+ .replaceAll('<', '&lt;')
151
+ .replaceAll('>', '&gt;')
152
+ .replaceAll('"', '&quot;')
153
+ .replaceAll("'", '&#39;')
154
+ }
155
+
156
+ /** Whether a link target is safe to render as an anchor. */
157
+ function safeHref(target) {
158
+ const trimmed = String(target).trim()
159
+ if (trimmed === '') return undefined
160
+ if (/^(https?:|mailto:|#|\/|\.\/|\.\.\/)/i.test(trimmed)) return trimmed
161
+ return undefined
162
+ }
163
+
164
+ /** Render one escaped text run with inline markdown (code, bold, italic, links). */
165
+ function renderInline(escaped) {
166
+ let out = escaped
167
+ out = out.replace(/`([^`]+)`/g, (_match, code) => `<code class="dsh-hitl-code">${code}</code>`)
168
+ out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, label, target) => {
169
+ const href = safeHref(target.replaceAll('&amp;', '&'))
170
+ if (href === undefined) return match
171
+ const safe = href.replaceAll('"', '%22').replaceAll('<', '%3C').replaceAll('>', '%3E')
172
+ return `<a class="dsh-hitl-link" href="${safe}" target="_blank" rel="noreferrer noopener">${label}</a>`
173
+ })
174
+ out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
175
+ out = out.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
176
+ return out
177
+ }
178
+
179
+ /**
180
+ * Render a small, safe markdown subset to HTML: fenced code, headings,
181
+ * blockquotes, lists, rules, paragraphs, and the inline forms above.
182
+ * Escaping happens before any tag is emitted, so raw HTML in the source
183
+ * can never become markup.
184
+ * @param text - the markdown source.
185
+ * @returns an HTML string built only from this function's own tags.
186
+ */
187
+ function renderMarkdownHtml(text) {
188
+ const lines = String(text).replaceAll('\r\n', '\n').split('\n')
189
+ const out = []
190
+ let index = 0
191
+ let list = null
192
+ const closeList = () => {
193
+ if (list !== null) {
194
+ out.push(`</${list}>`)
195
+ list = null
196
+ }
197
+ }
198
+ while (index < lines.length) {
199
+ const line = lines[index]
200
+ const fence = /^\s*```(.*)$/.exec(line)
201
+ if (fence !== null) {
202
+ closeList()
203
+ const language = escapeHtml(fence[1].trim())
204
+ index += 1
205
+ const body = []
206
+ while (index < lines.length && !/^\s*```\s*$/.test(lines[index])) {
207
+ body.push(lines[index])
208
+ index += 1
209
+ }
210
+ index += 1
211
+ out.push(`<pre class="dsh-hitl-pre"${language === '' ? '' : ` data-lang="${language}"`}><code>${escapeHtml(body.join('\n'))}</code></pre>`)
212
+ continue
213
+ }
214
+ if (/^\s*$/.test(line)) {
215
+ closeList()
216
+ index += 1
217
+ continue
218
+ }
219
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line)
220
+ if (heading !== null) {
221
+ closeList()
222
+ const level = heading[1].length + 2
223
+ out.push(`<h${level} class="dsh-hitl-h">${renderInline(escapeHtml(heading[2]))}</h${level}>`)
224
+ index += 1
225
+ continue
226
+ }
227
+ if (/^\s*([-*_])\s*\1\s*\1[\s-*_]*$/.test(line)) {
228
+ closeList()
229
+ out.push('<hr class="dsh-hitl-hr"/>')
230
+ index += 1
231
+ continue
232
+ }
233
+ const quote = /^\s*>\s?(.*)$/.exec(line)
234
+ if (quote !== null) {
235
+ closeList()
236
+ const body = [quote[1]]
237
+ index += 1
238
+ while (index < lines.length) {
239
+ const next = /^\s*>\s?(.*)$/.exec(lines[index])
240
+ if (next === null) break
241
+ body.push(next[1])
242
+ index += 1
243
+ }
244
+ out.push(`<blockquote class="dsh-hitl-quote">${renderInline(escapeHtml(body.join('\n')))}</blockquote>`)
245
+ continue
246
+ }
247
+ const unordered = /^\s*[-*+]\s+(.*)$/.exec(line)
248
+ const ordered = /^\s*\d+[.)]\s+(.*)$/.exec(line)
249
+ if (unordered !== null || ordered !== null) {
250
+ const kind = unordered === null ? 'ol' : 'ul'
251
+ if (list !== kind) {
252
+ closeList()
253
+ list = kind
254
+ out.push(`<${kind} class="dsh-hitl-list">`)
255
+ }
256
+ out.push(`<li>${renderInline(escapeHtml((unordered ?? ordered)[1]))}</li>`)
257
+ index += 1
258
+ continue
259
+ }
260
+ closeList()
261
+ const paragraph = [line]
262
+ index += 1
263
+ while (index < lines.length
264
+ && !/^\s*$/.test(lines[index])
265
+ && !/^\s*```/.test(lines[index])
266
+ && !/^#{1,6}\s/.test(lines[index])
267
+ && !/^\s*[-*+]\s+/.test(lines[index])
268
+ && !/^\s*\d+[.)]\s+/.test(lines[index])
269
+ && !/^\s*>/.test(lines[index])) {
270
+ paragraph.push(lines[index])
271
+ index += 1
272
+ }
273
+ out.push(`<p class="dsh-hitl-p">${renderInline(escapeHtml(paragraph.join('\n'))).replaceAll('\n', '<br/>')}</p>`)
274
+ }
275
+ closeList()
276
+ return out.join('')
277
+ }
278
+
279
+ /** Split one diff side into lines; empty text is no lines, not one empty line. */
280
+ function splitDiffSide(text) {
281
+ const value = String(text ?? '').replaceAll('\r\n', '\n')
282
+ return value === '' ? [] : value.split('\n')
283
+ }
284
+
285
+ /** Longest common subsequence trace of two line arrays, guarded by a size cap. */
286
+ function diffLines(oldText, newText) {
287
+ const before = splitDiffSide(oldText)
288
+ const after = splitDiffSide(newText)
289
+ if (before.length > DIFF_LINE_LIMIT || after.length > DIFF_LINE_LIMIT) return undefined
290
+ const rows = new Map()
291
+ const columns = after.length + 1
292
+ for (let i = 0; i <= before.length; i += 1) {
293
+ for (let j = 0; j <= after.length; j += 1) {
294
+ if (i === 0 || j === 0) rows.set(i * columns + j, 0)
295
+ else if (before[i - 1] === after[j - 1]) rows.set(i * columns + j, (rows.get((i - 1) * columns + (j - 1)) ?? 0) + 1)
296
+ else rows.set(i * columns + j, Math.max(rows.get((i - 1) * columns + j) ?? 0, rows.get(i * columns + (j - 1)) ?? 0))
297
+ }
298
+ }
299
+ const pairs = []
300
+ let i = before.length
301
+ let j = after.length
302
+ while (i > 0 && j > 0) {
303
+ if (before[i - 1] === after[j - 1]) {
304
+ pairs.push({ type: 'same', left: { number: i, text: before[i - 1] }, right: { number: j, text: after[j - 1] } })
305
+ i -= 1
306
+ j -= 1
307
+ continue
308
+ }
309
+ const up = rows.get((i - 1) * columns + j) ?? 0
310
+ const left = rows.get(i * columns + (j - 1)) ?? 0
311
+ if (left >= up) {
312
+ pairs.push({ type: 'added', left: null, right: { number: j, text: after[j - 1] } })
313
+ j -= 1
314
+ } else {
315
+ pairs.push({ type: 'removed', left: { number: i, text: before[i - 1] }, right: null })
316
+ i -= 1
317
+ }
318
+ }
319
+ while (i > 0) {
320
+ pairs.push({ type: 'removed', left: { number: i, text: before[i - 1] }, right: null })
321
+ i -= 1
322
+ }
323
+ while (j > 0) {
324
+ pairs.push({ type: 'added', left: null, right: { number: j, text: after[j - 1] } })
325
+ j -= 1
326
+ }
327
+ pairs.reverse()
328
+ let added = 0
329
+ let removed = 0
330
+ for (const row of pairs) {
331
+ if (row.type === 'added') added += 1
332
+ if (row.type === 'removed') removed += 1
333
+ }
334
+ return { rows: pairs, added, removed }
335
+ }
336
+
337
+ /** Second-granularity countdown label text. */
338
+ function formatRemaining(milliseconds) {
339
+ const seconds = Math.max(0, Math.ceil(milliseconds / 1000))
340
+ return String(seconds)
341
+ }
342
+
343
+ /**
344
+ * Which primary button the panel shows: an edited proposal turns Approve into Modify.
345
+ * @param edited - whether any editable field differs from its original value.
346
+ * @param request - the wire request.
347
+ * @param t - translator.
348
+ * @returns the primary button's label.
349
+ */
350
+ function primaryLabel(edited, request, t) {
351
+ const buttons = request.buttons ?? {}
352
+ if (edited) return buttons.modify ?? t('button.modify')
353
+ return buttons.approve ?? t('button.approve')
354
+ }
355
+
356
+ /** The changed editable fields of one proposal, as the modify decision carries them. */
357
+ function changedFields(fields, drafts) {
358
+ const changed = []
359
+ for (const field of fields) {
360
+ if (field.editable !== true) continue
361
+ const next = drafts[field.param]
362
+ if (typeof next !== 'string') continue
363
+ if (next === field.value) continue
364
+ changed.push({ param: field.param, text: next })
365
+ }
366
+ return changed
367
+ }
368
+
369
+ /** Seconds at which a countdown starts reading as urgent. */
370
+ const COUNTDOWN_WARN_SECONDS = 10
371
+
372
+ /**
373
+ * How loudly one countdown should read. A decision panel is a blocking
374
+ * interruption, so the remaining time must be visible at a glance rather
375
+ * than inferred from faint grey text.
376
+ * @param remainingMs - milliseconds left, or undefined without a countdown.
377
+ * @param held - whether the client froze the countdown for input.
378
+ * @param timedOut - whether the deadline already passed.
379
+ * @returns `timeout`, `held`, `warn`, or `normal`.
380
+ */
381
+ function countdownTone(remainingMs, held, timedOut) {
382
+ if (timedOut === true) return 'timeout'
383
+ if (held === true) return 'held'
384
+ if (typeof remainingMs === 'number' && remainingMs <= COUNTDOWN_WARN_SECONDS * 1000) return 'warn'
385
+ return 'normal'
386
+ }
387
+
388
+ /** How long a quiet stream may stay open before the client rebuilds it. */
389
+ const STREAM_SILENCE_MS = 45000
390
+
391
+ /**
392
+ * Whether an open stream went quiet for long enough to distrust it. A plugin
393
+ * reload or a sleeping laptop can leave a socket open that no longer carries
394
+ * anything, and an EventSource never notices by itself.
395
+ * @param lastFrameAt - when the last frame arrived.
396
+ * @param now - the current clock reading.
397
+ * @returns whether the stream should be rebuilt.
398
+ */
399
+ function streamStale(lastFrameAt, now) {
400
+ return now - lastFrameAt > STREAM_SILENCE_MS
401
+ }
402
+
403
+ /**
404
+ * The absolute deadline one host reading implies.
405
+ *
406
+ * A reading means "this much was left *when this page received it*", not
407
+ * "this much is left from now": anchoring it at the arrival time keeps a
408
+ * remount, a re-delivered frame, or a re-render from pushing the deadline
409
+ * forward, and it makes the panel independent of any clock skew between the
410
+ * host and the browser (only a duration is trusted, never a timestamp).
411
+ * @param reading - a request carrying `countdown.remainingMs` and `observedAt`.
412
+ * @param now - the current clock reading, used when no arrival time is known.
413
+ * @returns the deadline in client-clock milliseconds, or undefined without a countdown.
414
+ */
415
+ function deadlineOf(reading, now) {
416
+ const remainingMs = reading?.countdown?.remainingMs
417
+ if (typeof remainingMs !== 'number') return undefined
418
+ const observedAt = typeof reading.observedAt === 'number' ? reading.observedAt : now
419
+ return observedAt + remainingMs
420
+ }
421
+
422
+ /**
423
+ * Fold one render's clock inputs into the clock the panel draws.
424
+ *
425
+ * Three rules live here, and each one fixed a real defect:
426
+ *
427
+ * 1. A hold start captures the remainder once; while the hold lasts the
428
+ * number stays put, so a paused panel cannot keep counting down.
429
+ * 2. A hold release re-bases the deadline in the same call that stops
430
+ * showing the frozen number, so the release frame never draws the raw —
431
+ * already past — deadline.
432
+ * 3. With no hold, the remainder is derived from the deadline every render,
433
+ * which is what makes the countdown tick.
434
+ *
435
+ * @param input - the stored deadline, the stored frozen value, the host's hold verdict, and the clock.
436
+ * @returns the deadline, frozen value, and remainder to store and draw.
437
+ */
438
+ function reconcileClock({ id, at, frozen, hostHeld, now }) {
439
+ let deadlineAt = at
440
+ let hold = frozen
441
+ if (hostHeld) {
442
+ if (hold === undefined || hold.id !== id) {
443
+ hold = { id, remainingMs: deadlineAt === undefined ? 0 : Math.max(0, deadlineAt - now) }
444
+ }
445
+ } else if (hold !== undefined && hold.id === id) {
446
+ if (deadlineAt !== undefined) deadlineAt = now + hold.remainingMs
447
+ hold = undefined
448
+ }
449
+ const remaining = hold !== undefined
450
+ ? hold.remainingMs
451
+ : (deadlineAt === undefined ? undefined : deadlineAt - now)
452
+ return { at: deadlineAt, frozen: hold, remaining }
453
+ }
454
+
455
+ /** Short display form of a Session identity: its last dash-separated group. */
456
+ function shortSession(sessionId) {
457
+ if (typeof sessionId !== 'string' || sessionId === '') return ''
458
+ const tail = sessionId.includes('-') ? sessionId.slice(sessionId.lastIndexOf('-') + 1) : sessionId
459
+ return tail.length >= 6 ? tail : sessionId.slice(-6)
460
+ }
461
+
462
+ /**
463
+ * Where one request must be filed so the human meets it where they look.
464
+ *
465
+ * A request always belongs to the Session whose tool call was gated. That
466
+ * is the whole story while that Session is on screen — but a subagent's
467
+ * call belongs to a child Session nobody is looking at, and a pending
468
+ * interaction is only ever elected for the *displayed* Session. Filing a
469
+ * second copy under the displayed Session is what keeps a delegated call
470
+ * from waiting in a room no human ever walks into.
471
+ *
472
+ * @param request - the pending request.
473
+ * @param viewSessionId - the Session the composer chain is currently rendering.
474
+ * @returns the Session identities to file under, the asking one first.
475
+ */
476
+ function filingSessions(request, viewSessionId) {
477
+ const own = request?.sessionId
478
+ if (typeof own !== 'string' || own === '') return []
479
+ if (typeof viewSessionId !== 'string' || viewSessionId === '' || viewSessionId === own) return [own]
480
+ return [own, viewSessionId]
481
+ }
482
+
483
+ /** Whether a seat filed under `filedUnder` is answering some other Session's call. */
484
+ function isForeignRequest(request, filedUnder) {
485
+ const own = request?.sessionId
486
+ return typeof own === 'string' && own !== '' && typeof filedUnder === 'string' && filedUnder !== own
487
+ }
488
+
489
+ /**
490
+ * Whether the frame-level notice must speak for requests no composer seat shows.
491
+ *
492
+ * The seat is held exactly while a HITL panel is mounted in the composer, and
493
+ * a mounted panel already covers every live request of the displayed Session
494
+ * (it queues them) — but only while the human can actually see it. A
495
+ * full-viewport modal (the settings dialog is one) covers the composer
496
+ * without unmounting it, and a panel nobody can reach is no surface at all.
497
+ * Anything else — no Session selected, another main panel on screen, or a
498
+ * higher-precedence shipped panel holding the composer — already leaves the
499
+ * seat unclaimed.
500
+ *
501
+ * @param requests - every live request known to this page.
502
+ * @param seat - the seat snapshot: the claimed Session plus whether it is obscured, if any.
503
+ * @returns whether the notice must render.
504
+ */
505
+ function noticeVisible(requests, seat) {
506
+ if (!Array.isArray(requests) || requests.length === 0) return false
507
+ if (seat === undefined || seat === null) return true
508
+ return seat.obscured === true
509
+ }
510
+
511
+ /**
512
+ * Whether a mounted element is hidden from the human despite being on screen.
513
+ *
514
+ * Hit testing answers the question the seat actually asks: "can this request
515
+ * be decided where it is drawn?" A covering modal mask, a clipped ancestor,
516
+ * or a panel scrolled out of the viewport all return the same verdict, and
517
+ * none of them is visible in layout numbers alone.
518
+ *
519
+ * @param element - the mounted surface to test.
520
+ * @returns whether something else is on top of it, or it cannot be reached at all.
521
+ */
522
+ function isObscured(element) {
523
+ if (element === undefined || element === null || typeof element.getBoundingClientRect !== 'function') return true
524
+ const doc = typeof document === 'undefined' ? undefined : document
525
+ if (doc === undefined || typeof doc.elementFromPoint !== 'function') return false
526
+ const rect = element.getBoundingClientRect()
527
+ if (rect.width <= 0 || rect.height <= 0) return true
528
+ const x = rect.left + rect.width / 2
529
+ const y = rect.top + Math.min(rect.height / 2, OBSCURE_SAMPLE_INSET_PX)
530
+ if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) return true
531
+ const top = doc.elementFromPoint(x, y)
532
+ return top === null || top === undefined || !element.contains(top)
533
+ }
534
+
535
+ /**
536
+ * The composer-seat registry: which Session's composer currently shows a panel,
537
+ * and whether that panel is still reachable.
538
+ *
539
+ * The panel claims the seat on mount and releases it on unmount; while it is
540
+ * mounted it reports what hit testing says about its own visibility. The
541
+ * frame-level notice reads both, so "a human is looking at this request"
542
+ * is a measured fact rather than an assumption about Session state.
543
+ * @returns the observable seat with its claim, release, and visibility report.
544
+ */
545
+ function createSeat() {
546
+ let state
547
+ const listeners = new Set()
548
+ const emit = () => { for (const listener of listeners) listener() }
549
+ const publish = next => {
550
+ if (state === next) return
551
+ state = next
552
+ emit()
553
+ }
554
+ return {
555
+ subscribe(listener) {
556
+ listeners.add(listener)
557
+ return () => { listeners.delete(listener) }
558
+ },
559
+ /** Stable snapshot: a new object only when a field really changed. */
560
+ getSnapshot: () => state,
561
+ /** Claim the seat for one Session; the returned release only frees its own claim. */
562
+ hold(id) {
563
+ publish({ sessionId: id, obscured: false })
564
+ return () => {
565
+ if (state === undefined || state.sessionId !== id) return
566
+ publish(undefined)
567
+ }
568
+ },
569
+ /** Record whether the claimed panel is still reachable; ignored without a claim. */
570
+ reportObscured(obscured) {
571
+ if (state === undefined) return
572
+ const next = obscured === true
573
+ // A repeated verdict is not a change: React reads this snapshot every
574
+ // render, and a fresh object would re-render the whole frame each poll.
575
+ if (state.obscured === next) return
576
+ publish({ sessionId: state.sessionId, obscured: next })
577
+ },
578
+ }
579
+ }
580
+
581
+ /** Collapse preference shared by every request in one page session. */
582
+ const collapsePreference = { collapsed: false }
583
+
584
+ // ── store ────────────────────────────────────────────────────────────────
585
+
586
+ /** Observable snapshot for the panel: connection state, holds, and pending requests. */
587
+ function createStore() {
588
+ let state = {
589
+ connection: 'connecting',
590
+ requests: [],
591
+ settled: {},
592
+ holds: {},
593
+ revisions: {},
594
+ outcomes: {},
595
+ viewSessionId: undefined,
596
+ }
597
+ const listeners = new Set()
598
+ const emit = () => { for (const listener of listeners) listener() }
599
+ return {
600
+ subscribe(listener) {
601
+ listeners.add(listener)
602
+ return () => { listeners.delete(listener) }
603
+ },
604
+ getSnapshot: () => state,
605
+ setConnection(connection) {
606
+ if (state.connection === connection) return
607
+ state = { ...state, connection }
608
+ emit()
609
+ },
610
+ /**
611
+ * Record which Session the composer chain is rendering.
612
+ *
613
+ * Every filing decision reads this, so switching Sessions re-files the
614
+ * open requests onto the Session that just became visible.
615
+ */
616
+ setView(viewSessionId) {
617
+ if (state.viewSessionId === viewSessionId) return
618
+ state = { ...state, viewSessionId }
619
+ emit()
620
+ },
621
+ upsert(request) {
622
+ const stamped = { ...request, observedAt: Date.now() }
623
+ const requests = [...state.requests.filter(entry => entry.id !== request.id), stamped]
624
+ const revisions = { ...state.revisions, [request.id]: (state.revisions?.[request.id] ?? 0) + 1 }
625
+ state = { ...state, requests, revisions }
626
+ emit()
627
+ },
628
+ replace(requests) {
629
+ const receivedAt = Date.now()
630
+ const stamped = requests.map(request => ({ ...request, observedAt: receivedAt }))
631
+ const revisions = { ...state.revisions }
632
+ for (const request of stamped) revisions[request.id] = (revisions[request.id] ?? 0) + 1
633
+ state = { ...state, requests: stamped, revisions }
634
+ emit()
635
+ },
636
+ settle(id, outcome) {
637
+ const settled = { ...state.settled, [id]: outcome }
638
+ const outcomes = { ...state.outcomes }
639
+ delete outcomes[id]
640
+ state = { ...state, requests: state.requests.filter(entry => entry.id !== id), settled, outcomes }
641
+ emit()
642
+ },
643
+ /**
644
+ * Mark one request closed but keep it on screen, so a decision the human
645
+ * did not make can still be read instead of vanishing mid-sentence. The
646
+ * caller removes it when the reading time is over.
647
+ */
648
+ finish(id, outcome) {
649
+ if (!state.requests.some(entry => entry.id === id)) return
650
+ state = {
651
+ ...state,
652
+ settled: { ...state.settled, [id]: outcome },
653
+ outcomes: { ...state.outcomes, [id]: outcome },
654
+ }
655
+ emit()
656
+ },
657
+ remove(id) {
658
+ const outcomes = { ...state.outcomes }
659
+ delete outcomes[id]
660
+ state = { ...state, requests: state.requests.filter(entry => entry.id !== id), outcomes }
661
+ emit()
662
+ },
663
+ /**
664
+ * Adopt one host-computed remainder as the newest reading of a request:
665
+ * the panel re-bases its clock from it exactly as it would from a fresh
666
+ * request frame. Used by an interaction-hold acknowledgement, where the
667
+ * host's number is the only one that agrees with the host's deadline.
668
+ */
669
+ applyReading(id, remainingMs) {
670
+ if (!Number.isFinite(remainingMs)) return
671
+ let found = false
672
+ const receivedAt = Date.now()
673
+ const requests = state.requests.map((request) => {
674
+ if (request.id !== id || request.countdown === null || request.countdown === undefined) return request
675
+ found = true
676
+ return { ...request, observedAt: receivedAt, countdown: { ...request.countdown, remainingMs } }
677
+ })
678
+ if (!found) return
679
+ const revisions = { ...state.revisions, [id]: (state.revisions?.[id] ?? 0) + 1 }
680
+ state = { ...state, requests, revisions }
681
+ emit()
682
+ },
683
+
684
+ /**
685
+ * Record the host's verdict on one interaction hold. The panel trusts
686
+ * this and nothing else: a local focus flag must never claim a pause the
687
+ * host did not grant.
688
+ */
689
+ setHold(id, held) {
690
+ if ((state.holds?.[id] === true) === held) return
691
+ state = { ...state, holds: { ...state.holds, [id]: held } }
692
+ emit()
693
+ },
694
+ request(id) {
695
+ return state.requests.find(entry => entry.id === id)
696
+ },
697
+ }
698
+ }
699
+
700
+ // ── connection ───────────────────────────────────────────────────────────
701
+
702
+ /** How often an un-injected page re-reads its boot facts before giving up for that load. */
703
+ const BOOT_RETRY_MS = 5000
704
+
705
+ /**
706
+ * The plugin's own transport: an SSE downlink and one POST uplink.
707
+ *
708
+ * Boot facts are re-read from the page global on every attempt, and an
709
+ * attempt is retried while they are missing: a page that was already open
710
+ * when this bundle was first installed only receives them on its next load,
711
+ * so the client must not treat their absence as a permanent state.
712
+ * @param input - the boot facts observed at activation and the store to publish into.
713
+ * @returns the connection handle the panel calls.
714
+ */
715
+ function createConnection({ boot, store }) {
716
+ let source
717
+ let retry
718
+ let watchdog
719
+ let lastFrameAt = Date.now()
720
+ let disposed = false
721
+ const linger = new Map()
722
+
723
+ const facts = () => {
724
+ const candidate = typeof globalThis === 'undefined' ? undefined : globalThis.__DSH_HITL__
725
+ return candidate === null || candidate === undefined ? boot : candidate
726
+ }
727
+ const endpoint = value => typeof value?.endpoint === 'string' && value.endpoint !== '' ? value.endpoint : '/dsh-hitl'
728
+ const tokenOf = value => typeof value?.token === 'string' && value.token !== '' ? value.token : undefined
729
+
730
+ const handle = frame => {
731
+ if (frame === null || typeof frame !== 'object') return
732
+ lastFrameAt = Date.now()
733
+ if (frame.type === 'snapshot' && Array.isArray(frame.requests)) {
734
+ store.replace(frame.requests)
735
+ for (const request of frame.requests) {
736
+ if (request.countdown?.held === true) store.setHold(request.id, true)
737
+ }
738
+ return
739
+ }
740
+ if (frame.type === 'request' && frame.request !== undefined) {
741
+ store.upsert(frame.request)
742
+ if (frame.request.countdown?.held === true) store.setHold(frame.request.id, true)
743
+ return
744
+ }
745
+ if (frame.type === 'settled' && typeof frame.id === 'string') {
746
+ const outcome = frame.outcome ?? 'user'
747
+ // A timeout is the one outcome nobody chose: it keeps the panel on
748
+ // screen for a beat, saying what the host did, instead of vanishing the
749
+ // instant the countdown hits zero.
750
+ if (LINGER_OUTCOMES.includes(outcome)) {
751
+ store.finish(frame.id, outcome)
752
+ scheduleLinger(frame.id)
753
+ return
754
+ }
755
+ store.settle(frame.id, outcome)
756
+ return
757
+ }
758
+ if (frame.type === 'holdAck' && typeof frame.id === 'string') {
759
+ store.setHold(frame.id, frame.held === true)
760
+ store.applyReading(frame.id, frame.remainingMs)
761
+ }
762
+ }
763
+
764
+ function scheduleRetry() {
765
+ if (disposed || retry !== undefined) return
766
+ retry = setTimeout(() => {
767
+ retry = undefined
768
+ connect()
769
+ }, BOOT_RETRY_MS)
770
+ if (typeof retry?.unref === 'function') retry.unref()
771
+ }
772
+
773
+ /** Keep one closed request on screen for its reading time, then drop it. */
774
+ function scheduleLinger(id) {
775
+ const existing = linger.get(id)
776
+ if (existing !== undefined) clearTimeout(existing)
777
+ const timer = setTimeout(() => {
778
+ linger.delete(id)
779
+ store.remove(id)
780
+ }, LINGER_MS)
781
+ if (typeof timer?.unref === 'function') timer.unref()
782
+ linger.set(id, timer)
783
+ }
784
+
785
+ function connect() {
786
+ if (disposed) return
787
+ const current = facts()
788
+ const token = tokenOf(current)
789
+ if (token === undefined || typeof EventSource !== 'function') {
790
+ store.setConnection('unavailable')
791
+ scheduleRetry()
792
+ return
793
+ }
794
+ if (source !== undefined) return
795
+ try {
796
+ source = new EventSource(`${endpoint(current)}/events?token=${encodeURIComponent(token)}`)
797
+ } catch {
798
+ store.setConnection('unavailable')
799
+ scheduleRetry()
800
+ return
801
+ }
802
+ lastFrameAt = Date.now()
803
+ source.onopen = () => {
804
+ lastFrameAt = Date.now()
805
+ store.setConnection('open')
806
+ }
807
+ watchdog = setInterval(() => {
808
+ if (source === undefined || !streamStale(lastFrameAt, Date.now())) return
809
+ try {
810
+ source.close()
811
+ } catch {
812
+ /* already closed */
813
+ }
814
+ source = undefined
815
+ store.setConnection('connecting')
816
+ scheduleRetry()
817
+ }, 15000)
818
+ if (typeof watchdog?.unref === 'function') watchdog.unref()
819
+ source.onerror = () => {
820
+ // A closed stream never reconnects on its own (a refused token, or a
821
+ // restarted host), so this half rebuilds it from freshly read facts.
822
+ if (source !== undefined && source.readyState === 2) {
823
+ store.setConnection('unavailable')
824
+ if (watchdog !== undefined) {
825
+ clearInterval(watchdog)
826
+ watchdog = undefined
827
+ }
828
+ try {
829
+ source.close()
830
+ } catch {
831
+ /* already closed */
832
+ }
833
+ source = undefined
834
+ scheduleRetry()
835
+ return
836
+ }
837
+ store.setConnection('connecting')
838
+ }
839
+ source.onmessage = event => {
840
+ try {
841
+ handle(JSON.parse(event.data))
842
+ } catch {
843
+ /* a malformed frame is dropped; the next snapshot resynchronizes */
844
+ }
845
+ }
846
+ }
847
+
848
+ async function post(body) {
849
+ const current = facts()
850
+ const token = tokenOf(current)
851
+ if (token === undefined) throw new Error('the HITL decision channel is unavailable in this page')
852
+ const response = await fetch(`${endpoint(current)}/decide`, {
853
+ method: 'POST',
854
+ headers: {
855
+ 'content-type': 'application/json',
856
+ authorization: `Bearer ${token}`,
857
+ },
858
+ body: JSON.stringify(body),
859
+ })
860
+ let payload
861
+ try {
862
+ payload = await response.json()
863
+ } catch {
864
+ payload = undefined
865
+ }
866
+ if (!response.ok || payload?.ok !== true) {
867
+ const reason = payload?.message ?? `HTTP ${response.status}`
868
+ throw new Error(reason)
869
+ }
870
+ return payload
871
+ }
872
+
873
+ return {
874
+ start: connect,
875
+ dispose() {
876
+ disposed = true
877
+ if (retry !== undefined) {
878
+ clearTimeout(retry)
879
+ retry = undefined
880
+ }
881
+ if (watchdog !== undefined) {
882
+ clearInterval(watchdog)
883
+ watchdog = undefined
884
+ }
885
+ for (const timer of linger.values()) clearTimeout(timer)
886
+ linger.clear()
887
+ try {
888
+ source?.close()
889
+ } catch {
890
+ /* already closed */
891
+ }
892
+ },
893
+ decide(id, decision) {
894
+ return post({ type: 'decide', id, decision })
895
+ },
896
+ hold(id, held) {
897
+ return post({ type: 'hold', id, held }).catch(() => undefined)
898
+ },
899
+ }
900
+ }
901
+
902
+ // ── pending interaction ──────────────────────────────────────────────────
903
+
904
+ /** One request as the composer-chain selector and the panel see it. */
905
+ class PendingHitl {
906
+ constructor(request, connection, sequence) {
907
+ this.kind = KIND
908
+ this.key = request.id
909
+ this.sessionId = request.sessionId
910
+ this.request = request
911
+ this.sequence = sequence
912
+ this.connection = connection
913
+ this.delegated = false
914
+ }
915
+
916
+ /** Whether the panel may still send a decision. */
917
+ get answerable() {
918
+ return !this.delegated
919
+ }
920
+
921
+ /** Send one decision for this request. */
922
+ answer(decision) {
923
+ return this.connection.decide(this.request.id, decision)
924
+ }
925
+
926
+ /** The seat was released: stop offering the panel; the host keeps its own deadline. */
927
+ delegate() {
928
+ this.delegated = true
929
+ return Promise.resolve()
930
+ }
931
+
932
+ /** Whether an error means the panel should yield to another domain. */
933
+ isDelegation(error) {
934
+ return error === 'delegated'
935
+ }
936
+ }
937
+
938
+ /**
939
+ * One extra filing of a request under the Session the human is looking at.
940
+ *
941
+ * It is a copy, not a second owner: decisions, delegation and the request's
942
+ * own seat all stay with the primary filing, so answering from either seat
943
+ * settles the one call and releasing the copy can never disturb the other.
944
+ */
945
+ class PendingHitlView {
946
+ constructor(primary, sessionId) {
947
+ this.kind = KIND
948
+ this.key = `${primary.key}@view`
949
+ this.sessionId = sessionId
950
+ this.primary = primary
951
+ }
952
+
953
+ get request() { return this.primary.request }
954
+ get sequence() { return this.primary.sequence }
955
+ get answerable() { return this.primary.answerable }
956
+ answer(decision) { return this.primary.answer(decision) }
957
+ delegate() { return Promise.resolve() }
958
+ isDelegation(error) { return this.primary.isDelegation(error) }
959
+ }
960
+
961
+ // ── view ─────────────────────────────────────────────────────────────────
962
+
963
+ /** Small error boundary: a panel bug must not blank the composer. */
964
+ class PanelBoundary extends React.Component {
965
+ constructor(props) {
966
+ super(props)
967
+ this.state = { failed: false }
968
+ }
969
+
970
+ static getDerivedStateFromError() {
971
+ return { failed: true }
972
+ }
973
+
974
+ componentDidCatch(error) {
975
+ console.error('dsh-hitl: panel crashed', error)
976
+ }
977
+
978
+ render() {
979
+ if (this.state.failed) {
980
+ // `null` means silence: a frame-level surface that cannot draw itself
981
+ // must not leave an empty box floating over the application.
982
+ if (this.props.fallback === null) return null
983
+ return h('div', { className: 'dsh-hitl-panel' },
984
+ h('div', { className: 'dsh-hitl-title' }, this.props.fallback),
985
+ )
986
+ }
987
+ return this.props.children
988
+ }
989
+ }
990
+
991
+ /** Largest height an auto-growing editor reaches before it scrolls instead. */
992
+ const TEXTAREA_MAX_PX = 320
993
+
994
+ /**
995
+ * Nearest ancestor that actually scrolls, or undefined outside a browser or
996
+ * when nothing scrolls. Switching a field from its preview to its editor
997
+ * changes the height of everything below it, and a scroll container clamps a
998
+ * position it can no longer reach; the switch therefore has to put the
999
+ * position back.
1000
+ * @param element - the element whose ancestors are searched.
1001
+ * @returns the scrollable ancestor, when there is one.
1002
+ */
1003
+ function scrollableAncestor(element) {
1004
+ if (element === undefined || element === null || typeof getComputedStyle !== 'function') return undefined
1005
+ let node = element.parentElement
1006
+ while (node !== undefined && node !== null) {
1007
+ const overflowY = getComputedStyle(node).overflowY
1008
+ if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) return node
1009
+ node = node.parentElement
1010
+ }
1011
+ return undefined
1012
+ }
1013
+
1014
+ /**
1015
+ * Whether one field's render area accepts input: the field must say it is
1016
+ * editable AND the panel must still be taking decisions. A field whose
1017
+ * producer sent no `editable` flag stays read-only.
1018
+ * @param field - one wire field.
1019
+ * @param panelEditable - whether the panel is idle, unexpired, and unsubmitted.
1020
+ * @returns whether the field renders an editor.
1021
+ */
1022
+ function isFieldEditable(field, panelEditable) {
1023
+ return panelEditable === true && field?.editable === true
1024
+ }
1025
+
1026
+ /**
1027
+ * Whether one field's header may claim the field is editable.
1028
+ *
1029
+ * The header must never promise more than the control under it offers. The
1030
+ * field's own flag is not the whole answer: while the panel is submitting,
1031
+ * settled, or past its deadline, every render area is inert — a `text` field
1032
+ * turns into a read-only block there — so the header has to follow the panel,
1033
+ * not the field's intent.
1034
+ *
1035
+ * @param field - one wire field.
1036
+ * @param panelEditable - whether the panel is idle, unexpired, and unsubmitted.
1037
+ * @returns whether the header says "editable"; everything else reads "read-only".
1038
+ */
1039
+ function claimsEditable(field, panelEditable) {
1040
+ return field?.render !== 'diff' && isFieldEditable(field, panelEditable)
1041
+ }
1042
+
1043
+ /**
1044
+ * One-line-high textarea that grows with its content up to a capped height.
1045
+ *
1046
+ * The height is applied in a layout effect, before the browser paints: a
1047
+ * post-paint effect would expose one frame of a one-line box, which shrinks
1048
+ * the enclosing scroll area and makes the browser clamp its scroll position
1049
+ * back toward the top.
1050
+ */
1051
+ function AutoTextarea({ value, disabled, placeholder, ariaLabel, onChange, onFocus, onBlur }) {
1052
+ const ref = React.useRef(undefined)
1053
+ React.useLayoutEffect(() => {
1054
+ const element = ref.current
1055
+ if (element === undefined || element === null) return
1056
+ element.style.height = 'auto'
1057
+ element.style.height = `${Math.min(element.scrollHeight, TEXTAREA_MAX_PX)}px`
1058
+ }, [value])
1059
+ return h('textarea', {
1060
+ ref,
1061
+ className: 'dsh-hitl-textarea',
1062
+ value,
1063
+ rows: 1,
1064
+ spellCheck: false,
1065
+ disabled,
1066
+ ...(placeholder === undefined ? {} : { placeholder }),
1067
+ 'aria-label': ariaLabel,
1068
+ onChange: event => { onChange(event.target.value) },
1069
+ onFocus,
1070
+ onBlur,
1071
+ })
1072
+ }
1073
+
1074
+ /** Read-only JSON or read-only text view. */
1075
+ function JsonField({ field }) {
1076
+ return h('pre', { className: 'dsh-hitl-pre', 'aria-label': field.title }, field.value)
1077
+ }
1078
+
1079
+ /** Editable plain-text proposal field. */
1080
+ function TextField({ field, value, disabled, onChange, onFocus, onBlur }) {
1081
+ return h(AutoTextarea, {
1082
+ value,
1083
+ disabled,
1084
+ ariaLabel: field.title,
1085
+ onChange,
1086
+ onFocus,
1087
+ onBlur,
1088
+ })
1089
+ }
1090
+
1091
+ /**
1092
+ * Markdown field with an edit / preview / side-by-side switch.
1093
+ *
1094
+ * It opens on the rendered preview: a decision panel is read before it is
1095
+ * edited, and the editor is one click away on the field's own tabs. A
1096
+ * read-only markdown field keeps a selectable source pane — a block, not a
1097
+ * disabled input — and drops the edit view entirely.
1098
+ */
1099
+ function MarkdownField({ field, value, editable, t, onChange, onFocus, onBlur }) {
1100
+ const [mode, setMode] = React.useState('preview')
1101
+ const rootRef = React.useRef(undefined)
1102
+ const anchorRef = React.useRef(undefined)
1103
+ // Runs before paint, so the restored position is the one the reader sees.
1104
+ React.useLayoutEffect(() => {
1105
+ const anchor = anchorRef.current
1106
+ if (anchor === undefined) return
1107
+ anchorRef.current = undefined
1108
+ const scroller = scrollableAncestor(rootRef.current)
1109
+ if (scroller !== undefined) scroller.scrollTop = anchor
1110
+ }, [mode])
1111
+ const switchMode = id => {
1112
+ const scroller = scrollableAncestor(rootRef.current)
1113
+ anchorRef.current = scroller === undefined ? undefined : scroller.scrollTop
1114
+ setMode(id)
1115
+ }
1116
+ const html = React.useMemo(() => renderMarkdownHtml(value), [value])
1117
+ const tab = (id, label) => h('button', {
1118
+ key: id,
1119
+ type: 'button',
1120
+ className: `dsh-hitl-tab${mode === id ? ' is-active' : ''}`,
1121
+ onClick: () => { switchMode(id) },
1122
+ }, label)
1123
+ const editor = editable
1124
+ ? h(AutoTextarea, {
1125
+ value,
1126
+ disabled: false,
1127
+ ariaLabel: t('aria.field', { title: field.title }),
1128
+ onChange,
1129
+ onFocus,
1130
+ onBlur,
1131
+ })
1132
+ // A read-only source pane is a block, not a disabled input: disabled
1133
+ // text is neither selectable nor copyable in a browser, and the panel
1134
+ // would be showing text it refuses to let anyone take away.
1135
+ : h('pre', {
1136
+ className: 'dsh-hitl-pre',
1137
+ 'aria-label': t('aria.field', { title: field.title }),
1138
+ }, value)
1139
+ const preview = h('div', {
1140
+ className: 'dsh-hitl-markdown',
1141
+ // Every tag here is emitted by renderMarkdownHtml, which escapes first.
1142
+ dangerouslySetInnerHTML: { __html: value.trim() === '' ? `<p class="dsh-hitl-p">${escapeHtml(t('markdown.empty'))}</p>` : html },
1143
+ })
1144
+ const modes = editable ? ['preview', 'edit', 'split'] : ['preview', 'split']
1145
+ const labels = { edit: t('markdown.edit'), preview: t('markdown.preview'), split: t('markdown.split') }
1146
+ return h('div', { className: 'dsh-hitl-md', ref: rootRef },
1147
+ h('div', { className: 'dsh-hitl-tabs', role: 'tablist' }, modes.map(id => tab(id, labels[id]))),
1148
+ mode === 'edit' ? editor : null,
1149
+ mode === 'preview' ? preview : null,
1150
+ mode === 'split' ? h('div', { className: 'dsh-hitl-md-split' }, editor, preview) : null,
1151
+ )
1152
+ }
1153
+
1154
+ /** Read-only side-by-side diff. */
1155
+ function DiffField({ field, t }) {
1156
+ const diff = field.diff ?? { path: undefined, oldText: '', newText: '' }
1157
+ const computed = React.useMemo(() => diffLines(diff.oldText, diff.newText), [diff.oldText, diff.newText])
1158
+ const head = h('div', { className: 'dsh-hitl-diff-head' },
1159
+ diff.path === undefined || diff.path === '' ? t('label.diffPlain') : t('label.diffChanged', { path: diff.path }),
1160
+ computed === undefined ? ` · ${t('error.diffTooLarge')}` : ` · +${computed.added} −${computed.removed}`,
1161
+ )
1162
+ if (computed === undefined) {
1163
+ return h('div', { className: 'dsh-hitl-diff' },
1164
+ head,
1165
+ h('div', { className: 'dsh-hitl-diff-grid' },
1166
+ h('pre', { className: 'dsh-hitl-pre' }, diff.oldText),
1167
+ h('pre', { className: 'dsh-hitl-pre' }, diff.newText),
1168
+ ),
1169
+ )
1170
+ }
1171
+ const cell = (entry, type) => h('div', {
1172
+ key: `${type}-${entry === null ? 'blank' : entry.number}`,
1173
+ className: `dsh-hitl-diff-line is-${entry === null ? 'blank' : type}`,
1174
+ },
1175
+ h('span', { className: 'dsh-hitl-diff-number' }, entry === null ? '' : String(entry.number)),
1176
+ h('span', { className: 'dsh-hitl-diff-text' }, entry === null ? '' : entry.text === '' ? ' ' : entry.text),
1177
+ )
1178
+ return h('div', { className: 'dsh-hitl-diff' },
1179
+ head,
1180
+ h('div', { className: 'dsh-hitl-diff-grid' },
1181
+ h('div', { className: 'dsh-hitl-diff-side' }, computed.rows.map(row => cell(row.left, row.type === 'removed' ? 'removed' : 'same'))),
1182
+ h('div', { className: 'dsh-hitl-diff-side' }, computed.rows.map(row => cell(row.right, row.type === 'added' ? 'added' : 'same'))),
1183
+ ),
1184
+ )
1185
+ }
1186
+
1187
+ /** One field: its labels, its title, and its render area. */
1188
+ function FieldBlock({ field, drafts, panelEditable, t, onChange, onFocus, onBlur }) {
1189
+ if (field.render === 'hidden') return null
1190
+ const value = drafts[field.param] ?? field.value ?? ''
1191
+ const editable = isFieldEditable(field, panelEditable)
1192
+ const set = next => { onChange(field.param, next) }
1193
+ let area
1194
+ if (field.render === 'diff') area = h(DiffField, { field, t })
1195
+ else if (field.render === 'json') area = h(JsonField, { field })
1196
+ else if (field.render === 'text') {
1197
+ // A read-only text field is a block, not a disabled input: it carries no
1198
+ // affordance the panel would then have to refuse.
1199
+ area = editable
1200
+ ? h(TextField, { field, value, disabled: false, onChange: set, onFocus, onBlur })
1201
+ : h(JsonField, { field })
1202
+ } else area = h(MarkdownField, { field, value, editable, t, onChange: set, onFocus, onBlur })
1203
+ return h('section', { className: 'dsh-hitl-field', key: field.param },
1204
+ h('div', { className: 'dsh-hitl-field-head' },
1205
+ h('span', { className: 'dsh-hitl-field-title' }, field.title),
1206
+ h('span', { className: 'dsh-hitl-field-hint' },
1207
+ claimsEditable(field, panelEditable) ? t('label.editable') : t('label.readonly')),
1208
+ field.truncated === true ? h('span', { className: 'dsh-hitl-field-warn' }, t('label.truncated')) : null,
1209
+ ...(field.labels ?? []).map(label => h('span', { className: 'dsh-hitl-chip', key: label }, label)),
1210
+ ),
1211
+ field.description === undefined ? null : h('div', { className: 'dsh-hitl-field-desc' }, field.description),
1212
+ area,
1213
+ )
1214
+ }
1215
+
1216
+ /**
1217
+ * The decision panel itself.
1218
+ * @param props - chain props (`matched`), locale `t`, and the injected store face.
1219
+ */
1220
+ function HitlPanel(props) {
1221
+ const t = props.t ?? (props.hitl?.t ?? ((key) => key))
1222
+ const store = props.hitl?.store
1223
+ const snapshot = React.useSyncExternalStore(store.subscribe, store.getSnapshot)
1224
+ const pending = props.matched
1225
+ const request = snapshot.requests.find(entry => entry.id === pending.key) ?? pending.request
1226
+ const deadline = React.useRef(undefined)
1227
+ const [now, setNow] = React.useState(() => Date.now())
1228
+ const [drafts, setDrafts] = React.useState({})
1229
+ const [feedback, setFeedback] = React.useState('')
1230
+ const [busy, setBusy] = React.useState(false)
1231
+ const [failure, setFailure] = React.useState()
1232
+ const [settled, setSettled] = React.useState(false)
1233
+ const [collapsed, setCollapsed] = React.useState(collapsePreference.collapsed)
1234
+ const [focusFeedback, setFocusFeedback] = React.useState(false)
1235
+ const feedbackRef = React.useRef(undefined)
1236
+ const frozenRemaining = React.useRef(undefined)
1237
+ const held = React.useRef(false)
1238
+
1239
+ // Every frame carries the host's own remainder, so each fresh reading is
1240
+ // authoritative: re-base the local deadline instead of trusting the one
1241
+ // taken when this panel first appeared.
1242
+ // Whether the host granted this hold. It gates both the label and the
1243
+ // freeze, and it is read before the reading block because a reading that
1244
+ // arrives while frozen is the host moving its own remainder.
1245
+ const hostHeld = snapshot.holds?.[request.id] === true
1246
+ const revision = snapshot.revisions?.[request.id] ?? 0
1247
+ if (deadline.current === undefined || deadline.current.id !== request.id || deadline.current.revision !== revision) {
1248
+ const anchored = deadlineOf(request, Date.now())
1249
+ deadline.current = { id: request.id, revision, at: anchored }
1250
+ // A reading is adopted exactly once, on the frame that delivers it: the
1251
+ // frozen base must hold still afterwards, or the pause would keep
1252
+ // counting down while claiming to be paused.
1253
+ frozenRemaining.current = hostHeld && anchored !== undefined
1254
+ ? { id: request.id, remainingMs: Math.max(0, anchored - Date.now()) }
1255
+ : undefined
1256
+ }
1257
+
1258
+ React.useEffect(() => {
1259
+ if (deadline.current.at === undefined) return undefined
1260
+ const timer = setInterval(() => { setNow(Date.now()) }, 1000)
1261
+ return () => { clearInterval(timer) }
1262
+ }, [request.id])
1263
+
1264
+ // This panel is the composer seat for as long as it is mounted. The
1265
+ // frame-level notice reads that claim: while a seat is held there is no
1266
+ // request this page cannot answer from somewhere.
1267
+ const seat = props.hitl?.seat
1268
+ React.useEffect(() => {
1269
+ if (seat === undefined || seat === null) return undefined
1270
+ return seat.hold(pending.sessionId)
1271
+ }, [seat, pending.sessionId])
1272
+
1273
+ // Holding the seat is a claim about reachability, so it is re-tested rather
1274
+ // than assumed: a modal can cover this panel without unmounting it, and the
1275
+ // frame-level notice takes over exactly then.
1276
+ const panelRef = React.useRef(undefined)
1277
+ React.useEffect(() => {
1278
+ if (seat === undefined || seat === null || typeof seat.reportObscured !== 'function') return undefined
1279
+ const probe = () => { seat.reportObscured(isObscured(panelRef.current)) }
1280
+ probe()
1281
+ const timer = setInterval(probe, OBSCURE_POLL_MS)
1282
+ return () => { clearInterval(timer) }
1283
+ }, [seat, pending.sessionId])
1284
+
1285
+ // A different request takes this seat: start its draft state clean.
1286
+ React.useEffect(() => {
1287
+ setDrafts({})
1288
+ setFeedback('')
1289
+ setFailure(undefined)
1290
+ setSettled(false)
1291
+ setBusy(false)
1292
+ held.current = false
1293
+ }, [request.id])
1294
+
1295
+ const fields = request.fields ?? []
1296
+ const changed = changedFields(fields, drafts)
1297
+ const edited = changed.length > 0
1298
+ const primaryText = settled || busy ? t('button.submitting') : primaryLabel(edited, request, t)
1299
+ const feedbackConfig = request.buttons?.feedback ?? { enabled: false, required: false }
1300
+ // The seat is filed under the displayed Session even for a delegated
1301
+ // call, so every other live request is reachable through this panel.
1302
+ const queued = snapshot.requests.filter(entry => entry.id !== request.id
1303
+ && snapshot.outcomes?.[entry.id] === undefined).length
1304
+ const foreign = isForeignRequest(request, pending.sessionId)
1305
+ const gotoSession = props.hitl?.canNavigate?.() === true ? props.hitl?.openSession : undefined
1306
+ // Set while a closed request is being shown for its reading time: the host
1307
+ // already settled it, so nothing here may still offer a decision.
1308
+ const outcome = snapshot.outcomes?.[request.id]
1309
+
1310
+ // Hold bookkeeping is derived during the render, not in an effect: a
1311
+ // release must re-base the deadline on the same frame that stops showing
1312
+ // the frozen number. An effect runs after the render, so the frame in
1313
+ // between would draw the raw — already past — deadline and read as "the
1314
+ // pause and the countdown disagree".
1315
+ const clock = reconcileClock({
1316
+ id: request.id,
1317
+ at: deadline.current.at,
1318
+ frozen: frozenRemaining.current,
1319
+ hostHeld,
1320
+ now,
1321
+ })
1322
+ deadline.current.at = clock.at
1323
+ frozenRemaining.current = clock.frozen
1324
+ const remaining = clock.remaining
1325
+ const timedOut = remaining !== undefined && remaining <= 0
1326
+ const editable = outcome === undefined && !busy && !settled && !timedOut
1327
+ const tone = countdownTone(remaining, hostHeld, timedOut || outcome === 'timeout')
1328
+
1329
+ // A collapse is remembered for the next request: a long panel must not
1330
+ // keep hiding the transcript behind it every time a decision arrives.
1331
+ const toggleCollapsed = () => {
1332
+ setCollapsed((current) => {
1333
+ collapsePreference.collapsed = !current
1334
+ return !current
1335
+ })
1336
+ }
1337
+
1338
+ // A rejection that can carry words reopens the proposal first, so the
1339
+ // feedback box is never out of reach while the panel is collapsed.
1340
+ React.useEffect(() => {
1341
+ if (!focusFeedback) return
1342
+ const element = feedbackRef.current
1343
+ if (element === undefined || element === null) return
1344
+ element.focus()
1345
+ setFocusFeedback(false)
1346
+ }, [focusFeedback, collapsed])
1347
+
1348
+ const setHold = next => {
1349
+ if (held.current === next) return
1350
+ held.current = next
1351
+ props.hitl?.connection?.hold(request.id, next)
1352
+ }
1353
+ const onFocus = () => { setHold(true) }
1354
+ const onBlur = () => { setHold(false) }
1355
+ const onChange = (param, text) => { setDrafts(current => ({ ...current, [param]: text })) }
1356
+
1357
+ const submit = decision => {
1358
+ setBusy(true)
1359
+ setFailure(undefined)
1360
+ setHold(false)
1361
+ pending.answer(decision)
1362
+ .then(() => {
1363
+ setSettled(true)
1364
+ setTimeout(() => {
1365
+ if (!store.request(request.id)) return
1366
+ store.settle(request.id, 'user')
1367
+ }, SETTLE_GRACE_MS)
1368
+ })
1369
+ .catch(error => {
1370
+ setBusy(false)
1371
+ setFailure(error?.message ?? String(error))
1372
+ })
1373
+ }
1374
+
1375
+ const onApprove = () => {
1376
+ if (edited) submit({ kind: 'modify', fields: changed })
1377
+ else submit({ kind: 'approve' })
1378
+ }
1379
+ const onReject = () => {
1380
+ if (collapsed && feedbackConfig.enabled === true) {
1381
+ // The words belong to the rejection: reopen, then let the human type.
1382
+ setCollapsed(false)
1383
+ setFocusFeedback(true)
1384
+ return
1385
+ }
1386
+ if (feedbackConfig.required === true && feedback.trim() === '') {
1387
+ setFailure(t('feedback.required'))
1388
+ return
1389
+ }
1390
+ submit(feedback.trim() === '' ? { kind: 'reject' } : { kind: 'reject', feedback: feedback.trim() })
1391
+ }
1392
+ const onRetry = () => { setBusy(false); setFailure(undefined) }
1393
+
1394
+ const title = request.title ?? t('title.default', { tool: request.toolName })
1395
+ const countdownText = remaining === undefined
1396
+ ? undefined
1397
+ : (timedOut || outcome === 'timeout'
1398
+ ? t('label.timeout')
1399
+ : (hostHeld ? t('label.countdownHeld', { seconds: formatRemaining(remaining) }) : t('label.countdown', { seconds: formatRemaining(remaining) })))
1400
+ const connectionNote = snapshot.connection === 'unavailable'
1401
+ ? t('label.offlineNoToken')
1402
+ : (snapshot.connection === 'open' ? undefined : t('label.offline'))
1403
+ const collapseLabel = collapsed ? t('aria.expand') : t('aria.collapse')
1404
+ const chevron = h('button', {
1405
+ type: 'button',
1406
+ className: 'dsh-hitl-chevron',
1407
+ onClick: toggleCollapsed,
1408
+ 'aria-expanded': collapsed ? 'false' : 'true',
1409
+ 'aria-label': collapseLabel,
1410
+ title: collapseLabel,
1411
+ }, h('svg', { viewBox: '0 0 16 16', width: 14, height: 14, 'aria-hidden': true, focusable: 'false' },
1412
+ h('path', {
1413
+ d: collapsed ? 'M4 9.5 8 5.5 12 9.5' : 'M4 6.5 8 10.5 12 6.5',
1414
+ fill: 'none',
1415
+ stroke: 'currentColor',
1416
+ strokeWidth: 1.6,
1417
+ strokeLinecap: 'round',
1418
+ strokeLinejoin: 'round',
1419
+ })),
1420
+ h('span', { className: 'dsh-hitl-chevron-label' }, collapsed ? t('button.expand') : t('button.collapse')))
1421
+
1422
+ return h(PanelBoundary, { fallback: t('label.offlineNoToken') },
1423
+ h('div', {
1424
+ ref: panelRef,
1425
+ className: 'dsh-hitl-panel',
1426
+ role: 'dialog',
1427
+ 'aria-label': t('aria.dialog', { tool: request.toolName }),
1428
+ 'data-timed-out': timedOut ? 'true' : undefined,
1429
+ 'data-collapsed': collapsed ? 'true' : undefined,
1430
+ },
1431
+ h('style', null, CSS),
1432
+ h('div', { className: 'dsh-hitl-head' },
1433
+ h('h2', { className: 'dsh-hitl-title' }, title),
1434
+ chevron,
1435
+ ),
1436
+ h('div', { className: 'dsh-hitl-meta' },
1437
+ h('span', { className: 'dsh-hitl-tool' }, request.toolName),
1438
+ ...(request.labels ?? []).map(label => h('span', { className: 'dsh-hitl-chip', key: label }, label)),
1439
+ foreign
1440
+ ? h('span', { className: 'dsh-hitl-chip is-foreign' },
1441
+ t('label.foreign', { session: shortSession(request.sessionId) }))
1442
+ : null,
1443
+ foreign && typeof gotoSession === 'function'
1444
+ ? h('button', {
1445
+ type: 'button',
1446
+ className: 'dsh-hitl-btn is-ghost',
1447
+ onClick: () => { gotoSession(request.sessionId) },
1448
+ }, t('button.gotoSession'))
1449
+ : null,
1450
+ queued > 0 ? h('span', { className: 'dsh-hitl-queued' }, t('label.queued', { count: queued })) : null,
1451
+ connectionNote === undefined ? null : h('span', { className: 'dsh-hitl-offline' }, connectionNote),
1452
+ ),
1453
+ collapsed ? null : h(React.Fragment, null,
1454
+ h('div', { className: 'dsh-hitl-fields', 'data-layout': request.layout ?? 'stacked' },
1455
+ fields.map(field => h(FieldBlock, {
1456
+ field,
1457
+ key: field.param,
1458
+ drafts,
1459
+ panelEditable: editable,
1460
+ t,
1461
+ onChange,
1462
+ onFocus,
1463
+ onBlur,
1464
+ })),
1465
+ ),
1466
+ edited ? h('div', { className: 'dsh-hitl-note' }, t('modify.note')) : null,
1467
+ feedbackConfig.enabled === true
1468
+ ? h('div', { className: 'dsh-hitl-feedback' },
1469
+ h('label', { className: 'dsh-hitl-feedback-title' }, t('feedback.title')),
1470
+ h('textarea', {
1471
+ ref: feedbackRef,
1472
+ className: 'dsh-hitl-textarea',
1473
+ value: feedback,
1474
+ rows: 2,
1475
+ spellCheck: false,
1476
+ disabled: !editable,
1477
+ placeholder: feedbackConfig.prompt ?? t('feedback.placeholder'),
1478
+ 'aria-label': t('feedback.title'),
1479
+ onChange: event => { setFeedback(event.target.value) },
1480
+ onFocus,
1481
+ onBlur,
1482
+ }),
1483
+ )
1484
+ : null,
1485
+ ),
1486
+ failure === undefined ? null : h('div', { className: 'dsh-hitl-error' },
1487
+ t('error.submit', { message: failure }),
1488
+ h('button', { type: 'button', className: 'dsh-hitl-btn is-ghost', onClick: onRetry }, t('button.retry')),
1489
+ ),
1490
+ h('div', { className: 'dsh-hitl-actions' },
1491
+ countdownText === undefined ? null : h('span', { className: `dsh-hitl-countdown is-${tone}` }, countdownText),
1492
+ h('button', {
1493
+ type: 'button',
1494
+ className: 'dsh-hitl-btn is-outline',
1495
+ disabled: !editable,
1496
+ onClick: onReject,
1497
+ }, t('button.reject')),
1498
+ h('button', {
1499
+ type: 'button',
1500
+ className: 'dsh-hitl-btn is-primary',
1501
+ disabled: !editable,
1502
+ onClick: onApprove,
1503
+ }, primaryText),
1504
+ ),
1505
+ settled
1506
+ ? h('div', { className: 'dsh-hitl-settled' }, t('settled.byUser'))
1507
+ : (outcome === undefined
1508
+ ? null
1509
+ : h('div', { className: 'dsh-hitl-settled' },
1510
+ t(outcome === 'timeout' ? 'settled.byTimeout' : 'settled.byHost'))),
1511
+ ),
1512
+ )
1513
+ }
1514
+
1515
+ /**
1516
+ * Frame-level notice for requests no composer can show.
1517
+ *
1518
+ * A composer seat exists only where a Conversation is on screen. With no
1519
+ * Session selected, another main panel open, or a shipped panel outranking
1520
+ * this one for the composer, a gated call — most often a subagent's — would
1521
+ * otherwise have no surface at all. This notice is that surface: it renders
1522
+ * whenever nothing holds the seat while requests are live, and offers the
1523
+ * two decisions that need no editing, plus a jump to the asking Session for
1524
+ * the full panel.
1525
+ *
1526
+ * @param props - slot props: locale `t` and this plugin's face.
1527
+ * @returns the notice element, or null while a seat or the settle delay covers it.
1528
+ */
1529
+ function HitlNotice(props) {
1530
+ const t = props.t ?? (props.hitl?.t ?? ((key) => key))
1531
+ const store = props.hitl?.store
1532
+ const seat = props.hitl?.seat
1533
+ const connection = props.hitl?.connection
1534
+ const gotoSession = props.hitl?.canNavigate?.() === true ? props.hitl?.openSession : undefined
1535
+ const snapshot = React.useSyncExternalStore(store.subscribe, store.getSnapshot)
1536
+ const seatState = React.useSyncExternalStore(seat.subscribe, seat.getSnapshot)
1537
+ const [now, setNow] = React.useState(() => Date.now())
1538
+ const [shown, setShown] = React.useState(false)
1539
+ const [busy, setBusy] = React.useState(false)
1540
+ const [failure, setFailure] = React.useState()
1541
+ const noticeRef = React.useRef(undefined)
1542
+ // A closed request being read is not a request to act on: the notice only
1543
+ // ever speaks for calls that can still be decided.
1544
+ const live = snapshot.requests ?? []
1545
+ const requests = live.filter(request => snapshot.outcomes?.[request.id] === undefined)
1546
+ const wanted = noticeVisible(requests, seatState)
1547
+
1548
+ // A seat is claimed from a mount effect, which runs after paint: waiting
1549
+ // one beat keeps the notice from flashing over a panel that just arrived.
1550
+ React.useEffect(() => {
1551
+ if (!wanted) {
1552
+ setShown(false)
1553
+ return undefined
1554
+ }
1555
+ const timer = setTimeout(() => { setShown(true) }, NOTICE_DELAY_MS)
1556
+ return () => { clearTimeout(timer) }
1557
+ }, [wanted])
1558
+
1559
+ // The notice exists for the moments when the composer is unreachable, and
1560
+ // the usual reason is a modal dialog — which is portaled to `body` with a
1561
+ // z-index far above the shell's overlay layer. The popover top layer is the
1562
+ // one place a plugin can paint from without fighting that stacking order;
1563
+ // where the API is missing, the in-frame position stays as it is.
1564
+ React.useEffect(() => {
1565
+ if (!shown) return undefined
1566
+ const element = noticeRef.current
1567
+ if (element === undefined || element === null || typeof element.showPopover !== 'function') return undefined
1568
+ try {
1569
+ if (element.matches(':popover-open') !== true) element.showPopover()
1570
+ } catch {
1571
+ /* an unsupported or already-open popover keeps its static position */
1572
+ }
1573
+ return undefined
1574
+ }, [shown])
1575
+
1576
+ const ticking = shown && requests.some(request => deadlineOf(request, now) !== undefined)
1577
+ React.useEffect(() => {
1578
+ if (!ticking) return undefined
1579
+ const timer = setInterval(() => { setNow(Date.now()) }, 1000)
1580
+ return () => { clearInterval(timer) }
1581
+ }, [ticking])
1582
+
1583
+ if (!shown || requests.length === 0) return null
1584
+
1585
+ const head = requests[0]
1586
+ const rows = requests.slice(0, 3)
1587
+ const decide = decision => {
1588
+ if (typeof connection?.decide !== 'function') return
1589
+ setBusy(true)
1590
+ setFailure(undefined)
1591
+ connection.decide(head.id, decision)
1592
+ .then(() => { setBusy(false) })
1593
+ .catch(error => {
1594
+ setBusy(false)
1595
+ setFailure(error?.message ?? String(error))
1596
+ })
1597
+ }
1598
+ // The asking Session names itself: anything else on screen is somebody's
1599
+ // subagent, which is exactly what a human needs to know before deciding.
1600
+ const source = request => request.sessionId === store.getSnapshot().viewSessionId
1601
+ ? t('notice.own')
1602
+ : t('notice.subagent', { session: shortSession(request.sessionId) })
1603
+
1604
+ // The notice is the last surface between a blocked call and silence, and
1605
+ // it renders in the shell's overlay layer: a bug here must degrade to
1606
+ // nothing, never take the frame down with it.
1607
+ return h(PanelBoundary, {
1608
+ fallback: null,
1609
+ children: h('div', {
1610
+ ref: noticeRef,
1611
+ className: 'dsh-hitl-notice',
1612
+ popover: 'manual',
1613
+ role: 'status',
1614
+ 'aria-label': t('aria.notice'),
1615
+ },
1616
+ h('style', null, CSS),
1617
+ h('div', { className: 'dsh-hitl-notice-head' },
1618
+ h('span', { className: 'dsh-hitl-notice-title' }, t('notice.title', { count: requests.length })),
1619
+ h('span', { className: 'dsh-hitl-notice-hint' }, t('notice.hint')),
1620
+ ),
1621
+ h('ul', { className: 'dsh-hitl-notice-list' },
1622
+ rows.map(request => {
1623
+ const at = deadlineOf(request, now)
1624
+ const remaining = at === undefined ? undefined : at - now
1625
+ const timedOut = remaining !== undefined && remaining <= 0
1626
+ return h('li', { className: 'dsh-hitl-notice-row', key: request.id },
1627
+ h('span', { className: 'dsh-hitl-notice-tool' }, request.toolName),
1628
+ h('span', { className: 'dsh-hitl-notice-source' }, source(request)),
1629
+ remaining === undefined
1630
+ ? null
1631
+ : h('span', {
1632
+ className: `dsh-hitl-countdown is-${countdownTone(remaining, false, timedOut)}`,
1633
+ }, timedOut
1634
+ ? t('label.timeout')
1635
+ : t('label.countdown', { seconds: formatRemaining(remaining) })),
1636
+ )
1637
+ }),
1638
+ requests.length > rows.length
1639
+ ? h('li', { className: 'dsh-hitl-notice-row' }, t('notice.more', { count: requests.length - rows.length }))
1640
+ : null,
1641
+ ),
1642
+ failure === undefined ? null : h('div', { className: 'dsh-hitl-error' }, t('error.submit', { message: failure })),
1643
+ h('div', { className: 'dsh-hitl-notice-actions' },
1644
+ typeof gotoSession === 'function'
1645
+ ? h('button', {
1646
+ type: 'button',
1647
+ className: 'dsh-hitl-btn is-ghost',
1648
+ onClick: () => { gotoSession(head.sessionId) },
1649
+ }, t('button.gotoSession'))
1650
+ : null,
1651
+ h('button', {
1652
+ type: 'button',
1653
+ className: 'dsh-hitl-btn is-outline',
1654
+ disabled: busy,
1655
+ onClick: () => { decide({ kind: 'reject' }) },
1656
+ }, t('button.reject')),
1657
+ h('button', {
1658
+ type: 'button',
1659
+ className: 'dsh-hitl-btn is-primary',
1660
+ disabled: busy,
1661
+ onClick: () => { decide({ kind: 'approve' }) },
1662
+ }, busy ? t('button.submitting') : t('button.approve')),
1663
+ )),
1664
+ })
1665
+ }
1666
+
1667
+ /** Component styles: theme tokens only, every class prefixed for this plugin. */
1668
+ const CSS = `
1669
+ .dsh-hitl-panel{display:flex;flex-direction:column;gap:10px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:13px}
1670
+ .dsh-hitl-head{display:flex;align-items:flex-start;gap:8px}
1671
+ .dsh-hitl-title{flex:1 1 auto;min-width:0;margin:0;font-size:14px;font-weight:600;line-height:1.5;overflow-wrap:anywhere;white-space:normal}
1672
+ /* The countdown is a blocking deadline, so it reads as a chip, never as faint text. */
1673
+ .dsh-hitl-countdown{flex:none;display:inline-flex;align-items:center;padding:2px 9px;border:1px solid var(--dsw-alias-border-l3);border-radius:999px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);font-size:12px;font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
1674
+ .dsh-hitl-countdown.is-held{border-color:var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-weight:500}
1675
+ .dsh-hitl-countdown.is-warn{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);animation:dsh-hitl-pulse 1.4s ease-in-out infinite}
1676
+ .dsh-hitl-countdown.is-timeout{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}
1677
+ @keyframes dsh-hitl-pulse{0%,100%{opacity:1}50%{opacity:.6}}
1678
+ @media (prefers-reduced-motion:reduce){.dsh-hitl-countdown.is-warn{animation:none}}
1679
+ .dsh-hitl-chevron{flex:none;display:inline-flex;align-items:center;gap:3px;height:22px;padding:0 7px;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:11px;cursor:pointer}
1680
+ .dsh-hitl-chevron:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
1681
+ .dsh-hitl-chevron:focus-visible{outline:none;border-color:var(--dsw-alias-button-primary-fill)}
1682
+ .dsh-hitl-panel[data-collapsed="true"]{gap:8px}
1683
+ .dsh-hitl-meta{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary)}
1684
+ .dsh-hitl-tool{font-family:var(--dsw-font-family-code,ui-monospace,monospace);color:var(--dsw-alias-label-secondary)}
1685
+ .dsh-hitl-chip{padding:1px 6px;border:1px solid var(--dsw-alias-border-l3);border-radius:999px;font-size:11px;color:var(--dsw-alias-label-secondary)}
1686
+ .dsh-hitl-queued,.dsh-hitl-offline{color:var(--dsw-alias-state-warn-label,var(--dsw-alias-label-secondary))}
1687
+ .dsh-hitl-fields{display:flex;flex-direction:column;gap:10px;max-height:52vh;overflow-x:hidden;overflow-y:auto;padding-right:2px}
1688
+ .dsh-hitl-fields[data-layout="split"]{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:10px}
1689
+ .dsh-hitl-field{display:flex;flex-direction:column;gap:4px;min-width:0}
1690
+ .dsh-hitl-field-head{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
1691
+ .dsh-hitl-field-title{font-weight:600;overflow-wrap:anywhere}
1692
+ .dsh-hitl-field-hint{font-size:11px;color:var(--dsw-alias-label-tertiary)}
1693
+ .dsh-hitl-field-warn{font-size:11px;color:var(--dsw-alias-state-warn-label,var(--dsw-alias-label-tertiary))}
1694
+ .dsh-hitl-field-desc{font-size:12px;color:var(--dsw-alias-label-tertiary)}
1695
+ .dsh-hitl-textarea{width:100%;box-sizing:border-box;resize:none;overflow-y:auto;max-height:320px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-family:var(--ds-font-family-code,ui-monospace,monospace);font-size:12px;line-height:1.6}
1696
+ .dsh-hitl-textarea:focus{outline:none;border-color:var(--dsw-alias-button-primary-fill)}
1697
+ .dsh-hitl-textarea:disabled{opacity:.6;cursor:not-allowed}
1698
+ .dsh-hitl-tabs{display:flex;gap:4px;margin-bottom:4px}
1699
+ .dsh-hitl-tab{padding:2px 8px;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:11px;cursor:pointer}
1700
+ .dsh-hitl-tab.is-active{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}
1701
+ .dsh-hitl-md-split{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:8px}
1702
+ .dsh-hitl-markdown,.dsh-hitl-pre{margin:0;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);overflow:auto;max-height:320px}
1703
+ .dsh-hitl-markdown{font-size:12px;line-height:1.7}
1704
+ .dsh-hitl-pre{font-family:var(--ds-font-family-code,ui-monospace,monospace);font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere}
1705
+ /* Host-markdown parity: same tokens the shipped renderer uses for inline code. */
1706
+ .dsh-hitl-code{display:inline-flex;align-items:center;box-sizing:border-box;font-family:var(--ds-font-family-code,ui-monospace,monospace);font-size:.875em;background-color:var(--dsw-alias-markdown-inline-code);border:.5px solid var(--dsw-alias-border-l1);border-radius:var(--dsw-radius-sm,4px);padding:0 5px}
1707
+ .dsh-hitl-p{margin:0 0 8px}
1708
+ .dsh-hitl-p:last-child{margin-bottom:0}
1709
+ .dsh-hitl-h{margin:12px 0 6px;font-size:13px;font-weight:600;line-height:1.5}
1710
+ .dsh-hitl-h:first-child{margin-top:0}
1711
+ .dsh-hitl-list{margin:8px 0;padding-left:18px}
1712
+ .dsh-hitl-list li:not(:first-child){margin-top:4px}
1713
+ .dsh-hitl-list li::marker{color:var(--dsw-alias-label-secondary)}
1714
+ .dsh-hitl-quote{margin:8px 0;padding-left:12px;border-left:2px solid var(--dsw-alias-label-caption);color:var(--dsw-alias-label-secondary)}
1715
+ .dsh-hitl-hr{display:block;border:none;height:.5px;margin:16px 0;background:var(--dsw-alias-border-l2)}
1716
+ .dsh-hitl-link{color:var(--dsw-alias-link)}
1717
+ .dsh-hitl-diff{display:flex;flex-direction:column;gap:4px}
1718
+ .dsh-hitl-diff-head{font-size:11px;color:var(--dsw-alias-label-tertiary)}
1719
+ .dsh-hitl-diff-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:1px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-border-l2);overflow:auto;max-height:320px}
1720
+ .dsh-hitl-diff-side{background:var(--dsw-alias-bg-layer-2);min-width:0}
1721
+ .dsh-hitl-diff-line{display:flex;gap:8px;padding:0 8px;font-family:var(--ds-font-family-code,ui-monospace,monospace);font-size:12px;line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere}
1722
+ .dsh-hitl-diff-line.is-added{background:var(--dsw-alias-code-diff-added)}
1723
+ .dsh-hitl-diff-line.is-removed{background:var(--dsw-alias-code-diff-deleted)}
1724
+ .dsh-hitl-diff-number{flex:none;width:3ch;text-align:right;color:var(--dsw-alias-label-tertiary)}
1725
+ .dsh-hitl-diff-text{flex:1 1 auto;min-width:0}
1726
+ .dsh-hitl-note{font-size:12px;color:var(--dsw-alias-state-warn-label,var(--dsw-alias-label-secondary))}
1727
+ .dsh-hitl-feedback{display:flex;flex-direction:column;gap:4px}
1728
+ .dsh-hitl-feedback-title{font-size:12px;color:var(--dsw-alias-label-secondary)}
1729
+ .dsh-hitl-error{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-state-error-primary)}
1730
+ .dsh-hitl-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px}
1731
+ .dsh-hitl-actions .dsh-hitl-countdown{margin-right:auto}
1732
+ .dsh-hitl-btn{padding:6px 14px;border-radius:8px;font-size:13px;cursor:pointer;border:1px solid transparent}
1733
+ .dsh-hitl-btn.is-primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-button-primary-label,var(--dsw-alias-bg-base))}
1734
+ .dsh-hitl-btn.is-primary:hover{background:var(--dsw-alias-button-primary-hover)}
1735
+ .dsh-hitl-btn.is-outline{border-color:var(--dsw-alias-border-l3);background:transparent;color:var(--dsw-alias-label-primary)}
1736
+ .dsh-hitl-btn.is-outline:hover{background:var(--dsw-alias-interactive-bg-hover)}
1737
+ .dsh-hitl-btn.is-ghost{border:none;background:transparent;color:var(--dsw-alias-link);padding:2px 4px}
1738
+ .dsh-hitl-btn:disabled{opacity:.55;cursor:not-allowed}
1739
+ .dsh-hitl-settled{font-size:12px;color:var(--dsw-alias-label-tertiary)}
1740
+ .dsh-hitl-chip.is-foreign{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-label,var(--dsw-alias-label-secondary))}
1741
+ /* Frame-level notice. It is promoted to the popover top layer when the browser
1742
+ supports it, so a modal's mask cannot hide it; fixed positioning keeps the same
1743
+ placement inside the shell's overlay layer when it is not. */
1744
+ .dsh-hitl-notice{position:fixed;top:12px;left:50%;transform:translateX(-50%);margin:0;display:flex;flex-direction:column;gap:6px;width:min(620px,calc(100vw - 48px));padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-1);box-shadow:0 8px 28px rgb(0 0 0 / 18%);color:var(--dsw-alias-label-primary);font-size:12px}
1745
+ .dsh-hitl-notice-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px}
1746
+ .dsh-hitl-notice-title{font-weight:600}
1747
+ .dsh-hitl-notice-hint{color:var(--dsw-alias-label-tertiary);font-size:11px}
1748
+ .dsh-hitl-notice-list{display:flex;flex-direction:column;gap:4px;margin:0;padding:0;list-style:none}
1749
+ .dsh-hitl-notice-row{display:flex;align-items:center;gap:8px;min-width:0}
1750
+ .dsh-hitl-notice-tool{flex:none;font-family:var(--ds-font-family-code,ui-monospace,monospace)}
1751
+ .dsh-hitl-notice-source{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary)}
1752
+ .dsh-hitl-notice-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px}
1753
+ .dsh-hitl-notice-actions .dsh-hitl-btn.is-ghost{margin-right:auto}
1754
+ `
1755
+
1756
+ // ── plugin ───────────────────────────────────────────────────────────────
1757
+
1758
+ /** Required client services: locale, slots, and the Session pending-interaction registry. */
1759
+ const inject = ['locale', 'slots', 'uiSession']
1760
+
1761
+ /**
1762
+ * Client plugin body: dictionaries, the transport, the pending-interaction
1763
+ * domain, the composer-seat panel, and the frame-level fallback notice.
1764
+ * @param ctx - client root context.
1765
+ */
1766
+ function apply(ctx) {
1767
+ ctx.effect(() => ctx.locale.register(NS, 'zh', zh), 'dsh-hitl: zh dictionary')
1768
+ ctx.effect(() => ctx.locale.register(NS, 'en', en), 'dsh-hitl: en dictionary')
1769
+ const t = ctx.locale.bind(NS)
1770
+ const boot = typeof globalThis !== 'undefined' ? globalThis.__DSH_HITL__ : undefined
1771
+ const store = createStore()
1772
+ const connection = createConnection({ boot, store })
1773
+ const seat = createSeat()
1774
+ let sequence = 0
1775
+ const pendings = new Map()
1776
+
1777
+ ctx.effect(() => () => { connection.dispose() }, 'dsh-hitl: decision transport')
1778
+ connection.start()
1779
+
1780
+ // Which Session the composer chain renders. A pending interaction is only
1781
+ // ever elected for the displayed Session, so this value decides where a
1782
+ // delegated call's panel is allowed to appear.
1783
+ const readView = () => {
1784
+ const source = ctx.uiSession?.adapter?.current
1785
+ const binding = source === undefined || source === null ? undefined : source.value
1786
+ const id = binding === undefined || binding === null ? undefined : binding.key
1787
+ store.setView(typeof id === 'string' && id !== '' ? id : undefined)
1788
+ }
1789
+ ctx.effect(() => {
1790
+ const source = ctx.uiSession?.adapter?.current
1791
+ if (source === undefined || source === null || typeof source.subscribe !== 'function') return undefined
1792
+ readView()
1793
+ return source.subscribe(readView)
1794
+ }, 'dsh-hitl: displayed Session')
1795
+
1796
+ // Jumping to the asking Session is a capability, not a requirement: the
1797
+ // workspace plugin owns navigation, so a profile without it keeps every
1798
+ // decision path and only loses the jump button.
1799
+ let navigate
1800
+ const canNavigate = () => typeof navigate === 'function'
1801
+ const navigateSession = id => { if (typeof navigate === 'function') navigate(id) }
1802
+ ctx.inject(['uiWorkspace'], (scope) => {
1803
+ navigate = id => { scope.uiWorkspace.openSession(id) }
1804
+ scope.effect(() => () => { navigate = undefined }, 'dsh-hitl: Session jump')
1805
+ })
1806
+
1807
+ // One pending-interaction domain: a blocked tool call outranks the
1808
+ // shipped approval and question panels, and the oldest request wins the
1809
+ // seat so the earliest blocked call is the one being decided.
1810
+ const publish = ctx.uiSession.registerPendingInteraction(
1811
+ interaction => PRECEDENCE - (interaction.sequence ?? 0),
1812
+ )
1813
+
1814
+ const face = () => ({
1815
+ hitl: { store, connection, seat, t, canNavigate, openSession: navigateSession },
1816
+ })
1817
+
1818
+ const dropView = record => {
1819
+ const release = record.viewRelease
1820
+ record.viewRelease = undefined
1821
+ record.viewSessionId = undefined
1822
+ if (release === undefined) return
1823
+ try {
1824
+ release()
1825
+ } catch (error) {
1826
+ console.error('dsh-hitl: releasing a viewed-seat filing failed', error)
1827
+ }
1828
+ }
1829
+
1830
+ // A request is filed under the Session that asked, and — when the human is
1831
+ // looking somewhere else — under the displayed Session too, so a subagent's
1832
+ // gated call meets the human wherever they are instead of waiting in a
1833
+ // child Session nobody has open.
1834
+ const syncViews = () => {
1835
+ const viewSessionId = store.getSnapshot().viewSessionId
1836
+ for (const record of pendings.values()) {
1837
+ const filings = filingSessions(record.primary.request, viewSessionId)
1838
+ const target = filings.length > 1 ? filings[1] : undefined
1839
+ if (record.viewSessionId === target) continue
1840
+ dropView(record)
1841
+ if (target === undefined) continue
1842
+ const view = new PendingHitlView(record.primary, target)
1843
+ record.viewSessionId = target
1844
+ record.viewRelease = publish(view, () => view.delegate())
1845
+ }
1846
+ }
1847
+
1848
+ const publishRequest = request => {
1849
+ const existing = pendings.get(request.id)
1850
+ if (existing !== undefined) {
1851
+ existing.primary.request = request
1852
+ return existing
1853
+ }
1854
+ sequence += 1
1855
+ const primary = new PendingHitl(request, connection, sequence)
1856
+ const record = { primary, viewSessionId: undefined, viewRelease: undefined }
1857
+ record.primaryRelease = publish(primary, () => primary.delegate())
1858
+ pendings.set(request.id, record)
1859
+ syncViews()
1860
+ return record
1861
+ }
1862
+
1863
+ const unpublish = id => {
1864
+ const record = pendings.get(id)
1865
+ if (record === undefined) return
1866
+ pendings.delete(id)
1867
+ store.remove(id)
1868
+ dropView(record)
1869
+ try {
1870
+ record.primaryRelease?.()
1871
+ } catch (error) {
1872
+ console.error('dsh-hitl: releasing a pending decision failed', error)
1873
+ }
1874
+ }
1875
+
1876
+ const stopStore = store.subscribe(() => {
1877
+ const snapshot = store.getSnapshot()
1878
+ for (const request of snapshot.requests) publishRequest(request)
1879
+ for (const id of [...pendings.keys()]) {
1880
+ if (!snapshot.requests.some(request => request.id === id)) unpublish(id)
1881
+ }
1882
+ syncViews()
1883
+ })
1884
+ ctx.effect(() => () => {
1885
+ stopStore()
1886
+ for (const id of [...pendings.keys()]) unpublish(id)
1887
+ }, 'dsh-hitl: mirrored pending requests')
1888
+
1889
+ ctx.slots.inject('conversation.composer', () => ctx.slots.register({
1890
+ name: 'conversation.composer',
1891
+ priority: CHAIN_PRIORITY,
1892
+ select: ({ pendingInteraction }) => pendingInteraction instanceof PendingHitl
1893
+ || pendingInteraction instanceof PendingHitlView
1894
+ ? pendingInteraction
1895
+ : null,
1896
+ locale: NS,
1897
+ inject: face,
1898
+ }, HitlPanel))
1899
+
1900
+ // Frame-level fallback for the requests no composer can reach: no Session
1901
+ // selected, another main panel on screen, or a shipped panel outranking
1902
+ // this one. Registered by id so it never displaces another overlay entry.
1903
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
1904
+ name: 'shell.overlay',
1905
+ id: 'dsh-hitl.notice',
1906
+ locale: NS,
1907
+ inject: face,
1908
+ }, HitlNotice))
1909
+ }
1910
+
1911
+ return {
1912
+ inject,
1913
+ apply,
1914
+ /** Pure helpers, exported for the host-side tests of this file. */
1915
+ __test: { escapeHtml, renderMarkdownHtml, diffLines, formatRemaining, primaryLabel, changedFields, isFieldEditable, countdownTone, streamStale, deadlineOf, reconcileClock, shortSession, filingSessions, isForeignRequest, noticeVisible, createSeat, isObscured, claimsEditable, PendingHitl, PendingHitlView, createStore, createConnection, zh, en },
1916
+ }
1917
+ },
1918
+ })