@vibe-x/dsh-html-workbench 0.2.0 → 0.3.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/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,33 @@ 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-count { margin-left: 6px; padding: 0 5px; border-radius: 999px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-tertiary); font-family: var(--hwb-mono); font-size: 10px; }
102
+ .hwb-diag-row[data-level="error"] .hwb-diag-msg { color: var(--dsw-alias-state-error-primary); }
103
+ .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; }
104
+ .hwb-diag-empty { padding: 16px 14px; color: var(--dsw-alias-label-tertiary); font-size: 11.5px; }
105
+ .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; }
106
+ .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; }
107
+ .hwb-btn-quiet { background: transparent; border-color: var(--dsw-alias-border-l2); color: var(--dsw-alias-label-primary); }
108
+ .hwb-btn-quiet:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
109
+ .hwb-btn-sm { height: 26px; padding: 0 10px; border-radius: 7px; font-size: 11.5px; }
83
110
  .hwb-name { font-weight: 600; font-size: 13px; white-space: nowrap; }
84
111
  .hwb-sep { width: 1px; height: 14px; flex: none; background: var(--dsw-alias-border-l1); }
85
112
  .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 +174,33 @@ header:has([data-slot="conversation.session.header.utilities"]) {
147
174
  .hwb-body { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; background: var(--dsw-alias-bg-base); }
148
175
  .hwb-frame { flex: 1 1 auto; width: 100%; border: 0; background: #fff; }
149
176
 
177
+ /* ── Visual selection chips (rendered INTO the host composer) ─────────────── */
178
+ /* The workbench posts one selection at a time; each becomes a removable chip
179
+ above the chat input, mirroring how file attachments behave. The markdown
180
+ evidence is held in memory and only spliced into the message on send, so the
181
+ user never has to look at a wall of HTML while typing. */
182
+ .hwb-chips {
183
+ display: flex; flex-wrap: wrap; gap: 6px;
184
+ margin: 0 14px 8px; padding: 0;
185
+ }
186
+ .hwb-chip {
187
+ display: inline-flex; align-items: center; gap: 6px; max-width: 260px;
188
+ padding: 3px 4px 3px 8px; border-radius: 8px;
189
+ border: 1px solid var(--dsw-alias-border-l2);
190
+ background: var(--dsw-alias-bg-layer-1);
191
+ color: var(--dsw-alias-label-primary);
192
+ font-size: 11.5px; line-height: 18px;
193
+ }
194
+ .hwb-chip > svg { flex: none; color: var(--dsw-alias-state-business-primary, #4d6bfe); }
195
+ .hwb-chip-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--hwb-mono); }
196
+ .hwb-chip-x {
197
+ flex: none; display: grid; place-items: center; width: 18px; height: 18px; padding: 0;
198
+ border: 0; border-radius: 5px; background: transparent;
199
+ color: var(--dsw-alias-label-tertiary); cursor: pointer;
200
+ }
201
+ .hwb-chip-x:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
202
+ .hwb-chip-note { align-self: center; color: var(--dsw-alias-label-tertiary); font-size: 11px; }
203
+
150
204
  .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
205
  .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
206
  .hwb-blank-title { font-size: 13.5px; font-weight: 600; color: var(--dsw-alias-label-primary); }
@@ -220,6 +274,10 @@ header:has([data-slot="conversation.session.header.utilities"]) {
220
274
  error: null,
221
275
  pathInput: '',
222
276
  resolveState: 'idle',
277
+ context: null,
278
+ diag: null,
279
+ diagOpen: false,
280
+ restarting: false,
223
281
  listeners: [],
224
282
  subscribe(fn) { this.listeners.push(fn); return () => { this.listeners = this.listeners.filter((f) => f !== fn) } },
225
283
  set(patch) {
@@ -270,6 +328,20 @@ header:has([data-slot="conversation.session.header.utilities"]) {
270
328
  return true
271
329
  }
272
330
 
331
+ // The panel polls `list` every few seconds and the payload now carries the
332
+ // whole diagnostics journal — a fresh object every tick. Compare by the
333
+ // facts that actually change so polling does not re-render continuously.
334
+ const sameDiag = (a, b) => {
335
+ if (a === b) return true
336
+ if (!a || !b) return false
337
+ const ja = a.journal || []
338
+ const jb = b.journal || []
339
+ if (ja.length !== jb.length) return false
340
+ if (ja.length && (ja[0].at !== jb[0].at || ja[0].message !== jb[0].message)) return false
341
+ return a.running === b.running && a.startError === b.startError
342
+ && a.processStatus === b.processStatus && a.exitCode === b.exitCode
343
+ }
344
+
273
345
  const refresh = (explicit) => {
274
346
  if (explicit) store.set({ refreshing: true })
275
347
  return host.call('list').then((res) => {
@@ -278,6 +350,7 @@ header:has([data-slot="conversation.session.header.utilities"]) {
278
350
  store.set({
279
351
  assets: sameAssets(store.assets, next) ? store.assets : next,
280
352
  running: !!res.running,
353
+ diag: sameDiag(store.diag, res) ? store.diag : res,
281
354
  })
282
355
  } else {
283
356
  store.set({ error: (res && res.error) || 'list failed' })
@@ -286,6 +359,21 @@ header:has([data-slot="conversation.session.header.utilities"]) {
286
359
  .then(() => { if (explicit) store.set({ refreshing: false }) })
287
360
  }
288
361
 
362
+ // Killing and re-spawning is the one action that fixes most start failures,
363
+ // so it belongs next to the log that reports them.
364
+ const restartService = () => {
365
+ store.set({ restarting: true, error: null })
366
+ return host.call('restart').then((res) => {
367
+ store.set({
368
+ restarting: false,
369
+ running: !!(res && res.ok),
370
+ diag: (res && res.diagnostics) || store.diag,
371
+ error: res && res.ok ? null : (res && res.error) || '重启失败',
372
+ })
373
+ if (res && res.ok) refresh()
374
+ }).catch((e) => store.set({ restarting: false, error: String(e && e.message ? e.message : e) }))
375
+ }
376
+
289
377
  const openFile = (path) => {
290
378
  const file = String(path || '').trim()
291
379
  if (!file) return
@@ -303,7 +391,15 @@ header:has([data-slot="conversation.session.header.utilities"]) {
303
391
  nonce: store.nonce + 1,
304
392
  })
305
393
  } else {
306
- store.set({ loading: false, error: (res && res.error) || 'open failed' })
394
+ // A failed open used to collapse into one opaque line. The host now
395
+ // sends the journal along, so open the log on failure: the cause is
396
+ // one glance away instead of a terminal session away.
397
+ store.set({
398
+ loading: false,
399
+ error: (res && res.error) || 'open failed',
400
+ diag: (res && res.diagnostics) || store.diag,
401
+ diagOpen: !!(res && res.diagnostics),
402
+ })
307
403
  }
308
404
  }).catch((e) => store.set({ loading: false, error: String(e && e.message ? e.message : e) }))
309
405
  }
@@ -315,6 +411,204 @@ header:has([data-slot="conversation.session.header.utilities"]) {
315
411
  checking: '检测中…',
316
412
  }
317
413
 
414
+ // ── Visual selection → composer chips ────────────────────────────────────
415
+ //
416
+ // The workbench posts one selection per click. Each becomes a removable chip
417
+ // above the chat input, and the bulky markdown evidence stays in memory
418
+ // until the user actually sends — so they compose against a short list of
419
+ // labels instead of a wall of HTML.
420
+ //
421
+ // Two deliberate choices:
422
+ // 1. Chips are plain DOM appended into the host's composer card, not a
423
+ // React portal. The card is a flex column owned by the host's React
424
+ // tree; React leaves DOM nodes it never created alone, but it may
425
+ // `insertBefore` around them, so a MutationObserver re-asserts position.
426
+ // 2. The evidence is spliced in during the CAPTURE phase of send. React 18
427
+ // dispatches discrete events synchronously, so writing the value through
428
+ // the native setter + `input` event during capture means React state is
429
+ // already updated by the time the host's own handler reads it.
430
+
431
+ const CHIP_HOST = '.hwb-chips'
432
+ const pendingSelections = []
433
+ let chipRow = null
434
+ let composerObserver = null
435
+
436
+ const findComposer = () => {
437
+ const nodes = document.querySelectorAll('textarea')
438
+ for (let i = 0; i < nodes.length; i += 1) {
439
+ const node = nodes[i]
440
+ // The panel's own inputs are inside `.hwb-panel`; never target those.
441
+ if (node.closest('.hwb-panel')) continue
442
+ const box = node.getBoundingClientRect()
443
+ if (box.width >= 120 && box.height > 0) return node
444
+ }
445
+ return null
446
+ }
447
+
448
+ // The card is the flex column holding the textarea and the tool row. Walking
449
+ // up from the textarea keeps this working if the host renames its classes.
450
+ const findComposerCard = (textarea) => {
451
+ let node = textarea && textarea.parentElement
452
+ let depth = 0
453
+ while (node && depth < 6) {
454
+ const style = window.getComputedStyle(node)
455
+ if (style.display === 'flex' && style.flexDirection === 'column' && node.querySelector('button')) return node
456
+ node = node.parentElement
457
+ depth += 1
458
+ }
459
+ return textarea ? textarea.parentElement : null
460
+ }
461
+
462
+ const setComposerValue = (node, next) => {
463
+ const descriptor = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')
464
+ const setter = descriptor && descriptor.set
465
+ if (setter) setter.call(node, next)
466
+ else node.value = next
467
+ node.dispatchEvent(new Event('input', { bubbles: true }))
468
+ }
469
+
470
+ const evidenceBlock = () => {
471
+ if (!pendingSelections.length) return ''
472
+ return pendingSelections.map((item) => item.markdown).join('\n')
473
+ }
474
+
475
+ const clearSelections = () => {
476
+ pendingSelections.length = 0
477
+ renderChips()
478
+ }
479
+
480
+ const removeSelection = (key) => {
481
+ const index = pendingSelections.findIndex((item) => item.key === key)
482
+ if (index >= 0) pendingSelections.splice(index, 1)
483
+ renderChips()
484
+ }
485
+
486
+ 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>'
487
+
488
+ const renderChips = () => {
489
+ const textarea = findComposer()
490
+ const card = findComposerCard(textarea)
491
+ if (!card) return
492
+ if (!pendingSelections.length) {
493
+ if (chipRow && chipRow.parentNode) chipRow.parentNode.removeChild(chipRow)
494
+ chipRow = null
495
+ return
496
+ }
497
+ if (!chipRow) {
498
+ chipRow = document.createElement('div')
499
+ chipRow.className = CHIP_HOST.slice(1)
500
+ }
501
+ chipRow.textContent = ''
502
+ pendingSelections.forEach((item) => {
503
+ const chip = document.createElement('span')
504
+ chip.className = 'hwb-chip'
505
+ chip.title = item.textHint ? item.label + ' — ' + item.textHint : item.label
506
+ chip.innerHTML = chipIcon
507
+ const label = document.createElement('span')
508
+ label.className = 'hwb-chip-label'
509
+ label.textContent = item.label
510
+ const close = document.createElement('button')
511
+ close.type = 'button'
512
+ close.className = 'hwb-chip-x'
513
+ close.setAttribute('aria-label', '移除 ' + item.label)
514
+ 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>'
515
+ close.addEventListener('click', (event) => {
516
+ event.preventDefault()
517
+ event.stopPropagation()
518
+ removeSelection(item.key)
519
+ })
520
+ chip.appendChild(label)
521
+ chip.appendChild(close)
522
+ chipRow.appendChild(chip)
523
+ })
524
+ const note = document.createElement('span')
525
+ note.className = 'hwb-chip-note'
526
+ note.textContent = '发送时会自动附上选区源码'
527
+ chipRow.appendChild(note)
528
+
529
+ // Sit directly above the tool row (the last flex child), which puts the
530
+ // chips between the text input and the model/send controls.
531
+ const anchor = card.lastElementChild
532
+ if (chipRow.parentNode !== card || chipRow.nextElementSibling !== anchor) {
533
+ card.insertBefore(chipRow, anchor)
534
+ }
535
+ observeComposer(card)
536
+ }
537
+
538
+ const observeComposer = (card) => {
539
+ if (composerObserver) return
540
+ composerObserver = new MutationObserver(() => {
541
+ if (!pendingSelections.length) return
542
+ if (!chipRow || chipRow.parentNode !== card) renderChips()
543
+ })
544
+ composerObserver.observe(card, { childList: true })
545
+ ctx.effect(() => () => {
546
+ if (composerObserver) composerObserver.disconnect()
547
+ composerObserver = null
548
+ })
549
+ }
550
+
551
+ const receiveContext = (packet) => {
552
+ const key = packet.key || packet.label
553
+ const existing = pendingSelections.findIndex((item) => item.key === key)
554
+ const entry = {
555
+ key: key,
556
+ label: packet.label || 'element',
557
+ textHint: packet.textHint || '',
558
+ markdown: packet.markdown,
559
+ fileName: packet.fileName,
560
+ }
561
+ // Re-adding the same element refreshes its evidence rather than stacking a
562
+ // duplicate: the file may have changed since the first click.
563
+ if (existing >= 0) pendingSelections[existing] = entry
564
+ else pendingSelections.push(entry)
565
+ renderChips()
566
+ const textarea = findComposer()
567
+ if (textarea) textarea.focus()
568
+ store.set({ context: { count: pendingSelections.length, label: entry.label, at: Date.now() } })
569
+ }
570
+
571
+ // Splice the evidence in just before the host reads the composer, then drop
572
+ // the chips: the message now carries the context, so keeping them would
573
+ // silently re-attach the same source to the next turn.
574
+ const spliceEvidenceIntoMessage = () => {
575
+ if (!pendingSelections.length) return
576
+ const textarea = findComposer()
577
+ if (!textarea) return
578
+ const typed = (textarea.value || '').trim()
579
+ if (!typed) return
580
+ setComposerValue(textarea, evidenceBlock() + '\n' + typed)
581
+ clearSelections()
582
+ }
583
+
584
+ const bindSendInterception = () => {
585
+ const onClick = (event) => {
586
+ if (!pendingSelections.length) return
587
+ const target = event.target instanceof Element ? event.target : null
588
+ const button = target && target.closest('button')
589
+ if (!button || button.closest('.hwb-panel')) return
590
+ const label = button.getAttribute('aria-label') || ''
591
+ if (!/发送|send/i.test(label)) return
592
+ spliceEvidenceIntoMessage()
593
+ }
594
+ const onKeyDown = (event) => {
595
+ if (!pendingSelections.length) return
596
+ if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return
597
+ const target = event.target
598
+ if (!(target instanceof HTMLTextAreaElement) || target.closest('.hwb-panel')) return
599
+ spliceEvidenceIntoMessage()
600
+ }
601
+ document.addEventListener('click', onClick, true)
602
+ document.addEventListener('keydown', onKeyDown, true)
603
+ ctx.effect(() => () => {
604
+ document.removeEventListener('click', onClick, true)
605
+ document.removeEventListener('keydown', onKeyDown, true)
606
+ })
607
+ }
608
+
609
+ bindSendInterception()
610
+
611
+
318
612
  const checkPath = (value) => {
319
613
  const trimmed = (value || '').trim()
320
614
  if (resolveTimer) { clearTimeout(resolveTimer); resolveTimer = null }
@@ -327,8 +621,12 @@ header:has([data-slot="conversation.session.header.utilities"]) {
327
621
  if (!res.isHtml) store.set({ resolveState: 'invalid' })
328
622
  else if (res.exists === true) store.set({ resolveState: 'exists' })
329
623
  else if (res.exists === false) store.set({ resolveState: 'missing' })
624
+ // `exists: null` means the CHECK could not run (no interpreter, no
625
+ // shell). Staying silent made the field look unresponsive, so promote
626
+ // the host's reason to the banner instead of dropping it.
627
+ else if (res.error) store.set({ resolveState: 'idle', error: res.error })
330
628
  else store.set({ resolveState: 'idle' })
331
- }).catch(() => store.set({ resolveState: 'idle' }))
629
+ }).catch((e) => store.set({ resolveState: 'idle', error: String(e && e.message ? e.message : e) }))
332
630
  }, 300)
333
631
  }
334
632
 
@@ -361,6 +659,131 @@ header:has([data-slot="conversation.session.header.utilities"]) {
361
659
  path('M8 21h8', 'c'),
362
660
  path('M12 18v3', 'd'),
363
661
  ])
662
+ 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')])
663
+
664
+ // ── Diagnostics view ─────────────────────────────────────────────────────
665
+ //
666
+ // A start failure has exactly one visible symptom by default: a red dot. The
667
+ // cause lives in a Python traceback or a spawn error inside the host process,
668
+ // so surface it verbatim here — plus the facts needed to reproduce by hand
669
+ // (port, script path, exit code). That turns "服务起不来" into a fixable
670
+ // report without leaving the panel.
671
+
672
+ const clockTime = (at) => {
673
+ try {
674
+ return new Date(at).toLocaleTimeString('zh-CN', { hour12: false })
675
+ } catch (e) { return '' }
676
+ }
677
+
678
+ const Diagnostics = () => {
679
+ const s = useStore()
680
+ const d = s.diag
681
+ const journal = (d && d.journal) || []
682
+ const [copied, setCopied] = React.useState(false)
683
+
684
+ const fact = (key, value) => value == null || value === ''
685
+ ? null
686
+ : [
687
+ React.createElement('span', { className: 'hwb-diag-key', key: key + '-k' }, key),
688
+ React.createElement('span', { className: 'hwb-diag-val', key: key + '-v' }, String(value)),
689
+ ]
690
+
691
+ // A bug report is only useful if it carries the journal. Asking a user to
692
+ // screenshot a scrolling panel loses exactly the detail lines that matter,
693
+ // so hand them one paste-ready block instead — plain text, not JSON, so it
694
+ // survives being dropped into a chat window.
695
+ const copyReport = () => {
696
+ const lines = ['# HTML Workbench 诊断报告']
697
+ if (d) {
698
+ lines.push('插件版本: ' + (d.version || '未知(可能是动态热加载)'))
699
+ lines.push('平台: ' + (d.platform || '未知'))
700
+ lines.push('状态: ' + (d.running ? '运行中' : '未就绪'))
701
+ lines.push('端口: ' + d.port)
702
+ lines.push('进程: ' + (d.processStatus || '未由本插件启动')
703
+ + (d.exitCode == null ? '' : '(exit=' + d.exitCode + ')'))
704
+ lines.push('Python: ' + (d.pythonCommand || (d.pythonCandidates || []).join(' → ') || '未解析'))
705
+ lines.push('脚本: ' + (d.script || '未配置'))
706
+ lines.push('运行目录: ' + (d.runtimeDir || '未知'))
707
+ if (d.startError) lines.push('失败原因: ' + d.startError)
708
+ } else {
709
+ lines.push('(后台未返回诊断信息,通常说明请求根本没有到达 DSH)')
710
+ }
711
+ if (s.error) lines.push('界面报错: ' + s.error)
712
+ lines.push('', '## 日志(最新在前)')
713
+ if (journal.length) {
714
+ journal.forEach((entry) => {
715
+ lines.push('[' + clockTime(entry.at) + '] ' + entry.level + ': ' + entry.message)
716
+ if (entry.detail) lines.push(' ' + String(entry.detail).replace(/\n/g, '\n '))
717
+ })
718
+ } else {
719
+ lines.push('(无)')
720
+ }
721
+ const text = lines.join('\n')
722
+ const done = () => { setCopied(true); setTimeout(() => setCopied(false), 1600) }
723
+ if (navigator.clipboard && navigator.clipboard.writeText) {
724
+ navigator.clipboard.writeText(text).then(done, () => store.set({ error: '复制失败,请手动访问 /html-workbench/diagnostics 获取原始信息。' }))
725
+ } else {
726
+ store.set({ error: '当前环境不支持剪贴板,请访问 /html-workbench/diagnostics 获取原始信息。' })
727
+ }
728
+ }
729
+
730
+ return React.createElement('div', { className: 'hwb-diag' },
731
+ React.createElement('div', { className: 'hwb-diag-head' },
732
+ React.createElement('span', { className: 'hwb-diag-title' }, '服务诊断'),
733
+ React.createElement('span', { className: 'hwb-spacer' }),
734
+ React.createElement('button', {
735
+ type: 'button', className: 'hwb-btn hwb-btn-quiet hwb-btn-sm',
736
+ onClick: copyReport, title: '复制完整诊断信息,便于反馈问题',
737
+ }, copied ? '已复制' : '复制报告'),
738
+ React.createElement('button', {
739
+ type: 'button', className: 'hwb-btn hwb-btn-quiet hwb-btn-sm',
740
+ disabled: s.restarting, onClick: restartService,
741
+ }, s.restarting ? '重启中…' : '重启服务'),
742
+ React.createElement('button', {
743
+ type: 'button', className: 'hwb-icon', title: '收起诊断', 'aria-label': '收起诊断',
744
+ onClick: () => store.set({ diagOpen: false }),
745
+ }, I_CLOSE),
746
+ ),
747
+ d
748
+ ? React.createElement('div', { className: 'hwb-diag-facts' },
749
+ fact('状态', d.running ? '运行中' : (d.processStatus === 'running' ? '启动中' : '未就绪')),
750
+ fact('端口', d.port),
751
+ fact('进程', d.processStatus ? d.processStatus + (d.exitCode == null ? '' : '(exit=' + d.exitCode + ')') : '未由本插件启动'),
752
+ fact('Python', d.pythonCommand || ((d.pythonCandidates || []).join(' → ') || null)),
753
+ fact('运行目录', d.runtimeDir),
754
+ fact('失败原因', d.startError),
755
+ fact('脚本', d.script),
756
+ fact('版本', d.version ? d.version + (d.platform ? ' · ' + d.platform : '') : d.platform),
757
+ d.hasShell === false ? fact('shell', '不可用') : null,
758
+ )
759
+ : null,
760
+ React.createElement('div', { className: 'hwb-diag-body' },
761
+ journal.length
762
+ ? journal.map((entry, i) => React.createElement('div', {
763
+ className: 'hwb-diag-row', 'data-level': entry.level, key: String(entry.at) + '-' + i,
764
+ },
765
+ React.createElement('span', { className: 'hwb-diag-time' }, clockTime(entry.at)),
766
+ React.createElement('span', { className: 'hwb-diag-msg' },
767
+ entry.message,
768
+ // Folded duplicates would otherwise read as a single occurrence,
769
+ // hiding that the user retried and hit the same wall every time.
770
+ entry.repeated > 1
771
+ ? React.createElement('span', { className: 'hwb-diag-count' }, '×' + entry.repeated)
772
+ : null,
773
+ entry.detail ? React.createElement('span', { className: 'hwb-diag-detail' }, entry.detail) : null,
774
+ ),
775
+ ))
776
+ : React.createElement('div', { className: 'hwb-diag-empty' }, '暂无日志。点「重启服务」可重新拉起并记录完整过程。'),
777
+ ),
778
+ React.createElement('div', { className: 'hwb-diag-hint' },
779
+ '服务自身日志:',
780
+ React.createElement('code', null, (d && d.logDir) || '<工作区>/.html-workbench/logs/'),
781
+ ';也可直接访问 ',
782
+ React.createElement('code', null, '/html-workbench/diagnostics'),
783
+ ' 查看原始 JSON。',
784
+ ),
785
+ )
786
+ }
364
787
 
365
788
  const STATE_ICON = { exists: I_CHECK, missing: I_MISSING, invalid: I_WARN, checking: I_LOADING }
366
789
 
@@ -415,6 +838,19 @@ header:has([data-slot="conversation.session.header.utilities"]) {
415
838
  return () => { if (dispose) dispose() }
416
839
  }, [s.open])
417
840
 
841
+ // The workbench runs in an iframe, so its selections arrive as messages.
842
+ // Bind while the panel is mounted, regardless of which file is open.
843
+ React.useEffect(() => {
844
+ const onMessage = (event) => {
845
+ const data = event.data
846
+ if (!data || data.type !== 'html-workbench:context') return
847
+ if (!data.markdown) return
848
+ receiveContext(data)
849
+ }
850
+ window.addEventListener('message', onMessage)
851
+ return () => window.removeEventListener('message', onMessage)
852
+ }, [])
853
+
418
854
  // Drop any in-flight path check when the panel closes.
419
855
  React.useEffect(() => () => { if (resolveTimer) { clearTimeout(resolveTimer); resolveTimer = null } }, [])
420
856
 
@@ -451,6 +887,9 @@ header:has([data-slot="conversation.session.header.utilities"]) {
451
887
  inMenu && a.kind ? React.createElement('span', { className: 'hwb-tag', 'data-kind': a.kind }, a.kind === 'create' ? '新建' : '编辑') : null,
452
888
  )
453
889
 
890
+ // A dead service makes "填入路径开始编辑" a lie — every open will fail.
891
+ // Say what is actually wrong and offer the two things that help.
892
+ const serviceDown = !s.running && !!s.diag && !s.loading
454
893
  const body = s.loading
455
894
  ? React.createElement('div', { className: 'hwb-center' },
456
895
  React.createElement('div', { className: 'hwb-spinner' }),
@@ -463,7 +902,26 @@ header:has([data-slot="conversation.session.header.utilities"]) {
463
902
  src: s.current.url,
464
903
  title: 'HTML Workbench — ' + basename(s.current.path),
465
904
  })
466
- : React.createElement('div', { className: 'hwb-center' },
905
+ : serviceDown
906
+ ? React.createElement('div', { className: 'hwb-center' },
907
+ React.createElement('div', { className: 'hwb-blank' }, I_ALERT),
908
+ React.createElement('div', null,
909
+ React.createElement('div', { className: 'hwb-blank-title' }, '本地服务未就绪'),
910
+ React.createElement('div', { className: 'hwb-blank-desc' },
911
+ s.diag.startError || '服务进程没有响应健康检查,暂时无法打开文件。'),
912
+ ),
913
+ React.createElement('div', { style: { display: 'flex', gap: '8px' } },
914
+ React.createElement('button', {
915
+ type: 'button', className: 'hwb-btn hwb-btn-primary hwb-btn-sm',
916
+ disabled: s.restarting, onClick: restartService,
917
+ }, s.restarting ? '重启中…' : '重启服务'),
918
+ React.createElement('button', {
919
+ type: 'button', className: 'hwb-btn hwb-btn-quiet hwb-btn-sm',
920
+ onClick: () => store.set({ diagOpen: true }),
921
+ }, '查看诊断日志'),
922
+ ),
923
+ )
924
+ : React.createElement('div', { className: 'hwb-center' },
467
925
  React.createElement('div', { className: 'hwb-blank' }, I_BLANK),
468
926
  React.createElement('div', null,
469
927
  React.createElement('div', { className: 'hwb-blank-title' }, '还没有打开文件'),
@@ -502,12 +960,17 @@ header:has([data-slot="conversation.session.header.utilities"]) {
502
960
 
503
961
  React.createElement('div', { className: 'hwb-chrome' },
504
962
  React.createElement('div', { className: 'hwb-idrow' },
505
- React.createElement('div', { className: 'hwb-brand' },
963
+ React.createElement('button', {
964
+ type: 'button',
965
+ className: 'hwb-statusbtn',
966
+ 'aria-expanded': !!s.diagOpen,
967
+ title: s.running ? '本地服务运行中 — 点击查看诊断' : '本地服务未就绪 — 点击查看原因',
968
+ onClick: () => store.set({ diagOpen: !s.diagOpen }),
969
+ },
506
970
  React.createElement('span', {
507
971
  className: 'hwb-dot',
508
972
  'data-on': s.running ? '' : undefined,
509
973
  'data-off': !s.running ? '' : undefined,
510
- title: s.running ? '本地服务运行中' : '本地服务未就绪',
511
974
  }),
512
975
  React.createElement('span', { className: 'hwb-name' }, 'HTML Workbench'),
513
976
  ),
@@ -517,6 +980,11 @@ header:has([data-slot="conversation.session.header.utilities"]) {
517
980
  : null,
518
981
  React.createElement('span', { className: 'hwb-spacer' }),
519
982
  React.createElement('div', { className: 'hwb-actions' },
983
+ React.createElement('button', {
984
+ type: 'button', className: 'hwb-icon', title: '服务诊断',
985
+ 'aria-label': '服务诊断', 'aria-expanded': !!s.diagOpen,
986
+ onClick: () => store.set({ diagOpen: !s.diagOpen }),
987
+ }, I_LOGS),
520
988
  React.createElement('button', {
521
989
  type: 'button', className: 'hwb-icon', title: '刷新产物列表',
522
990
  'aria-label': '刷新产物列表', 'data-spin': s.refreshing ? '' : undefined,
@@ -593,6 +1061,10 @@ header:has([data-slot="conversation.session.header.utilities"]) {
593
1061
  ? React.createElement('div', { className: 'hwb-banner', role: 'alert' },
594
1062
  I_ALERT,
595
1063
  React.createElement('span', { className: 'hwb-banner-text' }, s.error),
1064
+ React.createElement('button', {
1065
+ type: 'button', className: 'hwb-icon', title: '查看诊断', 'aria-label': '查看诊断',
1066
+ onClick: () => store.set({ diagOpen: true }),
1067
+ }, I_LOGS),
596
1068
  React.createElement('button', {
597
1069
  type: 'button', className: 'hwb-icon', title: '忽略', 'aria-label': '忽略',
598
1070
  onClick: () => store.set({ error: null }),
@@ -600,6 +1072,8 @@ header:has([data-slot="conversation.session.header.utilities"]) {
600
1072
  )
601
1073
  : null,
602
1074
 
1075
+ s.diagOpen ? React.createElement(Diagnostics) : null,
1076
+
603
1077
  React.createElement('div', { className: 'hwb-body' }, body),
604
1078
  )
605
1079
  }