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

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