@vibe-x/dsh-html-workbench 0.2.0 → 0.3.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/src/client.js CHANGED
@@ -35,7 +35,7 @@ return {
35
35
  // positioned chevron collapsing into a block below the input). So manage the
36
36
  // <style> element here: drop every previous generation, then insert fresh.
37
37
  const STYLE_MARK = 'data-hwb-styles'
38
- const STYLE_VERSION = '2'
38
+ const STYLE_VERSION = '5'
39
39
 
40
40
  const CSS = `
41
41
  html #root {
@@ -80,6 +80,32 @@ header:has([data-slot="conversation.session.header.utilities"]) {
80
80
  .hwb-dot { position: relative; width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--dsw-alias-label-dimmed, var(--dsw-alias-label-tertiary)); box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 0%, transparent); }
81
81
  .hwb-dot[data-on] { background: var(--dsw-alias-state-success-primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-success-primary) 16%, transparent); }
82
82
  .hwb-dot[data-off] { background: var(--dsw-alias-state-error-primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-error-primary) 16%, transparent); }
83
+ /* The dot is the ONLY signal that a start failed, so make it the way in: it is a
84
+ button that opens the diagnostics log. */
85
+ .hwb-statusbtn { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 2px 6px 2px 4px; margin-left: -4px; border: 0; border-radius: 7px; background: transparent; color: inherit; font: inherit; cursor: pointer; }
86
+ .hwb-statusbtn:hover { background: var(--dsw-alias-interactive-bg-hover); }
87
+ .hwb-statusbtn:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #4d6bfe); outline-offset: 1px; }
88
+
89
+ /* ── Diagnostics ──────────────────────────────────────────────────────────── */
90
+ .hwb-diag { flex: none; display: flex; flex-direction: column; max-height: 46vh; border-bottom: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); }
91
+ .hwb-diag-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 14px; border-bottom: 1px solid var(--dsw-alias-border-l2); }
92
+ .hwb-diag-title { font-size: 12px; font-weight: 600; }
93
+ .hwb-diag-facts { flex: none; display: grid; grid-template-columns: auto 1fr; gap: 2px 10px; padding: 8px 14px; border-bottom: 1px solid var(--dsw-alias-border-l2); font-size: 11.5px; }
94
+ .hwb-diag-key { color: var(--dsw-alias-label-tertiary); white-space: nowrap; }
95
+ .hwb-diag-val { min-width: 0; font-family: var(--hwb-mono); word-break: break-all; }
96
+ .hwb-diag-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 6px 0; }
97
+ .hwb-diag-row { display: grid; grid-template-columns: auto 1fr; gap: 8px; padding: 4px 14px; font-size: 11.5px; line-height: 1.55; }
98
+ .hwb-diag-row + .hwb-diag-row { border-top: 1px solid color-mix(in srgb, var(--dsw-alias-border-l2) 50%, transparent); }
99
+ .hwb-diag-time { color: var(--dsw-alias-label-tertiary); font-family: var(--hwb-mono); font-size: 10.5px; padding-top: 1px; }
100
+ .hwb-diag-msg { min-width: 0; }
101
+ .hwb-diag-row[data-level="error"] .hwb-diag-msg { color: var(--dsw-alias-state-error-primary); }
102
+ .hwb-diag-detail { display: block; margin-top: 3px; padding: 6px 8px; border-radius: 6px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-secondary); font-family: var(--hwb-mono); font-size: 10.5px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; max-height: 180px; overflow-y: auto; }
103
+ .hwb-diag-empty { padding: 16px 14px; color: var(--dsw-alias-label-tertiary); font-size: 11.5px; }
104
+ .hwb-diag-hint { padding: 8px 14px; border-top: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 1.6; }
105
+ .hwb-diag-hint code { padding: 1px 4px; border-radius: 4px; background: var(--dsw-alias-bg-base); font-family: var(--hwb-mono); font-size: 10.5px; }
106
+ .hwb-btn-quiet { background: transparent; border-color: var(--dsw-alias-border-l2); color: var(--dsw-alias-label-primary); }
107
+ .hwb-btn-quiet:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
108
+ .hwb-btn-sm { height: 26px; padding: 0 10px; border-radius: 7px; font-size: 11.5px; }
83
109
  .hwb-name { font-weight: 600; font-size: 13px; white-space: nowrap; }
84
110
  .hwb-sep { width: 1px; height: 14px; flex: none; background: var(--dsw-alias-border-l1); }
85
111
  .hwb-filechip { display: flex; align-items: center; gap: 6px; min-width: 0; height: 22px; padding: 0 8px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2, var(--dsw-alias-interactive-bg-hover)); color: var(--dsw-alias-label-secondary); font-size: 11.5px; }
@@ -147,6 +173,33 @@ header:has([data-slot="conversation.session.header.utilities"]) {
147
173
  .hwb-body { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; background: var(--dsw-alias-bg-base); }
148
174
  .hwb-frame { flex: 1 1 auto; width: 100%; border: 0; background: #fff; }
149
175
 
176
+ /* ── Visual selection chips (rendered INTO the host composer) ─────────────── */
177
+ /* The workbench posts one selection at a time; each becomes a removable chip
178
+ above the chat input, mirroring how file attachments behave. The markdown
179
+ evidence is held in memory and only spliced into the message on send, so the
180
+ user never has to look at a wall of HTML while typing. */
181
+ .hwb-chips {
182
+ display: flex; flex-wrap: wrap; gap: 6px;
183
+ margin: 0 14px 8px; padding: 0;
184
+ }
185
+ .hwb-chip {
186
+ display: inline-flex; align-items: center; gap: 6px; max-width: 260px;
187
+ padding: 3px 4px 3px 8px; border-radius: 8px;
188
+ border: 1px solid var(--dsw-alias-border-l2);
189
+ background: var(--dsw-alias-bg-layer-1);
190
+ color: var(--dsw-alias-label-primary);
191
+ font-size: 11.5px; line-height: 18px;
192
+ }
193
+ .hwb-chip > svg { flex: none; color: var(--dsw-alias-state-business-primary, #4d6bfe); }
194
+ .hwb-chip-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--hwb-mono); }
195
+ .hwb-chip-x {
196
+ flex: none; display: grid; place-items: center; width: 18px; height: 18px; padding: 0;
197
+ border: 0; border-radius: 5px; background: transparent;
198
+ color: var(--dsw-alias-label-tertiary); cursor: pointer;
199
+ }
200
+ .hwb-chip-x:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
201
+ .hwb-chip-note { align-self: center; color: var(--dsw-alias-label-tertiary); font-size: 11px; }
202
+
150
203
  .hwb-center { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; padding: 24px; overflow-y: auto; text-align: center; }
151
204
  .hwb-blank { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 14px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-tertiary); }
152
205
  .hwb-blank-title { font-size: 13.5px; font-weight: 600; color: var(--dsw-alias-label-primary); }
@@ -220,6 +273,10 @@ header:has([data-slot="conversation.session.header.utilities"]) {
220
273
  error: null,
221
274
  pathInput: '',
222
275
  resolveState: 'idle',
276
+ context: null,
277
+ diag: null,
278
+ diagOpen: false,
279
+ restarting: false,
223
280
  listeners: [],
224
281
  subscribe(fn) { this.listeners.push(fn); return () => { this.listeners = this.listeners.filter((f) => f !== fn) } },
225
282
  set(patch) {
@@ -270,6 +327,20 @@ header:has([data-slot="conversation.session.header.utilities"]) {
270
327
  return true
271
328
  }
272
329
 
330
+ // The panel polls `list` every few seconds and the payload now carries the
331
+ // whole diagnostics journal — a fresh object every tick. Compare by the
332
+ // facts that actually change so polling does not re-render continuously.
333
+ const sameDiag = (a, b) => {
334
+ if (a === b) return true
335
+ if (!a || !b) return false
336
+ const ja = a.journal || []
337
+ const jb = b.journal || []
338
+ if (ja.length !== jb.length) return false
339
+ if (ja.length && (ja[0].at !== jb[0].at || ja[0].message !== jb[0].message)) return false
340
+ return a.running === b.running && a.startError === b.startError
341
+ && a.processStatus === b.processStatus && a.exitCode === b.exitCode
342
+ }
343
+
273
344
  const refresh = (explicit) => {
274
345
  if (explicit) store.set({ refreshing: true })
275
346
  return host.call('list').then((res) => {
@@ -278,6 +349,7 @@ header:has([data-slot="conversation.session.header.utilities"]) {
278
349
  store.set({
279
350
  assets: sameAssets(store.assets, next) ? store.assets : next,
280
351
  running: !!res.running,
352
+ diag: sameDiag(store.diag, res) ? store.diag : res,
281
353
  })
282
354
  } else {
283
355
  store.set({ error: (res && res.error) || 'list failed' })
@@ -286,6 +358,21 @@ header:has([data-slot="conversation.session.header.utilities"]) {
286
358
  .then(() => { if (explicit) store.set({ refreshing: false }) })
287
359
  }
288
360
 
361
+ // Killing and re-spawning is the one action that fixes most start failures,
362
+ // so it belongs next to the log that reports them.
363
+ const restartService = () => {
364
+ store.set({ restarting: true, error: null })
365
+ return host.call('restart').then((res) => {
366
+ store.set({
367
+ restarting: false,
368
+ running: !!(res && res.ok),
369
+ diag: (res && res.diagnostics) || store.diag,
370
+ error: res && res.ok ? null : (res && res.error) || '重启失败',
371
+ })
372
+ if (res && res.ok) refresh()
373
+ }).catch((e) => store.set({ restarting: false, error: String(e && e.message ? e.message : e) }))
374
+ }
375
+
289
376
  const openFile = (path) => {
290
377
  const file = String(path || '').trim()
291
378
  if (!file) return
@@ -303,7 +390,15 @@ header:has([data-slot="conversation.session.header.utilities"]) {
303
390
  nonce: store.nonce + 1,
304
391
  })
305
392
  } else {
306
- store.set({ loading: false, error: (res && res.error) || 'open failed' })
393
+ // A failed open used to collapse into one opaque line. The host now
394
+ // sends the journal along, so open the log on failure: the cause is
395
+ // one glance away instead of a terminal session away.
396
+ store.set({
397
+ loading: false,
398
+ error: (res && res.error) || 'open failed',
399
+ diag: (res && res.diagnostics) || store.diag,
400
+ diagOpen: !!(res && res.diagnostics),
401
+ })
307
402
  }
308
403
  }).catch((e) => store.set({ loading: false, error: String(e && e.message ? e.message : e) }))
309
404
  }
@@ -315,6 +410,204 @@ header:has([data-slot="conversation.session.header.utilities"]) {
315
410
  checking: '检测中…',
316
411
  }
317
412
 
413
+ // ── Visual selection → composer chips ────────────────────────────────────
414
+ //
415
+ // The workbench posts one selection per click. Each becomes a removable chip
416
+ // above the chat input, and the bulky markdown evidence stays in memory
417
+ // until the user actually sends — so they compose against a short list of
418
+ // labels instead of a wall of HTML.
419
+ //
420
+ // Two deliberate choices:
421
+ // 1. Chips are plain DOM appended into the host's composer card, not a
422
+ // React portal. The card is a flex column owned by the host's React
423
+ // tree; React leaves DOM nodes it never created alone, but it may
424
+ // `insertBefore` around them, so a MutationObserver re-asserts position.
425
+ // 2. The evidence is spliced in during the CAPTURE phase of send. React 18
426
+ // dispatches discrete events synchronously, so writing the value through
427
+ // the native setter + `input` event during capture means React state is
428
+ // already updated by the time the host's own handler reads it.
429
+
430
+ const CHIP_HOST = '.hwb-chips'
431
+ const pendingSelections = []
432
+ let chipRow = null
433
+ let composerObserver = null
434
+
435
+ const findComposer = () => {
436
+ const nodes = document.querySelectorAll('textarea')
437
+ for (let i = 0; i < nodes.length; i += 1) {
438
+ const node = nodes[i]
439
+ // The panel's own inputs are inside `.hwb-panel`; never target those.
440
+ if (node.closest('.hwb-panel')) continue
441
+ const box = node.getBoundingClientRect()
442
+ if (box.width >= 120 && box.height > 0) return node
443
+ }
444
+ return null
445
+ }
446
+
447
+ // The card is the flex column holding the textarea and the tool row. Walking
448
+ // up from the textarea keeps this working if the host renames its classes.
449
+ const findComposerCard = (textarea) => {
450
+ let node = textarea && textarea.parentElement
451
+ let depth = 0
452
+ while (node && depth < 6) {
453
+ const style = window.getComputedStyle(node)
454
+ if (style.display === 'flex' && style.flexDirection === 'column' && node.querySelector('button')) return node
455
+ node = node.parentElement
456
+ depth += 1
457
+ }
458
+ return textarea ? textarea.parentElement : null
459
+ }
460
+
461
+ const setComposerValue = (node, next) => {
462
+ const descriptor = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')
463
+ const setter = descriptor && descriptor.set
464
+ if (setter) setter.call(node, next)
465
+ else node.value = next
466
+ node.dispatchEvent(new Event('input', { bubbles: true }))
467
+ }
468
+
469
+ const evidenceBlock = () => {
470
+ if (!pendingSelections.length) return ''
471
+ return pendingSelections.map((item) => item.markdown).join('\n')
472
+ }
473
+
474
+ const clearSelections = () => {
475
+ pendingSelections.length = 0
476
+ renderChips()
477
+ }
478
+
479
+ const removeSelection = (key) => {
480
+ const index = pendingSelections.findIndex((item) => item.key === key)
481
+ if (index >= 0) pendingSelections.splice(index, 1)
482
+ renderChips()
483
+ }
484
+
485
+ const chipIcon = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7.5v-3A1.5 1.5 0 0 1 4.5 3h3M16.5 3h3A1.5 1.5 0 0 1 21 4.5v3M21 16.5v3a1.5 1.5 0 0 1-1.5 1.5h-3M7.5 21h-3A1.5 1.5 0 0 1 3 19.5v-3"/></svg>'
486
+
487
+ const renderChips = () => {
488
+ const textarea = findComposer()
489
+ const card = findComposerCard(textarea)
490
+ if (!card) return
491
+ if (!pendingSelections.length) {
492
+ if (chipRow && chipRow.parentNode) chipRow.parentNode.removeChild(chipRow)
493
+ chipRow = null
494
+ return
495
+ }
496
+ if (!chipRow) {
497
+ chipRow = document.createElement('div')
498
+ chipRow.className = CHIP_HOST.slice(1)
499
+ }
500
+ chipRow.textContent = ''
501
+ pendingSelections.forEach((item) => {
502
+ const chip = document.createElement('span')
503
+ chip.className = 'hwb-chip'
504
+ chip.title = item.textHint ? item.label + ' — ' + item.textHint : item.label
505
+ chip.innerHTML = chipIcon
506
+ const label = document.createElement('span')
507
+ label.className = 'hwb-chip-label'
508
+ label.textContent = item.label
509
+ const close = document.createElement('button')
510
+ close.type = 'button'
511
+ close.className = 'hwb-chip-x'
512
+ close.setAttribute('aria-label', '移除 ' + item.label)
513
+ close.innerHTML = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>'
514
+ close.addEventListener('click', (event) => {
515
+ event.preventDefault()
516
+ event.stopPropagation()
517
+ removeSelection(item.key)
518
+ })
519
+ chip.appendChild(label)
520
+ chip.appendChild(close)
521
+ chipRow.appendChild(chip)
522
+ })
523
+ const note = document.createElement('span')
524
+ note.className = 'hwb-chip-note'
525
+ note.textContent = '发送时会自动附上选区源码'
526
+ chipRow.appendChild(note)
527
+
528
+ // Sit directly above the tool row (the last flex child), which puts the
529
+ // chips between the text input and the model/send controls.
530
+ const anchor = card.lastElementChild
531
+ if (chipRow.parentNode !== card || chipRow.nextElementSibling !== anchor) {
532
+ card.insertBefore(chipRow, anchor)
533
+ }
534
+ observeComposer(card)
535
+ }
536
+
537
+ const observeComposer = (card) => {
538
+ if (composerObserver) return
539
+ composerObserver = new MutationObserver(() => {
540
+ if (!pendingSelections.length) return
541
+ if (!chipRow || chipRow.parentNode !== card) renderChips()
542
+ })
543
+ composerObserver.observe(card, { childList: true })
544
+ ctx.effect(() => () => {
545
+ if (composerObserver) composerObserver.disconnect()
546
+ composerObserver = null
547
+ })
548
+ }
549
+
550
+ const receiveContext = (packet) => {
551
+ const key = packet.key || packet.label
552
+ const existing = pendingSelections.findIndex((item) => item.key === key)
553
+ const entry = {
554
+ key: key,
555
+ label: packet.label || 'element',
556
+ textHint: packet.textHint || '',
557
+ markdown: packet.markdown,
558
+ fileName: packet.fileName,
559
+ }
560
+ // Re-adding the same element refreshes its evidence rather than stacking a
561
+ // duplicate: the file may have changed since the first click.
562
+ if (existing >= 0) pendingSelections[existing] = entry
563
+ else pendingSelections.push(entry)
564
+ renderChips()
565
+ const textarea = findComposer()
566
+ if (textarea) textarea.focus()
567
+ store.set({ context: { count: pendingSelections.length, label: entry.label, at: Date.now() } })
568
+ }
569
+
570
+ // Splice the evidence in just before the host reads the composer, then drop
571
+ // the chips: the message now carries the context, so keeping them would
572
+ // silently re-attach the same source to the next turn.
573
+ const spliceEvidenceIntoMessage = () => {
574
+ if (!pendingSelections.length) return
575
+ const textarea = findComposer()
576
+ if (!textarea) return
577
+ const typed = (textarea.value || '').trim()
578
+ if (!typed) return
579
+ setComposerValue(textarea, evidenceBlock() + '\n' + typed)
580
+ clearSelections()
581
+ }
582
+
583
+ const bindSendInterception = () => {
584
+ const onClick = (event) => {
585
+ if (!pendingSelections.length) return
586
+ const target = event.target instanceof Element ? event.target : null
587
+ const button = target && target.closest('button')
588
+ if (!button || button.closest('.hwb-panel')) return
589
+ const label = button.getAttribute('aria-label') || ''
590
+ if (!/发送|send/i.test(label)) return
591
+ spliceEvidenceIntoMessage()
592
+ }
593
+ const onKeyDown = (event) => {
594
+ if (!pendingSelections.length) return
595
+ if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return
596
+ const target = event.target
597
+ if (!(target instanceof HTMLTextAreaElement) || target.closest('.hwb-panel')) return
598
+ spliceEvidenceIntoMessage()
599
+ }
600
+ document.addEventListener('click', onClick, true)
601
+ document.addEventListener('keydown', onKeyDown, true)
602
+ ctx.effect(() => () => {
603
+ document.removeEventListener('click', onClick, true)
604
+ document.removeEventListener('keydown', onKeyDown, true)
605
+ })
606
+ }
607
+
608
+ bindSendInterception()
609
+
610
+
318
611
  const checkPath = (value) => {
319
612
  const trimmed = (value || '').trim()
320
613
  if (resolveTimer) { clearTimeout(resolveTimer); resolveTimer = null }
@@ -361,6 +654,79 @@ header:has([data-slot="conversation.session.header.utilities"]) {
361
654
  path('M8 21h8', 'c'),
362
655
  path('M12 18v3', 'd'),
363
656
  ])
657
+ const I_LOGS = icon(15, [path('M8 6h10', 'a'), path('M8 12h10', 'b'), path('M8 18h6', 'c'), path('M4 6h.01', 'd'), path('M4 12h.01', 'e'), path('M4 18h.01', 'f')])
658
+
659
+ // ── Diagnostics view ─────────────────────────────────────────────────────
660
+ //
661
+ // A start failure has exactly one visible symptom by default: a red dot. The
662
+ // cause lives in a Python traceback or a spawn error inside the host process,
663
+ // so surface it verbatim here — plus the facts needed to reproduce by hand
664
+ // (port, script path, exit code). That turns "服务起不来" into a fixable
665
+ // report without leaving the panel.
666
+
667
+ const clockTime = (at) => {
668
+ try {
669
+ return new Date(at).toLocaleTimeString('zh-CN', { hour12: false })
670
+ } catch (e) { return '' }
671
+ }
672
+
673
+ const Diagnostics = () => {
674
+ const s = useStore()
675
+ const d = s.diag
676
+ const journal = (d && d.journal) || []
677
+
678
+ const fact = (key, value) => value == null || value === ''
679
+ ? null
680
+ : [
681
+ React.createElement('span', { className: 'hwb-diag-key', key: key + '-k' }, key),
682
+ React.createElement('span', { className: 'hwb-diag-val', key: key + '-v' }, String(value)),
683
+ ]
684
+
685
+ return React.createElement('div', { className: 'hwb-diag' },
686
+ React.createElement('div', { className: 'hwb-diag-head' },
687
+ React.createElement('span', { className: 'hwb-diag-title' }, '服务诊断'),
688
+ React.createElement('span', { className: 'hwb-spacer' }),
689
+ React.createElement('button', {
690
+ type: 'button', className: 'hwb-btn hwb-btn-quiet hwb-btn-sm',
691
+ disabled: s.restarting, onClick: restartService,
692
+ }, s.restarting ? '重启中…' : '重启服务'),
693
+ React.createElement('button', {
694
+ type: 'button', className: 'hwb-icon', title: '收起诊断', 'aria-label': '收起诊断',
695
+ onClick: () => store.set({ diagOpen: false }),
696
+ }, I_CLOSE),
697
+ ),
698
+ d
699
+ ? React.createElement('div', { className: 'hwb-diag-facts' },
700
+ fact('状态', d.running ? '运行中' : (d.processStatus === 'running' ? '启动中' : '未就绪')),
701
+ fact('端口', d.port),
702
+ fact('进程', d.processStatus ? d.processStatus + (d.exitCode == null ? '' : '(exit=' + d.exitCode + ')') : '未由本插件启动'),
703
+ fact('失败原因', d.startError),
704
+ fact('脚本', d.script),
705
+ d.hasShell === false ? fact('shell', '不可用') : null,
706
+ )
707
+ : null,
708
+ React.createElement('div', { className: 'hwb-diag-body' },
709
+ journal.length
710
+ ? journal.map((entry, i) => React.createElement('div', {
711
+ className: 'hwb-diag-row', 'data-level': entry.level, key: String(entry.at) + '-' + i,
712
+ },
713
+ React.createElement('span', { className: 'hwb-diag-time' }, clockTime(entry.at)),
714
+ React.createElement('span', { className: 'hwb-diag-msg' },
715
+ entry.message,
716
+ entry.detail ? React.createElement('span', { className: 'hwb-diag-detail' }, entry.detail) : null,
717
+ ),
718
+ ))
719
+ : React.createElement('div', { className: 'hwb-diag-empty' }, '暂无日志。点「重启服务」可重新拉起并记录完整过程。'),
720
+ ),
721
+ React.createElement('div', { className: 'hwb-diag-hint' },
722
+ '服务自身日志:',
723
+ React.createElement('code', null, (d && d.logDir) || '/tmp/html-workbench-dsh/logs/'),
724
+ ';也可直接访问 ',
725
+ React.createElement('code', null, '/html-workbench/diagnostics'),
726
+ ' 查看原始 JSON。',
727
+ ),
728
+ )
729
+ }
364
730
 
365
731
  const STATE_ICON = { exists: I_CHECK, missing: I_MISSING, invalid: I_WARN, checking: I_LOADING }
366
732
 
@@ -415,6 +781,19 @@ header:has([data-slot="conversation.session.header.utilities"]) {
415
781
  return () => { if (dispose) dispose() }
416
782
  }, [s.open])
417
783
 
784
+ // The workbench runs in an iframe, so its selections arrive as messages.
785
+ // Bind while the panel is mounted, regardless of which file is open.
786
+ React.useEffect(() => {
787
+ const onMessage = (event) => {
788
+ const data = event.data
789
+ if (!data || data.type !== 'html-workbench:context') return
790
+ if (!data.markdown) return
791
+ receiveContext(data)
792
+ }
793
+ window.addEventListener('message', onMessage)
794
+ return () => window.removeEventListener('message', onMessage)
795
+ }, [])
796
+
418
797
  // Drop any in-flight path check when the panel closes.
419
798
  React.useEffect(() => () => { if (resolveTimer) { clearTimeout(resolveTimer); resolveTimer = null } }, [])
420
799
 
@@ -451,6 +830,9 @@ header:has([data-slot="conversation.session.header.utilities"]) {
451
830
  inMenu && a.kind ? React.createElement('span', { className: 'hwb-tag', 'data-kind': a.kind }, a.kind === 'create' ? '新建' : '编辑') : null,
452
831
  )
453
832
 
833
+ // A dead service makes "填入路径开始编辑" a lie — every open will fail.
834
+ // Say what is actually wrong and offer the two things that help.
835
+ const serviceDown = !s.running && !!s.diag && !s.loading
454
836
  const body = s.loading
455
837
  ? React.createElement('div', { className: 'hwb-center' },
456
838
  React.createElement('div', { className: 'hwb-spinner' }),
@@ -463,7 +845,26 @@ header:has([data-slot="conversation.session.header.utilities"]) {
463
845
  src: s.current.url,
464
846
  title: 'HTML Workbench — ' + basename(s.current.path),
465
847
  })
466
- : React.createElement('div', { className: 'hwb-center' },
848
+ : serviceDown
849
+ ? React.createElement('div', { className: 'hwb-center' },
850
+ React.createElement('div', { className: 'hwb-blank' }, I_ALERT),
851
+ React.createElement('div', null,
852
+ React.createElement('div', { className: 'hwb-blank-title' }, '本地服务未就绪'),
853
+ React.createElement('div', { className: 'hwb-blank-desc' },
854
+ s.diag.startError || '服务进程没有响应健康检查,暂时无法打开文件。'),
855
+ ),
856
+ React.createElement('div', { style: { display: 'flex', gap: '8px' } },
857
+ React.createElement('button', {
858
+ type: 'button', className: 'hwb-btn hwb-btn-primary hwb-btn-sm',
859
+ disabled: s.restarting, onClick: restartService,
860
+ }, s.restarting ? '重启中…' : '重启服务'),
861
+ React.createElement('button', {
862
+ type: 'button', className: 'hwb-btn hwb-btn-quiet hwb-btn-sm',
863
+ onClick: () => store.set({ diagOpen: true }),
864
+ }, '查看诊断日志'),
865
+ ),
866
+ )
867
+ : React.createElement('div', { className: 'hwb-center' },
467
868
  React.createElement('div', { className: 'hwb-blank' }, I_BLANK),
468
869
  React.createElement('div', null,
469
870
  React.createElement('div', { className: 'hwb-blank-title' }, '还没有打开文件'),
@@ -502,12 +903,17 @@ header:has([data-slot="conversation.session.header.utilities"]) {
502
903
 
503
904
  React.createElement('div', { className: 'hwb-chrome' },
504
905
  React.createElement('div', { className: 'hwb-idrow' },
505
- React.createElement('div', { className: 'hwb-brand' },
906
+ React.createElement('button', {
907
+ type: 'button',
908
+ className: 'hwb-statusbtn',
909
+ 'aria-expanded': !!s.diagOpen,
910
+ title: s.running ? '本地服务运行中 — 点击查看诊断' : '本地服务未就绪 — 点击查看原因',
911
+ onClick: () => store.set({ diagOpen: !s.diagOpen }),
912
+ },
506
913
  React.createElement('span', {
507
914
  className: 'hwb-dot',
508
915
  'data-on': s.running ? '' : undefined,
509
916
  'data-off': !s.running ? '' : undefined,
510
- title: s.running ? '本地服务运行中' : '本地服务未就绪',
511
917
  }),
512
918
  React.createElement('span', { className: 'hwb-name' }, 'HTML Workbench'),
513
919
  ),
@@ -517,6 +923,11 @@ header:has([data-slot="conversation.session.header.utilities"]) {
517
923
  : null,
518
924
  React.createElement('span', { className: 'hwb-spacer' }),
519
925
  React.createElement('div', { className: 'hwb-actions' },
926
+ React.createElement('button', {
927
+ type: 'button', className: 'hwb-icon', title: '服务诊断',
928
+ 'aria-label': '服务诊断', 'aria-expanded': !!s.diagOpen,
929
+ onClick: () => store.set({ diagOpen: !s.diagOpen }),
930
+ }, I_LOGS),
520
931
  React.createElement('button', {
521
932
  type: 'button', className: 'hwb-icon', title: '刷新产物列表',
522
933
  'aria-label': '刷新产物列表', 'data-spin': s.refreshing ? '' : undefined,
@@ -593,6 +1004,10 @@ header:has([data-slot="conversation.session.header.utilities"]) {
593
1004
  ? React.createElement('div', { className: 'hwb-banner', role: 'alert' },
594
1005
  I_ALERT,
595
1006
  React.createElement('span', { className: 'hwb-banner-text' }, s.error),
1007
+ React.createElement('button', {
1008
+ type: 'button', className: 'hwb-icon', title: '查看诊断', 'aria-label': '查看诊断',
1009
+ onClick: () => store.set({ diagOpen: true }),
1010
+ }, I_LOGS),
596
1011
  React.createElement('button', {
597
1012
  type: 'button', className: 'hwb-icon', title: '忽略', 'aria-label': '忽略',
598
1013
  onClick: () => store.set({ error: null }),
@@ -600,6 +1015,8 @@ header:has([data-slot="conversation.session.header.utilities"]) {
600
1015
  )
601
1016
  : null,
602
1017
 
1018
+ s.diagOpen ? React.createElement(Diagnostics) : null,
1019
+
603
1020
  React.createElement('div', { className: 'hwb-body' }, body),
604
1021
  )
605
1022
  }