dsh-skill-picker 0.5.24 → 0.5.26
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 +3 -1
- package/lib/client.js +336 -263
- package/lib/client.js.map +2 -2
- package/package.json +1 -1
- package/src/client/index.jsx +385 -137
package/src/client/index.jsx
CHANGED
|
@@ -107,6 +107,71 @@ async function skillOp(action, payload = {}) {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Hand the caret back to the composer after a pick, at the END of the text.
|
|
112
|
+
*
|
|
113
|
+
* Picking from this panel used to leave the input filled but WITHOUT a caret:
|
|
114
|
+
* the click focused the row, the panel then unmounted, and focus fell back to
|
|
115
|
+
* the body — so the user had to click the input again before typing. Picking
|
|
116
|
+
* from the official `/` menu never has that problem, because the composer keeps
|
|
117
|
+
* focus there. 国豪 reported exactly this difference.
|
|
118
|
+
*
|
|
119
|
+
* The first fix focused the editor but stopped there, and the caret landed at
|
|
120
|
+
* the START of the line: focusing a `contenteditable` with no selection is
|
|
121
|
+
* specified to put the caret at the beginning. So the caret is seated
|
|
122
|
+
* explicitly here.
|
|
123
|
+
*
|
|
124
|
+
* Seating a collapsed Range at the end is deliberately the *same* thing a click
|
|
125
|
+
* at the end of the line does, which is how the official editor normally
|
|
126
|
+
* receives a caret — it syncs its internal selection from the DOM. Merely
|
|
127
|
+
* calling `setDraft` again would not help: it early-returns when the text is
|
|
128
|
+
* unchanged, so the caret would never move.
|
|
129
|
+
*
|
|
130
|
+
* Best-effort: a failure here must never break the pick itself.
|
|
131
|
+
*
|
|
132
|
+
* @param from - a node inside the composer (our own button).
|
|
133
|
+
* @param onlyIfFocused - re-seat the caret only when the composer already holds
|
|
134
|
+
* focus. Used by the delayed second call, so a fast click somewhere else is
|
|
135
|
+
* never overridden.
|
|
136
|
+
* @returns whether an editor was found and focused.
|
|
137
|
+
*/
|
|
138
|
+
function focusComposer(from, onlyIfFocused = false) {
|
|
139
|
+
try {
|
|
140
|
+
let node = from
|
|
141
|
+
for (let depth = 0; depth < 8 && node !== null && node !== undefined; depth += 1) {
|
|
142
|
+
const editor = typeof node.querySelector === 'function'
|
|
143
|
+
? node.querySelector('[contenteditable="true"], textarea')
|
|
144
|
+
: null
|
|
145
|
+
if (editor !== null && editor !== undefined) {
|
|
146
|
+
if (onlyIfFocused && document.activeElement !== editor) return false
|
|
147
|
+
if (typeof editor.focus === 'function') editor.focus()
|
|
148
|
+
try {
|
|
149
|
+
if (editor.isContentEditable === true) {
|
|
150
|
+
const range = document.createRange()
|
|
151
|
+
range.selectNodeContents(editor)
|
|
152
|
+
range.collapse(false)
|
|
153
|
+
const selection = window.getSelection()
|
|
154
|
+
if (selection !== null && selection !== undefined) {
|
|
155
|
+
selection.removeAllRanges()
|
|
156
|
+
selection.addRange(range)
|
|
157
|
+
}
|
|
158
|
+
} else if (typeof editor.setSelectionRange === 'function') {
|
|
159
|
+
const end = String(editor.value ?? '').length
|
|
160
|
+
editor.setSelectionRange(end, end)
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
/* the focus alone is still an improvement over no caret at all */
|
|
164
|
+
}
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
node = node.parentElement
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
/* focus is a convenience, never a requirement */
|
|
171
|
+
}
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
|
|
110
175
|
/**
|
|
111
176
|
* Whether a skill entry may be offered by a human-facing surface (this panel
|
|
112
177
|
* and the `/` completion it feeds). The official `skills/list` DTO
|
|
@@ -157,9 +222,12 @@ function groupByPinned(skills, usage, pinned) {
|
|
|
157
222
|
else rest.push(skill)
|
|
158
223
|
}
|
|
159
224
|
return [
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
225
|
+
// Plain titles, no emoji: these are section headings, and DSH's own
|
|
226
|
+
// headings (侧栏的「工作区」/「会话」) are plain text. 国豪: "加图标,那前面
|
|
227
|
+
// 一团火,这种图标看着不像 DSH 的风格".
|
|
228
|
+
{ title: '置顶', items: pinnedList },
|
|
229
|
+
{ title: '最近使用', items: recent },
|
|
230
|
+
{ title: '全部', items: rest },
|
|
163
231
|
].filter((group) => group.items.length > 0)
|
|
164
232
|
}
|
|
165
233
|
|
|
@@ -274,10 +342,34 @@ const buttonStyle = {
|
|
|
274
342
|
flex: 'none',
|
|
275
343
|
}
|
|
276
344
|
|
|
345
|
+
/**
|
|
346
|
+
* DSH's own UI font, so the panel reads as part of the harness rather than as a
|
|
347
|
+
* bolted-on popup.
|
|
348
|
+
*
|
|
349
|
+
* The skill NAME used to be rendered in `--ds-font-family-code` (the monospace
|
|
350
|
+
* face) because a `/skill` reads like a command — 国豪 spotted it immediately:
|
|
351
|
+
* "我想你用和右下角选择模型里的英文和中文一样的字体". The model picker uses
|
|
352
|
+
* `--dsw-font-family`, so that is what everything here uses now, and the sizes
|
|
353
|
+
* follow the composer (14px / 24px) instead of the browser's default button size
|
|
354
|
+
* (13.3333px), which is what the rows had been falling back to.
|
|
355
|
+
*/
|
|
356
|
+
const FONT_FAMILY = 'var(--dsw-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif)'
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Panel placement.
|
|
360
|
+
*
|
|
361
|
+
* Fixed (not absolute) and anchored to the RIGHT EDGE OF THE WINDOW, with the
|
|
362
|
+
* vertical offset measured from the ⚡ button so it still opens just above the
|
|
363
|
+
* composer.
|
|
364
|
+
*
|
|
365
|
+
* Why not `absolute; right: 0` on the button's own wrapper — which is what it
|
|
366
|
+
* used to be: the ⚡ sits next to the model picker, well left of the composer's
|
|
367
|
+
* right edge, so the panel was right-aligned to *that* and threw itself across
|
|
368
|
+
* the middle of the conversation, covering the text. 国豪 spotted it.
|
|
369
|
+
*/
|
|
277
370
|
const popoverStyle = {
|
|
278
|
-
position: '
|
|
279
|
-
|
|
280
|
-
right: '0',
|
|
371
|
+
position: 'fixed',
|
|
372
|
+
right: '24px',
|
|
281
373
|
width: '340px',
|
|
282
374
|
maxHeight: '320px',
|
|
283
375
|
display: 'flex',
|
|
@@ -288,21 +380,57 @@ const popoverStyle = {
|
|
|
288
380
|
boxShadow: '0 8px 28px rgba(0,0,0,0.35)',
|
|
289
381
|
overflow: 'hidden',
|
|
290
382
|
zIndex: 1000,
|
|
383
|
+
fontFamily: FONT_FAMILY,
|
|
384
|
+
fontSize: '14px',
|
|
385
|
+
lineHeight: '24px',
|
|
291
386
|
}
|
|
292
387
|
|
|
388
|
+
/**
|
|
389
|
+
* The search field, styled like the composer's own pickers.
|
|
390
|
+
*
|
|
391
|
+
* 国豪: "我这个搜索框能不能变成这个模型搜索的样式?我感觉确实可以跟 DSH 去统一一下".
|
|
392
|
+
* The model picker's field (which is not an <input>, so its computed style could
|
|
393
|
+
* not be read) is visibly BORDERLESS with a faint fill; this one had a 1px
|
|
394
|
+
* border. So: a transparent border (keeps the geometry identical), the same
|
|
395
|
+
* faint fill, and — because a borderless field needs some focus affordance — a
|
|
396
|
+
* thin ring while focused, applied from `searchFocus` below since inline styles
|
|
397
|
+
* cannot express `:focus`.
|
|
398
|
+
*
|
|
399
|
+
* Height: it used to be 36px (padding 6+6 + line-height 22 + border 1+1). 国豪
|
|
400
|
+
* tuned it by eye: "改成30看看,就是行高减个六" → "再加回3吧" → "32吧", so 32px,
|
|
401
|
+
* as an explicit border-box height with the text centred.
|
|
402
|
+
*
|
|
403
|
+
* Worth remembering: the first two numbers never actually rendered — the panel
|
|
404
|
+
* is a column flex container with a max-height, so the field was being shrunk to
|
|
405
|
+
* 21px regardless of what the style said. Only after pinning `flex: none` did
|
|
406
|
+
* the declared height take effect at all.
|
|
407
|
+
*/
|
|
293
408
|
const searchStyle = {
|
|
294
409
|
boxSizing: 'border-box',
|
|
295
410
|
width: 'calc(100% - 16px)',
|
|
411
|
+
height: '32px',
|
|
412
|
+
// The panel is a column flex container with a max-height, so any child that
|
|
413
|
+
// can shrink will: the field measured 21px instead of the declared height
|
|
414
|
+
// until this was pinned. The list below is the thing that should scroll.
|
|
415
|
+
flex: 'none',
|
|
296
416
|
margin: '8px',
|
|
297
|
-
padding: '
|
|
298
|
-
border: '1px solid
|
|
417
|
+
padding: '0 10px',
|
|
418
|
+
border: '1px solid transparent',
|
|
299
419
|
borderRadius: '8px',
|
|
300
420
|
background: 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.1))',
|
|
301
421
|
color: 'var(--dsw-alias-label-primary, #e6ebf2)',
|
|
302
|
-
|
|
422
|
+
fontFamily: FONT_FAMILY,
|
|
423
|
+
fontSize: '14px',
|
|
424
|
+
lineHeight: '20px',
|
|
303
425
|
outline: 'none',
|
|
304
426
|
}
|
|
305
427
|
|
|
428
|
+
/** Focus state for the borderless search field. */
|
|
429
|
+
const searchFocusStyle = {
|
|
430
|
+
background: 'var(--dsw-alias-interactive-bg-active, var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16)))',
|
|
431
|
+
boxShadow: '0 0 0 1px var(--dsw-alias-border-l2, rgba(128,128,128,0.35))',
|
|
432
|
+
}
|
|
433
|
+
|
|
306
434
|
const listStyle = {
|
|
307
435
|
overflowY: 'auto',
|
|
308
436
|
flex: 'auto',
|
|
@@ -322,16 +450,23 @@ const itemStyle = {
|
|
|
322
450
|
color: 'var(--dsw-alias-label-primary, #e6ebf2)',
|
|
323
451
|
cursor: 'pointer',
|
|
324
452
|
textAlign: 'left',
|
|
453
|
+
// Explicit: a bare <button> keeps the browser's own 13.3333px / Arial.
|
|
454
|
+
fontFamily: FONT_FAMILY,
|
|
455
|
+
fontSize: '14px',
|
|
456
|
+
lineHeight: '22px',
|
|
457
|
+
fontWeight: 400,
|
|
325
458
|
}
|
|
326
459
|
|
|
327
460
|
const nameStyle = {
|
|
328
|
-
fontFamily:
|
|
329
|
-
fontSize: '
|
|
330
|
-
fontWeight:
|
|
461
|
+
fontFamily: FONT_FAMILY,
|
|
462
|
+
fontSize: '14px',
|
|
463
|
+
fontWeight: 400,
|
|
464
|
+
lineHeight: '22px',
|
|
331
465
|
}
|
|
332
466
|
|
|
333
467
|
const descStyle = {
|
|
334
468
|
color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
469
|
+
fontFamily: FONT_FAMILY,
|
|
335
470
|
fontSize: '12px',
|
|
336
471
|
lineHeight: '16px',
|
|
337
472
|
overflow: 'hidden',
|
|
@@ -343,23 +478,10 @@ const descStyle = {
|
|
|
343
478
|
const statusStyle = {
|
|
344
479
|
padding: '12px',
|
|
345
480
|
color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
481
|
+
fontFamily: FONT_FAMILY,
|
|
346
482
|
fontSize: '13px',
|
|
347
483
|
}
|
|
348
484
|
|
|
349
|
-
/** One row action (关闭 / 定位 / 卸载); matches the pin button's weight. */
|
|
350
|
-
const actionSpanStyle = {
|
|
351
|
-
flex: 'none',
|
|
352
|
-
marginLeft: '6px',
|
|
353
|
-
padding: '2px 4px',
|
|
354
|
-
borderRadius: '6px',
|
|
355
|
-
fontSize: '12px',
|
|
356
|
-
lineHeight: '16px',
|
|
357
|
-
cursor: 'pointer',
|
|
358
|
-
color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
359
|
-
opacity: 0.55,
|
|
360
|
-
userSelect: 'none',
|
|
361
|
-
}
|
|
362
|
-
|
|
363
485
|
/** Result line under the list, with an optional undo for uninstall. */
|
|
364
486
|
const noticeStyle = {
|
|
365
487
|
display: 'flex',
|
|
@@ -374,27 +496,52 @@ const noticeStyle = {
|
|
|
374
496
|
color: 'var(--dsw-alias-label-secondary, #b6bfcc)',
|
|
375
497
|
}
|
|
376
498
|
|
|
377
|
-
/**
|
|
499
|
+
/**
|
|
500
|
+
* The right-click / long-press actions menu, positioned against the viewport.
|
|
501
|
+
*
|
|
502
|
+
* Colours are DSH theme variables, and the background is deliberately the SAME
|
|
503
|
+
* one the panel above it already uses (`--dsw-specific-tip`):
|
|
504
|
+
*
|
|
505
|
+
* panel + menu --dsw-specific-tip (opaque in both themes)
|
|
506
|
+
* text --dsw-alias-label-primary (flips with the theme)
|
|
507
|
+
* hover --dsw-alias-interactive-bg-hover
|
|
508
|
+
*
|
|
509
|
+
* Two wrong turns here, both caught by 国豪:
|
|
510
|
+
*
|
|
511
|
+
* 1. `--dsw-alias-bg-elevated` does not exist in DSH at all, so a hardcoded
|
|
512
|
+
* dark fallback always won while the text kept following the theme —
|
|
513
|
+
* dark-on-dark, a measured contrast ratio of 1.25 on the light theme.
|
|
514
|
+
* 2. `--dsw-specific-menu` does exist, but on the desktop's dark theme it is
|
|
515
|
+
* `#43454a73`: alpha 0.45, meant to sit behind DSH's own backdrop blur.
|
|
516
|
+
* With no blur the conversation showed straight through the menu.
|
|
517
|
+
*
|
|
518
|
+
* `--dsw-specific-tip` is opaque in both themes and is exactly what the panel
|
|
519
|
+
* beside it uses, so the two match. `backdropFilter` stays as a safety net in
|
|
520
|
+
* case a kernel ever ships a translucent value there.
|
|
521
|
+
*/
|
|
378
522
|
const menuStyle = {
|
|
379
523
|
position: 'fixed',
|
|
380
524
|
zIndex: 2147483000,
|
|
381
525
|
minWidth: '196px',
|
|
382
526
|
padding: '4px',
|
|
383
527
|
borderRadius: '10px',
|
|
384
|
-
border: '1px solid var(--dsw-alias-border-
|
|
385
|
-
background: 'var(--dsw-
|
|
528
|
+
border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.35))',
|
|
529
|
+
background: 'var(--dsw-specific-tip, #23262e)',
|
|
530
|
+
backdropFilter: 'blur(8px)',
|
|
386
531
|
boxShadow: '0 8px 28px rgba(0,0,0,0.36)',
|
|
387
|
-
color: 'var(--dsw-alias-label-primary, #
|
|
388
|
-
|
|
532
|
+
color: 'var(--dsw-alias-label-primary, #e6ebf2)',
|
|
533
|
+
fontFamily: FONT_FAMILY,
|
|
534
|
+
fontSize: '14px',
|
|
535
|
+
lineHeight: '24px',
|
|
389
536
|
userSelect: 'none',
|
|
390
537
|
}
|
|
391
538
|
|
|
392
539
|
/** The skill name at the top of the menu (the thing being acted on). */
|
|
393
540
|
const menuHeaderStyle = {
|
|
394
541
|
padding: '6px 10px 4px',
|
|
395
|
-
fontSize: '
|
|
542
|
+
fontSize: '12px',
|
|
396
543
|
color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
397
|
-
borderBottom: '1px solid var(--dsw-alias-border-
|
|
544
|
+
borderBottom: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.28))',
|
|
398
545
|
marginBottom: '4px',
|
|
399
546
|
overflow: 'hidden',
|
|
400
547
|
textOverflow: 'ellipsis',
|
|
@@ -409,6 +556,19 @@ const menuItemStyle = {
|
|
|
409
556
|
whiteSpace: 'nowrap',
|
|
410
557
|
}
|
|
411
558
|
|
|
559
|
+
/**
|
|
560
|
+
* Hover feedback for a menu row. Inline styles cannot express `:hover`, and the
|
|
561
|
+
* panel's own rows already use this same mutate-on-hover approach.
|
|
562
|
+
*/
|
|
563
|
+
const menuItemHandlers = {
|
|
564
|
+
onMouseEnter: (event) => {
|
|
565
|
+
event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))'
|
|
566
|
+
},
|
|
567
|
+
onMouseLeave: (event) => {
|
|
568
|
+
event.currentTarget.style.background = 'transparent'
|
|
569
|
+
},
|
|
570
|
+
}
|
|
571
|
+
|
|
412
572
|
/** The picker's bolt glyph: DeepSeek palette gradient + slim stroke. */
|
|
413
573
|
function BoltIcon() {
|
|
414
574
|
return (
|
|
@@ -449,6 +609,18 @@ function SkillPickerButton(props) {
|
|
|
449
609
|
const [busySkill, setBusySkill] = useState('')
|
|
450
610
|
/** Result line under the list: { kind, text, undo? }. */
|
|
451
611
|
const [notice, setNotice] = useState(undefined)
|
|
612
|
+
/**
|
|
613
|
+
* The notice is a toast, not a fixture — it dismisses itself.
|
|
614
|
+
*
|
|
615
|
+
* 国豪 caught it sitting on screen indefinitely after a trivial "located it in
|
|
616
|
+
* the file manager" action. Anything carrying an undo gets a much longer
|
|
617
|
+
* window, because dismissing it also removes the only way back.
|
|
618
|
+
*/
|
|
619
|
+
useEffect(() => {
|
|
620
|
+
if (notice === undefined) return undefined
|
|
621
|
+
const timer = window.setTimeout(() => setNotice(undefined), notice.undo === undefined ? 2600 : 10000)
|
|
622
|
+
return () => window.clearTimeout(timer)
|
|
623
|
+
}, [notice])
|
|
452
624
|
/**
|
|
453
625
|
* The right-click menu: { x, y, skill, disabled }.
|
|
454
626
|
*
|
|
@@ -458,6 +630,16 @@ function SkillPickerButton(props) {
|
|
|
458
630
|
* menu, because a phone has no right button.
|
|
459
631
|
*/
|
|
460
632
|
const [menu, setMenu] = useState(undefined)
|
|
633
|
+
/**
|
|
634
|
+
* Which list the panel is showing: 'all' or the closed-skills view ('off').
|
|
635
|
+
*
|
|
636
|
+
* The closed skills used to be a permanent section under the list; 国豪 asked
|
|
637
|
+
* for them behind a small entry at the bottom-right instead, because a
|
|
638
|
+
* section that is almost never used should not hold vertical space forever.
|
|
639
|
+
*/
|
|
640
|
+
const [view, setView] = useState('all')
|
|
641
|
+
/** The search field is borderless now, so focus needs its own affordance. */
|
|
642
|
+
const [searchFocus, setSearchFocus] = useState(false)
|
|
461
643
|
const [query, setQuery] = useState('')
|
|
462
644
|
const [usage, setUsage] = useState(() => loadUsage())
|
|
463
645
|
const [pinned, setPinned] = useState(() => loadPinned())
|
|
@@ -466,6 +648,12 @@ function SkillPickerButton(props) {
|
|
|
466
648
|
const itemRefs = useRef([])
|
|
467
649
|
/** Pending long-press timer for the touch path (see the menu below). */
|
|
468
650
|
const longPressRef = useRef(0)
|
|
651
|
+
/**
|
|
652
|
+
* Distance from the window bottom to the panel's bottom edge, measured from
|
|
653
|
+
* the ⚡ button each time the panel opens. See `popoverStyle`: the horizontal
|
|
654
|
+
* anchor is the window's right edge, only the height follows the button.
|
|
655
|
+
*/
|
|
656
|
+
const [panelBottom, setPanelBottom] = useState(88)
|
|
469
657
|
|
|
470
658
|
// The usage store is shared with the official `/` menu: picks made there go
|
|
471
659
|
// through window.__dshSkillPickerTrack (localStorage only). Refresh this
|
|
@@ -546,7 +734,10 @@ function SkillPickerButton(props) {
|
|
|
546
734
|
const res = await fetch(`/dsh-skill-picker/skills${cwd}`, { headers: { accept: 'application/json' } })
|
|
547
735
|
const json = await res.json()
|
|
548
736
|
if (!json.ok) throw new Error(json.error || 'bad response')
|
|
549
|
-
|
|
737
|
+
const listed = Array.isArray(json.skills) ? json.skills : []
|
|
738
|
+
// The scan deliberately reports switched-off skills too (that is how the
|
|
739
|
+
// closed view can list them), but they must never enter THIS list.
|
|
740
|
+
setSkills(listed.filter((skill) => isUserFacingSkill(skill) && skill.disabled !== true))
|
|
550
741
|
setSource('host')
|
|
551
742
|
} catch (cause) {
|
|
552
743
|
setError(String(cause?.message ?? cause))
|
|
@@ -671,11 +862,22 @@ function SkillPickerButton(props) {
|
|
|
671
862
|
|
|
672
863
|
const toggle = () => {
|
|
673
864
|
if (!open) {
|
|
865
|
+
// Measure before opening so the panel is already in place on its first
|
|
866
|
+
// paint (no visible jump).
|
|
867
|
+
const el = boxRef.current
|
|
868
|
+
if (el !== null) {
|
|
869
|
+
const rect = el.getBoundingClientRect()
|
|
870
|
+
setPanelBottom(Math.max(64, Math.round(window.innerHeight - rect.top + 8)))
|
|
871
|
+
}
|
|
674
872
|
// Pull first: a pin or a pick made on another client (desktop ↔ web ↔
|
|
675
873
|
// phone) should be visible the moment this panel opens. The pull fires
|
|
676
874
|
// SHARED_STATE_EVENT, which is what refreshes `usage`/`pinned` here.
|
|
677
875
|
void syncSharedState()
|
|
678
876
|
setUsage(loadUsage())
|
|
877
|
+
// A fresh open starts clean: a toast from last time is stale by then, and
|
|
878
|
+
// the panel should not reopen inside the closed-skills view.
|
|
879
|
+
setNotice(undefined)
|
|
880
|
+
setView('all')
|
|
679
881
|
void load()
|
|
680
882
|
void loadDisabled()
|
|
681
883
|
}
|
|
@@ -706,6 +908,18 @@ function SkillPickerButton(props) {
|
|
|
706
908
|
|
|
707
909
|
setOpen(false)
|
|
708
910
|
setQuery('')
|
|
911
|
+
|
|
912
|
+
// Put the caret back in the composer (see `focusComposer`). In a rAF so the
|
|
913
|
+
// panel has unmounted and React has flushed the draft write first.
|
|
914
|
+
const from = boxRef.current
|
|
915
|
+
window.requestAnimationFrame(() => {
|
|
916
|
+
focusComposer(from)
|
|
917
|
+
// Second, guarded seat: a rich editor may re-apply its own selection
|
|
918
|
+
// right after being focused, which would drop the caret back to the
|
|
919
|
+
// start. Re-assert the end caret shortly after — but only if the composer
|
|
920
|
+
// still holds focus, so a fast click elsewhere is never overridden.
|
|
921
|
+
window.setTimeout(() => focusComposer(from, true), 60)
|
|
922
|
+
})
|
|
709
923
|
}
|
|
710
924
|
|
|
711
925
|
const togglePin = (name) => {
|
|
@@ -726,7 +940,23 @@ function SkillPickerButton(props) {
|
|
|
726
940
|
|
|
727
941
|
// Grouped ordering: pinned first (manual), then usage-ranked (recent then
|
|
728
942
|
// frequent then untouched by name). Shared rule with the `/` completion.
|
|
729
|
-
|
|
943
|
+
/**
|
|
944
|
+
* The main list, with switched-off skills removed BY NAME.
|
|
945
|
+
*
|
|
946
|
+
* The official RPC should not report a disabled skill at all (the official
|
|
947
|
+
* provider keys on the entry file name), and the host scan reports them on
|
|
948
|
+
* purpose so the closed view can work — but this is the one place the main
|
|
949
|
+
* list is actually built, so the invariant is enforced here no matter which
|
|
950
|
+
* source produced the data.
|
|
951
|
+
*
|
|
952
|
+
* 国豪 caught the leak: a skill he had closed was still listed under 全部.
|
|
953
|
+
*/
|
|
954
|
+
const disabledNames = new Set(disabledSkills.map((skill) => skill.name))
|
|
955
|
+
const groups = groupByPinned(
|
|
956
|
+
(skills ?? []).filter((skill) => !disabledNames.has(skill.name)),
|
|
957
|
+
usage,
|
|
958
|
+
pinned,
|
|
959
|
+
)
|
|
730
960
|
const flat = groups.flatMap((group) => group.items)
|
|
731
961
|
|
|
732
962
|
const filtered = (() => {
|
|
@@ -745,6 +975,17 @@ function SkillPickerButton(props) {
|
|
|
745
975
|
const showTitles = query.trim() === '' && groups.length > 1
|
|
746
976
|
const filteredNames = new Set(filtered.map((skill) => skill.name))
|
|
747
977
|
|
|
978
|
+
/**
|
|
979
|
+
* The closed-skills view, filtered by the same search box. The official RPC
|
|
980
|
+
* never reports these, so without this view a disabled skill would be
|
|
981
|
+
* unreachable — that is still true, it just lives one click away now.
|
|
982
|
+
*/
|
|
983
|
+
const offFiltered = (() => {
|
|
984
|
+
const q = query.trim().toLowerCase()
|
|
985
|
+
if (q === '') return disabledSkills
|
|
986
|
+
return disabledSkills.filter((skill) => matchRank(skill, q) < 4)
|
|
987
|
+
})()
|
|
988
|
+
|
|
748
989
|
// Keyboard navigation (#1): reset highlight when the query changes, keep it
|
|
749
990
|
// in range when the result list shrinks, and keep the highlighted row visible.
|
|
750
991
|
useEffect(() => {
|
|
@@ -777,7 +1018,12 @@ function SkillPickerButton(props) {
|
|
|
777
1018
|
}
|
|
778
1019
|
|
|
779
1020
|
return (
|
|
780
|
-
|
|
1021
|
+
// The marker is how dsh-pocket's mobile "file open guard" learns to leave
|
|
1022
|
+
// this panel alone: its rows are <button>s whose text starts with "/技能名",
|
|
1023
|
+
// which its looksLikeFilePath() reads as a Windows path, so it swallowed the
|
|
1024
|
+
// click and popped "手机上无法直接打开电脑上的文件" (see the pocket patch of
|
|
1025
|
+
// 2026-10-02). Guarded there, marked here.
|
|
1026
|
+
<div ref={boxRef} data-dsh-skill-picker="1" style={{ position: 'relative', display: 'inline-flex', flex: 'none' }}>
|
|
781
1027
|
<button
|
|
782
1028
|
type="button"
|
|
783
1029
|
onClick={toggle}
|
|
@@ -791,13 +1037,15 @@ function SkillPickerButton(props) {
|
|
|
791
1037
|
<BoltIcon />
|
|
792
1038
|
</button>
|
|
793
1039
|
{open && (
|
|
794
|
-
<div style={popoverStyle}>
|
|
1040
|
+
<div style={{ ...popoverStyle, bottom: `${panelBottom}px` }}>
|
|
795
1041
|
<input
|
|
796
1042
|
value={query}
|
|
797
1043
|
onChange={(event) => setQuery(event.target.value)}
|
|
798
1044
|
onKeyDown={onKeyDown}
|
|
1045
|
+
onFocus={() => setSearchFocus(true)}
|
|
1046
|
+
onBlur={() => setSearchFocus(false)}
|
|
799
1047
|
placeholder="搜索技能…(↑↓ 选择,Enter 插入)"
|
|
800
|
-
style={searchStyle}
|
|
1048
|
+
style={{ ...searchStyle, ...(searchFocus ? searchFocusStyle : {}) }}
|
|
801
1049
|
autoFocus
|
|
802
1050
|
/>
|
|
803
1051
|
{error !== undefined ? (
|
|
@@ -807,7 +1055,38 @@ function SkillPickerButton(props) {
|
|
|
807
1055
|
) : (
|
|
808
1056
|
<>
|
|
809
1057
|
<div style={listStyle}>
|
|
810
|
-
{
|
|
1058
|
+
{view === 'off' ? (
|
|
1059
|
+
offFiltered.length === 0 ? (
|
|
1060
|
+
<div style={statusStyle}>
|
|
1061
|
+
{query.trim() === '' ? '没有被关闭的技能' : '没有匹配的已关闭技能'}
|
|
1062
|
+
</div>
|
|
1063
|
+
) : (
|
|
1064
|
+
offFiltered.map((skill) => (
|
|
1065
|
+
<div
|
|
1066
|
+
key={`off-${skill.name}`}
|
|
1067
|
+
onContextMenu={(event) => {
|
|
1068
|
+
event.preventDefault()
|
|
1069
|
+
event.stopPropagation()
|
|
1070
|
+
openMenu(event, skill, true)
|
|
1071
|
+
}}
|
|
1072
|
+
onTouchStart={(event) => {
|
|
1073
|
+
const touch = event.touches?.[0]
|
|
1074
|
+
if (touch === undefined) return
|
|
1075
|
+
longPressRef.current = window.setTimeout(
|
|
1076
|
+
() => openMenu({ clientX: touch.clientX, clientY: touch.clientY }, skill, true),
|
|
1077
|
+
500,
|
|
1078
|
+
)
|
|
1079
|
+
}}
|
|
1080
|
+
onTouchEnd={() => window.clearTimeout(longPressRef.current)}
|
|
1081
|
+
onTouchMove={() => window.clearTimeout(longPressRef.current)}
|
|
1082
|
+
style={{ ...itemStyle, flexDirection: 'column', alignItems: 'flex-start', opacity: 0.6 }}
|
|
1083
|
+
>
|
|
1084
|
+
<span style={nameStyle}>{`/${skill.name}`}</span>
|
|
1085
|
+
<span style={descStyle}>{skill.description ?? ''}</span>
|
|
1086
|
+
</div>
|
|
1087
|
+
))
|
|
1088
|
+
)
|
|
1089
|
+
) : filtered.length === 0 ? (
|
|
811
1090
|
<div style={statusStyle}>没有匹配的技能</div>
|
|
812
1091
|
) : (() => {
|
|
813
1092
|
let itemIndex = 0
|
|
@@ -860,32 +1139,10 @@ function SkillPickerButton(props) {
|
|
|
860
1139
|
<span style={nameStyle}>{`/${skill.name}`}</span>
|
|
861
1140
|
<span style={descStyle}>{skill.description ?? ''}</span>
|
|
862
1141
|
</span>
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
aria-label={pinned.includes(skill.name) ? '取消置顶' : '置顶'}
|
|
868
|
-
onClick={(event) => {
|
|
869
|
-
event.stopPropagation()
|
|
870
|
-
togglePin(skill.name)
|
|
871
|
-
}}
|
|
872
|
-
style={{
|
|
873
|
-
flex: 'none',
|
|
874
|
-
marginLeft: '6px',
|
|
875
|
-
padding: '2px 4px',
|
|
876
|
-
borderRadius: '6px',
|
|
877
|
-
fontSize: '12px',
|
|
878
|
-
lineHeight: '16px',
|
|
879
|
-
cursor: 'pointer',
|
|
880
|
-
color: pinned.includes(skill.name)
|
|
881
|
-
? 'var(--dsw-alias-label-primary-bluish, #4cc9f0)'
|
|
882
|
-
: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
883
|
-
opacity: pinned.includes(skill.name) ? 1 : 0.55,
|
|
884
|
-
userSelect: 'none',
|
|
885
|
-
}}
|
|
886
|
-
>
|
|
887
|
-
{pinned.includes(skill.name) ? '📌' : '📍'}
|
|
888
|
-
</span>
|
|
1142
|
+
{/* No pin glyph on the row any more: 置顶/取消置顶 lives in
|
|
1143
|
+
the right-click menu, and the 置顶 group already shows
|
|
1144
|
+
which skills are pinned. 国豪: "那个置顶的按钮也不需要了
|
|
1145
|
+
吧,毕竟右键它就能有置顶". */}
|
|
889
1146
|
</button>
|
|
890
1147
|
)
|
|
891
1148
|
if (!showTitles) {
|
|
@@ -903,9 +1160,14 @@ function SkillPickerButton(props) {
|
|
|
903
1160
|
justifyContent: 'space-between',
|
|
904
1161
|
padding: '6px 10px 2px',
|
|
905
1162
|
color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1163
|
+
// Measured against DSH's own sidebar heading
|
|
1164
|
+
// (「工作区」): it is 14px / weight 400 / normal
|
|
1165
|
+
// letter-spacing. Mine had been 11px / 600 / 0.04em,
|
|
1166
|
+
// which is exactly what made it look foreign.
|
|
1167
|
+
fontSize: '14px',
|
|
1168
|
+
fontWeight: 400,
|
|
1169
|
+
letterSpacing: 'normal',
|
|
1170
|
+
lineHeight: '24px',
|
|
909
1171
|
}}
|
|
910
1172
|
>
|
|
911
1173
|
<span>{group.title}</span>
|
|
@@ -935,70 +1197,52 @@ function SkillPickerButton(props) {
|
|
|
935
1197
|
)}
|
|
936
1198
|
</div>
|
|
937
1199
|
)}
|
|
938
|
-
{/*
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
{busySkill === skill.name ? '…' : '⏻ 开启'}
|
|
985
|
-
</span>
|
|
986
|
-
<span
|
|
987
|
-
role="button"
|
|
988
|
-
tabIndex={-1}
|
|
989
|
-
title="在文件管理器中定位"
|
|
990
|
-
aria-label="在文件管理器中定位"
|
|
991
|
-
onClick={() => void runSkillOp('reveal', skill)}
|
|
992
|
-
style={actionSpanStyle}
|
|
993
|
-
>
|
|
994
|
-
📂
|
|
995
|
-
</span>
|
|
996
|
-
</div>
|
|
997
|
-
))}
|
|
998
|
-
</div>
|
|
999
|
-
)}
|
|
1000
|
-
<div style={{ margin: '0 10px 8px', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary, #8a94a6)' }}>
|
|
1001
|
-
右键技能 = 置顶 / 关闭 / 定位 / 卸载(手机长按)
|
|
1200
|
+
{/* The footer: a short hint on the left, the 「已关闭」 entry on the
|
|
1201
|
+
right. It used to be a full section listing every disabled
|
|
1202
|
+
skill — too much room for something reached rarely (国豪's
|
|
1203
|
+
call), so it is a small chip that opens its own view.
|
|
1204
|
+
The hint itself: first written long ("右键技能 = 置顶 / 关闭 /
|
|
1205
|
+
定位 / 卸载(手机长按)"), which did not line up with the rows
|
|
1206
|
+
and got clipped to "手机…" — then shortened to this and kept
|
|
1207
|
+
permanently, because at this length it is quiet and fits
|
|
1208
|
+
(国豪: "我感觉你变成这一行就不错,那你就可以永久留着了").
|
|
1209
|
+
Its left margin matches the row text (list padding 6 + row
|
|
1210
|
+
padding 10) so it lines up with everything above it. */}
|
|
1211
|
+
<div
|
|
1212
|
+
style={{
|
|
1213
|
+
display: 'flex',
|
|
1214
|
+
alignItems: 'center',
|
|
1215
|
+
justifyContent: 'flex-end',
|
|
1216
|
+
gap: '8px',
|
|
1217
|
+
margin: '0 12px 8px 16px',
|
|
1218
|
+
fontSize: '12px',
|
|
1219
|
+
lineHeight: '18px',
|
|
1220
|
+
color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
1221
|
+
}}
|
|
1222
|
+
>
|
|
1223
|
+
<span style={{ minWidth: '0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
1224
|
+
{view === 'off' ? '右键可开启 · 手机长按' : '右键可管理 · 手机长按'}
|
|
1225
|
+
</span>
|
|
1226
|
+
<span
|
|
1227
|
+
role="button"
|
|
1228
|
+
tabIndex={-1}
|
|
1229
|
+
title={view === 'off' ? '回到全部技能' : '查看被关闭的技能'}
|
|
1230
|
+
onClick={() => {
|
|
1231
|
+
setView(view === 'off' ? 'all' : 'off')
|
|
1232
|
+
setQuery('')
|
|
1233
|
+
}}
|
|
1234
|
+
style={{
|
|
1235
|
+
flex: 'none',
|
|
1236
|
+
marginLeft: 'auto',
|
|
1237
|
+
padding: '2px 8px',
|
|
1238
|
+
borderRadius: '999px',
|
|
1239
|
+
border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.35))',
|
|
1240
|
+
cursor: 'pointer',
|
|
1241
|
+
whiteSpace: 'nowrap',
|
|
1242
|
+
}}
|
|
1243
|
+
>
|
|
1244
|
+
{view === 'off' ? '← 全部技能' : `已关闭 ${disabledSkills.length}`}
|
|
1245
|
+
</span>
|
|
1002
1246
|
</div>
|
|
1003
1247
|
{/* The actions menu. Rendered here (not inside the row) so it can
|
|
1004
1248
|
be positioned against the viewport and never clipped by the
|
|
@@ -1014,18 +1258,20 @@ function SkillPickerButton(props) {
|
|
|
1014
1258
|
style={menuItemStyle}
|
|
1015
1259
|
role="button"
|
|
1016
1260
|
tabIndex={-1}
|
|
1261
|
+
{...menuItemHandlers}
|
|
1017
1262
|
onClick={() => {
|
|
1018
1263
|
setMenu(undefined)
|
|
1019
1264
|
togglePin(menu.skill.name)
|
|
1020
1265
|
}}
|
|
1021
1266
|
>
|
|
1022
|
-
{pinned.includes(menu.skill.name) ? '
|
|
1267
|
+
{pinned.includes(menu.skill.name) ? '取消置顶' : '置顶到顶部'}
|
|
1023
1268
|
</div>
|
|
1024
1269
|
)}
|
|
1025
1270
|
<div
|
|
1026
1271
|
style={menuItemStyle}
|
|
1027
1272
|
role="button"
|
|
1028
1273
|
tabIndex={-1}
|
|
1274
|
+
{...menuItemHandlers}
|
|
1029
1275
|
onClick={() => {
|
|
1030
1276
|
const target = menu.skill
|
|
1031
1277
|
const turningOff = !menu.disabled
|
|
@@ -1033,25 +1279,27 @@ function SkillPickerButton(props) {
|
|
|
1033
1279
|
void runSkillOp(turningOff ? 'disable' : 'enable', target)
|
|
1034
1280
|
}}
|
|
1035
1281
|
>
|
|
1036
|
-
{menu.disabled ? '
|
|
1282
|
+
{menu.disabled ? '开启' : '关闭(agent 也不再加载)'}
|
|
1037
1283
|
</div>
|
|
1038
1284
|
<div
|
|
1039
1285
|
style={menuItemStyle}
|
|
1040
1286
|
role="button"
|
|
1041
1287
|
tabIndex={-1}
|
|
1288
|
+
{...menuItemHandlers}
|
|
1042
1289
|
onClick={() => {
|
|
1043
1290
|
const target = menu.skill
|
|
1044
1291
|
setMenu(undefined)
|
|
1045
1292
|
void runSkillOp('reveal', target)
|
|
1046
1293
|
}}
|
|
1047
1294
|
>
|
|
1048
|
-
|
|
1295
|
+
在文件管理器中定位
|
|
1049
1296
|
</div>
|
|
1050
1297
|
{!menu.disabled && (
|
|
1051
1298
|
<div
|
|
1052
|
-
style={{ ...menuItemStyle, color: 'var(--dsw-alias-label-error, #
|
|
1299
|
+
style={{ ...menuItemStyle, color: 'var(--dsw-alias-label-error, #e5534b)' }}
|
|
1053
1300
|
role="button"
|
|
1054
1301
|
tabIndex={-1}
|
|
1302
|
+
{...menuItemHandlers}
|
|
1055
1303
|
onClick={() => {
|
|
1056
1304
|
const target = menu.skill
|
|
1057
1305
|
setMenu(undefined)
|
|
@@ -1059,7 +1307,7 @@ function SkillPickerButton(props) {
|
|
|
1059
1307
|
void runSkillOp('uninstall', target)
|
|
1060
1308
|
}}
|
|
1061
1309
|
>
|
|
1062
|
-
|
|
1310
|
+
卸载(移入备份,可撤回)
|
|
1063
1311
|
</div>
|
|
1064
1312
|
)}
|
|
1065
1313
|
</div>
|