@lsfcy/dsh-composer-predictions 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,1072 @@
1
+ // GENERATED FILE — edit src/ghost.js or src/main.js and run: node build.mjs
2
+ window.__ModuleLoader__.load({
3
+ id: "@lsfcy/dsh-composer-predictions",
4
+ factory: (require) => {
5
+ var module = { exports: {} }
6
+ var exports = module.exports
7
+ /**
8
+ * Ghost-text layer for the DSH composer.
9
+ *
10
+ * Paints one dimmed line of "predicted next message" inside an EMPTY composer,
11
+ * the way Codex shows composer predictions. Pure DOM: no React, no imports, so
12
+ * the DSH client bundle and the demo pages run the exact same code (build.mjs
13
+ * concatenates this file into client.js).
14
+ *
15
+ * The composer is a Lexical contenteditable (`[data-composer-input]`), not a
16
+ * textarea, and it already ships its own absolutely-positioned placeholder
17
+ * (`[data-composer-placeholder]`) inside the same `position:relative` `.grow`
18
+ * wrapper. The layer therefore measures THAT element on every paint and copies
19
+ * its typography, so it sits exactly where the placeholder sits without any
20
+ * hard-coded inset. A plain textarea (the demo harness) falls back to the
21
+ * target's own border box.
22
+ *
23
+ * The placeholder is hidden with `visibility` rather than `display`: its box
24
+ * (and therefore the geometry we measure) survives, and React re-creating the
25
+ * placeholder after a draft round-trip cannot leave two lines painted at once —
26
+ * each paint re-queries the live node and hides the current one.
27
+ *
28
+ * Contract:
29
+ * const layer = createGhostLayer(target, { onAccept, onDismiss, canIntercept })
30
+ * layer.setText('run the tests') // '' hides
31
+ * layer.getText() // current line ('' when hidden)
32
+ * layer.destroy()
33
+ *
34
+ * Keys while the ghost is showing, captured on the CONTAINER (an ancestor), so
35
+ * the listener runs before Lexical's own root listener on the target:
36
+ * Tab -> accept: hide, then onAccept(text)
37
+ * Escape -> dismiss: hide, then onDismiss()
38
+ * typing -> dismiss; the composer inserts the character as usual
39
+ */
40
+ ;(function (global) {
41
+ 'use strict'
42
+
43
+ var COPIED_PROPERTIES = [
44
+ 'font-family',
45
+ 'font-size',
46
+ 'font-weight',
47
+ 'font-style',
48
+ 'font-variant',
49
+ 'font-stretch',
50
+ 'letter-spacing',
51
+ 'line-height',
52
+ 'text-transform',
53
+ 'text-indent',
54
+ 'tab-size',
55
+ 'direction',
56
+ 'padding-top',
57
+ 'padding-right',
58
+ 'padding-bottom',
59
+ 'padding-left',
60
+ 'border-top-width',
61
+ 'border-right-width',
62
+ 'border-bottom-width',
63
+ 'border-left-width',
64
+ 'box-sizing',
65
+ 'overflow-wrap',
66
+ 'word-break',
67
+ 'text-align',
68
+ ]
69
+
70
+ /** True when the composer holds no text of the user's own. */
71
+ function isEmpty(target) {
72
+ if (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT') return target.value === ''
73
+ return String(target.textContent || '').replace(/\u200b/g, '').trim() === ''
74
+ }
75
+
76
+ function copyStyle(from, parts) {
77
+ var style = global.getComputedStyle(from)
78
+ for (var index = 0; index < COPIED_PROPERTIES.length; index += 1) {
79
+ var name = COPIED_PROPERTIES[index]
80
+ var value = style.getPropertyValue(name)
81
+ if (value) parts.push(name + ':' + value)
82
+ }
83
+ if (style.color) parts.push('color:' + style.color)
84
+ }
85
+
86
+ function createGhostLayer(target, options) {
87
+ var opts = options || {}
88
+ if (!target || !target.parentElement) return null
89
+
90
+ var container = target.parentElement
91
+ var previousPosition = container.style.position
92
+ if (global.getComputedStyle(container).position === 'static') {
93
+ container.style.position = 'relative'
94
+ }
95
+
96
+ var element = document.createElement('div')
97
+ element.dataset.composerPrediction = 'ghost'
98
+ element.setAttribute('aria-hidden', 'true')
99
+ // Keep the pre-first-paint state out of flow so the composer never grows a
100
+ // stray line before a prediction exists.
101
+ element.style.display = 'none'
102
+ container.appendChild(element)
103
+
104
+ var shown = ''
105
+ var placeholder = null
106
+
107
+ function livePlaceholder() {
108
+ return container.querySelector('[data-composer-placeholder]')
109
+ }
110
+
111
+ /** Hide the CURRENT placeholder. A React remount is handled on every paint. */
112
+ function hidePlaceholder() {
113
+ var next = livePlaceholder()
114
+ if (placeholder !== next) {
115
+ restorePlaceholder()
116
+ placeholder = next
117
+ }
118
+ // DSH never sets an inline visibility on this node, so '' is the honest
119
+ // neutral value. Keeping the box (rather than display:none) is what lets
120
+ // the next paint measure the same rectangle.
121
+ if (next) next.style.visibility = 'hidden'
122
+ }
123
+
124
+ function restorePlaceholder() {
125
+ if (placeholder && placeholder.style) placeholder.style.visibility = ''
126
+ placeholder = null
127
+ }
128
+
129
+ function measure(source) {
130
+ if (source !== target) {
131
+ return {
132
+ left: source.offsetLeft,
133
+ top: source.offsetTop,
134
+ width: source.offsetWidth,
135
+ height: source.offsetHeight,
136
+ }
137
+ }
138
+ var box = target.getBoundingClientRect()
139
+ var frame = container.getBoundingClientRect()
140
+ return {
141
+ left: box.left - frame.left - container.clientLeft + container.scrollLeft,
142
+ top: box.top - frame.top - container.clientTop + container.scrollTop,
143
+ width: box.width,
144
+ height: box.height,
145
+ }
146
+ }
147
+
148
+ function paint() {
149
+ var parts = [
150
+ 'display:block',
151
+ 'position:absolute',
152
+ 'pointer-events:none',
153
+ 'user-select:none',
154
+ 'z-index:4',
155
+ 'margin:0',
156
+ 'overflow:hidden',
157
+ 'text-overflow:ellipsis',
158
+ 'white-space:nowrap',
159
+ 'opacity:.5',
160
+ ]
161
+ var source = livePlaceholder() || target
162
+ copyStyle(source, parts)
163
+ // Single-line ellipsis wins over the placeholder's own wrapping rules.
164
+ parts.push('white-space:nowrap', 'overflow:hidden', 'text-overflow:ellipsis')
165
+ if (source === target) {
166
+ parts.push('border-style:solid', 'border-color:transparent', 'background:transparent')
167
+ }
168
+ var box = measure(source)
169
+ if (box.width > 0) {
170
+ parts.push('left:' + box.left + 'px', 'top:' + box.top + 'px', 'width:' + box.width + 'px')
171
+ if (box.height > 0) parts.push('height:' + box.height + 'px')
172
+ }
173
+ element.style.cssText = parts.join(';')
174
+ }
175
+
176
+ function render() {
177
+ // A non-empty draft always wins: the user is already typing their own message.
178
+ var blocked =
179
+ isEmpty(target) === false ||
180
+ target.getAttribute('aria-disabled') === 'true' ||
181
+ target.getBoundingClientRect().width === 0
182
+ if (!shown || blocked) {
183
+ element.style.display = 'none'
184
+ element.textContent = ''
185
+ restorePlaceholder()
186
+ return
187
+ }
188
+ element.textContent = shown
189
+ paint()
190
+ element.style.display = 'block'
191
+ // Hide only after measuring: the hidden node keeps its box, so the next
192
+ // paint still measures the same rectangle.
193
+ hidePlaceholder()
194
+ }
195
+
196
+ function hide() {
197
+ shown = ''
198
+ render()
199
+ }
200
+
201
+ function canIntercept() {
202
+ return typeof opts.canIntercept === 'function' ? opts.canIntercept() !== false : true
203
+ }
204
+
205
+ function acceptShown() {
206
+ var text = shown
207
+ hide()
208
+ if (opts.onAccept) opts.onAccept(text)
209
+ }
210
+
211
+ function onKeyDown(event) {
212
+ if (!shown || element.style.display === 'none') return
213
+ if (event.isComposing || event.keyCode === 229) return
214
+ if (event.key === 'Tab' && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) {
215
+ // The slash/@ completion menu owns Tab while it is open.
216
+ if (!canIntercept()) return
217
+ event.preventDefault()
218
+ event.stopPropagation()
219
+ acceptShown()
220
+ return
221
+ }
222
+ if (event.key === 'Escape') {
223
+ // A dialog or menu owns Escape; never close it on the user's behalf.
224
+ if (!canIntercept()) return
225
+ event.preventDefault()
226
+ event.stopPropagation()
227
+ hide()
228
+ if (opts.onDismiss) opts.onDismiss()
229
+ return
230
+ }
231
+ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
232
+ hide()
233
+ if (opts.onDismiss) opts.onDismiss()
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Tab with nothing focused. Switching sessions in DSH leaves focus on
239
+ * <body>; the container listener never sees that key, so the visible
240
+ * suggestion would otherwise be unreachable without clicking the composer
241
+ * first. Only the no-focus state is claimed: a focused control keeps its
242
+ * normal Tab navigation.
243
+ */
244
+ function onDocumentKeyDown(event) {
245
+ if (!shown || element.style.display === 'none') return
246
+ if (event.defaultPrevented || event.isComposing || event.keyCode === 229) return
247
+ if (event.key !== 'Tab' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return
248
+ var active = global.document ? global.document.activeElement : null
249
+ if (active !== null && active !== global.document.body && active !== global.document.documentElement) return
250
+ if (!canIntercept()) return
251
+ event.preventDefault()
252
+ event.stopPropagation()
253
+ acceptShown()
254
+ }
255
+
256
+ function onInput() {
257
+ render()
258
+ }
259
+
260
+ function onCompositionStart() {
261
+ if (!shown) return
262
+ hide()
263
+ if (opts.onDismiss) opts.onDismiss()
264
+ }
265
+
266
+ var observer = typeof global.ResizeObserver === 'function' ? new global.ResizeObserver(render) : null
267
+ if (observer) observer.observe(target)
268
+ // The placeholder is a React child: a draft round-trip replaces the node.
269
+ // Re-hide the live one instead of trusting the node captured at attach time.
270
+ var mutations =
271
+ typeof global.MutationObserver === 'function'
272
+ ? new global.MutationObserver(function () {
273
+ if (shown) render()
274
+ })
275
+ : null
276
+ if (mutations) mutations.observe(container, { childList: true })
277
+ global.addEventListener('resize', render)
278
+ // Capture on the ancestor: for listeners registered on the SAME node as
279
+ // Lexical's, capture and bubble listeners both run in registration order,
280
+ // so the ancestor is the only place that reliably runs first.
281
+ container.addEventListener('keydown', onKeyDown, true)
282
+ container.addEventListener('input', onInput, true)
283
+ container.addEventListener('compositionstart', onCompositionStart, true)
284
+ target.addEventListener('focus', render)
285
+ if (global.document) global.document.addEventListener('keydown', onDocumentKeyDown, true)
286
+
287
+ return {
288
+ element: element,
289
+ setText: function (text) {
290
+ shown = String(text == null ? '' : text).replace(/\s*\n+\s*/g, ' ').trim()
291
+ render()
292
+ },
293
+ getText: function () {
294
+ return shown
295
+ },
296
+ isShowing: function () {
297
+ return shown !== '' && element.style.display !== 'none'
298
+ },
299
+ hide: hide,
300
+ destroy: function () {
301
+ restorePlaceholder()
302
+ if (observer) observer.disconnect()
303
+ if (mutations) mutations.disconnect()
304
+ global.removeEventListener('resize', render)
305
+ container.removeEventListener('keydown', onKeyDown, true)
306
+ container.removeEventListener('input', onInput, true)
307
+ container.removeEventListener('compositionstart', onCompositionStart, true)
308
+ target.removeEventListener('focus', render)
309
+ if (global.document) global.document.removeEventListener('keydown', onDocumentKeyDown, true)
310
+ element.remove()
311
+ container.style.position = previousPosition
312
+ },
313
+ }
314
+ }
315
+
316
+ if (typeof module === 'object' && module && module.exports) {
317
+ module.exports.createGhostLayer = createGhostLayer
318
+ }
319
+ // Also reachable as a global so the standalone demo pages can load this file
320
+ // with a plain <script> and exercise the very same implementation.
321
+ global.createComposerGhost = createGhostLayer
322
+ global.composerGhostIsEmpty = isEmpty
323
+ })(typeof globalThis === 'undefined' ? this : globalThis)
324
+
325
+ // Browser half body. build.mjs concatenates src/ghost.js (which defines
326
+ // globalThis.createComposerGhost) in front of this file and wraps the pair in
327
+ // the lazy-CJS bundle envelope, so `require` here is the module loader's.
328
+ //
329
+ // What this registers: one entry in the additive "conversation.input.dock"
330
+ // slot (kind: list, scope: session). The dock renders inside the composer stack
331
+ // and hands every entry { session, input } plus the standard session kit —
332
+ // including `sessionId` and `inputActions`. That is everything a prediction
333
+ // needs:
334
+ //
335
+ // running: true -> a turn is in flight; drop any suggestion
336
+ // running: false -> a turn just settled; ask the host for a prediction
337
+ // draft !== '' -> the user is writing; dismiss it and never refetch
338
+ //
339
+ // The visible suggestion is NOT React: src/ghost.js paints it into the Lexical
340
+ // composer (`[data-composer-input]`), because the only in-box seat DSH exposes
341
+ // is a full composer takeover. This entry renders a hidden anchor, which lets
342
+ // the layer find the composer seat that owns THIS session (so a second mounted
343
+ // conversation can never paint into the wrong editor) without adding a row,
344
+ // margin, or hint line that would shift the composer.
345
+
346
+ const react = require('react')
347
+ const h = react.createElement
348
+ const createGhostLayer = globalThis.createComposerGhost
349
+
350
+ const PREDICT_PATH = '/composer-predictions/predict'
351
+ const CONFIG_PATH = '/composer-predictions/config'
352
+ const CLIENT_TIMEOUT_MS = 30_000
353
+ const ATTACH_FRAMES = 120
354
+
355
+ const selectRunning = (state) => !!(state && state.running)
356
+ const noSession = () => undefined
357
+
358
+ /**
359
+ * One config snapshot shared by the dock (whether to ask at all) and the
360
+ * Settings page (what to show). The host owns persistence; this store only
361
+ * caches the last read so a re-render never refetches.
362
+ */
363
+ const configStore = (() => {
364
+ let snapshot = null
365
+ let pending = null
366
+ const listeners = new Set()
367
+ const publish = (next) => {
368
+ snapshot = next
369
+ for (const listener of listeners) listener(next)
370
+ }
371
+ return {
372
+ get: () => snapshot,
373
+ subscribe(listener) {
374
+ listeners.add(listener)
375
+ return () => listeners.delete(listener)
376
+ },
377
+ load() {
378
+ if (pending) return pending
379
+ pending = fetch(CONFIG_PATH, {
380
+ headers: { accept: 'application/json' },
381
+ cache: 'no-store',
382
+ credentials: 'same-origin',
383
+ })
384
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error('HTTP ' + response.status))))
385
+ .then((payload) => {
386
+ if (payload && payload.config) publish(payload.config)
387
+ return snapshot
388
+ })
389
+ .catch((error) => {
390
+ console.warn(
391
+ '[composer-predictions] config load failed:',
392
+ error && error.message ? error.message : error,
393
+ )
394
+ // A stale host (client updated first) must not silently disable
395
+ // predictions: fall back to the default-on behaviour and let the
396
+ // settings page explain that persistence is unavailable.
397
+ publish({ enabled: true, unavailable: true })
398
+ return snapshot
399
+ })
400
+ .finally(() => {
401
+ pending = null
402
+ })
403
+ return pending
404
+ },
405
+ async save(patch) {
406
+ const response = await fetch(CONFIG_PATH, {
407
+ method: 'POST',
408
+ headers: { 'content-type': 'application/json' },
409
+ body: JSON.stringify({ config: patch }),
410
+ cache: 'no-store',
411
+ credentials: 'same-origin',
412
+ })
413
+ const payload = await response.json().catch(() => ({}))
414
+ if (!response.ok) throw new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
415
+ if (payload && payload.config) publish(payload.config)
416
+ return payload && payload.config
417
+ },
418
+ }
419
+ })()
420
+
421
+ function useConfig() {
422
+ const [snapshot, setSnapshot] = react.useState(configStore.get)
423
+ react.useEffect(() => configStore.subscribe(setSnapshot), [])
424
+ react.useEffect(() => {
425
+ if (configStore.get() === null) configStore.load()
426
+ }, [])
427
+ return snapshot
428
+ }
429
+
430
+ const COPY = {
431
+ zh: {
432
+ section: '输入框预测',
433
+ title: '输入框预测',
434
+ intro: '一轮回答结束后,在空的输入框里用灰字给出你接下来可能发送的消息。Tab 接受,Esc 忽略。',
435
+ enabled: '启用预测',
436
+ enabledHint: '关闭后不再请求模型,也不显示灰字;已显示的建议会立即隐藏。',
437
+ provider: '模型 provider',
438
+ providerHint: '留空 = 自动使用当前选中的 provider。',
439
+ model: '模型 id',
440
+ modelHint: '留空 = 自动选该 provider 下最便宜的型号。',
441
+ effort: '推理档',
442
+ effortHint: '留空 = 自动选支持的最省一档。',
443
+ auto: '自动',
444
+ save: '保存',
445
+ reset: '恢复默认',
446
+ saving: '保存中…',
447
+ saved: '已保存',
448
+ failed: '保存失败:',
449
+ loading: '正在读取配置…',
450
+ readOnly: '当前运行时不支持持久化配置。',
451
+ },
452
+ en: {
453
+ section: 'Composer prediction',
454
+ title: 'Composer prediction',
455
+ intro: 'After a turn settles, the empty composer shows a dim next-message suggestion. Tab accepts it; Escape dismisses it.',
456
+ enabled: 'Enable predictions',
457
+ enabledHint: 'Off stops every request and hides the ghost immediately.',
458
+ provider: 'Model provider',
459
+ providerHint: 'Empty = the provider you are currently using.',
460
+ model: 'Model id',
461
+ modelHint: 'Empty = the cheapest model on that provider.',
462
+ effort: 'Reasoning effort',
463
+ effortHint: 'Empty = the cheapest effort the model supports.',
464
+ auto: 'Auto',
465
+ save: 'Save',
466
+ reset: 'Reset to defaults',
467
+ saving: 'Saving…',
468
+ saved: 'Saved',
469
+ failed: 'Save failed: ',
470
+ loading: 'Loading configuration…',
471
+ readOnly: 'This runtime cannot persist configuration.',
472
+ },
473
+ }
474
+
475
+ // The app's locale service mirrors the active language onto <html lang>; the
476
+ // Electron renderer's navigator.language stays en-US, so reading it would keep
477
+ // an English page under a Chinese app. Track the attribute and re-render on it.
478
+ function currentLocale() {
479
+ if (typeof document !== 'undefined' && document.documentElement && document.documentElement.lang) {
480
+ return document.documentElement.lang
481
+ }
482
+ return (typeof navigator !== 'undefined' && navigator.language) || 'en'
483
+ }
484
+
485
+ function copy(locale) {
486
+ const language = String(locale || currentLocale()).toLowerCase()
487
+ return language.indexOf('zh') === 0 ? COPY.zh : COPY.en
488
+ }
489
+
490
+ function useLocaleId() {
491
+ const [locale, setLocale] = react.useState(currentLocale)
492
+ react.useEffect(() => {
493
+ if (typeof document === 'undefined' || !document.documentElement) return undefined
494
+ const element = document.documentElement
495
+ const sync = () => setLocale(currentLocale())
496
+ const observer = new MutationObserver(sync)
497
+ observer.observe(element, { attributes: true, attributeFilter: ['lang'] })
498
+ sync()
499
+ return () => observer.disconnect()
500
+ }, [])
501
+ return locale
502
+ }
503
+
504
+ const FIELD_STYLE = {
505
+ width: '100%',
506
+ boxSizing: 'border-box',
507
+ height: 34,
508
+ padding: '0 10px',
509
+ borderRadius: 8,
510
+ border: '1px solid var(--dsw-alias-border-l2, #e5e6eb)',
511
+ background: 'var(--dsw-alias-bg-module-platform, #fff)',
512
+ color: 'var(--dsw-alias-label-primary, #1d2129)',
513
+ font: 'inherit',
514
+ fontSize: 13,
515
+ outline: 'none',
516
+ }
517
+
518
+ const CARD_STYLE = {
519
+ display: 'flex',
520
+ flexDirection: 'column',
521
+ gap: 18,
522
+ padding: '18px 20px',
523
+ borderRadius: 12,
524
+ border: '1px solid var(--dsw-alias-border-l2, #e5e6eb)',
525
+ background: 'var(--dsw-alias-bg-layer-3, transparent)',
526
+ }
527
+
528
+ const LABEL_STYLE = {
529
+ display: 'flex',
530
+ flexDirection: 'column',
531
+ gap: 6,
532
+ fontSize: 13,
533
+ color: 'var(--dsw-alias-label-primary, #1d2129)',
534
+ }
535
+
536
+ const HINT_STYLE = { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary, #86909c)' }
537
+
538
+ function Switch({ checked, label, onToggle }) {
539
+ return h(
540
+ 'button',
541
+ {
542
+ type: 'button',
543
+ role: 'switch',
544
+ 'aria-checked': checked,
545
+ 'aria-label': label,
546
+ onClick: () => onToggle(!checked),
547
+ style: {
548
+ position: 'relative',
549
+ flex: 'none',
550
+ width: 40,
551
+ height: 22,
552
+ padding: 0,
553
+ border: 'none',
554
+ borderRadius: 999,
555
+ cursor: 'pointer',
556
+ background: checked
557
+ ? 'var(--dsw-alias-state-business-primary, #2f6fed)'
558
+ : 'var(--dsw-alias-border-l2, #c9cdd4)',
559
+ transition: 'background .15s ease',
560
+ },
561
+ },
562
+ h('span', {
563
+ style: {
564
+ position: 'absolute',
565
+ top: 2,
566
+ left: checked ? 20 : 2,
567
+ width: 18,
568
+ height: 18,
569
+ borderRadius: '50%',
570
+ background: '#fff',
571
+ transition: 'left .15s ease',
572
+ boxShadow: '0 1px 2px rgba(0,0,0,.2)',
573
+ },
574
+ }),
575
+ )
576
+ }
577
+
578
+ function Field({ label, hint, children }) {
579
+ return h('label', { style: LABEL_STYLE }, h('span', null, label), children, hint ? h('span', { style: HINT_STYLE }, hint) : null)
580
+ }
581
+
582
+ function PredictionSettings() {
583
+ const config = useConfig()
584
+ const locale = useLocaleId()
585
+ const words = copy(locale)
586
+ const [draft, setDraft] = react.useState(null)
587
+ const [status, setStatus] = react.useState('')
588
+ const [busy, setBusy] = react.useState(false)
589
+
590
+ react.useEffect(() => {
591
+ if (!config || config.unavailable) return
592
+ setDraft((current) =>
593
+ current ?? {
594
+ enabled: config.enabled !== false,
595
+ provider: config.provider || '',
596
+ model: config.model || '',
597
+ reasoningEffort: config.reasoningEffort || '',
598
+ },
599
+ )
600
+ }, [config])
601
+
602
+ const save = async (next) => {
603
+ setBusy(true)
604
+ setStatus('')
605
+ try {
606
+ await configStore.save(next)
607
+ setStatus(words.saved)
608
+ } catch (error) {
609
+ setStatus(words.failed + (error && error.message ? error.message : String(error)))
610
+ } finally {
611
+ setBusy(false)
612
+ }
613
+ }
614
+
615
+ if (config && config.unavailable) {
616
+ return h('section', { style: { padding: '8px 0', color: 'var(--dsw-alias-label-tertiary, #86909c)', fontSize: 13 } }, words.readOnly)
617
+ }
618
+ if (!config || !draft) {
619
+ return h('section', { style: { padding: '8px 0', color: 'var(--dsw-alias-label-tertiary, #86909c)', fontSize: 13 } }, words.loading)
620
+ }
621
+
622
+ const update = (patch) => setDraft({ ...draft, ...patch })
623
+
624
+ return h(
625
+ 'section',
626
+ { style: { display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 640, padding: '8px 0 24px' } },
627
+ h('h2', { style: { margin: 0, fontSize: 16, fontWeight: 600 } }, words.title),
628
+ h('p', { style: { margin: 0, fontSize: 13, lineHeight: '20px', color: 'var(--dsw-alias-label-secondary, #4e5969)' } }, words.intro),
629
+ h(
630
+ 'div',
631
+ { style: CARD_STYLE },
632
+ h(
633
+ 'div',
634
+ { style: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 } },
635
+ h(
636
+ 'div',
637
+ { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
638
+ h('span', { style: { fontSize: 13 } }, words.enabled),
639
+ h('span', { style: HINT_STYLE }, words.enabledHint),
640
+ ),
641
+ h(Switch, {
642
+ checked: draft.enabled !== false,
643
+ label: words.enabled,
644
+ onToggle: (enabled) => update({ enabled }),
645
+ }),
646
+ ),
647
+ h(
648
+ Field,
649
+ { label: words.provider, hint: words.providerHint },
650
+ h('input', {
651
+ type: 'text',
652
+ value: draft.provider,
653
+ placeholder: words.auto,
654
+ onChange: (event) => update({ provider: event.target.value }),
655
+ style: FIELD_STYLE,
656
+ }),
657
+ ),
658
+ h(
659
+ Field,
660
+ { label: words.model, hint: words.modelHint },
661
+ h('input', {
662
+ type: 'text',
663
+ value: draft.model,
664
+ placeholder: words.auto,
665
+ onChange: (event) => update({ model: event.target.value }),
666
+ style: FIELD_STYLE,
667
+ }),
668
+ ),
669
+ h(
670
+ Field,
671
+ { label: words.effort, hint: words.effortHint },
672
+ h(
673
+ 'select',
674
+ {
675
+ value: draft.reasoningEffort,
676
+ onChange: (event) => update({ reasoningEffort: event.target.value }),
677
+ style: FIELD_STYLE,
678
+ },
679
+ h('option', { value: '' }, words.auto),
680
+ ...[['off'], ['none'], ['minimal'], ['low'], ['high']].map(([value]) => h('option', { key: value, value }, value)),
681
+ ),
682
+ ),
683
+ ),
684
+ h(
685
+ 'div',
686
+ { style: { display: 'flex', alignItems: 'center', gap: 12 } },
687
+ h(
688
+ 'button',
689
+ {
690
+ type: 'button',
691
+ disabled: busy,
692
+ onClick: () => save(draft),
693
+ style: {
694
+ height: 32,
695
+ padding: '0 16px',
696
+ borderRadius: 8,
697
+ border: 'none',
698
+ cursor: busy ? 'default' : 'pointer',
699
+ opacity: busy ? 0.6 : 1,
700
+ background: 'var(--dsw-alias-state-business-primary, #2f6fed)',
701
+ color: '#fff',
702
+ font: 'inherit',
703
+ fontSize: 13,
704
+ },
705
+ },
706
+ busy ? words.saving : words.save,
707
+ ),
708
+ h(
709
+ 'button',
710
+ {
711
+ type: 'button',
712
+ disabled: busy,
713
+ onClick: () => {
714
+ const next = { enabled: true, provider: '', model: '', reasoningEffort: '' }
715
+ setDraft(next)
716
+ save(next)
717
+ },
718
+ style: {
719
+ height: 32,
720
+ padding: '0 12px',
721
+ borderRadius: 8,
722
+ border: '1px solid var(--dsw-alias-border-l2, #e5e6eb)',
723
+ background: 'transparent',
724
+ color: 'var(--dsw-alias-label-secondary, #4e5969)',
725
+ cursor: busy ? 'default' : 'pointer',
726
+ font: 'inherit',
727
+ fontSize: 13,
728
+ },
729
+ },
730
+ words.reset,
731
+ ),
732
+ status ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #86909c)' } }, status) : null,
733
+ ),
734
+ )
735
+ }
736
+
737
+ /**
738
+ * The composer editable surface that belongs to this dock's conversation seat.
739
+ * `[data-composer-input]` is a Lexical contenteditable, not a textarea.
740
+ */
741
+ function findComposerInput(anchor) {
742
+ const seat = anchor && typeof anchor.closest === 'function'
743
+ ? anchor.closest('[data-conversation-region="composer"]')
744
+ : null
745
+ const scope = seat || document
746
+ const nodes = scope.querySelectorAll('[data-composer-input]')
747
+ for (let index = nodes.length - 1; index >= 0; index -= 1) {
748
+ const element = nodes[index]
749
+ const box = element.getBoundingClientRect()
750
+ if (box.width > 0 && box.height > 0) return element
751
+ }
752
+ return null
753
+ }
754
+
755
+ /**
756
+ * A completion menu owns Tab/Escape while it is open; never steal either.
757
+ *
758
+ * The overlay anchor always exists and always contains the slot wrapper (and a
759
+ * hidden placeholder span in the closed state), so `childElementCount > 0` is
760
+ * permanently true — the bug that made Tab do nothing in the first real build.
761
+ * Only a laid-out overlay node counts: slot wrappers (`display: contents`) and
762
+ * hidden nodes are skipped, and the remaining node must have a real box.
763
+ */
764
+ function completionMenuOpen(anchor) {
765
+ const seat = anchor && typeof anchor.closest === 'function'
766
+ ? anchor.closest('[data-conversation-region="composer"]')
767
+ : null
768
+ const overlay = (seat || document).querySelector('[data-composer-overlay]')
769
+ if (!overlay) return false
770
+ const nodes = overlay.querySelectorAll('*')
771
+ for (let index = 0; index < nodes.length; index += 1) {
772
+ const node = nodes[index]
773
+ if (node.hasAttribute('data-slot') || node.hasAttribute('hidden')) continue
774
+ const style = getComputedStyle(node)
775
+ if (style.display === 'none' || style.visibility === 'hidden') continue
776
+ const box = node.getBoundingClientRect()
777
+ if (box.width > 0 && box.height > 0) return true
778
+ }
779
+ return false
780
+ }
781
+
782
+ function PredictionDock(props) {
783
+ const session = props.session || {}
784
+ const input = props.input || {}
785
+ const draft = typeof input.draft === 'string' ? input.draft : ''
786
+ // The standard session kit always supplies useSession; the fallback keeps the
787
+ // component usable in the bundle test and during a partial boot.
788
+ const useSession = typeof props.useSession === 'function' ? props.useSession : noSession
789
+ const liveRunning = useSession(selectRunning)
790
+ const running = typeof liveRunning === 'boolean' ? liveRunning : !!(session && session.running)
791
+ const pending = Array.isArray(session.pendingSubmissions)
792
+ ? session.pendingSubmissions
793
+ : Array.isArray(session.pending)
794
+ ? session.pending
795
+ : []
796
+ // Only queued submissions hold a turn open; transcript-placement entries are
797
+ // already part of the conversation.
798
+ const queued = pending.some((item) => item && item.placement === 'queued')
799
+ const sessionId = props.sessionId || session.sessionId || ''
800
+ const config = useConfig()
801
+ const configReady = config !== null
802
+ const disabled = config !== null && config.enabled === false
803
+
804
+ const [prediction, setPrediction] = react.useState('')
805
+ const predictionRef = react.useRef('')
806
+ predictionRef.current = prediction
807
+ const draftRef = react.useRef(draft)
808
+ draftRef.current = draft
809
+ const actionsRef = react.useRef(props.inputActions)
810
+ actionsRef.current = props.inputActions
811
+ // A request is wanted after mount, after a session switch, and after each
812
+ // finished turn. The host re-uses its per-session cache, so re-entering an
813
+ // idle session costs one loopback round-trip, not a model call.
814
+ const needs = react.useRef(true)
815
+ const requestSeq = react.useRef(0)
816
+ const retryCount = react.useRef(0)
817
+ const retryTimer = react.useRef(null)
818
+ const [retryTick, setRetryTick] = react.useState(0)
819
+ const anchorRef = react.useRef(null)
820
+ const ghostRef = react.useRef(null)
821
+ const editorRef = react.useRef(null)
822
+ const lastSession = react.useRef(sessionId)
823
+
824
+ /** Accepting writes a real draft: the text lands in the box and is editable. */
825
+ function accept(value) {
826
+ const text = String(value || predictionRef.current || '').trim()
827
+ if (!text) return
828
+ requestSeq.current += 1
829
+ predictionRef.current = ''
830
+ setPrediction('')
831
+ if (ghostRef.current) ghostRef.current.hide()
832
+ const actions = actionsRef.current
833
+ if (!actions || typeof actions.setDraft !== 'function') return
834
+ actions.setDraft(text)
835
+ const editor = editorRef.current
836
+ if (editor && editor.isConnected && typeof editor.focus === 'function') editor.focus()
837
+ }
838
+
839
+ function dismiss() {
840
+ requestSeq.current += 1
841
+ predictionRef.current = ''
842
+ setPrediction('')
843
+ if (ghostRef.current) ghostRef.current.hide()
844
+ }
845
+
846
+ function ensureGhost() {
847
+ const anchor = anchorRef.current
848
+ const editor = findComposerInput(anchor)
849
+ if (!editor) {
850
+ if (ghostRef.current) ghostRef.current.destroy()
851
+ ghostRef.current = null
852
+ editorRef.current = null
853
+ return null
854
+ }
855
+ if (ghostRef.current && editorRef.current === editor) return ghostRef.current
856
+ if (ghostRef.current) ghostRef.current.destroy()
857
+ editorRef.current = editor
858
+ ghostRef.current = createGhostLayer(editor, {
859
+ onAccept: (text) => accept(text),
860
+ onDismiss: () => dismiss(),
861
+ canIntercept: () => !completionMenuOpen(anchorRef.current),
862
+ })
863
+ return ghostRef.current
864
+ }
865
+
866
+ // One layer per mounted composer. The composer can mount a frame or two after
867
+ // the dock (hero → conversation transition), so retry briefly.
868
+ react.useEffect(() => {
869
+ let disposed = false
870
+ let frames = 0
871
+ const retry = () => {
872
+ if (disposed) return
873
+ const ghost = ensureGhost()
874
+ if (ghost) {
875
+ ghost.setText(draftRef.current === '' ? predictionRef.current : '')
876
+ return
877
+ }
878
+ frames += 1
879
+ if (frames < ATTACH_FRAMES && typeof requestAnimationFrame === 'function') {
880
+ requestAnimationFrame(retry)
881
+ }
882
+ }
883
+ retry()
884
+ return () => {
885
+ disposed = true
886
+ if (ghostRef.current) ghostRef.current.destroy()
887
+ ghostRef.current = null
888
+ editorRef.current = null
889
+ }
890
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- attach once; refs carry state
891
+ }, [])
892
+
893
+ // A reused dock instance must never paint the previous session's prediction.
894
+ react.useEffect(() => {
895
+ if (lastSession.current === sessionId) return
896
+ lastSession.current = sessionId
897
+ if (retryTimer.current) {
898
+ clearTimeout(retryTimer.current)
899
+ retryTimer.current = null
900
+ }
901
+ retryCount.current = 0
902
+ needs.current = true
903
+ requestSeq.current += 1
904
+ predictionRef.current = ''
905
+ setPrediction('')
906
+ }, [sessionId])
907
+
908
+ react.useEffect(
909
+ () => () => {
910
+ if (retryTimer.current) clearTimeout(retryTimer.current)
911
+ retryTimer.current = null
912
+ },
913
+ [],
914
+ )
915
+
916
+ // Writing dismisses for good: clearing the box later must not resurrect a
917
+ // suggestion that belonged to a previous moment in the conversation.
918
+ react.useEffect(() => {
919
+ if (draft === '' || predictionRef.current === '') return
920
+ requestSeq.current += 1
921
+ predictionRef.current = ''
922
+ setPrediction('')
923
+ }, [draft])
924
+
925
+ // Paint (or clear) the ghost text.
926
+ react.useEffect(() => {
927
+ const ghost = ensureGhost()
928
+ if (!ghost) return
929
+ ghost.setText(disabled || draft !== '' ? '' : prediction)
930
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- refs are stable
931
+ }, [draft, prediction, sessionId, disabled])
932
+
933
+ // Ask the host once per settled turn.
934
+ react.useEffect(() => {
935
+ if (!sessionId) return undefined
936
+ // The master switch lives host-side; wait for the snapshot before asking.
937
+ if (!configReady) return undefined
938
+ if (disabled) {
939
+ needs.current = true
940
+ if (predictionRef.current !== '') {
941
+ requestSeq.current += 1
942
+ predictionRef.current = ''
943
+ setPrediction('')
944
+ }
945
+ return undefined
946
+ }
947
+
948
+ if (running) {
949
+ // The turn that just started will earn the next suggestion.
950
+ needs.current = true
951
+ if (predictionRef.current !== '') {
952
+ requestSeq.current += 1
953
+ predictionRef.current = ''
954
+ setPrediction('')
955
+ }
956
+ return undefined
957
+ }
958
+
959
+ if (!needs.current || draft !== '' || queued) return undefined
960
+ if (predictionRef.current !== '') {
961
+ needs.current = false
962
+ return undefined
963
+ }
964
+ needs.current = false
965
+
966
+ const seq = requestSeq.current + 1
967
+ requestSeq.current = seq
968
+ const controller = new AbortController()
969
+ const timer = setTimeout(() => controller.abort(), CLIENT_TIMEOUT_MS)
970
+ const settle = () => clearTimeout(timer)
971
+
972
+ fetch(PREDICT_PATH, {
973
+ method: 'POST',
974
+ headers: { 'content-type': 'application/json' },
975
+ body: JSON.stringify({ sessionId }),
976
+ cache: 'no-store',
977
+ credentials: 'same-origin',
978
+ signal: controller.signal,
979
+ })
980
+ .then(async (response) => {
981
+ if (response.ok) return response.json()
982
+ let detail = ''
983
+ try {
984
+ const body = await response.json()
985
+ detail = body && typeof body.error === 'string' ? body.error : ''
986
+ } catch {
987
+ detail = ''
988
+ }
989
+ const error = new Error('HTTP ' + response.status + (detail ? ': ' + detail : ''))
990
+ error.status = response.status
991
+ throw error
992
+ })
993
+ .then((payload) => {
994
+ retryCount.current = 0
995
+ if (controller.signal.aborted || requestSeq.current !== seq) return
996
+ if (draftRef.current !== '') return
997
+ const text = payload && typeof payload.text === 'string' ? payload.text.trim() : ''
998
+ if (!text || text === 'SKIP') return
999
+ predictionRef.current = text
1000
+ setPrediction(text)
1001
+ })
1002
+ .catch((error) => {
1003
+ if (controller.signal.aborted) return
1004
+ const status = error && typeof error.status === 'number' ? error.status : 0
1005
+ console.warn(
1006
+ '[composer-predictions] prediction failed' + (sessionId ? ' for ' + sessionId : '') + ':',
1007
+ error && error.message ? error.message : error,
1008
+ )
1009
+ // Opening an old session resumes it on the Host; the first request can
1010
+ // race that resume and see 404. Re-arm and retry with backoff instead
1011
+ // of leaving this session permanently without a suggestion.
1012
+ if (status === 404 || status === 0) {
1013
+ needs.current = true
1014
+ if (retryCount.current < 6) {
1015
+ retryCount.current += 1
1016
+ if (retryTimer.current) clearTimeout(retryTimer.current)
1017
+ retryTimer.current = setTimeout(() => {
1018
+ retryTimer.current = null
1019
+ setRetryTick((value) => value + 1)
1020
+ }, 350 * retryCount.current)
1021
+ }
1022
+ }
1023
+ })
1024
+ .finally(settle)
1025
+
1026
+ return () => {
1027
+ settle()
1028
+ controller.abort()
1029
+ }
1030
+ }, [sessionId, running, draft, queued, retryTick, configReady, disabled])
1031
+
1032
+ return h('div', {
1033
+ ref: anchorRef,
1034
+ 'data-composer-predictions': 'anchor',
1035
+ 'aria-hidden': 'true',
1036
+ style: { display: 'none' },
1037
+ })
1038
+ }
1039
+
1040
+ function apply(ctx) {
1041
+ ctx.slots.inject('conversation.input.dock', () =>
1042
+ ctx.slots.register(
1043
+ {
1044
+ name: 'conversation.input.dock',
1045
+ id: 'composer-predictions',
1046
+ // Existing dock entries: todo=0, goal=10, queue=20.
1047
+ order: 30,
1048
+ },
1049
+ PredictionDock,
1050
+ ),
1051
+ )
1052
+ // Settings nav sits after "Plugins" (15) and before "Agent presets" (20).
1053
+ ctx.slots.inject('settings.section', () =>
1054
+ ctx.slots.register(
1055
+ {
1056
+ name: 'settings.section',
1057
+ id: 'composer-predictions',
1058
+ order: 18,
1059
+ label: () => copy().section,
1060
+ },
1061
+ PredictionSettings,
1062
+ ),
1063
+ )
1064
+ }
1065
+
1066
+ exports.name = 'composer-predictions'
1067
+ exports.inject = ['slots']
1068
+ exports.apply = apply
1069
+
1070
+ return module.exports
1071
+ },
1072
+ })