@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/lib/client.js CHANGED
@@ -30,7 +30,26 @@ window.__ModuleLoader__.load({
30
30
  .map((k) => encodeURIComponent(k) + '=' + encodeURIComponent(String(a[k])))
31
31
  .join('&');
32
32
  const url = '/html-workbench/' + method + (params ? '?' + params : '');
33
- return fetch(url).then((r) => r.json());
33
+ // A browser client may update before DSH reloads the Node-side host. An
34
+ // old host does not know newer routes (e.g. diagnostics/restart) and
35
+ // falls through to the SPA HTML shell. Never expose that as the useless
36
+ // Do not expose a raw JSON parse error; name the version mismatch and
37
+ // tell the user to restart the DSH host process.
38
+ return fetch(url).then(async (response) => {
39
+ const text = await response.text();
40
+ const contentType = response.headers.get('content-type') || '';
41
+ if (!contentType.includes('application/json')) {
42
+ const staleHost = /<!doctype html|<html[s>]/i.test(text);
43
+ throw new Error(staleHost
44
+ ? 'HTML Workbench 的界面已更新,但 DSH 后台仍是旧版本,暂不支持「' + method + '」。请完全重启 DSH 后重试。'
45
+ : '本地服务返回了非 JSON 响应(HTTP ' + response.status + ')。');
46
+ }
47
+ try {
48
+ return JSON.parse(text);
49
+ } catch (error) {
50
+ throw new Error('本地服务返回了无法解析的 JSON(HTTP ' + response.status + ')。');
51
+ }
52
+ });
34
53
  },
35
54
  };
36
55
 
@@ -72,7 +91,7 @@ return {
72
91
  // positioned chevron collapsing into a block below the input). So manage the
73
92
  // <style> element here: drop every previous generation, then insert fresh.
74
93
  const STYLE_MARK = 'data-hwb-styles'
75
- const STYLE_VERSION = '2'
94
+ const STYLE_VERSION = '5'
76
95
 
77
96
  const CSS = `
78
97
  html #root {
@@ -117,6 +136,32 @@ header:has([data-slot="conversation.session.header.utilities"]) {
117
136
  .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); }
118
137
  .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); }
119
138
  .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); }
139
+ /* The dot is the ONLY signal that a start failed, so make it the way in: it is a
140
+ button that opens the diagnostics log. */
141
+ .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; }
142
+ .hwb-statusbtn:hover { background: var(--dsw-alias-interactive-bg-hover); }
143
+ .hwb-statusbtn:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #4d6bfe); outline-offset: 1px; }
144
+
145
+ /* ── Diagnostics ──────────────────────────────────────────────────────────── */
146
+ .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); }
147
+ .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); }
148
+ .hwb-diag-title { font-size: 12px; font-weight: 600; }
149
+ .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; }
150
+ .hwb-diag-key { color: var(--dsw-alias-label-tertiary); white-space: nowrap; }
151
+ .hwb-diag-val { min-width: 0; font-family: var(--hwb-mono); word-break: break-all; }
152
+ .hwb-diag-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 6px 0; }
153
+ .hwb-diag-row { display: grid; grid-template-columns: auto 1fr; gap: 8px; padding: 4px 14px; font-size: 11.5px; line-height: 1.55; }
154
+ .hwb-diag-row + .hwb-diag-row { border-top: 1px solid color-mix(in srgb, var(--dsw-alias-border-l2) 50%, transparent); }
155
+ .hwb-diag-time { color: var(--dsw-alias-label-tertiary); font-family: var(--hwb-mono); font-size: 10.5px; padding-top: 1px; }
156
+ .hwb-diag-msg { min-width: 0; }
157
+ .hwb-diag-row[data-level="error"] .hwb-diag-msg { color: var(--dsw-alias-state-error-primary); }
158
+ .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; }
159
+ .hwb-diag-empty { padding: 16px 14px; color: var(--dsw-alias-label-tertiary); font-size: 11.5px; }
160
+ .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; }
161
+ .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; }
162
+ .hwb-btn-quiet { background: transparent; border-color: var(--dsw-alias-border-l2); color: var(--dsw-alias-label-primary); }
163
+ .hwb-btn-quiet:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
164
+ .hwb-btn-sm { height: 26px; padding: 0 10px; border-radius: 7px; font-size: 11.5px; }
120
165
  .hwb-name { font-weight: 600; font-size: 13px; white-space: nowrap; }
121
166
  .hwb-sep { width: 1px; height: 14px; flex: none; background: var(--dsw-alias-border-l1); }
122
167
  .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; }
@@ -184,6 +229,33 @@ header:has([data-slot="conversation.session.header.utilities"]) {
184
229
  .hwb-body { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; background: var(--dsw-alias-bg-base); }
185
230
  .hwb-frame { flex: 1 1 auto; width: 100%; border: 0; background: #fff; }
186
231
 
232
+ /* ── Visual selection chips (rendered INTO the host composer) ─────────────── */
233
+ /* The workbench posts one selection at a time; each becomes a removable chip
234
+ above the chat input, mirroring how file attachments behave. The markdown
235
+ evidence is held in memory and only spliced into the message on send, so the
236
+ user never has to look at a wall of HTML while typing. */
237
+ .hwb-chips {
238
+ display: flex; flex-wrap: wrap; gap: 6px;
239
+ margin: 0 14px 8px; padding: 0;
240
+ }
241
+ .hwb-chip {
242
+ display: inline-flex; align-items: center; gap: 6px; max-width: 260px;
243
+ padding: 3px 4px 3px 8px; border-radius: 8px;
244
+ border: 1px solid var(--dsw-alias-border-l2);
245
+ background: var(--dsw-alias-bg-layer-1);
246
+ color: var(--dsw-alias-label-primary);
247
+ font-size: 11.5px; line-height: 18px;
248
+ }
249
+ .hwb-chip > svg { flex: none; color: var(--dsw-alias-state-business-primary, #4d6bfe); }
250
+ .hwb-chip-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--hwb-mono); }
251
+ .hwb-chip-x {
252
+ flex: none; display: grid; place-items: center; width: 18px; height: 18px; padding: 0;
253
+ border: 0; border-radius: 5px; background: transparent;
254
+ color: var(--dsw-alias-label-tertiary); cursor: pointer;
255
+ }
256
+ .hwb-chip-x:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
257
+ .hwb-chip-note { align-self: center; color: var(--dsw-alias-label-tertiary); font-size: 11px; }
258
+
187
259
  .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; }
188
260
  .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); }
189
261
  .hwb-blank-title { font-size: 13.5px; font-weight: 600; color: var(--dsw-alias-label-primary); }
@@ -257,6 +329,10 @@ header:has([data-slot="conversation.session.header.utilities"]) {
257
329
  error: null,
258
330
  pathInput: '',
259
331
  resolveState: 'idle',
332
+ context: null,
333
+ diag: null,
334
+ diagOpen: false,
335
+ restarting: false,
260
336
  listeners: [],
261
337
  subscribe(fn) { this.listeners.push(fn); return () => { this.listeners = this.listeners.filter((f) => f !== fn) } },
262
338
  set(patch) {
@@ -307,6 +383,20 @@ header:has([data-slot="conversation.session.header.utilities"]) {
307
383
  return true
308
384
  }
309
385
 
386
+ // The panel polls `list` every few seconds and the payload now carries the
387
+ // whole diagnostics journal — a fresh object every tick. Compare by the
388
+ // facts that actually change so polling does not re-render continuously.
389
+ const sameDiag = (a, b) => {
390
+ if (a === b) return true
391
+ if (!a || !b) return false
392
+ const ja = a.journal || []
393
+ const jb = b.journal || []
394
+ if (ja.length !== jb.length) return false
395
+ if (ja.length && (ja[0].at !== jb[0].at || ja[0].message !== jb[0].message)) return false
396
+ return a.running === b.running && a.startError === b.startError
397
+ && a.processStatus === b.processStatus && a.exitCode === b.exitCode
398
+ }
399
+
310
400
  const refresh = (explicit) => {
311
401
  if (explicit) store.set({ refreshing: true })
312
402
  return host.call('list').then((res) => {
@@ -315,6 +405,7 @@ header:has([data-slot="conversation.session.header.utilities"]) {
315
405
  store.set({
316
406
  assets: sameAssets(store.assets, next) ? store.assets : next,
317
407
  running: !!res.running,
408
+ diag: sameDiag(store.diag, res) ? store.diag : res,
318
409
  })
319
410
  } else {
320
411
  store.set({ error: (res && res.error) || 'list failed' })
@@ -323,6 +414,21 @@ header:has([data-slot="conversation.session.header.utilities"]) {
323
414
  .then(() => { if (explicit) store.set({ refreshing: false }) })
324
415
  }
325
416
 
417
+ // Killing and re-spawning is the one action that fixes most start failures,
418
+ // so it belongs next to the log that reports them.
419
+ const restartService = () => {
420
+ store.set({ restarting: true, error: null })
421
+ return host.call('restart').then((res) => {
422
+ store.set({
423
+ restarting: false,
424
+ running: !!(res && res.ok),
425
+ diag: (res && res.diagnostics) || store.diag,
426
+ error: res && res.ok ? null : (res && res.error) || '重启失败',
427
+ })
428
+ if (res && res.ok) refresh()
429
+ }).catch((e) => store.set({ restarting: false, error: String(e && e.message ? e.message : e) }))
430
+ }
431
+
326
432
  const openFile = (path) => {
327
433
  const file = String(path || '').trim()
328
434
  if (!file) return
@@ -340,7 +446,15 @@ header:has([data-slot="conversation.session.header.utilities"]) {
340
446
  nonce: store.nonce + 1,
341
447
  })
342
448
  } else {
343
- store.set({ loading: false, error: (res && res.error) || 'open failed' })
449
+ // A failed open used to collapse into one opaque line. The host now
450
+ // sends the journal along, so open the log on failure: the cause is
451
+ // one glance away instead of a terminal session away.
452
+ store.set({
453
+ loading: false,
454
+ error: (res && res.error) || 'open failed',
455
+ diag: (res && res.diagnostics) || store.diag,
456
+ diagOpen: !!(res && res.diagnostics),
457
+ })
344
458
  }
345
459
  }).catch((e) => store.set({ loading: false, error: String(e && e.message ? e.message : e) }))
346
460
  }
@@ -352,6 +466,204 @@ header:has([data-slot="conversation.session.header.utilities"]) {
352
466
  checking: '检测中…',
353
467
  }
354
468
 
469
+ // ── Visual selection → composer chips ────────────────────────────────────
470
+ //
471
+ // The workbench posts one selection per click. Each becomes a removable chip
472
+ // above the chat input, and the bulky markdown evidence stays in memory
473
+ // until the user actually sends — so they compose against a short list of
474
+ // labels instead of a wall of HTML.
475
+ //
476
+ // Two deliberate choices:
477
+ // 1. Chips are plain DOM appended into the host's composer card, not a
478
+ // React portal. The card is a flex column owned by the host's React
479
+ // tree; React leaves DOM nodes it never created alone, but it may
480
+ // `insertBefore` around them, so a MutationObserver re-asserts position.
481
+ // 2. The evidence is spliced in during the CAPTURE phase of send. React 18
482
+ // dispatches discrete events synchronously, so writing the value through
483
+ // the native setter + `input` event during capture means React state is
484
+ // already updated by the time the host's own handler reads it.
485
+
486
+ const CHIP_HOST = '.hwb-chips'
487
+ const pendingSelections = []
488
+ let chipRow = null
489
+ let composerObserver = null
490
+
491
+ const findComposer = () => {
492
+ const nodes = document.querySelectorAll('textarea')
493
+ for (let i = 0; i < nodes.length; i += 1) {
494
+ const node = nodes[i]
495
+ // The panel's own inputs are inside `.hwb-panel`; never target those.
496
+ if (node.closest('.hwb-panel')) continue
497
+ const box = node.getBoundingClientRect()
498
+ if (box.width >= 120 && box.height > 0) return node
499
+ }
500
+ return null
501
+ }
502
+
503
+ // The card is the flex column holding the textarea and the tool row. Walking
504
+ // up from the textarea keeps this working if the host renames its classes.
505
+ const findComposerCard = (textarea) => {
506
+ let node = textarea && textarea.parentElement
507
+ let depth = 0
508
+ while (node && depth < 6) {
509
+ const style = window.getComputedStyle(node)
510
+ if (style.display === 'flex' && style.flexDirection === 'column' && node.querySelector('button')) return node
511
+ node = node.parentElement
512
+ depth += 1
513
+ }
514
+ return textarea ? textarea.parentElement : null
515
+ }
516
+
517
+ const setComposerValue = (node, next) => {
518
+ const descriptor = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')
519
+ const setter = descriptor && descriptor.set
520
+ if (setter) setter.call(node, next)
521
+ else node.value = next
522
+ node.dispatchEvent(new Event('input', { bubbles: true }))
523
+ }
524
+
525
+ const evidenceBlock = () => {
526
+ if (!pendingSelections.length) return ''
527
+ return pendingSelections.map((item) => item.markdown).join('\n')
528
+ }
529
+
530
+ const clearSelections = () => {
531
+ pendingSelections.length = 0
532
+ renderChips()
533
+ }
534
+
535
+ const removeSelection = (key) => {
536
+ const index = pendingSelections.findIndex((item) => item.key === key)
537
+ if (index >= 0) pendingSelections.splice(index, 1)
538
+ renderChips()
539
+ }
540
+
541
+ 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>'
542
+
543
+ const renderChips = () => {
544
+ const textarea = findComposer()
545
+ const card = findComposerCard(textarea)
546
+ if (!card) return
547
+ if (!pendingSelections.length) {
548
+ if (chipRow && chipRow.parentNode) chipRow.parentNode.removeChild(chipRow)
549
+ chipRow = null
550
+ return
551
+ }
552
+ if (!chipRow) {
553
+ chipRow = document.createElement('div')
554
+ chipRow.className = CHIP_HOST.slice(1)
555
+ }
556
+ chipRow.textContent = ''
557
+ pendingSelections.forEach((item) => {
558
+ const chip = document.createElement('span')
559
+ chip.className = 'hwb-chip'
560
+ chip.title = item.textHint ? item.label + ' — ' + item.textHint : item.label
561
+ chip.innerHTML = chipIcon
562
+ const label = document.createElement('span')
563
+ label.className = 'hwb-chip-label'
564
+ label.textContent = item.label
565
+ const close = document.createElement('button')
566
+ close.type = 'button'
567
+ close.className = 'hwb-chip-x'
568
+ close.setAttribute('aria-label', '移除 ' + item.label)
569
+ 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>'
570
+ close.addEventListener('click', (event) => {
571
+ event.preventDefault()
572
+ event.stopPropagation()
573
+ removeSelection(item.key)
574
+ })
575
+ chip.appendChild(label)
576
+ chip.appendChild(close)
577
+ chipRow.appendChild(chip)
578
+ })
579
+ const note = document.createElement('span')
580
+ note.className = 'hwb-chip-note'
581
+ note.textContent = '发送时会自动附上选区源码'
582
+ chipRow.appendChild(note)
583
+
584
+ // Sit directly above the tool row (the last flex child), which puts the
585
+ // chips between the text input and the model/send controls.
586
+ const anchor = card.lastElementChild
587
+ if (chipRow.parentNode !== card || chipRow.nextElementSibling !== anchor) {
588
+ card.insertBefore(chipRow, anchor)
589
+ }
590
+ observeComposer(card)
591
+ }
592
+
593
+ const observeComposer = (card) => {
594
+ if (composerObserver) return
595
+ composerObserver = new MutationObserver(() => {
596
+ if (!pendingSelections.length) return
597
+ if (!chipRow || chipRow.parentNode !== card) renderChips()
598
+ })
599
+ composerObserver.observe(card, { childList: true })
600
+ ctx.effect(() => () => {
601
+ if (composerObserver) composerObserver.disconnect()
602
+ composerObserver = null
603
+ })
604
+ }
605
+
606
+ const receiveContext = (packet) => {
607
+ const key = packet.key || packet.label
608
+ const existing = pendingSelections.findIndex((item) => item.key === key)
609
+ const entry = {
610
+ key: key,
611
+ label: packet.label || 'element',
612
+ textHint: packet.textHint || '',
613
+ markdown: packet.markdown,
614
+ fileName: packet.fileName,
615
+ }
616
+ // Re-adding the same element refreshes its evidence rather than stacking a
617
+ // duplicate: the file may have changed since the first click.
618
+ if (existing >= 0) pendingSelections[existing] = entry
619
+ else pendingSelections.push(entry)
620
+ renderChips()
621
+ const textarea = findComposer()
622
+ if (textarea) textarea.focus()
623
+ store.set({ context: { count: pendingSelections.length, label: entry.label, at: Date.now() } })
624
+ }
625
+
626
+ // Splice the evidence in just before the host reads the composer, then drop
627
+ // the chips: the message now carries the context, so keeping them would
628
+ // silently re-attach the same source to the next turn.
629
+ const spliceEvidenceIntoMessage = () => {
630
+ if (!pendingSelections.length) return
631
+ const textarea = findComposer()
632
+ if (!textarea) return
633
+ const typed = (textarea.value || '').trim()
634
+ if (!typed) return
635
+ setComposerValue(textarea, evidenceBlock() + '\n' + typed)
636
+ clearSelections()
637
+ }
638
+
639
+ const bindSendInterception = () => {
640
+ const onClick = (event) => {
641
+ if (!pendingSelections.length) return
642
+ const target = event.target instanceof Element ? event.target : null
643
+ const button = target && target.closest('button')
644
+ if (!button || button.closest('.hwb-panel')) return
645
+ const label = button.getAttribute('aria-label') || ''
646
+ if (!/发送|send/i.test(label)) return
647
+ spliceEvidenceIntoMessage()
648
+ }
649
+ const onKeyDown = (event) => {
650
+ if (!pendingSelections.length) return
651
+ if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return
652
+ const target = event.target
653
+ if (!(target instanceof HTMLTextAreaElement) || target.closest('.hwb-panel')) return
654
+ spliceEvidenceIntoMessage()
655
+ }
656
+ document.addEventListener('click', onClick, true)
657
+ document.addEventListener('keydown', onKeyDown, true)
658
+ ctx.effect(() => () => {
659
+ document.removeEventListener('click', onClick, true)
660
+ document.removeEventListener('keydown', onKeyDown, true)
661
+ })
662
+ }
663
+
664
+ bindSendInterception()
665
+
666
+
355
667
  const checkPath = (value) => {
356
668
  const trimmed = (value || '').trim()
357
669
  if (resolveTimer) { clearTimeout(resolveTimer); resolveTimer = null }
@@ -398,6 +710,79 @@ header:has([data-slot="conversation.session.header.utilities"]) {
398
710
  path('M8 21h8', 'c'),
399
711
  path('M12 18v3', 'd'),
400
712
  ])
713
+ 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')])
714
+
715
+ // ── Diagnostics view ─────────────────────────────────────────────────────
716
+ //
717
+ // A start failure has exactly one visible symptom by default: a red dot. The
718
+ // cause lives in a Python traceback or a spawn error inside the host process,
719
+ // so surface it verbatim here — plus the facts needed to reproduce by hand
720
+ // (port, script path, exit code). That turns "服务起不来" into a fixable
721
+ // report without leaving the panel.
722
+
723
+ const clockTime = (at) => {
724
+ try {
725
+ return new Date(at).toLocaleTimeString('zh-CN', { hour12: false })
726
+ } catch (e) { return '' }
727
+ }
728
+
729
+ const Diagnostics = () => {
730
+ const s = useStore()
731
+ const d = s.diag
732
+ const journal = (d && d.journal) || []
733
+
734
+ const fact = (key, value) => value == null || value === ''
735
+ ? null
736
+ : [
737
+ React.createElement('span', { className: 'hwb-diag-key', key: key + '-k' }, key),
738
+ React.createElement('span', { className: 'hwb-diag-val', key: key + '-v' }, String(value)),
739
+ ]
740
+
741
+ return React.createElement('div', { className: 'hwb-diag' },
742
+ React.createElement('div', { className: 'hwb-diag-head' },
743
+ React.createElement('span', { className: 'hwb-diag-title' }, '服务诊断'),
744
+ React.createElement('span', { className: 'hwb-spacer' }),
745
+ React.createElement('button', {
746
+ type: 'button', className: 'hwb-btn hwb-btn-quiet hwb-btn-sm',
747
+ disabled: s.restarting, onClick: restartService,
748
+ }, s.restarting ? '重启中…' : '重启服务'),
749
+ React.createElement('button', {
750
+ type: 'button', className: 'hwb-icon', title: '收起诊断', 'aria-label': '收起诊断',
751
+ onClick: () => store.set({ diagOpen: false }),
752
+ }, I_CLOSE),
753
+ ),
754
+ d
755
+ ? React.createElement('div', { className: 'hwb-diag-facts' },
756
+ fact('状态', d.running ? '运行中' : (d.processStatus === 'running' ? '启动中' : '未就绪')),
757
+ fact('端口', d.port),
758
+ fact('进程', d.processStatus ? d.processStatus + (d.exitCode == null ? '' : '(exit=' + d.exitCode + ')') : '未由本插件启动'),
759
+ fact('失败原因', d.startError),
760
+ fact('脚本', d.script),
761
+ d.hasShell === false ? fact('shell', '不可用') : null,
762
+ )
763
+ : null,
764
+ React.createElement('div', { className: 'hwb-diag-body' },
765
+ journal.length
766
+ ? journal.map((entry, i) => React.createElement('div', {
767
+ className: 'hwb-diag-row', 'data-level': entry.level, key: String(entry.at) + '-' + i,
768
+ },
769
+ React.createElement('span', { className: 'hwb-diag-time' }, clockTime(entry.at)),
770
+ React.createElement('span', { className: 'hwb-diag-msg' },
771
+ entry.message,
772
+ entry.detail ? React.createElement('span', { className: 'hwb-diag-detail' }, entry.detail) : null,
773
+ ),
774
+ ))
775
+ : React.createElement('div', { className: 'hwb-diag-empty' }, '暂无日志。点「重启服务」可重新拉起并记录完整过程。'),
776
+ ),
777
+ React.createElement('div', { className: 'hwb-diag-hint' },
778
+ '服务自身日志:',
779
+ React.createElement('code', null, (d && d.logDir) || '/tmp/html-workbench-dsh/logs/'),
780
+ ';也可直接访问 ',
781
+ React.createElement('code', null, '/html-workbench/diagnostics'),
782
+ ' 查看原始 JSON。',
783
+ ),
784
+ )
785
+ }
401
786
 
402
787
  const STATE_ICON = { exists: I_CHECK, missing: I_MISSING, invalid: I_WARN, checking: I_LOADING }
403
788
 
@@ -452,6 +837,19 @@ header:has([data-slot="conversation.session.header.utilities"]) {
452
837
  return () => { if (dispose) dispose() }
453
838
  }, [s.open])
454
839
 
840
+ // The workbench runs in an iframe, so its selections arrive as messages.
841
+ // Bind while the panel is mounted, regardless of which file is open.
842
+ React.useEffect(() => {
843
+ const onMessage = (event) => {
844
+ const data = event.data
845
+ if (!data || data.type !== 'html-workbench:context') return
846
+ if (!data.markdown) return
847
+ receiveContext(data)
848
+ }
849
+ window.addEventListener('message', onMessage)
850
+ return () => window.removeEventListener('message', onMessage)
851
+ }, [])
852
+
455
853
  // Drop any in-flight path check when the panel closes.
456
854
  React.useEffect(() => () => { if (resolveTimer) { clearTimeout(resolveTimer); resolveTimer = null } }, [])
457
855
 
@@ -488,6 +886,9 @@ header:has([data-slot="conversation.session.header.utilities"]) {
488
886
  inMenu && a.kind ? React.createElement('span', { className: 'hwb-tag', 'data-kind': a.kind }, a.kind === 'create' ? '新建' : '编辑') : null,
489
887
  )
490
888
 
889
+ // A dead service makes "填入路径开始编辑" a lie — every open will fail.
890
+ // Say what is actually wrong and offer the two things that help.
891
+ const serviceDown = !s.running && !!s.diag && !s.loading
491
892
  const body = s.loading
492
893
  ? React.createElement('div', { className: 'hwb-center' },
493
894
  React.createElement('div', { className: 'hwb-spinner' }),
@@ -500,7 +901,26 @@ header:has([data-slot="conversation.session.header.utilities"]) {
500
901
  src: s.current.url,
501
902
  title: 'HTML Workbench — ' + basename(s.current.path),
502
903
  })
503
- : React.createElement('div', { className: 'hwb-center' },
904
+ : serviceDown
905
+ ? React.createElement('div', { className: 'hwb-center' },
906
+ React.createElement('div', { className: 'hwb-blank' }, I_ALERT),
907
+ React.createElement('div', null,
908
+ React.createElement('div', { className: 'hwb-blank-title' }, '本地服务未就绪'),
909
+ React.createElement('div', { className: 'hwb-blank-desc' },
910
+ s.diag.startError || '服务进程没有响应健康检查,暂时无法打开文件。'),
911
+ ),
912
+ React.createElement('div', { style: { display: 'flex', gap: '8px' } },
913
+ React.createElement('button', {
914
+ type: 'button', className: 'hwb-btn hwb-btn-primary hwb-btn-sm',
915
+ disabled: s.restarting, onClick: restartService,
916
+ }, s.restarting ? '重启中…' : '重启服务'),
917
+ React.createElement('button', {
918
+ type: 'button', className: 'hwb-btn hwb-btn-quiet hwb-btn-sm',
919
+ onClick: () => store.set({ diagOpen: true }),
920
+ }, '查看诊断日志'),
921
+ ),
922
+ )
923
+ : React.createElement('div', { className: 'hwb-center' },
504
924
  React.createElement('div', { className: 'hwb-blank' }, I_BLANK),
505
925
  React.createElement('div', null,
506
926
  React.createElement('div', { className: 'hwb-blank-title' }, '还没有打开文件'),
@@ -539,12 +959,17 @@ header:has([data-slot="conversation.session.header.utilities"]) {
539
959
 
540
960
  React.createElement('div', { className: 'hwb-chrome' },
541
961
  React.createElement('div', { className: 'hwb-idrow' },
542
- React.createElement('div', { className: 'hwb-brand' },
962
+ React.createElement('button', {
963
+ type: 'button',
964
+ className: 'hwb-statusbtn',
965
+ 'aria-expanded': !!s.diagOpen,
966
+ title: s.running ? '本地服务运行中 — 点击查看诊断' : '本地服务未就绪 — 点击查看原因',
967
+ onClick: () => store.set({ diagOpen: !s.diagOpen }),
968
+ },
543
969
  React.createElement('span', {
544
970
  className: 'hwb-dot',
545
971
  'data-on': s.running ? '' : undefined,
546
972
  'data-off': !s.running ? '' : undefined,
547
- title: s.running ? '本地服务运行中' : '本地服务未就绪',
548
973
  }),
549
974
  React.createElement('span', { className: 'hwb-name' }, 'HTML Workbench'),
550
975
  ),
@@ -554,6 +979,11 @@ header:has([data-slot="conversation.session.header.utilities"]) {
554
979
  : null,
555
980
  React.createElement('span', { className: 'hwb-spacer' }),
556
981
  React.createElement('div', { className: 'hwb-actions' },
982
+ React.createElement('button', {
983
+ type: 'button', className: 'hwb-icon', title: '服务诊断',
984
+ 'aria-label': '服务诊断', 'aria-expanded': !!s.diagOpen,
985
+ onClick: () => store.set({ diagOpen: !s.diagOpen }),
986
+ }, I_LOGS),
557
987
  React.createElement('button', {
558
988
  type: 'button', className: 'hwb-icon', title: '刷新产物列表',
559
989
  'aria-label': '刷新产物列表', 'data-spin': s.refreshing ? '' : undefined,
@@ -630,6 +1060,10 @@ header:has([data-slot="conversation.session.header.utilities"]) {
630
1060
  ? React.createElement('div', { className: 'hwb-banner', role: 'alert' },
631
1061
  I_ALERT,
632
1062
  React.createElement('span', { className: 'hwb-banner-text' }, s.error),
1063
+ React.createElement('button', {
1064
+ type: 'button', className: 'hwb-icon', title: '查看诊断', 'aria-label': '查看诊断',
1065
+ onClick: () => store.set({ diagOpen: true }),
1066
+ }, I_LOGS),
633
1067
  React.createElement('button', {
634
1068
  type: 'button', className: 'hwb-icon', title: '忽略', 'aria-label': '忽略',
635
1069
  onClick: () => store.set({ error: null }),
@@ -637,6 +1071,8 @@ header:has([data-slot="conversation.session.header.utilities"]) {
637
1071
  )
638
1072
  : null,
639
1073
 
1074
+ s.diagOpen ? React.createElement(Diagnostics) : null,
1075
+
640
1076
  React.createElement('div', { className: 'hwb-body' }, body),
641
1077
  )
642
1078
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-x/dsh-html-workbench",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "DeepSeek Harness 插件:在右侧栏预览并可视化编辑 agent 生成的 HTML(复用/拉起本地 html-workbench 服务)。",
5
5
  "license": "MIT",
6
6
  "type": "module",