dsh-skill-picker 0.5.25 → 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 +2 -1
- package/lib/client.js +328 -260
- package/lib/client.js.map +2 -2
- package/package.json +1 -1
- package/src/client/index.jsx +357 -133
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,6 +342,19 @@ 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
|
+
|
|
277
358
|
/**
|
|
278
359
|
* Panel placement.
|
|
279
360
|
*
|
|
@@ -299,21 +380,57 @@ const popoverStyle = {
|
|
|
299
380
|
boxShadow: '0 8px 28px rgba(0,0,0,0.35)',
|
|
300
381
|
overflow: 'hidden',
|
|
301
382
|
zIndex: 1000,
|
|
383
|
+
fontFamily: FONT_FAMILY,
|
|
384
|
+
fontSize: '14px',
|
|
385
|
+
lineHeight: '24px',
|
|
302
386
|
}
|
|
303
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
|
+
*/
|
|
304
408
|
const searchStyle = {
|
|
305
409
|
boxSizing: 'border-box',
|
|
306
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',
|
|
307
416
|
margin: '8px',
|
|
308
|
-
padding: '
|
|
309
|
-
border: '1px solid
|
|
417
|
+
padding: '0 10px',
|
|
418
|
+
border: '1px solid transparent',
|
|
310
419
|
borderRadius: '8px',
|
|
311
420
|
background: 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.1))',
|
|
312
421
|
color: 'var(--dsw-alias-label-primary, #e6ebf2)',
|
|
313
|
-
|
|
422
|
+
fontFamily: FONT_FAMILY,
|
|
423
|
+
fontSize: '14px',
|
|
424
|
+
lineHeight: '20px',
|
|
314
425
|
outline: 'none',
|
|
315
426
|
}
|
|
316
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
|
+
|
|
317
434
|
const listStyle = {
|
|
318
435
|
overflowY: 'auto',
|
|
319
436
|
flex: 'auto',
|
|
@@ -333,16 +450,23 @@ const itemStyle = {
|
|
|
333
450
|
color: 'var(--dsw-alias-label-primary, #e6ebf2)',
|
|
334
451
|
cursor: 'pointer',
|
|
335
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,
|
|
336
458
|
}
|
|
337
459
|
|
|
338
460
|
const nameStyle = {
|
|
339
|
-
fontFamily:
|
|
340
|
-
fontSize: '
|
|
341
|
-
fontWeight:
|
|
461
|
+
fontFamily: FONT_FAMILY,
|
|
462
|
+
fontSize: '14px',
|
|
463
|
+
fontWeight: 400,
|
|
464
|
+
lineHeight: '22px',
|
|
342
465
|
}
|
|
343
466
|
|
|
344
467
|
const descStyle = {
|
|
345
468
|
color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
469
|
+
fontFamily: FONT_FAMILY,
|
|
346
470
|
fontSize: '12px',
|
|
347
471
|
lineHeight: '16px',
|
|
348
472
|
overflow: 'hidden',
|
|
@@ -354,23 +478,10 @@ const descStyle = {
|
|
|
354
478
|
const statusStyle = {
|
|
355
479
|
padding: '12px',
|
|
356
480
|
color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
481
|
+
fontFamily: FONT_FAMILY,
|
|
357
482
|
fontSize: '13px',
|
|
358
483
|
}
|
|
359
484
|
|
|
360
|
-
/** One row action (关闭 / 定位 / 卸载); matches the pin button's weight. */
|
|
361
|
-
const actionSpanStyle = {
|
|
362
|
-
flex: 'none',
|
|
363
|
-
marginLeft: '6px',
|
|
364
|
-
padding: '2px 4px',
|
|
365
|
-
borderRadius: '6px',
|
|
366
|
-
fontSize: '12px',
|
|
367
|
-
lineHeight: '16px',
|
|
368
|
-
cursor: 'pointer',
|
|
369
|
-
color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
370
|
-
opacity: 0.55,
|
|
371
|
-
userSelect: 'none',
|
|
372
|
-
}
|
|
373
|
-
|
|
374
485
|
/** Result line under the list, with an optional undo for uninstall. */
|
|
375
486
|
const noticeStyle = {
|
|
376
487
|
display: 'flex',
|
|
@@ -385,27 +496,52 @@ const noticeStyle = {
|
|
|
385
496
|
color: 'var(--dsw-alias-label-secondary, #b6bfcc)',
|
|
386
497
|
}
|
|
387
498
|
|
|
388
|
-
/**
|
|
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
|
+
*/
|
|
389
522
|
const menuStyle = {
|
|
390
523
|
position: 'fixed',
|
|
391
524
|
zIndex: 2147483000,
|
|
392
525
|
minWidth: '196px',
|
|
393
526
|
padding: '4px',
|
|
394
527
|
borderRadius: '10px',
|
|
395
|
-
border: '1px solid var(--dsw-alias-border-
|
|
396
|
-
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)',
|
|
397
531
|
boxShadow: '0 8px 28px rgba(0,0,0,0.36)',
|
|
398
|
-
color: 'var(--dsw-alias-label-primary, #
|
|
399
|
-
|
|
532
|
+
color: 'var(--dsw-alias-label-primary, #e6ebf2)',
|
|
533
|
+
fontFamily: FONT_FAMILY,
|
|
534
|
+
fontSize: '14px',
|
|
535
|
+
lineHeight: '24px',
|
|
400
536
|
userSelect: 'none',
|
|
401
537
|
}
|
|
402
538
|
|
|
403
539
|
/** The skill name at the top of the menu (the thing being acted on). */
|
|
404
540
|
const menuHeaderStyle = {
|
|
405
541
|
padding: '6px 10px 4px',
|
|
406
|
-
fontSize: '
|
|
542
|
+
fontSize: '12px',
|
|
407
543
|
color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
408
|
-
borderBottom: '1px solid var(--dsw-alias-border-
|
|
544
|
+
borderBottom: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.28))',
|
|
409
545
|
marginBottom: '4px',
|
|
410
546
|
overflow: 'hidden',
|
|
411
547
|
textOverflow: 'ellipsis',
|
|
@@ -420,6 +556,19 @@ const menuItemStyle = {
|
|
|
420
556
|
whiteSpace: 'nowrap',
|
|
421
557
|
}
|
|
422
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
|
+
|
|
423
572
|
/** The picker's bolt glyph: DeepSeek palette gradient + slim stroke. */
|
|
424
573
|
function BoltIcon() {
|
|
425
574
|
return (
|
|
@@ -460,6 +609,18 @@ function SkillPickerButton(props) {
|
|
|
460
609
|
const [busySkill, setBusySkill] = useState('')
|
|
461
610
|
/** Result line under the list: { kind, text, undo? }. */
|
|
462
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])
|
|
463
624
|
/**
|
|
464
625
|
* The right-click menu: { x, y, skill, disabled }.
|
|
465
626
|
*
|
|
@@ -469,6 +630,16 @@ function SkillPickerButton(props) {
|
|
|
469
630
|
* menu, because a phone has no right button.
|
|
470
631
|
*/
|
|
471
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)
|
|
472
643
|
const [query, setQuery] = useState('')
|
|
473
644
|
const [usage, setUsage] = useState(() => loadUsage())
|
|
474
645
|
const [pinned, setPinned] = useState(() => loadPinned())
|
|
@@ -563,7 +734,10 @@ function SkillPickerButton(props) {
|
|
|
563
734
|
const res = await fetch(`/dsh-skill-picker/skills${cwd}`, { headers: { accept: 'application/json' } })
|
|
564
735
|
const json = await res.json()
|
|
565
736
|
if (!json.ok) throw new Error(json.error || 'bad response')
|
|
566
|
-
|
|
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))
|
|
567
741
|
setSource('host')
|
|
568
742
|
} catch (cause) {
|
|
569
743
|
setError(String(cause?.message ?? cause))
|
|
@@ -700,6 +874,10 @@ function SkillPickerButton(props) {
|
|
|
700
874
|
// SHARED_STATE_EVENT, which is what refreshes `usage`/`pinned` here.
|
|
701
875
|
void syncSharedState()
|
|
702
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')
|
|
703
881
|
void load()
|
|
704
882
|
void loadDisabled()
|
|
705
883
|
}
|
|
@@ -730,6 +908,18 @@ function SkillPickerButton(props) {
|
|
|
730
908
|
|
|
731
909
|
setOpen(false)
|
|
732
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
|
+
})
|
|
733
923
|
}
|
|
734
924
|
|
|
735
925
|
const togglePin = (name) => {
|
|
@@ -750,7 +940,23 @@ function SkillPickerButton(props) {
|
|
|
750
940
|
|
|
751
941
|
// Grouped ordering: pinned first (manual), then usage-ranked (recent then
|
|
752
942
|
// frequent then untouched by name). Shared rule with the `/` completion.
|
|
753
|
-
|
|
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
|
+
)
|
|
754
960
|
const flat = groups.flatMap((group) => group.items)
|
|
755
961
|
|
|
756
962
|
const filtered = (() => {
|
|
@@ -769,6 +975,17 @@ function SkillPickerButton(props) {
|
|
|
769
975
|
const showTitles = query.trim() === '' && groups.length > 1
|
|
770
976
|
const filteredNames = new Set(filtered.map((skill) => skill.name))
|
|
771
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
|
+
|
|
772
989
|
// Keyboard navigation (#1): reset highlight when the query changes, keep it
|
|
773
990
|
// in range when the result list shrinks, and keep the highlighted row visible.
|
|
774
991
|
useEffect(() => {
|
|
@@ -801,7 +1018,12 @@ function SkillPickerButton(props) {
|
|
|
801
1018
|
}
|
|
802
1019
|
|
|
803
1020
|
return (
|
|
804
|
-
|
|
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' }}>
|
|
805
1027
|
<button
|
|
806
1028
|
type="button"
|
|
807
1029
|
onClick={toggle}
|
|
@@ -820,8 +1042,10 @@ function SkillPickerButton(props) {
|
|
|
820
1042
|
value={query}
|
|
821
1043
|
onChange={(event) => setQuery(event.target.value)}
|
|
822
1044
|
onKeyDown={onKeyDown}
|
|
1045
|
+
onFocus={() => setSearchFocus(true)}
|
|
1046
|
+
onBlur={() => setSearchFocus(false)}
|
|
823
1047
|
placeholder="搜索技能…(↑↓ 选择,Enter 插入)"
|
|
824
|
-
style={searchStyle}
|
|
1048
|
+
style={{ ...searchStyle, ...(searchFocus ? searchFocusStyle : {}) }}
|
|
825
1049
|
autoFocus
|
|
826
1050
|
/>
|
|
827
1051
|
{error !== undefined ? (
|
|
@@ -831,7 +1055,38 @@ function SkillPickerButton(props) {
|
|
|
831
1055
|
) : (
|
|
832
1056
|
<>
|
|
833
1057
|
<div style={listStyle}>
|
|
834
|
-
{
|
|
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 ? (
|
|
835
1090
|
<div style={statusStyle}>没有匹配的技能</div>
|
|
836
1091
|
) : (() => {
|
|
837
1092
|
let itemIndex = 0
|
|
@@ -884,32 +1139,10 @@ function SkillPickerButton(props) {
|
|
|
884
1139
|
<span style={nameStyle}>{`/${skill.name}`}</span>
|
|
885
1140
|
<span style={descStyle}>{skill.description ?? ''}</span>
|
|
886
1141
|
</span>
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
aria-label={pinned.includes(skill.name) ? '取消置顶' : '置顶'}
|
|
892
|
-
onClick={(event) => {
|
|
893
|
-
event.stopPropagation()
|
|
894
|
-
togglePin(skill.name)
|
|
895
|
-
}}
|
|
896
|
-
style={{
|
|
897
|
-
flex: 'none',
|
|
898
|
-
marginLeft: '6px',
|
|
899
|
-
padding: '2px 4px',
|
|
900
|
-
borderRadius: '6px',
|
|
901
|
-
fontSize: '12px',
|
|
902
|
-
lineHeight: '16px',
|
|
903
|
-
cursor: 'pointer',
|
|
904
|
-
color: pinned.includes(skill.name)
|
|
905
|
-
? 'var(--dsw-alias-label-primary-bluish, #4cc9f0)'
|
|
906
|
-
: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
907
|
-
opacity: pinned.includes(skill.name) ? 1 : 0.55,
|
|
908
|
-
userSelect: 'none',
|
|
909
|
-
}}
|
|
910
|
-
>
|
|
911
|
-
{pinned.includes(skill.name) ? '📌' : '📍'}
|
|
912
|
-
</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
|
+
吧,毕竟右键它就能有置顶". */}
|
|
913
1146
|
</button>
|
|
914
1147
|
)
|
|
915
1148
|
if (!showTitles) {
|
|
@@ -927,9 +1160,14 @@ function SkillPickerButton(props) {
|
|
|
927
1160
|
justifyContent: 'space-between',
|
|
928
1161
|
padding: '6px 10px 2px',
|
|
929
1162
|
color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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',
|
|
933
1171
|
}}
|
|
934
1172
|
>
|
|
935
1173
|
<span>{group.title}</span>
|
|
@@ -959,70 +1197,52 @@ function SkillPickerButton(props) {
|
|
|
959
1197
|
)}
|
|
960
1198
|
</div>
|
|
961
1199
|
)}
|
|
962
|
-
{/*
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
{busySkill === skill.name ? '…' : '⏻ 开启'}
|
|
1009
|
-
</span>
|
|
1010
|
-
<span
|
|
1011
|
-
role="button"
|
|
1012
|
-
tabIndex={-1}
|
|
1013
|
-
title="在文件管理器中定位"
|
|
1014
|
-
aria-label="在文件管理器中定位"
|
|
1015
|
-
onClick={() => void runSkillOp('reveal', skill)}
|
|
1016
|
-
style={actionSpanStyle}
|
|
1017
|
-
>
|
|
1018
|
-
📂
|
|
1019
|
-
</span>
|
|
1020
|
-
</div>
|
|
1021
|
-
))}
|
|
1022
|
-
</div>
|
|
1023
|
-
)}
|
|
1024
|
-
<div style={{ margin: '0 10px 8px', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary, #8a94a6)' }}>
|
|
1025
|
-
右键技能 = 置顶 / 关闭 / 定位 / 卸载(手机长按)
|
|
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>
|
|
1026
1246
|
</div>
|
|
1027
1247
|
{/* The actions menu. Rendered here (not inside the row) so it can
|
|
1028
1248
|
be positioned against the viewport and never clipped by the
|
|
@@ -1038,18 +1258,20 @@ function SkillPickerButton(props) {
|
|
|
1038
1258
|
style={menuItemStyle}
|
|
1039
1259
|
role="button"
|
|
1040
1260
|
tabIndex={-1}
|
|
1261
|
+
{...menuItemHandlers}
|
|
1041
1262
|
onClick={() => {
|
|
1042
1263
|
setMenu(undefined)
|
|
1043
1264
|
togglePin(menu.skill.name)
|
|
1044
1265
|
}}
|
|
1045
1266
|
>
|
|
1046
|
-
{pinned.includes(menu.skill.name) ? '
|
|
1267
|
+
{pinned.includes(menu.skill.name) ? '取消置顶' : '置顶到顶部'}
|
|
1047
1268
|
</div>
|
|
1048
1269
|
)}
|
|
1049
1270
|
<div
|
|
1050
1271
|
style={menuItemStyle}
|
|
1051
1272
|
role="button"
|
|
1052
1273
|
tabIndex={-1}
|
|
1274
|
+
{...menuItemHandlers}
|
|
1053
1275
|
onClick={() => {
|
|
1054
1276
|
const target = menu.skill
|
|
1055
1277
|
const turningOff = !menu.disabled
|
|
@@ -1057,25 +1279,27 @@ function SkillPickerButton(props) {
|
|
|
1057
1279
|
void runSkillOp(turningOff ? 'disable' : 'enable', target)
|
|
1058
1280
|
}}
|
|
1059
1281
|
>
|
|
1060
|
-
{menu.disabled ? '
|
|
1282
|
+
{menu.disabled ? '开启' : '关闭(agent 也不再加载)'}
|
|
1061
1283
|
</div>
|
|
1062
1284
|
<div
|
|
1063
1285
|
style={menuItemStyle}
|
|
1064
1286
|
role="button"
|
|
1065
1287
|
tabIndex={-1}
|
|
1288
|
+
{...menuItemHandlers}
|
|
1066
1289
|
onClick={() => {
|
|
1067
1290
|
const target = menu.skill
|
|
1068
1291
|
setMenu(undefined)
|
|
1069
1292
|
void runSkillOp('reveal', target)
|
|
1070
1293
|
}}
|
|
1071
1294
|
>
|
|
1072
|
-
|
|
1295
|
+
在文件管理器中定位
|
|
1073
1296
|
</div>
|
|
1074
1297
|
{!menu.disabled && (
|
|
1075
1298
|
<div
|
|
1076
|
-
style={{ ...menuItemStyle, color: 'var(--dsw-alias-label-error, #
|
|
1299
|
+
style={{ ...menuItemStyle, color: 'var(--dsw-alias-label-error, #e5534b)' }}
|
|
1077
1300
|
role="button"
|
|
1078
1301
|
tabIndex={-1}
|
|
1302
|
+
{...menuItemHandlers}
|
|
1079
1303
|
onClick={() => {
|
|
1080
1304
|
const target = menu.skill
|
|
1081
1305
|
setMenu(undefined)
|
|
@@ -1083,7 +1307,7 @@ function SkillPickerButton(props) {
|
|
|
1083
1307
|
void runSkillOp('uninstall', target)
|
|
1084
1308
|
}}
|
|
1085
1309
|
>
|
|
1086
|
-
|
|
1310
|
+
卸载(移入备份,可撤回)
|
|
1087
1311
|
</div>
|
|
1088
1312
|
)}
|
|
1089
1313
|
</div>
|