@zongcn/dsh-next-steps 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -18,7 +18,7 @@ It costs one short model call per qualifying turn. It never sends anything on yo
18
18
 
19
19
  **Main features.**
20
20
 
21
- - **Chips above the composer.** Three numbered prompts, one click away. Clicking one writes it into the composer as a draft — edit it, then press Enter yourself. Regenerate the row in place for a fresh set, or dismiss it when it is not what you wanted.
21
+ - **Chips above the composer.** Three numbered prompts, one click away. Hover or keyboard-focus a chip to read its full prompt in a tooltip; clicking one writes it into the composer as a draft — edit it, then press Enter yourself. Regenerate the row in place for a fresh set, or dismiss it when it is not what you wanted.
22
22
  - **Suggestions that know the session.** The model sees the last ten human and model messages, and — when enabled — the session's skill catalog, so a suggestion can be a real `/skill` prompt rather than generic advice. Prompts come back in the language you were already using.
23
23
  - **No provider to set up.** Suggestions run on whatever model route the session already uses, so there is no second API key and no extra bill to reconcile.
24
24
 
@@ -77,7 +77,7 @@ Options live in `cordis.patch.yml`, under the bundle row's `config`:
77
77
 
78
78
  ```yaml
79
79
  - insert:
80
- - id: next-steps
80
+ - id: dsh-next-steps
81
81
  name: '@zongcn/dsh-next-steps'
82
82
  config:
83
83
  maxSuggestions: 3
@@ -125,12 +125,14 @@ The plugin serves only the two local state routes it needs, and it never submits
125
125
  ## Development
126
126
 
127
127
  ```sh
128
- npm test # 33 host checks + 10 dock checks, no dependencies, no browser
128
+ npm test # 33 host checks + 13 dock checks, no dependencies, no browser
129
129
  node scripts/selftest.mjs # host half only
130
130
  node scripts/verify-dock.mjs # browser half: the composer band, driven by a React stub
131
131
  node scripts/verify-ui.mjs # optional Playwright run against a live GUI (see the script header)
132
132
  ```
133
133
 
134
+ **Releasing.** The full release checklist — version bump, CI, npm publish, GitHub Release, and clean-profile verification — lives in [English](RELEASING.md) and [简体中文](RELEASING.zh-CN.md).
135
+
134
136
  The source is small enough to read in one sitting:
135
137
 
136
138
  ```text
package/README.zh-CN.md CHANGED
@@ -18,7 +18,7 @@ Next steps 是 DeepSeek Harness(DSH)的一个插件。每轮回答结束,
18
18
 
19
19
  **主要功能。**
20
20
 
21
- - **输入框上方的建议 chips。** 三条带编号的提示,点一下就写进输入框变成草稿——改完自己按回车;想换一批可以原地重新生成,不需要时点 dismiss 收起。
21
+ - **输入框上方的建议 chips。** 三条带编号的提示;悬停或键盘聚焦会显示完整提示,点一下就写进输入框变成草稿——改完自己按回车;想换一批可以原地重新生成,不需要时点 dismiss 收起。
22
22
  - **懂这段对话的建议。** 模型看到最近十条人和模型的消息,以及(默认开启的)本会话技能清单,所以建议可以是一个真正的 `/skill` 提示,而不是泛泛而谈;建议会沿用你刚才使用的语言。
23
23
  - **不用再配一个 provider。** 建议跑在会话本来就在用的模型路由上,不需要第二把 API key,也不会多出一份账单。
24
24
 
@@ -77,7 +77,7 @@ dsh plugin --profile web add "$PWD/dsh-next-steps"
77
77
 
78
78
  ```yaml
79
79
  - insert:
80
- - id: next-steps
80
+ - id: dsh-next-steps
81
81
  name: '@zongcn/dsh-next-steps'
82
82
  config:
83
83
  maxSuggestions: 3
@@ -125,12 +125,14 @@ dsh plugin --profile web add "$PWD/dsh-next-steps"
125
125
  ## 开发
126
126
 
127
127
  ```sh
128
- npm test # 33 项宿主检查 + 10 项横条检查,零依赖、无浏览器
128
+ npm test # 33 项宿主检查 + 13 项横条检查,零依赖、无浏览器
129
129
  node scripts/selftest.mjs # 仅宿主半
130
130
  node scripts/verify-dock.mjs # 浏览器半:用 React 桩驱动输入框横条
131
131
  node scripts/verify-ui.mjs # 可选:对一个真实 GUI 跑 Playwright(见脚本头部说明)
132
132
  ```
133
133
 
134
+ **发布。** 完整发布清单(改版本、CI、npm publish、GitHub Release、干净 profile 验证)见 [简体中文](RELEASING.zh-CN.md) 与 [English](RELEASING.md)。
135
+
134
136
  源码很少,一口气能读完:
135
137
 
136
138
  ```text
package/client.js CHANGED
@@ -13,7 +13,8 @@
13
13
  */
14
14
 
15
15
  window.__ModuleLoader__.load({
16
- id: 'dsh-next-steps',
16
+ // The graph row id is the package name (client-modules requires them to match).
17
+ id: '@zongcn/dsh-next-steps',
17
18
  factory(require) {
18
19
  const React = require('react')
19
20
  const h = React.createElement
@@ -112,6 +113,10 @@ window.__ModuleLoader__.load({
112
113
  '.nsx-band-label{font-size:11px;color:var(--dsw-alias-label-secondary);padding:0 2px;flex:none}',
113
114
  '.nsx-band-error{flex:1 1 100%;min-width:0;white-space:normal;overflow-wrap:anywhere;line-height:1.5;color:var(--dsw-alias-state-error-primary)}',
114
115
  '.nsx-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0}',
116
+ '.nsx-chip-wrap{position:relative;display:inline-flex;max-width:100%}',
117
+ '.nsx-tip{position:absolute;left:0;bottom:calc(100% + 8px);z-index:30;box-sizing:border-box;width:max-content;max-width:min(420px, calc(100vw - 48px));padding:8px 10px;border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-panel);font-size:11.5px;line-height:1.5;white-space:normal;overflow-wrap:anywhere;pointer-events:none;animation:nsx-tip-in .12s ease-out}',
118
+ '@keyframes nsx-tip-in{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:none}}',
119
+ '@media (prefers-reduced-motion:reduce){.nsx-tip{animation:none}}',
115
120
  '.nsx-chip{font:inherit;display:inline-flex;align-items:center;gap:7px;max-width:100%;height:28px;padding:0 10px 0 6px;border-radius:7px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);cursor:pointer;box-sizing:border-box}',
116
121
  '.nsx-chip:hover:enabled{border-color:var(--dsw-alias-brand-primary)}',
117
122
  '.nsx-chip:disabled{opacity:.55;cursor:default}',
@@ -391,6 +396,41 @@ window.__ModuleLoader__.load({
391
396
  const [pendingFill, setPendingFill] = React.useState(null)
392
397
  const [regenerating, setRegenerating] = React.useState(false)
393
398
  const [refreshError, setRefreshError] = React.useState('')
399
+ // A chip shows a short label, so hover and focus preview the full prompt
400
+ // in a tooltip the plugin renders itself. The window does not surface a
401
+ // native `title` hint, so the affordance has to exist in the DOM.
402
+ const [tip, setTip] = React.useState(null)
403
+ const tipTimer = React.useRef(null)
404
+ const hideTip = () => {
405
+ if (tipTimer.current !== null) {
406
+ clearTimeout(tipTimer.current)
407
+ tipTimer.current = null
408
+ }
409
+ setTip(null)
410
+ }
411
+ const showTip = (index, delay) => {
412
+ if (tipTimer.current !== null) {
413
+ clearTimeout(tipTimer.current)
414
+ tipTimer.current = null
415
+ }
416
+ if (delay <= 0) {
417
+ setTip(index)
418
+ return
419
+ }
420
+ tipTimer.current = setTimeout(() => {
421
+ tipTimer.current = null
422
+ setTip(index)
423
+ }, delay)
424
+ }
425
+ React.useEffect(
426
+ () => () => {
427
+ if (tipTimer.current !== null) clearTimeout(tipTimer.current)
428
+ },
429
+ [],
430
+ )
431
+ React.useEffect(() => {
432
+ hideTip()
433
+ }, [at, status])
394
434
  // A fresh round or any edit to the draft invalidates a pending replace;
395
435
  // the regenerate spinner and its error keep their own reset trigger.
396
436
  React.useEffect(() => {
@@ -525,11 +565,32 @@ window.__ModuleLoader__.load({
525
565
  items.map((item, index) => {
526
566
  const prompt = item !== null && typeof item === 'object' && typeof item.prompt === 'string' ? item.prompt : ''
527
567
  const label = item !== null && typeof item === 'object' && typeof item.label === 'string' && item.label !== '' ? item.label : prompt
568
+ const tipId = `nsx-tip-${at}-${index}`
528
569
  return h(
529
- 'button',
530
- { key: index, type: 'button', className: 'nsx-chip', title: prompt, onClick: () => fill(prompt) },
531
- h('span', { className: 'nsx-chip-n' }, String(index + 1)),
532
- h('span', { className: 'nsx-chip-text' }, label),
570
+ 'span',
571
+ {
572
+ key: index,
573
+ className: 'nsx-chip-wrap',
574
+ onPointerEnter: () => showTip(index, 260),
575
+ onPointerLeave: hideTip,
576
+ },
577
+ h(
578
+ 'button',
579
+ {
580
+ type: 'button',
581
+ className: 'nsx-chip',
582
+ 'aria-describedby': tip === index ? tipId : undefined,
583
+ onClick: () => {
584
+ hideTip()
585
+ fill(prompt)
586
+ },
587
+ onFocus: () => showTip(index, 0),
588
+ onBlur: hideTip,
589
+ },
590
+ h('span', { className: 'nsx-chip-n' }, String(index + 1)),
591
+ h('span', { className: 'nsx-chip-text' }, label),
592
+ ),
593
+ tip === index ? h('span', { id: tipId, role: 'tooltip', className: 'nsx-tip' }, prompt) : null,
533
594
  )
534
595
  }),
535
596
  ),
package/cordis.patch.yml CHANGED
@@ -2,7 +2,7 @@
2
2
  # plugin); the `dsh.client` declaration in package.json makes the Web app load
3
3
  # ./client.js as the browser half.
4
4
  - insert:
5
- - id: next-steps
5
+ - id: dsh-next-steps
6
6
  name: '@zongcn/dsh-next-steps'
7
7
  config:
8
8
  enabled: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zongcn/dsh-next-steps",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Suggests up to three prompts to send next after each finished turn, right above the composer.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -14,6 +14,7 @@
14
14
  * Exits non-zero when a check fails.
15
15
  */
16
16
  import assert from 'node:assert/strict'
17
+ import { readFile } from 'node:fs/promises'
17
18
 
18
19
  // ---- Minimal React -------------------------------------------------------
19
20
  // Only the four hooks the bundle calls, with the same observable rules:
@@ -71,6 +72,14 @@ globalThis.window = {
71
72
  await import(new URL('../client.js', import.meta.url).href)
72
73
  assert.ok(captured, 'client.js registered a module definition')
73
74
 
75
+ // client-modules keys each bundle factory by its boot row id, which is the
76
+ // package name. When the literal differs the loader believes the batch "loaded
77
+ // without registering", refetches the bundle by its own URL, and the second
78
+ // load() throws "duplicate factory registration" — the browser half then never
79
+ // mounts. Keep the literal pinned to package.json.
80
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'))
81
+ assert.equal(captured.id, pkg.name, 'client.js registers the loader id matching the package name')
82
+
74
83
  const EN = {}
75
84
  const seats = {}
76
85
  const ctx = {
@@ -212,6 +221,8 @@ async function mount(sessions, draft) {
212
221
 
213
222
  /** The Host poll runs every 2 s; one round of it settles a scripted change. */
214
223
  const nextPoll = () => new Promise((resolve) => setTimeout(resolve, 2200))
224
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
225
+ const tooltipNode = () => visible().find((node) => node.props !== undefined && node.props.role === 'tooltip')
215
226
 
216
227
  // ---- Checks --------------------------------------------------------------
217
228
  let passed = 0
@@ -353,6 +364,47 @@ await check('typing during an in-flight regenerate keeps the spinner', async ()
353
364
  assert.ok(texts().includes('Regenerating…'), 'success alone does not unlock before the poll')
354
365
  })
355
366
 
367
+ await check('hovering a suggestion chip shows its full prompt in a tooltip', async () => {
368
+ await mount(ready(1200), '')
369
+ assert.equal(tooltipNode(), undefined, 'no tooltip before hovering')
370
+ const wrap = visible().find((node) => String(node.props.className).includes('nsx-chip-wrap'))
371
+ assert.ok(wrap, 'each chip is wrapped so its tooltip can be placed above it')
372
+ wrap.props.onPointerEnter()
373
+ await flush()
374
+ await wait(400)
375
+ await flush()
376
+ const tip = tooltipNode()
377
+ assert.ok(tip, 'a tooltip appears on hover: ' + texts())
378
+ assert.equal(textOf(tip), 'run the tests', 'the tooltip carries the full prompt')
379
+ wrap.props.onPointerLeave()
380
+ await flush()
381
+ assert.equal(tooltipNode(), undefined, 'the tooltip hides when the pointer leaves')
382
+ })
383
+
384
+ await check('focusing a suggestion chip shows its tooltip without waiting', async () => {
385
+ await mount(ready(1300), '')
386
+ const chip = suggestionChips()[0]
387
+ assert.ok(chip, 'a suggestion chip is rendered')
388
+ chip.props.onFocus()
389
+ await flush()
390
+ const tip = tooltipNode()
391
+ assert.ok(tip, 'a tooltip appears on keyboard focus')
392
+ assert.equal(textOf(tip), 'run the tests', 'the tooltip carries the full prompt')
393
+ })
394
+
395
+ await check('a tooltip does not survive the next round', async () => {
396
+ await mount(ready(1400), '')
397
+ visible().find((node) => String(node.props.className).includes('nsx-chip-wrap')).props.onPointerEnter()
398
+ await flush()
399
+ await wait(400)
400
+ await flush()
401
+ assert.ok(tooltipNode(), 'tooltip visible before the next round')
402
+ payload = baseValue(ready(1500, [{ label: 'Fresh', prompt: 'fresh prompt' }]))
403
+ await nextPoll()
404
+ await flush()
405
+ assert.equal(tooltipNode(), undefined, 'the old tooltip is gone after the round changes')
406
+ })
407
+
356
408
  dispose()
357
409
 
358
410
  if (failures.length > 0) {
@@ -125,9 +125,21 @@ try {
125
125
  check('regenerate control is enabled', await regenerate.isEnabled())
126
126
  }
127
127
  await shot(page, '02-band-light')
128
- // A press writes the draft and must not submit it.
129
- const firstPrompt = await chips.first().getAttribute('title')
130
- await chips.first().click()
128
+ // Hovering a chip previews the full prompt in the plugin's own tooltip; a
129
+ // press then writes that prompt as a draft and must not submit it.
130
+ const firstChip = chips.first()
131
+ await firstChip.hover()
132
+ let firstPrompt = null
133
+ try {
134
+ const tooltip = band.locator('.nsx-tip')
135
+ await tooltip.waitFor({ state: 'visible', timeout: 5000 })
136
+ firstPrompt = (await tooltip.innerText()).trim()
137
+ check('hovering a chip previews its full prompt', firstPrompt.length > 0, firstPrompt.slice(0, 60))
138
+ await shot(page, '02b-chip-tooltip')
139
+ } catch {
140
+ check('hovering a chip previews its full prompt', false, 'no .nsx-tip appeared on hover')
141
+ }
142
+ await firstChip.click()
131
143
  await page.waitForTimeout(400)
132
144
  const editor = page.locator('[contenteditable="true"]').first()
133
145
  let draft = ''