@zongcn/dsh-next-steps 0.1.1 → 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 +5 -3
- package/README.zh-CN.md +5 -3
- package/client.js +66 -5
- package/cordis.patch.yml +1 -1
- package/package.json +1 -1
- package/scripts/verify-dock.mjs +52 -0
- package/scripts/verify-ui.mjs +15 -3
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.
|
|
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 +
|
|
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。**
|
|
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 项宿主检查 +
|
|
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
|
|
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
|
-
'
|
|
530
|
-
{
|
|
531
|
-
|
|
532
|
-
|
|
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
package/package.json
CHANGED
package/scripts/verify-dock.mjs
CHANGED
|
@@ -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) {
|
package/scripts/verify-ui.mjs
CHANGED
|
@@ -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
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
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 = ''
|