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/LICENSE +21 -0
- package/README.md +415 -0
- package/README.zh.md +490 -0
- package/client.js +1918 -0
- package/cordis.patch.yml +17 -0
- package/docs/01-panel-cn.png +0 -0
- package/docs/01-panel-en.png +0 -0
- package/docs/02-fields-cn.png +0 -0
- package/docs/02-fields-en.png +0 -0
- package/docs/03-subagent-cn.png +0 -0
- package/docs/03-subagent-en.png +0 -0
- package/docs/04-overlay-cn.png +0 -0
- package/docs/04-overlay-en.png +0 -0
- package/icon.svg +6 -0
- package/index.js +629 -0
- package/lib/fields.js +244 -0
- package/lib/pending.js +217 -0
- package/lib/protocol.js +182 -0
- package/lib/resolve.js +302 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +69 -0
- package/tests/client.test.js +818 -0
- package/tests/docs.test.js +116 -0
- package/tests/fields.test.js +269 -0
- package/tests/manifest.test.js +61 -0
- package/tests/pending.test.js +240 -0
- package/tests/protocol.test.js +117 -0
- package/tests/resolve.test.js +162 -0
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('<', '<')
|
|
151
|
+
.replaceAll('>', '>')
|
|
152
|
+
.replaceAll('"', '"')
|
|
153
|
+
.replaceAll("'", ''')
|
|
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('&', '&'))
|
|
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
|
+
})
|