@rs-dsh/dsh-core 0.0.0-stage → 0.1.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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +35 -2
- package/client.js +566 -0
- package/cordis.patch.yml +5 -0
- package/index.js +6 -0
- package/package.json +58 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
- **Chinese wording** reviewed: messages follow DSH's own terms (指令 for commands) and end with 。.
|
|
6
|
+
- **Triple-click selections** now get the Comment button. A triple-click on a message's last paragraph runs on to the start of the next message, so the selection belonged to no message; it is now trimmed to the text it covers.
|
|
7
|
+
- **Cards focus their text field** when they open, so you can type a comment or a quote note right away.
|
|
8
|
+
- **The selected passage stays highlighted** while its comment or quote card is open, though focusing the field clears the page selection.
|
|
9
|
+
|
|
10
|
+
## 0.1.0
|
|
11
|
+
|
|
12
|
+
The first release. dsh-threads 0.2.0 and dsh-quote 0.2.0 are built on it.
|
|
13
|
+
|
|
14
|
+
- **One selection toolbar** for every rs-dsh plugin's actions. A button shows only where its action applies.
|
|
15
|
+
- **Cards drawn with DSH's own components** (`MenuSurface`, `Button`, `InlineEditor`, `MarkdownText`, `TextShimmer`), closing on **Esc** or a click outside, one at a time.
|
|
16
|
+
- **A namespace per plugin** and a repository-wide test that keeps their styles apart.
|
|
17
|
+
- **Requires** DSH 0.2.1-rc.2 or later with the web GUI.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Romany Saad
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rs-dsh/dsh-core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The shared base of the rs-dsh plugins for DeepSeek Harness. It gives them one selection toolbar, so their buttons share one box, and draws their boxes with DSH's own components. You don't install it yourself: [dsh-threads](https://github.com/romany-saad/dsh-plugins/tree/main/packages/dsh-threads) and [dsh-quote](https://github.com/romany-saad/dsh-plugins/tree/main/packages/dsh-quote) bring it with them.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Install the plugins you want. Each one brings this package and adds it to the profile:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
dsh plugin --profile <name> add @rs-dsh/dsh-threads@0.3.0 @rs-dsh/dsh-quote@0.2.1
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then reload the DSH web page. However many rs-dsh plugins you install, the core runs once.
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- DSH 0.2.1-rc.2 or later. It is tested with 0.2.1-rc.2.
|
|
20
|
+
- A profile with the web GUI (`@deepseek-ai/dsh-web-app`), such as `web`.
|
|
21
|
+
|
|
22
|
+
## What you see
|
|
23
|
+
|
|
24
|
+
- **One selection toolbar.** Select text in a conversation and the actions of every installed rs-dsh plugin show in one box, such as **Quote** and **Comment**. An action only shows where it applies: for example, there's no **Comment** on tool output.
|
|
25
|
+
- **Boxes that look like DSH.** Buttons, text boxes, menus and replies are DSH's own components, so they follow DSH's theme and its updates. A box closes with **Esc** or a click outside it, and only one is open at a time.
|
|
26
|
+
|
|
27
|
+
## Limits
|
|
28
|
+
|
|
29
|
+
- **Web GUI only.**
|
|
30
|
+
- **Needs DSH's UI library.** The core uses `@deepseek-ai/dsh-client-ui-primitives`, which DSH's web app ships. If a DSH release drops it or renames a part the core uses, the rs-dsh plugins stop at start with a message saying so, until they're updated.
|
|
31
|
+
|
|
32
|
+
## More
|
|
33
|
+
|
|
34
|
+
- [CHANGELOG.md](https://github.com/romany-saad/dsh-plugins/blob/main/packages/dsh-core/CHANGELOG.md)
|
|
35
|
+
- [MIT License](https://github.com/romany-saad/dsh-plugins/blob/main/packages/dsh-core/LICENSE)
|
|
36
|
+
- For maintainers, and for writing a plugin on the core: [DEVELOPING.md](https://github.com/romany-saad/dsh-plugins/blob/main/packages/dsh-core/DEVELOPING.md)
|
package/client.js
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
// @rs-dsh/dsh-core: browser half (hand-written, no build step).
|
|
2
|
+
//
|
|
3
|
+
// The shared UI layer for rs-dsh plugins, built on DSH's own UI library
|
|
4
|
+
// (@deepseek-ai/dsh-client-ui-primitives) so the plugins look like DSH and
|
|
5
|
+
// follow its updates. It provides one service, `rsDshCore`:
|
|
6
|
+
//
|
|
7
|
+
// ui the DSH components and icons the plugins use
|
|
8
|
+
// selection.register() a button in the one selection toolbar
|
|
9
|
+
// popover.open() an anchored card (React) with outside-click and Escape
|
|
10
|
+
// style(plugin, css) one stylesheet per plugin, scoped by convention
|
|
11
|
+
// scope(plugin) the root attribute a plugin's elements carry
|
|
12
|
+
// selectionContext() what the current selection is over
|
|
13
|
+
//
|
|
14
|
+
// The library is assumed present: DSH's web shell ships it in its shared
|
|
15
|
+
// module table. If it is ever missing, the core fails loudly at start with
|
|
16
|
+
// one message instead of drawing look-alikes (see README "Limits").
|
|
17
|
+
window.__ModuleLoader__.load({
|
|
18
|
+
id: '@rs-dsh/dsh-core',
|
|
19
|
+
factory: (require) => {
|
|
20
|
+
'use strict'
|
|
21
|
+
const module = { exports: {} }
|
|
22
|
+
|
|
23
|
+
const NS = 'dsh-core'
|
|
24
|
+
const ROOT = 'data-rs-dsh'
|
|
25
|
+
const PRIMITIVES = '@deepseek-ai/dsh-client-ui-primitives'
|
|
26
|
+
|
|
27
|
+
// The components and icons the rs-dsh plugins use. A test checks every
|
|
28
|
+
// name against the real library at the pinned DSH version.
|
|
29
|
+
const UI_EXPORTS = [
|
|
30
|
+
'Button', 'MenuSurface', 'InlineEditor', 'TextShimmer', 'MarkdownText', 'Tooltip', 'Toast',
|
|
31
|
+
'IconTrashOutlineRegular', 'IconSendOutlineRegular', 'IconCopyOutlineRegular', 'IconCloseOutlineRegular',
|
|
32
|
+
'IconNewChatOutlineRegular', 'IconEditOutlineRegular', 'IconCheckOutlineRegular', 'IconRefreshOutlineRegular',
|
|
33
|
+
'IconWarningOutlineRegular', 'IconChevronDownOutlineRegular', 'IconChevronUpOutlineRegular',
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
// ------------------------------------------------------------ pure logic
|
|
37
|
+
|
|
38
|
+
/** The library's exports the plugins use; throws one clear error if any is gone. */
|
|
39
|
+
function pickUi(lib) {
|
|
40
|
+
if (lib === null || typeof lib !== 'object') {
|
|
41
|
+
throw new Error(`dsh-core: ${PRIMITIVES} is not available in this DSH build; the rs-dsh plugins need it`)
|
|
42
|
+
}
|
|
43
|
+
const missing = UI_EXPORTS.filter((name) => lib[name] === undefined)
|
|
44
|
+
if (missing.length > 0) {
|
|
45
|
+
throw new Error(`dsh-core: this DSH build's ${PRIMITIVES} lacks ${missing.join(', ')}; update the rs-dsh plugins`)
|
|
46
|
+
}
|
|
47
|
+
const ui = {}
|
|
48
|
+
for (const name of UI_EXPORTS) ui[name] = lib[name]
|
|
49
|
+
return ui
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A plugin name usable in an attribute value and a CSS selector. */
|
|
53
|
+
function checkPlugin(plugin) {
|
|
54
|
+
if (typeof plugin !== 'string' || !/^[a-z][a-z0-9-]*$/.test(plugin)) {
|
|
55
|
+
throw new Error('dsh-core: plugin names are lowercase letters, digits and dashes: ' + JSON.stringify(plugin))
|
|
56
|
+
}
|
|
57
|
+
return plugin
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Where to put a floating card of `size` next to `rect`, inside the
|
|
62
|
+
* viewport: above when it fits there (`prefer: 'above'`), else below.
|
|
63
|
+
*/
|
|
64
|
+
function placeNear(rect, size, viewport, { prefer = 'below', gap = 8, align = 'start' } = {}) {
|
|
65
|
+
const margin = 8
|
|
66
|
+
const fitsBelow = rect.bottom + gap + size.height <= viewport.height - margin
|
|
67
|
+
const fitsAbove = rect.top - gap - size.height >= margin
|
|
68
|
+
const above = prefer === 'above' ? fitsAbove || !fitsBelow : !fitsBelow && fitsAbove
|
|
69
|
+
let top = above ? rect.top - gap - size.height : rect.bottom + gap
|
|
70
|
+
top = Math.max(margin, Math.min(top, viewport.height - size.height - margin))
|
|
71
|
+
let left = align === 'center' ? rect.left + rect.width / 2 - size.width / 2 : rect.left
|
|
72
|
+
left = Math.max(margin, Math.min(left, viewport.width - size.width - margin))
|
|
73
|
+
return { top: Math.round(top), left: Math.round(left), above }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Collapse whitespace and cut to n code points with an ellipsis; never forges editor placeholders. */
|
|
77
|
+
function oneLine(s, n) {
|
|
78
|
+
const flat = String(s).replace(/[\uE100-\uE11D\uFFFC]/gu, '').replace(/\s+/g, ' ').trim()
|
|
79
|
+
const chars = Array.from(flat)
|
|
80
|
+
return chars.length <= n ? flat : chars.slice(0, n - 1).join('') + '\u2026'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Insert one chip into a draft snapshot, after the chips already stacked
|
|
85
|
+
* at the top whose source is in `stackAfter` (by default its own), each on
|
|
86
|
+
* its own line, so chips keep the order they were added in. Other chips
|
|
87
|
+
* shift; text is kept. Returns the next snapshot (the shape DSH persists).
|
|
88
|
+
*/
|
|
89
|
+
function stackChip(snapshot, insert, stackAfter = [insert.source]) {
|
|
90
|
+
const text = snapshot.text
|
|
91
|
+
const refs = snapshot.references.slice().sort((a, b) => a.offset - b.offset)
|
|
92
|
+
let at = 0
|
|
93
|
+
for (;;) {
|
|
94
|
+
const head = refs.find((r) => r.offset === at && stackAfter.includes(r.source))
|
|
95
|
+
if (head === undefined) break
|
|
96
|
+
at = head.offset + head.length
|
|
97
|
+
if (text.charAt(at) === '\n') at += 1
|
|
98
|
+
else break
|
|
99
|
+
}
|
|
100
|
+
const lead = at > 0 && text.charAt(at - 1) !== '\n' ? '\n' : ''
|
|
101
|
+
const added = lead + insert.clipboardText + '\n'
|
|
102
|
+
const offset = at + lead.length
|
|
103
|
+
const shifted = refs.map((r) => (r.offset >= at ? { ...r, offset: r.offset + added.length } : r))
|
|
104
|
+
const chip = {
|
|
105
|
+
source: insert.source,
|
|
106
|
+
ref: insert.ref,
|
|
107
|
+
offset,
|
|
108
|
+
length: insert.clipboardText.length,
|
|
109
|
+
label: insert.label,
|
|
110
|
+
clipboardText: insert.clipboardText,
|
|
111
|
+
}
|
|
112
|
+
const references = shifted.concat([chip]).sort((a, b) => a.offset - b.offset)
|
|
113
|
+
const next = { text: text.slice(0, at) + added + text.slice(at), references }
|
|
114
|
+
if (snapshot.command !== undefined) next.command = snapshot.command
|
|
115
|
+
return next
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Toolbar buttons in order: by `order`, then by registration. */
|
|
119
|
+
function sortButtons(buttons) {
|
|
120
|
+
return buttons.slice().sort((a, b) => a.order - b.order || a.seq - b.seq)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Normalize a raw DOM selection string. */
|
|
124
|
+
function cleanSelection(raw) {
|
|
125
|
+
return String(raw).replace(/\r\n?/g, '\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Whether a pointer or key event happened inside `el`, across portals that keep a DOM path. */
|
|
129
|
+
function eventInside(event, el) {
|
|
130
|
+
if (el === null || el === undefined) return false
|
|
131
|
+
const path = typeof event.composedPath === 'function' ? event.composedPath() : []
|
|
132
|
+
if (path.includes(el)) return true
|
|
133
|
+
return event.target instanceof Node && el.contains(event.target)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ------------------------------------------------------------ strings
|
|
137
|
+
|
|
138
|
+
const en = {
|
|
139
|
+
actions: 'Selection actions',
|
|
140
|
+
busy: 'The composer is busy; try again in a moment',
|
|
141
|
+
command: 'This cannot go into a command draft',
|
|
142
|
+
noSession: 'No composer found for this conversation',
|
|
143
|
+
}
|
|
144
|
+
const zh = {
|
|
145
|
+
actions: '选中文本操作',
|
|
146
|
+
busy: '输入框暂时无法编辑,请稍后再试。',
|
|
147
|
+
command: '输入指令时无法插入内容。',
|
|
148
|
+
noSession: '找不到该会话的输入框。',
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The only stylesheet the core owns: positioning of its two floating hosts.
|
|
152
|
+
// Everything visible inside them is DSH's own components and tokens. The
|
|
153
|
+
// toolbar keeps the menu's translucent material; text-heavy cards take the
|
|
154
|
+
// solid layer of DSH's preview cards, so the page never shows through.
|
|
155
|
+
// The passage a toolbar action was taken on, painted while its card is open.
|
|
156
|
+
const HELD = 'dsh-core-held'
|
|
157
|
+
const STYLE = `
|
|
158
|
+
[${ROOT}="dsh-core"][data-part]{position:fixed;z-index:1200;max-width:calc(100vw - 16px)}
|
|
159
|
+
[${ROOT}="dsh-core"][data-part][hidden]{display:none}
|
|
160
|
+
[${ROOT}="dsh-core"] [data-part-card]{box-sizing:border-box;padding:4px;--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);
|
|
161
|
+
box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);
|
|
162
|
+
--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2)}
|
|
163
|
+
[${ROOT}="dsh-core"][data-part="toolbar"] [data-part-card]{display:flex;gap:2px}
|
|
164
|
+
[${ROOT}="dsh-core"][data-part="popover"] [data-part-card]{padding:10px;display:flex;flex-direction:column;max-height:min(70vh,560px);
|
|
165
|
+
background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-panel)}
|
|
166
|
+
[${ROOT}="dsh-core"][data-part="popover"] [data-part-card]>[aria-hidden="true"]:first-child{display:none}
|
|
167
|
+
::highlight(${HELD}){background-color:Highlight;color:HighlightText}
|
|
168
|
+
`
|
|
169
|
+
// ------------------------------------------------------------ plugin body
|
|
170
|
+
|
|
171
|
+
function apply(ctx) {
|
|
172
|
+
const React = require('react')
|
|
173
|
+
const { jsx, jsxs } = require('react/jsx-runtime')
|
|
174
|
+
const { createRoot } = require('react-dom/client')
|
|
175
|
+
let lib
|
|
176
|
+
try { lib = require(PRIMITIVES) } catch (_) { lib = null }
|
|
177
|
+
const ui = pickUi(lib)
|
|
178
|
+
|
|
179
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-core: dictionaries')
|
|
180
|
+
const t = ctx.locale.bind(NS)
|
|
181
|
+
|
|
182
|
+
// -------------------------------------------------- styles
|
|
183
|
+
|
|
184
|
+
const sheets = new Map() // plugin → <style>
|
|
185
|
+
/** One stylesheet per plugin; returns a disposer. A second call replaces the text. */
|
|
186
|
+
function style(plugin, css) {
|
|
187
|
+
checkPlugin(plugin)
|
|
188
|
+
let el = sheets.get(plugin)
|
|
189
|
+
if (el === undefined) {
|
|
190
|
+
el = document.createElement('style')
|
|
191
|
+
el.setAttribute('data-rs-dsh-style', plugin)
|
|
192
|
+
document.head.appendChild(el)
|
|
193
|
+
sheets.set(plugin, el)
|
|
194
|
+
}
|
|
195
|
+
el.textContent = css
|
|
196
|
+
return () => {
|
|
197
|
+
if (sheets.get(plugin) === el) { el.remove(); sheets.delete(plugin) }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const scope = (plugin) => ({ [ROOT]: checkPlugin(plugin) })
|
|
201
|
+
const selector = (plugin) => `[${ROOT}="${checkPlugin(plugin)}"]`
|
|
202
|
+
|
|
203
|
+
// -------------------------------------------------- composer
|
|
204
|
+
|
|
205
|
+
/** A session's composer when it can take a chip, or { error } in the user's language. */
|
|
206
|
+
function editableInput(sessionId) {
|
|
207
|
+
if (ctx.sessions === undefined || ctx.conversation === undefined) return { error: t('noSession') }
|
|
208
|
+
const actx = ctx.sessions.scope(sessionId)
|
|
209
|
+
if (actx === undefined) return { error: t('noSession') }
|
|
210
|
+
const input = ctx.conversation.input.for(actx)
|
|
211
|
+
const phase = input.state.getSnapshot().phase
|
|
212
|
+
if (phase === 'claimed') return { error: t('command') }
|
|
213
|
+
if (phase !== 'plain') return { error: t('busy') }
|
|
214
|
+
return { actx, input }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Chip sources of the rs-dsh plugins; a new chip stacks after all of them.
|
|
218
|
+
const chipSources = new Set()
|
|
219
|
+
/** Declare a chip source as an rs-dsh one; returns a disposer. */
|
|
220
|
+
function claimChipSource(source) {
|
|
221
|
+
chipSources.add(source)
|
|
222
|
+
return () => chipSources.delete(source)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Stack a chip at the top of a session's draft. Returns null, or the reason it could not. */
|
|
226
|
+
function insertChip(sessionId, insert) {
|
|
227
|
+
const { actx, input, error } = editableInput(sessionId)
|
|
228
|
+
if (error !== undefined) return error
|
|
229
|
+
const snapshot = input.draftSnapshot
|
|
230
|
+
if (snapshot !== undefined && snapshot !== null && Array.isArray(snapshot.references)) {
|
|
231
|
+
if (snapshot.command !== undefined) return t('command')
|
|
232
|
+
input.setDraft(stackChip(snapshot, insert, [...chipSources, insert.source]))
|
|
233
|
+
} else {
|
|
234
|
+
const span = input.actions.captureInsertion()
|
|
235
|
+
if (actx.bail(actx, 'slash/input-insert-reference', { reference: insert, span }) !== true) return t('busy')
|
|
236
|
+
}
|
|
237
|
+
input.focus()
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Show a notice under a session's composer. */
|
|
242
|
+
function notify(sessionId, text, level = 'error') {
|
|
243
|
+
const actx = ctx.sessions?.scope(sessionId)
|
|
244
|
+
if (actx !== undefined) ctx.conversation.input.for(actx).notify(level, text)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// -------------------------------------------------- selection context
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* What the current selection is over, or null: the conversation it is
|
|
251
|
+
* in, the chat row, the range and its rectangle, and the cleaned text.
|
|
252
|
+
* Selections in the composer, in inputs or in rs-dsh's own UI are none.
|
|
253
|
+
*/
|
|
254
|
+
/**
|
|
255
|
+
* The range shrunk to the text it actually covers. A triple-click on
|
|
256
|
+
* the last paragraph of a message ends the range at offset 0 of the
|
|
257
|
+
* next message, so its common ancestor would be the whole chat and the
|
|
258
|
+
* selection would belong to no row.
|
|
259
|
+
*/
|
|
260
|
+
function trimRange(range) {
|
|
261
|
+
const root = range.commonAncestorContainer
|
|
262
|
+
if (root.nodeType === 3 || typeof document.createTreeWalker !== 'function') return range
|
|
263
|
+
const walker = document.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */)
|
|
264
|
+
let first = null, last = null
|
|
265
|
+
for (let n = walker.nextNode(); n !== null; n = walker.nextNode()) {
|
|
266
|
+
if (!range.intersectsNode(n)) continue
|
|
267
|
+
const start = n === range.startContainer ? range.startOffset : 0
|
|
268
|
+
const end = n === range.endContainer ? range.endOffset : n.data.length
|
|
269
|
+
if (start >= end || n.data.slice(start, end).trim() === '') continue
|
|
270
|
+
if (first === null) first = { n, start }
|
|
271
|
+
last = { n, end }
|
|
272
|
+
}
|
|
273
|
+
if (first === null) return range
|
|
274
|
+
const trimmed = range.cloneRange()
|
|
275
|
+
trimmed.setStart(first.n, first.start)
|
|
276
|
+
trimmed.setEnd(last.n, last.end)
|
|
277
|
+
return trimmed
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function selectionContext() {
|
|
281
|
+
const sel = window.getSelection()
|
|
282
|
+
if (sel === null || sel.isCollapsed || sel.rangeCount === 0) return null
|
|
283
|
+
const range = trimRange(sel.getRangeAt(0))
|
|
284
|
+
const node = range.commonAncestorContainer
|
|
285
|
+
const el = node instanceof Element ? node : node.parentElement
|
|
286
|
+
if (el === null) return null
|
|
287
|
+
const region = el.closest('[data-conversation-region="chat"][data-conversation-session]')
|
|
288
|
+
if (region === null) return null
|
|
289
|
+
if (el.closest(`[data-composer-card],[${ROOT}],input,textarea,[contenteditable="true"]`) !== null) return null
|
|
290
|
+
const text = cleanSelection(sel.toString())
|
|
291
|
+
if (text === '') return null
|
|
292
|
+
const rect = range.getBoundingClientRect()
|
|
293
|
+
if (rect.width === 0 && rect.height === 0) return null
|
|
294
|
+
return {
|
|
295
|
+
sessionId: region.getAttribute('data-conversation-session'),
|
|
296
|
+
region,
|
|
297
|
+
element: el,
|
|
298
|
+
row: el.closest('[data-chat-node-key]'),
|
|
299
|
+
range: range.cloneRange(),
|
|
300
|
+
rect,
|
|
301
|
+
text,
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// -------------------------------------------------- popover host
|
|
306
|
+
|
|
307
|
+
// One anchored card at a time, rendered with React into a host of its
|
|
308
|
+
// own on <body>. Opening another closes the current one.
|
|
309
|
+
let current = null // { host, root, onClose, anchor }
|
|
310
|
+
|
|
311
|
+
function closePopover(reason = 'api') {
|
|
312
|
+
const open = current
|
|
313
|
+
if (open === null) return
|
|
314
|
+
current = null
|
|
315
|
+
if (open.held) releaseSelection()
|
|
316
|
+
// Close is often called from inside the card's own React event (Enter
|
|
317
|
+
// in an editor, a button). Detach now; unmount once React has finished
|
|
318
|
+
// that event, never during it.
|
|
319
|
+
open.host.remove()
|
|
320
|
+
setTimeout(() => open.root.unmount(), 0)
|
|
321
|
+
try { open.onClose?.(reason) } catch (error) { console.error(error) }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Open a card next to `anchor` (a DOMRect or () => DOMRect).
|
|
326
|
+
* `render(close)` returns the React content. Returns { close, update }.
|
|
327
|
+
*/
|
|
328
|
+
function openPopover({ plugin, anchor, render, width = 360, prefer = 'below', align = 'start', onClose, label }) {
|
|
329
|
+
checkPlugin(plugin)
|
|
330
|
+
closePopover('replaced')
|
|
331
|
+
hideToolbar()
|
|
332
|
+
const host = document.createElement('div')
|
|
333
|
+
host.setAttribute(ROOT, 'dsh-core')
|
|
334
|
+
host.setAttribute('data-part', 'popover')
|
|
335
|
+
host.setAttribute('data-plugin', plugin)
|
|
336
|
+
host.setAttribute('role', 'dialog')
|
|
337
|
+
if (label) host.setAttribute('aria-label', label)
|
|
338
|
+
host.style.width = width + 'px'
|
|
339
|
+
host.style.visibility = 'hidden'
|
|
340
|
+
document.body.appendChild(host)
|
|
341
|
+
const root = createRoot(host)
|
|
342
|
+
const entry = { host, root, onClose, anchor, prefer, align }
|
|
343
|
+
current = entry
|
|
344
|
+
const close = (reason = 'api') => { if (current === entry) closePopover(reason) }
|
|
345
|
+
const place = () => {
|
|
346
|
+
if (current !== entry) return
|
|
347
|
+
const rect = typeof entry.anchor === 'function' ? entry.anchor() : entry.anchor
|
|
348
|
+
if (!rect) return
|
|
349
|
+
const box = host.getBoundingClientRect()
|
|
350
|
+
const at = placeNear(rect, { width: box.width || width, height: box.height || 200 }, { width: window.innerWidth, height: window.innerHeight }, { prefer: entry.prefer, align: entry.align })
|
|
351
|
+
host.style.left = at.left + 'px'
|
|
352
|
+
host.style.top = at.top + 'px'
|
|
353
|
+
host.style.visibility = ''
|
|
354
|
+
// The card mounts hidden until placed, so a field's autoFocus fires
|
|
355
|
+
// while it cannot take focus. Focus its first field once it shows.
|
|
356
|
+
if (!entry.focused) {
|
|
357
|
+
entry.focused = true
|
|
358
|
+
if (!host.contains(document.activeElement)) {
|
|
359
|
+
const field = host.querySelector('textarea,input:not([type="hidden"]),[contenteditable="true"]')
|
|
360
|
+
if (field !== null) {
|
|
361
|
+
field.focus({ preventScroll: true })
|
|
362
|
+
if (typeof field.setSelectionRange === 'function') field.setSelectionRange(field.value.length, field.value.length)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const draw = () => {
|
|
368
|
+
root.render(jsx(ui.MenuSurface, { 'data-part-card': '', children: jsx(PopoverBody, { render, close, place }) }))
|
|
369
|
+
}
|
|
370
|
+
draw()
|
|
371
|
+
return { close, update: draw, place }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Re-places the card after each render, so it follows its content's size. */
|
|
375
|
+
function PopoverBody({ render, close, place }) {
|
|
376
|
+
React.useLayoutEffect(() => { place() })
|
|
377
|
+
return render(close)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// -------------------------------------------------- held selection
|
|
381
|
+
//
|
|
382
|
+
// Focusing the card's field takes the page selection away. So when a
|
|
383
|
+
// toolbar action opens a card, the passage stays painted with a CSS
|
|
384
|
+
// highlight, in the selection's colours, until the card closes.
|
|
385
|
+
|
|
386
|
+
const canHold = typeof Highlight === 'function' && window.CSS !== undefined && window.CSS.highlights !== undefined
|
|
387
|
+
|
|
388
|
+
function holdSelection(range) {
|
|
389
|
+
if (!canHold || !range) return false
|
|
390
|
+
window.CSS.highlights.set(HELD, new Highlight(range))
|
|
391
|
+
return true
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function releaseSelection() {
|
|
395
|
+
if (canHold) window.CSS.highlights.delete(HELD)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// -------------------------------------------------- selection toolbar
|
|
399
|
+
|
|
400
|
+
const buttons = [] // { id, plugin, label, icon, order, seq, when, onSelect }
|
|
401
|
+
let seq = 0
|
|
402
|
+
let pending = null // selectionContext() the toolbar shows for
|
|
403
|
+
const toolbar = document.createElement('div')
|
|
404
|
+
toolbar.setAttribute(ROOT, 'dsh-core')
|
|
405
|
+
toolbar.setAttribute('data-part', 'toolbar')
|
|
406
|
+
toolbar.setAttribute('role', 'toolbar')
|
|
407
|
+
toolbar.hidden = true
|
|
408
|
+
const toolbarRoot = createRoot(toolbar)
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Add a button to the selection toolbar.
|
|
412
|
+
* @param spec.id unique within the plugin
|
|
413
|
+
* @param spec.label text on the button
|
|
414
|
+
* @param spec.onSelect (selection) => void, with selectionContext()'s value
|
|
415
|
+
* @param spec.when optional (selection) => boolean; hidden when false
|
|
416
|
+
* @param spec.icon optional React node
|
|
417
|
+
* @param spec.order optional number, lower first (default 100)
|
|
418
|
+
* @returns a disposer
|
|
419
|
+
*/
|
|
420
|
+
function registerButton(spec) {
|
|
421
|
+
const plugin = checkPlugin(spec.plugin)
|
|
422
|
+
if (typeof spec.id !== 'string' || spec.id === '' || typeof spec.onSelect !== 'function') {
|
|
423
|
+
throw new Error('dsh-core: a selection button needs plugin, id, label and onSelect')
|
|
424
|
+
}
|
|
425
|
+
if (buttons.some((b) => b.plugin === plugin && b.id === spec.id)) throw new Error(`dsh-core: ${plugin}/${spec.id} is already registered`)
|
|
426
|
+
const entry = { order: 100, ...spec, plugin, seq: seq++ }
|
|
427
|
+
buttons.push(entry)
|
|
428
|
+
if (pending !== null) showToolbar(pending)
|
|
429
|
+
return () => {
|
|
430
|
+
const at = buttons.indexOf(entry)
|
|
431
|
+
if (at !== -1) buttons.splice(at, 1)
|
|
432
|
+
if (pending !== null) showToolbar(pending)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function visibleButtons(target) {
|
|
437
|
+
return sortButtons(buttons).filter((b) => {
|
|
438
|
+
if (typeof b.when !== 'function') return true
|
|
439
|
+
try { return b.when(target) === true } catch (error) { console.error(error); return false }
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function Toolbar({ target, items }) {
|
|
444
|
+
return jsx(React.Fragment, {
|
|
445
|
+
children: items.map((b) => jsx(ui.Button, {
|
|
446
|
+
variant: 'ghost',
|
|
447
|
+
size: 'sm',
|
|
448
|
+
icon: b.icon,
|
|
449
|
+
'data-plugin': b.plugin,
|
|
450
|
+
'data-action': b.id,
|
|
451
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
452
|
+
onClick: () => {
|
|
453
|
+
hideToolbar()
|
|
454
|
+
const before = current
|
|
455
|
+
try { b.onSelect(target) } catch (error) { console.error(error) }
|
|
456
|
+
// The action opened a card: keep its passage painted while it is open.
|
|
457
|
+
if (current !== null && current !== before) current.held = holdSelection(target.range)
|
|
458
|
+
},
|
|
459
|
+
children: typeof b.label === 'function' ? b.label() : b.label,
|
|
460
|
+
}, b.plugin + '/' + b.id)),
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function showToolbar(target) {
|
|
465
|
+
const items = visibleButtons(target)
|
|
466
|
+
if (items.length === 0) { hideToolbar(); return }
|
|
467
|
+
pending = target
|
|
468
|
+
toolbar.setAttribute('aria-label', t('actions'))
|
|
469
|
+
toolbarRoot.render(jsx(ui.MenuSurface, { compact: true, 'data-part-card': '', children: jsx(Toolbar, { target, items }) }))
|
|
470
|
+
toolbar.hidden = false
|
|
471
|
+
requestAnimationFrame(() => {
|
|
472
|
+
if (pending !== target) return
|
|
473
|
+
const box = toolbar.getBoundingClientRect()
|
|
474
|
+
const at = placeNear(target.rect, { width: box.width || 120, height: box.height || 36 }, { width: window.innerWidth, height: window.innerHeight }, { prefer: 'above', align: 'center' })
|
|
475
|
+
toolbar.style.left = at.left + 'px'
|
|
476
|
+
toolbar.style.top = at.top + 'px'
|
|
477
|
+
})
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function hideToolbar() {
|
|
481
|
+
toolbar.hidden = true
|
|
482
|
+
pending = null
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function refresh() {
|
|
486
|
+
if (current !== null) { hideToolbar(); return }
|
|
487
|
+
const target = selectionContext()
|
|
488
|
+
if (target === null) { hideToolbar(); return }
|
|
489
|
+
showToolbar(target)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let timer = null
|
|
493
|
+
const onSelectionChange = () => {
|
|
494
|
+
if (timer !== null) clearTimeout(timer)
|
|
495
|
+
timer = setTimeout(() => { timer = null; refresh() }, 120)
|
|
496
|
+
}
|
|
497
|
+
const onPointerDown = (e) => {
|
|
498
|
+
if (current !== null && !eventInside(e, current.host)) closePopover('outside')
|
|
499
|
+
}
|
|
500
|
+
const onKeyDown = (e) => {
|
|
501
|
+
if (e.key !== 'Escape') return
|
|
502
|
+
if (current !== null) { e.stopPropagation(); closePopover('escape'); return }
|
|
503
|
+
if (!toolbar.hidden) hideToolbar()
|
|
504
|
+
}
|
|
505
|
+
const onScroll = (e) => {
|
|
506
|
+
if (!toolbar.hidden) hideToolbar()
|
|
507
|
+
if (current !== null && !eventInside(e, current.host)) {
|
|
508
|
+
const rect = typeof current.anchor === 'function' ? current.anchor() : null
|
|
509
|
+
if (rect) {
|
|
510
|
+
const box = current.host.getBoundingClientRect()
|
|
511
|
+
const at = placeNear(rect, { width: box.width, height: box.height }, { width: window.innerWidth, height: window.innerHeight }, { prefer: current.prefer, align: current.align })
|
|
512
|
+
current.host.style.left = at.left + 'px'
|
|
513
|
+
current.host.style.top = at.top + 'px'
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
ctx.effect(() => {
|
|
519
|
+
const disposeStyle = style('dsh-core', STYLE)
|
|
520
|
+
document.body.appendChild(toolbar)
|
|
521
|
+
document.addEventListener('selectionchange', onSelectionChange)
|
|
522
|
+
document.addEventListener('pointerdown', onPointerDown, true)
|
|
523
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
524
|
+
window.addEventListener('scroll', onScroll, true)
|
|
525
|
+
window.addEventListener('resize', onScroll)
|
|
526
|
+
return () => {
|
|
527
|
+
if (timer !== null) clearTimeout(timer)
|
|
528
|
+
document.removeEventListener('selectionchange', onSelectionChange)
|
|
529
|
+
document.removeEventListener('pointerdown', onPointerDown, true)
|
|
530
|
+
document.removeEventListener('keydown', onKeyDown, true)
|
|
531
|
+
window.removeEventListener('scroll', onScroll, true)
|
|
532
|
+
window.removeEventListener('resize', onScroll)
|
|
533
|
+
closePopover('dispose')
|
|
534
|
+
toolbarRoot.unmount()
|
|
535
|
+
toolbar.remove()
|
|
536
|
+
disposeStyle()
|
|
537
|
+
}
|
|
538
|
+
}, 'dsh-core: selection toolbar and popover host')
|
|
539
|
+
|
|
540
|
+
const api = {
|
|
541
|
+
version: 1,
|
|
542
|
+
ui,
|
|
543
|
+
React,
|
|
544
|
+
jsx,
|
|
545
|
+
jsxs,
|
|
546
|
+
style,
|
|
547
|
+
scope,
|
|
548
|
+
selector,
|
|
549
|
+
selectionContext,
|
|
550
|
+
oneLine,
|
|
551
|
+
stackChip,
|
|
552
|
+
composer: { editable: editableInput, insertChip, notify, claimChipSource },
|
|
553
|
+
selection: { register: registerButton, hide: hideToolbar },
|
|
554
|
+
popover: { open: openPopover, close: () => closePopover('api'), isOpen: () => current !== null },
|
|
555
|
+
}
|
|
556
|
+
ctx.provide('rsDshCore', api)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
module.exports.name = '@rs-dsh/dsh-core'
|
|
560
|
+
module.exports.inject = ['conversation', 'locale', 'sessions']
|
|
561
|
+
module.exports.apply = apply
|
|
562
|
+
// Pure helpers, exported for tests only.
|
|
563
|
+
module.exports._internals = { UI_EXPORTS, pickUi, checkPlugin, placeNear, sortButtons, cleanSelection, eventInside, oneLine, stackChip, ROOT }
|
|
564
|
+
return module.exports
|
|
565
|
+
},
|
|
566
|
+
})
|
package/cordis.patch.yml
ADDED
package/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @rs-dsh/dsh-core: Node half. Everything runs in the browser bundle
|
|
2
|
+
// (client.js); the host side has nothing to do. The row exists so the
|
|
3
|
+
// client half loads once, however many rs-dsh plugins insert it.
|
|
4
|
+
export const name = 'dsh-core'
|
|
5
|
+
|
|
6
|
+
export function apply() {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rs-dsh/dsh-core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Shared UI layer for the rs-dsh DSH plugins: one selection toolbar, anchored cards and DSH's own components, so the plugins look like DSH and stay small.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/romany-saad/dsh-plugins.git",
|
|
10
|
+
"directory": "packages/dsh-core"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"provenance": true
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"dsh",
|
|
18
|
+
"dsh-plugin",
|
|
19
|
+
"deepseek-harness"
|
|
20
|
+
],
|
|
21
|
+
"main": "index.js",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./index.js",
|
|
24
|
+
"./client": "./client.js",
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"index.js",
|
|
29
|
+
"client.js",
|
|
30
|
+
"cordis.patch.yml",
|
|
31
|
+
"README.md",
|
|
32
|
+
"CHANGELOG.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"dsh": {
|
|
36
|
+
"bundle": {
|
|
37
|
+
"patch": "./cordis.patch.yml"
|
|
38
|
+
},
|
|
39
|
+
"client": {
|
|
40
|
+
"platform": "web",
|
|
41
|
+
"inject": [
|
|
42
|
+
"@deepseek-ai/dsh-client-locale"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.2.1-rc.2"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@deepseek-ai/dsh-client-ui-primitives": "0.2.1-rc.2",
|
|
51
|
+
"jsdom": "26.1.0",
|
|
52
|
+
"react": "19.2.0",
|
|
53
|
+
"react-dom": "19.2.0"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=22"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"test": "node --check client.js && node --import ./test/no-network.mjs --test test/*.test.js"
|
|
60
|
+
}
|
|
6
61
|
}
|