@dreanzy/dsh-skill-pack 0.1.0

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.
@@ -0,0 +1,2024 @@
1
+ /**
2
+ * 浏览器半边。手写单文件:宿主用 `require('react')` 提供 React,
3
+ * 因此不引入 JSX、CSS Modules 或打包器,避免给这个仓库加第二套构建链。
4
+ */
5
+ window.__ModuleLoader__.load({
6
+ id: '@dreanzy/dsh-skill-pack',
7
+ factory(require) {
8
+ const React = require('react')
9
+ const h = React.createElement
10
+ const { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } = React
11
+
12
+ const PANEL_ID = 'skill-pack'
13
+ const CHIP_ID = 'skill-pack-chip'
14
+ const MODAL_ID = 'skill-pack-chip-modal'
15
+ const TITLE = 'Skill Pack'
16
+ /** 内置 pack 的 id。未绑定的工作区按它求值。 */
17
+ const DEFAULT_PACK_ID = 'default'
18
+ /** 工作区清单上限:会话一多,绑定表会变成一堵墙。 */
19
+ const MAX_WORKSPACES = 30
20
+ /**
21
+ * chip 文字隐藏的断点。
22
+ *
23
+ * 宿主输入框底栏 `.row` 声明了 `container-type: inline-size`,并用
24
+ * `@container (width <= 560px)` 控制这一带按钮的间距。跟随同一个断点,
25
+ * chip 就和官方那几个按钮同步折叠,而不是各自算一套阈值。
26
+ *
27
+ * 用 CSS 容器查询而不是 JS 量宽:插槽把 chip 包在一个与它等宽的容器里,
28
+ * 量父级永远偏小;往上找祖先又依赖具体层级,两种做法都会误判。
29
+ */
30
+ const CHIP_CSS = '@container (max-width: 560px) { .dsh-skill-pack-chip-text { display: none } }'
31
+
32
+ // ---------- 传输 ----------
33
+
34
+ const PATHS = {
35
+ global: '/api/skill-pack/global',
36
+ workspace: '/api/skill-pack/workspace',
37
+ apply: '/api/skill-pack/apply',
38
+ }
39
+
40
+ async function post(path, body) {
41
+ let response
42
+ try {
43
+ response = await fetch(path, {
44
+ method: 'POST',
45
+ cache: 'no-store',
46
+ headers: { 'content-type': 'application/json' },
47
+ body: JSON.stringify(body || {}),
48
+ })
49
+ } catch (error) {
50
+ throw new Error('网络请求失败:' + String(error))
51
+ }
52
+ let payload
53
+ try {
54
+ payload = await response.json()
55
+ } catch {
56
+ throw new Error('响应不是合法 JSON(HTTP ' + response.status + ')')
57
+ }
58
+ if (payload && payload.ok === true) return payload.value
59
+ const message = payload && payload.error && payload.error.message ? payload.error.message : '请求失败'
60
+ throw new Error(message)
61
+ }
62
+
63
+ const api = {
64
+ // 带上当前会话:服务端优先用它的 scope 读清单,否则会退回到「恰好排第一的那个会话」。
65
+ global: (sessionId) => post(PATHS.global, sessionId ? { sessionId: sessionId } : {}),
66
+ workspace: (sessionId) => post(PATHS.workspace, { sessionId: sessionId }),
67
+ apply: (commands, sessionId) =>
68
+ post(PATHS.apply, sessionId ? { sessionId: sessionId, commands: commands } : { commands: commands }),
69
+ }
70
+
71
+ // ---------- 样式 ----------
72
+
73
+ /**
74
+ * 颜色一律取宿主主题 token。
75
+ *
76
+ * 宿主的真实前缀是 `--dsw-`(不是 `--dsh-`),且全部 token 都要求浅色与深色两套取值,
77
+ * 底色用 `--dsw-alias-bg-layer-1`,不用 `--dsw-alias-bg-overlay`:后者在暗色主题下
78
+ * 绑定的是 neutral-bluish-700(中灰),弹窗会比页面亮一大截;后者是给「半透明浮层」
79
+ * 用的,语义也不是卡片底。layer-1 与页面同色,界线交给边框与阴影。
80
+ * 弹窗优先用它,弹层才与它自绘的浮层同色。
81
+ */
82
+ const T = {
83
+ text: 'var(--dsw-alias-label-primary, CanvasText)',
84
+ dim: 'var(--dsw-alias-label-secondary, GrayText)',
85
+ faint: 'var(--dsw-alias-label-tertiary, GrayText)',
86
+ surface:
87
+ 'var(--dsw-alias-bg-layer-1, Canvas)',
88
+ border: 'var(--dsw-alias-border-l1, rgba(127,127,127,0.35))',
89
+ borderStrong: 'var(--dsw-alias-border-l2, rgba(127,127,127,0.55))',
90
+ // 不用 --dsw-alias-state-idle-primary:暗色主题下它解析成接近白色,
91
+ // 与白色圆点叠成一条纯白胶囊,开/关完全看不出来。这里用固定的半透明灰,
92
+ // 再给轨道加一圈内描边,暗色与亮色下都有轮廓。
93
+ idle: 'rgba(127, 127, 127, 0.38)',
94
+ error: 'var(--dsw-alias-state-error-primary, #c0392b)',
95
+ onAccent: '#ffffff',
96
+ /*
97
+ * 开关配色照官方「反色」风格:开态轨道取前景色(暗色主题下是亮白),圆点取底色
98
+ * (近黑);关态轨道是中灰、圆点是浅圆点。反色在亮暗两套主题里都成立,
99
+ * 也不依赖品牌色——品牌色在主题里存在与否不确定,撞色时开关就废了。
100
+ */
101
+ track: 'var(--dsw-alias-label-primary, #e6e6e6)',
102
+ trackOff: 'rgba(127, 127, 127, 0.30)',
103
+ knob: 'var(--dsw-alias-bg-layer-1, #111111)',
104
+ knobOff: 'rgba(255, 255, 255, 0.92)',
105
+ }
106
+
107
+ const S = {
108
+ title: { fontSize: '15px', fontWeight: 600, color: T.text },
109
+ hint: { color: T.dim, fontSize: '12px', lineHeight: 1.6 },
110
+ bar: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap', margin: '14px 0 10px' },
111
+ input: {
112
+ flex: '1 1 200px',
113
+ minWidth: '160px',
114
+ padding: '6px 8px',
115
+ border: '1px solid ' + T.border,
116
+ borderRadius: '6px',
117
+ background: 'transparent',
118
+ color: T.text,
119
+ fontSize: '13px',
120
+ },
121
+ // 分段控件照官方:容器不画整条外框,每个选项是各自独立的小按钮;
122
+ // 选中项用浅底 + 更亮的文字,而不是整块品牌色——官方设置页就是这个观感,
123
+ // 品牌色实心块在暗色主题下会盖掉周围所有信息。
124
+ segment: { display: 'flex', gap: '6px', flexWrap: 'wrap', alignItems: 'center' },
125
+ segItem: (active) => ({
126
+ padding: '4px 10px',
127
+ cursor: 'pointer',
128
+ borderRadius: '6px',
129
+ border: '1px solid ' + (active ? T.borderStrong : T.border),
130
+ background: active ? 'rgba(127, 127, 127, 0.22)' : 'transparent',
131
+ color: active ? T.text : T.dim,
132
+ fontSize: '12px',
133
+ lineHeight: 1.4,
134
+ whiteSpace: 'nowrap',
135
+ transition: 'background 120ms ease, color 120ms ease, border-color 120ms ease',
136
+ }),
137
+ group: { borderRadius: '8px', border: '1px solid ' + T.border, marginBottom: '8px' },
138
+ groupHead: { display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 10px', cursor: 'pointer' },
139
+ groupName: { fontWeight: 600, flex: '1 1 auto', color: T.text },
140
+ badge: {
141
+ fontSize: '11px',
142
+ padding: '1px 6px',
143
+ borderRadius: '999px',
144
+ background: 'transparent',
145
+ border: '1px solid ' + T.border,
146
+ color: T.dim,
147
+ whiteSpace: 'nowrap',
148
+ },
149
+ row: {
150
+ display: 'flex',
151
+ alignItems: 'center',
152
+ gap: '10px',
153
+ padding: '6px 10px 6px 22px',
154
+ borderTop: '1px solid ' + T.border,
155
+ },
156
+ rowMain: { flex: '1 1 auto', minWidth: 0 },
157
+ rowName: { fontFamily: 'ui-monospace, monospace', fontSize: '12px', color: T.text },
158
+ origin: {
159
+ marginLeft: '6px',
160
+ padding: '0 4px',
161
+ fontSize: '10px',
162
+ fontFamily: 'inherit',
163
+ color: T.dim,
164
+ border: '1px solid ' + T.border,
165
+ borderRadius: '3px',
166
+ whiteSpace: 'nowrap',
167
+ },
168
+ groupInput: {
169
+ flex: '1 1 auto',
170
+ minWidth: '80px',
171
+ fontWeight: 600,
172
+ fontSize: '13px',
173
+ color: T.text,
174
+ background: 'transparent',
175
+ border: '1px solid ' + T.border,
176
+ borderRadius: '4px',
177
+ padding: '2px 6px',
178
+ },
179
+ rowDesc: { color: T.dim, fontSize: '11px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
180
+ sw: (on, disabled) => ({
181
+ width: '36px',
182
+ height: '20px',
183
+ borderRadius: '999px',
184
+ border: 0,
185
+ padding: 0,
186
+ cursor: disabled ? 'not-allowed' : 'pointer',
187
+ opacity: disabled ? 0.45 : 1,
188
+ background: on ? T.track : T.trackOff,
189
+ position: 'relative',
190
+ flex: '0 0 auto',
191
+ transition: 'background 140ms ease',
192
+ }),
193
+ knob: (on) => ({
194
+ position: 'absolute',
195
+ top: '2px',
196
+ left: on ? '18px' : '2px',
197
+ width: '16px',
198
+ height: '16px',
199
+ borderRadius: '999px',
200
+ background: on ? T.knob : T.knobOff,
201
+ boxShadow: '0 1px 2px rgba(0, 0, 0, 0.28)',
202
+ transition: 'left 140ms ease, background 140ms ease',
203
+ }),
204
+ reset: { border: 0, background: 'transparent', cursor: 'pointer', color: T.dim, fontSize: '12px', padding: '2px 4px' },
205
+ /*
206
+ * 自绘下拉。原生 <select> 的弹出层由系统绘制,不跟主题:暗色下一点开是一块
207
+ * 蓝底浮层,与官方设置页的语言选择器完全不是一个观感。这里改成按钮 + 浮层。
208
+ */
209
+ pickerTrigger: {
210
+ display: 'inline-flex',
211
+ alignItems: 'center',
212
+ justifyContent: 'space-between',
213
+ gap: '8px',
214
+ width: '100%',
215
+ padding: '5px 8px',
216
+ border: '1px solid ' + T.border,
217
+ borderRadius: '8px',
218
+ background: 'transparent',
219
+ color: T.text,
220
+ fontSize: '12px',
221
+ lineHeight: 1.5,
222
+ cursor: 'pointer',
223
+ textAlign: 'left',
224
+ },
225
+ pickerLabel: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
226
+ pickerMenu: {
227
+ position: 'absolute',
228
+ top: 'calc(100% + 4px)',
229
+ left: 0,
230
+ right: 0,
231
+ zIndex: 30,
232
+ padding: '4px',
233
+ borderRadius: '10px',
234
+ border: '1px solid ' + T.border,
235
+ background: 'var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1, Canvas))',
236
+ boxShadow: '0 8px 24px rgba(0, 0, 0, 0.28)',
237
+ maxHeight: '260px',
238
+ overflowY: 'auto',
239
+ },
240
+ pickerItem: (active, hover) => ({
241
+ display: 'flex',
242
+ alignItems: 'center',
243
+ justifyContent: 'space-between',
244
+ gap: '10px',
245
+ padding: '6px 8px',
246
+ borderRadius: '6px',
247
+ fontSize: '12px',
248
+ color: T.text,
249
+ cursor: 'pointer',
250
+ background: active || hover ? 'rgba(127, 127, 127, 0.18)' : 'transparent',
251
+ }),
252
+ error: { color: T.error, fontSize: '12px', marginBottom: '8px' },
253
+ empty: { color: T.faint, padding: '12px 4px', fontSize: '12px' },
254
+ modalBack: {
255
+ position: 'fixed',
256
+ inset: 0,
257
+ background: 'rgba(0,0,0,0.42)',
258
+ display: 'flex',
259
+ alignItems: 'center',
260
+ justifyContent: 'center',
261
+ zIndex: 9999,
262
+ },
263
+ modal: {
264
+ width: 'min(560px, 92vw)',
265
+ maxHeight: '78vh',
266
+ // 下拉浮层要溢出弹层边缘,裁切会把它切掉一半;内容本来就短,不需要滚动。
267
+ overflow: 'visible',
268
+ background: T.surface,
269
+ color: T.text,
270
+ border: '1px solid ' + T.borderStrong,
271
+ borderRadius: '12px',
272
+ padding: '16px 18px',
273
+ boxShadow: '0 18px 48px rgba(0,0,0,0.28)',
274
+ fontSize: '13px',
275
+ },
276
+ block: { marginTop: '16px', paddingTop: '12px', borderTop: '1px solid ' + T.border },
277
+ blockTitle: { fontSize: '12px', fontWeight: 600, color: T.text, marginBottom: '6px' },
278
+ packRow: {
279
+ display: 'flex',
280
+ alignItems: 'center',
281
+ gap: '8px',
282
+ padding: '6px 8px',
283
+ borderRadius: '6px',
284
+ cursor: 'pointer',
285
+ border: '1px solid ' + T.border,
286
+ marginBottom: '6px',
287
+ },
288
+ path: { fontFamily: 'ui-monospace, monospace', fontSize: '11px', color: T.dim, wordBreak: 'break-all' },
289
+ chip: {
290
+ display: 'inline-flex',
291
+ alignItems: 'center',
292
+ gap: '4px',
293
+ padding: '3px 8px',
294
+ borderRadius: '999px',
295
+ border: '1px solid ' + T.border,
296
+ background: 'transparent',
297
+ color: T.text,
298
+ fontSize: '11px',
299
+ cursor: 'pointer',
300
+ },
301
+ }
302
+
303
+ /**
304
+ * 极小的可观测量。
305
+ *
306
+ * 设置页改的是持久化配置,槽位注册却发生在 `apply` 里,两者在 React 上没有
307
+ * 父子关系,改完不会自动重挂;chip 在会话页、设置页在设置弹层里,同理。
308
+ * 一个 set/subscribe 就够了,不引任何状态库。
309
+ */
310
+ function createSignal(initial) {
311
+ let value = initial
312
+ const listeners = new Set()
313
+ return {
314
+ read: () => value,
315
+ set: (next) => {
316
+ if (value === next) return
317
+ value = next
318
+ for (const listener of listeners) listener()
319
+ },
320
+ subscribe: (listener) => {
321
+ listeners.add(listener)
322
+ return () => listeners.delete(listener)
323
+ },
324
+ }
325
+ }
326
+
327
+ /**
328
+ * 跨组件共享的界面状态。
329
+ *
330
+ * `showChip` 由 `apply` 订阅:设置页拨动后立刻挂上或摘下槽位。
331
+ * `chipName` 是 chip 上显示的 pack 名。
332
+ */
333
+ const env = {
334
+ onChipChange: null,
335
+ sessionId: undefined,
336
+ }
337
+ const chipName = createSignal(null)
338
+ /**
339
+ * 未绑定 pack 时 chip 上的字。
340
+ *
341
+ * 跟宿主的语言走:中文下「默认」比 `Default` 自然。取不到语言服务就退回英文,
342
+ * 不让 chip 因为一个文案取不到而消失。
343
+ */
344
+ const chipFallback = createSignal('Default')
345
+
346
+ // ---------- 基础组件 ----------
347
+
348
+ /**
349
+ * 策略文件的告警。
350
+ *
351
+ * 写入失败不再是错误信封:它和既有的读告警一样,跟着视图的 `warnings` 回来,
352
+ * 所以面板得有一处能看见。默认折起只显示条数——正常情况一条都没有,出现时才
353
+ * 占位置;展开才列出全文。
354
+ */
355
+ function Warnings(props) {
356
+ const [open, setOpen] = useState(false)
357
+ const list = Array.isArray(props.warnings) ? props.warnings : []
358
+ if (list.length === 0) return null
359
+ return h(
360
+ 'div',
361
+ { style: { marginTop: '8px' } },
362
+ h(
363
+ 'button',
364
+ {
365
+ type: 'button',
366
+ 'aria-expanded': open,
367
+ style: { ...S.badge, cursor: 'pointer' },
368
+ onClick: () => setOpen(!open),
369
+ },
370
+ (open ? '▾ ' : '▸ ') + list.length + ' 条告警',
371
+ ),
372
+ open
373
+ ? h(
374
+ 'div',
375
+ { style: { ...S.hint, marginTop: '4px' } },
376
+ list.map((warning, index) => h('div', { key: index }, warning)),
377
+ )
378
+ : null,
379
+ )
380
+ }
381
+
382
+ function Switch(props) {
383
+ return h(
384
+ 'button',
385
+ {
386
+ type: 'button',
387
+ 'aria-pressed': props.on,
388
+ title: props.title || (props.on ? '已开启' : '已关闭'),
389
+ disabled: props.disabled,
390
+ style: S.sw(props.on, props.disabled),
391
+ onClick: props.disabled ? undefined : () => props.onChange(!props.on),
392
+ },
393
+ h('span', { style: S.knob(props.on) }),
394
+ )
395
+ }
396
+
397
+ function Segmented(props) {
398
+ return h(
399
+ 'div',
400
+ { style: S.segment },
401
+ props.options.map((option) =>
402
+ h(
403
+ 'button',
404
+ {
405
+ key: option.value,
406
+ type: 'button',
407
+ style: S.segItem(props.value === option.value),
408
+ onClick: () => props.onChange(option.value),
409
+ },
410
+ option.label,
411
+ ),
412
+ ),
413
+ )
414
+ }
415
+
416
+ /**
417
+ * 三态开关:开 / 关 / 跟随。
418
+ *
419
+ * 「跟随」必须是独立可点的第三个选项:显式关掉与「不表态」是两件事,
420
+ * 用「点两次循环」或和「开」共用一个按钮都表达不出来。选中态沿用分段控件的
421
+ * 浅底样式,不引新样式。
422
+ */
423
+ const TRI_STATE = [
424
+ { value: 'on', label: '开', title: '显式打开(压过组)' },
425
+ { value: 'off', label: '关', title: '显式关闭(压过组)' },
426
+ { value: 'follow', label: '跟随', title: '不记录,按组与默认求值' },
427
+ ]
428
+
429
+ function triStateOf(value, allowInherit) {
430
+ if (value === true) return 'on'
431
+ if (value === false) return 'off'
432
+ // default pack 没有上一层:未记录就是取内置缺省(开),显示成「开」而不是「跟随」。
433
+ return allowInherit === false ? 'on' : 'follow'
434
+ }
435
+
436
+ function TriState(props) {
437
+ // `default` 是求值链的终点,没有「跟随」可言;两态就够,多一个按钮反而让人
438
+ // 以为还能往上找。
439
+ const allowInherit = props.allowInherit !== false
440
+ const active = triStateOf(props.value, allowInherit)
441
+ const options = allowInherit ? TRI_STATE : TRI_STATE.filter((option) => option.value !== 'follow')
442
+ return h(
443
+ 'div',
444
+ { style: { display: 'inline-flex', gap: '4px', alignItems: 'center', flex: '0 0 auto' } },
445
+ options.map((option) =>
446
+ h(
447
+ 'button',
448
+ {
449
+ key: option.value,
450
+ type: 'button',
451
+ title: option.title,
452
+ 'aria-pressed': active === option.value,
453
+ disabled: props.disabled,
454
+ style: {
455
+ ...S.segItem(active === option.value),
456
+ fontSize: '11px',
457
+ padding: '2px 7px',
458
+ opacity: props.disabled ? 0.45 : 1,
459
+ cursor: props.disabled ? 'not-allowed' : 'pointer',
460
+ },
461
+ onClick: () => {
462
+ if (props.disabled) return
463
+ props.onChange(option.value === 'on' ? true : option.value === 'off' ? false : null)
464
+ },
465
+ },
466
+ option.label,
467
+ ),
468
+ ),
469
+ )
470
+ }
471
+
472
+ /**
473
+ * 删除的二次确认:第一次点变成「删除」(警告色),再点一次才执行。
474
+ *
475
+ * 不用弹窗:这类删除都发生在列表行里,弹窗要额外一次「关闭」动作;
476
+ * 行内二次确认把成本压到一次点击,误点的代价只是多看一眼。
477
+ * 进入确认态后 4 秒自动撤销,避免一直停在危险状态。
478
+ */
479
+ function ConfirmButton(props) {
480
+ const [armed, setArmed] = useState(false)
481
+ const disabled = props.disabled === true
482
+ useEffect(() => {
483
+ if (!armed) return undefined
484
+ const timer = setTimeout(() => setArmed(false), 4000)
485
+ return () => clearTimeout(timer)
486
+ }, [armed])
487
+ return h(
488
+ 'button',
489
+ {
490
+ type: 'button',
491
+ disabled: disabled,
492
+ style: armed
493
+ ? { ...S.reset, color: T.error, fontWeight: 600, whiteSpace: 'nowrap' }
494
+ : props.style || S.reset,
495
+ title: armed ? '再点一次确认删除' : props.title || '',
496
+ onClick: (event) => {
497
+ event.stopPropagation()
498
+ if (disabled) return
499
+ if (!armed) {
500
+ setArmed(true)
501
+ return
502
+ }
503
+ setArmed(false)
504
+ props.onConfirm()
505
+ },
506
+ },
507
+ armed ? props.confirmLabel || '删除' : props.label || '✕',
508
+ )
509
+ }
510
+
511
+ function Chevron() {
512
+ return h(
513
+ 'svg',
514
+ {
515
+ width: 10,
516
+ height: 10,
517
+ viewBox: '0 0 10 10',
518
+ 'aria-hidden': true,
519
+ style: { flex: '0 0 auto', opacity: 0.65 },
520
+ },
521
+ h('path', {
522
+ d: 'M2.5 4L5 6.5L7.5 4',
523
+ stroke: 'currentColor',
524
+ strokeWidth: 1.3,
525
+ fill: 'none',
526
+ strokeLinecap: 'round',
527
+ strokeLinejoin: 'round',
528
+ }),
529
+ )
530
+ }
531
+
532
+ /**
533
+ * 下拉选择器。
534
+ *
535
+ * `options` 是 `[{ value, label }]`。点外部或按 Esc 关闭:监听挂在文档上,
536
+ * 不依赖父级的点击处理,所以嵌在可点击的列表行里也不会误触。
537
+ */
538
+ function Picker(props) {
539
+ const [open, setOpen] = useState(false)
540
+ const [hover, setHover] = useState(null)
541
+ const boxRef = useRef(null)
542
+ const options = Array.isArray(props.options) ? props.options : []
543
+ let current = null
544
+ for (const option of options) {
545
+ if (option.value === props.value) current = option
546
+ }
547
+ const label = current === null ? props.placeholder || '' : current.label
548
+
549
+ useEffect(() => {
550
+ if (!open) return undefined
551
+ const onDown = (event) => {
552
+ const node = boxRef.current
553
+ if (node && node.contains(event.target)) return
554
+ setOpen(false)
555
+ }
556
+ const onKey = (event) => {
557
+ if (event.key === 'Escape') setOpen(false)
558
+ }
559
+ document.addEventListener('mousedown', onDown, true)
560
+ document.addEventListener('keydown', onKey, true)
561
+ return () => {
562
+ document.removeEventListener('mousedown', onDown, true)
563
+ document.removeEventListener('keydown', onKey, true)
564
+ }
565
+ }, [open])
566
+
567
+ return h(
568
+ 'div',
569
+ { ref: boxRef, style: { position: 'relative', minWidth: props.minWidth || '120px' } },
570
+ h(
571
+ 'button',
572
+ {
573
+ type: 'button',
574
+ style: { ...S.pickerTrigger, ...(props.triggerStyle || {}) },
575
+ title: props.title || label,
576
+ 'aria-haspopup': 'listbox',
577
+ 'aria-expanded': open,
578
+ onClick: (event) => {
579
+ event.stopPropagation()
580
+ setOpen(!open)
581
+ },
582
+ },
583
+ h('span', { style: S.pickerLabel }, label),
584
+ h(Chevron),
585
+ ),
586
+ open
587
+ ? h(
588
+ 'div',
589
+ { style: S.pickerMenu, role: 'listbox' },
590
+ options.map((option) =>
591
+ h(
592
+ 'div',
593
+ {
594
+ key: option.value,
595
+ role: 'option',
596
+ 'aria-selected': option.value === props.value,
597
+ style: S.pickerItem(option.value === props.value, hover === option.value),
598
+ onMouseEnter: () => setHover(option.value),
599
+ onMouseLeave: () => setHover(null),
600
+ onClick: (event) => {
601
+ event.stopPropagation()
602
+ setOpen(false)
603
+ props.onChange(option.value)
604
+ },
605
+ },
606
+ h('span', { style: S.pickerLabel }, option.label),
607
+ option.value === props.value ? h('span', { style: { opacity: 0.9 } }, '✓') : null,
608
+ ),
609
+ ),
610
+ )
611
+ : null,
612
+ )
613
+ }
614
+
615
+ // ---------- 宿主与求值 ----------
616
+
617
+ /**
618
+ * 取 `ctx.remote`。
619
+ *
620
+ * 服务是否在 `ctx` 上由 `inject` 决定:声明了才可能有值。但 `ctx.get` 对取不到
621
+ * 的已声明服务是**抛错**而不是返回 undefined,所以探测必须包 try/catch——
622
+ * 这一行漏了会让整个 client 模块加载失败,桌面端直接起不来。
623
+ * 取不到就退化成 `remote: null`,工作区清单只剩 extra 里的路径,其它功能照常。
624
+ */
625
+ function createHost(ctx) {
626
+ let remote = null
627
+ try {
628
+ remote = (ctx && ctx.remote) || null
629
+ } catch {
630
+ remote = null
631
+ }
632
+ if (remote === null || !remote.session) {
633
+ try {
634
+ const session = typeof ctx.get === 'function' ? ctx.get('remote.session') : undefined
635
+ if (session) remote = { session: session }
636
+ } catch {
637
+ // 取不到就保持 null。
638
+ }
639
+ }
640
+ return { ctx: ctx, remote: remote }
641
+ }
642
+
643
+ /**
644
+ * 工作区键归一,必须与服务端 `normalizeCwd` 逐字节一致:
645
+ * 反斜杠转正斜杠、去尾斜杠、Windows 下转小写、盘符根保留尾斜杠(`c:/`)。
646
+ * 这里只用它回读 `bindings`;发出去的 cwd 保持原样,由服务端自己归一。
647
+ */
648
+ function normalizeCwd(cwd) {
649
+ if (typeof cwd !== 'string') return null
650
+ const trimmed = cwd.trim()
651
+ if (trimmed === '') return null
652
+ let unified = trimmed.replace(/\\/gu, '/')
653
+ if (isWindows()) unified = unified.toLowerCase()
654
+ if (unified.length > 1) {
655
+ const stripped = unified.replace(/\/+$/u, '')
656
+ if (stripped === '') return '/'
657
+ return /^[a-z]:$/iu.test(stripped) ? stripped + '/' : stripped
658
+ }
659
+ return unified
660
+ }
661
+
662
+ function isWindows() {
663
+ try {
664
+ if (typeof process !== 'undefined' && process && typeof process.platform === 'string') {
665
+ return process.platform === 'win32'
666
+ }
667
+ } catch {
668
+ // 浏览器里没有 process。
669
+ }
670
+ try {
671
+ return typeof navigator !== 'undefined' && /windows/i.test(String(navigator.userAgent || ''))
672
+ } catch {
673
+ return false
674
+ }
675
+ }
676
+
677
+ /** 读绑定:先精确匹配;历史键大小写不同时再比一次小写。 */
678
+ function bindingOf(bindings, cwd) {
679
+ if (!bindings || !bindings.bindings) return null
680
+ const key = normalizeCwd(cwd)
681
+ if (key === null) return null
682
+ const exact = bindings.bindings[key]
683
+ if (typeof exact === 'string') return exact
684
+ const lower = key.toLowerCase()
685
+ for (const candidate of Object.keys(bindings.bindings)) {
686
+ if (candidate.toLowerCase() === lower) return bindings.bindings[candidate]
687
+ }
688
+ return null
689
+ }
690
+
691
+ function hasPack(packs, id) {
692
+ if (typeof id !== 'string') return false
693
+ for (const pack of packs || []) {
694
+ if (pack.id === id) return true
695
+ }
696
+ return false
697
+ }
698
+
699
+ function packById(packs, id) {
700
+ if (!Array.isArray(packs)) return null
701
+ for (const pack of packs) {
702
+ if (pack.id === id) return pack
703
+ }
704
+ return packs.length > 0 ? packs[0] : null
705
+ }
706
+
707
+ function defaultPackOf(packs) {
708
+ for (const pack of packs || []) {
709
+ if (pack.id === DEFAULT_PACK_ID) return pack
710
+ }
711
+ return null
712
+ }
713
+
714
+ /** 内置 pack 的显示名。数据里仍是 `default`,不写盘,也不参与重名校验。 */
715
+ function packLabel(pack) {
716
+ if (pack === null || pack === undefined) return ''
717
+ return pack.builtin === true || pack.id === DEFAULT_PACK_ID ? '默认' : pack.name
718
+ }
719
+
720
+ /** pack 里的项级显式值;没有键返回 null(跟随)。 */
721
+ function itemValue(pack, name) {
722
+ const value = pack && pack.items ? pack.items[name] : undefined
723
+ return typeof value === 'boolean' ? value : null
724
+ }
725
+
726
+ /** pack 里的组级显式值;没有键返回 null(跟随)。 */
727
+ function groupValue(pack, groupId) {
728
+ const value = pack && pack.groups ? pack.groups[groupId] : undefined
729
+ return typeof value === 'boolean' ? value : null
730
+ }
731
+
732
+ function groupIdOf(row) {
733
+ return row.groupId || null
734
+ }
735
+
736
+ /**
737
+ * 排序档位:显式开 0、显式关 1、跟随且落到开 2、跟随且落到关 3。
738
+ *
739
+ * 两种跟随在界面上长得一样,但一个等于开、一个等于关,混在同一档里
740
+ * 用户没法按状态扫读。
741
+ *
742
+ * `default` 没有上一层,它的「未记录」就等于开,所以直接归 0——否则同一屏会
743
+ * 出现「排在跟随档、按钮却亮在开上」的错位。
744
+ */
745
+ function rankOf(value, isDefault, inherit) {
746
+ if (value === true) return 0
747
+ if (value === false) return 1
748
+ if (isDefault) return 0
749
+ return inherit ? 2 : 3
750
+ }
751
+
752
+ function groupNameOf(groups, groupId) {
753
+ for (const group of groups || []) {
754
+ if (group.id === groupId) return group.name
755
+ }
756
+ return '未分组'
757
+ }
758
+
759
+ /**
760
+ * `ui` 段。
761
+ *
762
+ * 服务端一定有 `ui`;旧面板靠 `policy` 投影兜底的那条分支随兼容层一起删掉了。
763
+ */
764
+ function uiOf(view) {
765
+ if (!view) return null
766
+ if (view.ui) return view.ui
767
+ return { showOfficialSkills: false, showChip: true, defaultTab: 'groups' }
768
+ }
769
+
770
+ /**
771
+ * 已知工作区:从会话列表里取 cwd,排除子代理会话,按最近更新倒序去重,
772
+ * 当前会话所在目录置顶。取不到会话时返回空列表——面板仍能编辑 pack,
773
+ * 只是绑定块里只剩手动添加的路径。
774
+ */
775
+ async function listWorkspaceCwds(host, sessionId) {
776
+ const session = host && host.remote ? host.remote.session : null
777
+ if (!session || typeof session.list !== 'function') return []
778
+ let result
779
+ try {
780
+ result = await session.list({})
781
+ } catch {
782
+ return []
783
+ }
784
+ if (!result || result.ok !== true) return []
785
+ const items = result.value && Array.isArray(result.value.items) ? result.value.items : []
786
+ const roots = items.filter(
787
+ (item) => item && typeof item.cwd === 'string' && item.cwd !== '' && item.origin !== 'subagent',
788
+ )
789
+ let pinned = null
790
+ if (typeof sessionId === 'string' && sessionId !== '') {
791
+ for (const item of roots) {
792
+ const id = item.sessionId ?? item.id ?? (item.session && item.session.id)
793
+ if (id === sessionId) {
794
+ pinned = item.cwd
795
+ break
796
+ }
797
+ }
798
+ }
799
+ roots.sort((a, b) => (Number(b.updatedAt) || 0) - (Number(a.updatedAt) || 0))
800
+ const cwds = []
801
+ if (pinned !== null) cwds.push(pinned)
802
+ for (const item of roots) {
803
+ if (cwds.indexOf(item.cwd) < 0) cwds.push(item.cwd)
804
+ if (cwds.length >= MAX_WORKSPACES) break
805
+ }
806
+ return cwds
807
+ }
808
+
809
+ // ---------- 面板 ----------
810
+
811
+ function useGlobalView() {
812
+ const [state, setState] = useState({ loading: true, error: null, data: null })
813
+ // 单调序号:连点时多个请求同时在途,旧响应可能后到;只认最后一次。
814
+ const seq = useRef(0)
815
+
816
+ const load = useCallback(async () => {
817
+ const mine = (seq.current += 1)
818
+ try {
819
+ // 带当前会话:服务端用它的 scope 读清单,否则会退回到 list() 的第一个。
820
+ const data = await api.global(env.sessionId)
821
+ if (mine === seq.current) setState({ loading: false, error: null, data: data })
822
+ return data
823
+ } catch (error) {
824
+ if (mine === seq.current) {
825
+ setState({ loading: false, error: String(error.message || error), data: null })
826
+ }
827
+ return null
828
+ }
829
+ }, [])
830
+
831
+ useEffect(() => {
832
+ void load()
833
+ }, [load])
834
+
835
+ const run = useCallback(
836
+ async (commands) => {
837
+ const mine = (seq.current += 1)
838
+ try {
839
+ const data = await api.apply(commands)
840
+ if (mine === seq.current) {
841
+ setState({ loading: false, error: null, data: data })
842
+ // 绑定改了要把 chip 上的 pack 名一起更新:chip 直接读当前会话的工作区视图,
843
+ // 与设置页之间没有通道,只能在这里主动重读一次。
844
+ if (commands.some((command) => command.op === 'bind')) refreshChipName(env.sessionId)
845
+ }
846
+ return true
847
+ } catch (error) {
848
+ if (mine === seq.current) {
849
+ setState((prev) => ({ ...prev, error: String(error.message || error) }))
850
+ }
851
+ await load()
852
+ return false
853
+ }
854
+ },
855
+ [load],
856
+ )
857
+
858
+ return { state, run, reload: load }
859
+ }
860
+
861
+ /** 新建分组。组定义跨工作区共享,建一次处处可用。 */
862
+ function GroupCreator(props) {
863
+ const [name, setName] = useState('')
864
+ const submit = () => {
865
+ const trimmed = name.trim()
866
+ if (trimmed === '') return
867
+ props.onCreate(trimmed)
868
+ setName('')
869
+ }
870
+ return h(
871
+ 'div',
872
+ { style: { marginTop: '0', marginBottom: '8px', display: 'flex', gap: '8px', alignItems: 'center' } },
873
+ h('input', {
874
+ style: { ...S.input, flex: '0 0 200px' },
875
+ placeholder: '新分组名',
876
+ value: name,
877
+ onChange: (event) => setName(event.target.value),
878
+ onKeyDown: (event) => {
879
+ if (event.key === 'Enter') submit()
880
+ },
881
+ }),
882
+ h(
883
+ 'button',
884
+ {
885
+ type: 'button',
886
+ style: { ...S.segItem(false), border: '1px solid ' + T.border, borderRadius: '6px' },
887
+ onClick: submit,
888
+ },
889
+ '新建分组',
890
+ ),
891
+ h('span', { style: S.hint }, '分组对全部 pack 共享;这里改的是成员归属,不是开关。'),
892
+ )
893
+ }
894
+
895
+ // ---------- 弹层 ----------
896
+
897
+ /**
898
+ * 当前 pack 相对 default 的差异:分组在上,技能在下。
899
+ *
900
+ * 只列「记录过、且与 default 取值不同」的键。没有键是「不表态」,与 default
901
+ * 同值的记录不改变任何结果——两者都不算「差在哪」。
902
+ * 没绑 pack 时的生效值是 `default.items ?? default.groups[该技能的组] ?? true`。
903
+ */
904
+ function diffOf(data) {
905
+ const packs = Array.isArray(data.packs) ? data.packs : []
906
+ const pack = packById(packs, data.packId)
907
+ const fallback = defaultPackOf(packs)
908
+ const assignments = data.groups && data.groups.assignments ? data.groups.assignments : {}
909
+ const rows = []
910
+ // 组已从注册表删掉时没有名字可显示:那是悬空引用,不是差异。
911
+ const registry = (data.groups && data.groups.groups) || []
912
+ for (const group of registry) {
913
+ const own = groupValue(pack, group.id)
914
+ if (own === null) continue
915
+ if (own === (groupValue(fallback, group.id) ?? true)) continue
916
+ rows.push({ key: 'group:' + group.id, isGroup: true, name: group.name, on: own })
917
+ }
918
+ for (const skill of data.skills || []) {
919
+ const own = itemValue(pack, skill.name)
920
+ if (own === null) continue
921
+ const gid = assignments[skill.name] || null
922
+ const base = itemValue(fallback, skill.name) ?? (gid === null ? null : groupValue(fallback, gid))
923
+ if (own === (base ?? true)) continue
924
+ rows.push({ key: 'skill:' + skill.name, isGroup: false, name: skill.name, on: own })
925
+ }
926
+ return rows
927
+ }
928
+
929
+ /**
930
+ * chip 的弹层:这张 pack 与默认差在哪,就地切换。
931
+ *
932
+ * 选「默认」发 `packId: null`(解除绑定),不是写一个 default 的 id——
933
+ * 未绑定与悬空引用在服务端是同一种状态。
934
+ */
935
+ function ChipModal(props) {
936
+ const open = props.open === true
937
+ const sessionId = props.sessionId
938
+ const [state, setState] = useState({ loading: false, error: null, data: null })
939
+
940
+ useEffect(() => {
941
+ if (!open) return undefined
942
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined
943
+ let alive = true
944
+ setState((prev) => ({ loading: prev.data === null, error: null, data: prev.data }))
945
+ api.workspace(sessionId).then(
946
+ (data) => {
947
+ if (alive) setState({ loading: false, error: null, data: data })
948
+ },
949
+ (error) => {
950
+ if (alive) setState({ loading: false, error: String(error.message || error), data: null })
951
+ },
952
+ )
953
+ return () => {
954
+ alive = false
955
+ }
956
+ }, [open, sessionId])
957
+
958
+ if (!open) return null
959
+
960
+ const data = state.data
961
+ const bindTo = (value) => {
962
+ const cwd = data ? data.cwd : null
963
+ if (typeof cwd !== 'string' || cwd === '') return
964
+ // 带 sessionId 才拿得到 WorkspaceView(`binding` / `cwd`);不带只回 GlobalView。
965
+ api.apply([{ op: 'bind', cwd: cwd, packId: value === DEFAULT_PACK_ID ? null : value }], sessionId).then(
966
+ (view) => {
967
+ setState({ loading: false, error: null, data: view })
968
+ chipName.set(view && view.packId !== DEFAULT_PACK_ID ? view.packName : null)
969
+ },
970
+ (error) => setState((prev) => ({ ...prev, error: String(error.message || error) })),
971
+ )
972
+ }
973
+
974
+ // 生效的 pack 就是 default 时没有差异可言:整块只留一句说明。
975
+ const usingDefault = data !== null && (data.binding === null || data.packId === DEFAULT_PACK_ID)
976
+ const rows = data !== null && !usingDefault ? diffOf(data) : []
977
+ const bound =
978
+ data !== null && data.binding !== null && hasPack(data.packs, data.binding) ? data.binding : DEFAULT_PACK_ID
979
+
980
+ return h(
981
+ 'div',
982
+ { style: S.modalBack, onClick: props.onClose },
983
+ h(
984
+ 'div',
985
+ { style: S.modal, onClick: (event) => event.stopPropagation() },
986
+ // 插件名常驻在标题上方:这一排 chip 只靠图标区分,弹层里不写明是哪个插件,
987
+ // 从图标点进来的人会先愣一下。
988
+ h('div', { style: { ...S.hint, fontSize: '11px' } }, TITLE),
989
+ h(
990
+ 'div',
991
+ { style: { display: 'flex', alignItems: 'baseline', gap: '10px', marginTop: '2px' } },
992
+ h('div', { style: S.title }, data === null ? '读取中' : usingDefault ? '默认' : data.packName),
993
+ h('span', { style: { ...S.hint, marginLeft: 'auto' } }, '当前工作区'),
994
+ ),
995
+ state.error ? h('div', { style: { ...S.error, marginTop: '8px' } }, state.error) : null,
996
+ h(Warnings, { warnings: data === null ? null : data.warnings }),
997
+ data === null
998
+ ? h('div', { style: S.empty }, state.loading ? '加载中…' : '读不到当前工作区')
999
+ : h(
1000
+ 'div',
1001
+ { style: { marginTop: '10px' } },
1002
+ usingDefault
1003
+ ? h('div', { style: S.hint }, '这个目录没有指定 pack,按默认运行。')
1004
+ : h(
1005
+ 'div',
1006
+ null,
1007
+ h('div', { style: { ...S.hint, marginBottom: '4px' } }, '与默认相比'),
1008
+ rows.length === 0
1009
+ ? h('div', { style: S.hint }, '没有任何改动。')
1010
+ : rows.map((row) =>
1011
+ h(
1012
+ 'div',
1013
+ {
1014
+ key: row.key,
1015
+ style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '4px 0' },
1016
+ },
1017
+ h(
1018
+ 'span',
1019
+ {
1020
+ style: row.isGroup
1021
+ ? { ...S.rowMain, fontWeight: 600, color: T.text }
1022
+ : { ...S.rowMain, ...S.rowName },
1023
+ },
1024
+ row.name,
1025
+ ),
1026
+ h(
1027
+ 'span',
1028
+ { style: row.on ? S.badge : { ...S.badge, color: T.text } },
1029
+ row.on ? '开' : '关',
1030
+ ),
1031
+ ),
1032
+ ),
1033
+ ),
1034
+ h(
1035
+ 'div',
1036
+ { style: { ...S.hint, marginTop: '14px', marginBottom: '6px' } },
1037
+ '给这个目录换一张 pack',
1038
+ ),
1039
+ h(Picker, {
1040
+ value: bound,
1041
+ options: [
1042
+ { value: DEFAULT_PACK_ID, label: '默认' },
1043
+ ...data.packs
1044
+ .filter((pack) => pack.id !== DEFAULT_PACK_ID)
1045
+ .map((pack) => ({ value: pack.id, label: pack.name })),
1046
+ ],
1047
+ onChange: (next) => bindTo(next),
1048
+ }),
1049
+ h(
1050
+ 'div',
1051
+ { style: { ...S.hint, marginTop: '6px' } },
1052
+ '保存后,这个目录里已开着的会话也会在下一个回合边界重新求值。',
1053
+ ),
1054
+ ),
1055
+ h(
1056
+ 'div',
1057
+ { style: { display: 'flex', justifyContent: 'flex-end', marginTop: '14px' } },
1058
+ h(
1059
+ 'button',
1060
+ {
1061
+ type: 'button',
1062
+ style: { ...S.segItem(false), border: '1px solid ' + T.border, borderRadius: '6px' },
1063
+ onClick: props.onClose,
1064
+ },
1065
+ '关闭',
1066
+ ),
1067
+ ),
1068
+ ),
1069
+ )
1070
+ }
1071
+ /** chip 与设置页共用的图标;`size` 缺省 12,设置页会传 16。 */
1072
+ function ChipIcon(props) {
1073
+ const size = props && props.size ? props.size : 12
1074
+ return h(
1075
+ 'svg',
1076
+ {
1077
+ width: size,
1078
+ height: size,
1079
+ viewBox: '0 0 16 16',
1080
+ 'aria-hidden': true,
1081
+ style: { display: 'block' },
1082
+ },
1083
+ h('path', {
1084
+ d: 'M3 4h10M3 8h6M3 12h8',
1085
+ stroke: 'currentColor',
1086
+ strokeWidth: 1.6,
1087
+ strokeLinecap: 'round',
1088
+ fill: 'none',
1089
+ }),
1090
+ )
1091
+ }
1092
+
1093
+ // ---------- 设置页 ----------
1094
+
1095
+ function SettingRow(props) {
1096
+ return h(
1097
+ 'div',
1098
+ {
1099
+ style: {
1100
+ display: 'flex',
1101
+ alignItems: 'center',
1102
+ gap: '12px',
1103
+ padding: '10px 0',
1104
+ borderTop: '1px solid ' + T.border,
1105
+ },
1106
+ },
1107
+ h(
1108
+ 'div',
1109
+ { style: { flex: '1 1 auto', minWidth: 0 } },
1110
+ h('div', { style: { color: T.text, fontSize: '13px' } }, props.label),
1111
+ props.hint ? h('div', { style: { color: T.dim, fontSize: '11px', marginTop: '2px' } }, props.hint) : null,
1112
+ ),
1113
+ h(Switch, { on: props.on, onChange: props.onChange }),
1114
+ )
1115
+ }
1116
+
1117
+ /** 设置页的三页,顺序固定:分组 → 分包 → 工作区。 */
1118
+ const TABS = [
1119
+ { value: 'groups', label: '分组' },
1120
+ { value: 'packs', label: '分包' },
1121
+ { value: 'workspace', label: '工作区' },
1122
+ ]
1123
+
1124
+ /**
1125
+ * 设置 → Skill Pack。
1126
+ *
1127
+ * 三页:分组管结构(项与分组),分包管 pack 名册与包内开关,工作区管目录到
1128
+ * pack 的绑定。「当前页」与「默认页」是两件事:切页只改当前页,打开设置时落在
1129
+ * 哪一页由顶部那组单选写盘(`ui.defaultTab`)——两者混成一个状态时,
1130
+ * 「临时看一眼分包」会顺手改掉用户的默认页。
1131
+ */
1132
+ function SettingsPanel(props) {
1133
+ const host = props.host
1134
+ const { state, run } = useGlobalView()
1135
+ // 当前页只由分页按钮切;初值取 `ui.defaultTab`,之后不再跟随任何外部请求。
1136
+ const [tab, setTab] = useState(null)
1137
+
1138
+ const data = state.data
1139
+ const ui = uiOf(data)
1140
+ const defaultTab = ui ? ui.defaultTab : 'groups'
1141
+ const current = tab !== null ? tab : defaultTab
1142
+
1143
+ if (state.loading) return h('div', { style: S.hint }, '加载中…')
1144
+ if (!data) return h('div', { style: S.error }, state.error || '读取失败')
1145
+
1146
+ return h(
1147
+ 'div',
1148
+ { style: { fontSize: '13px', color: T.text } },
1149
+ h('div', { style: S.title }, TITLE),
1150
+ h(
1151
+ 'div',
1152
+ { style: { ...S.hint, marginTop: '4px' } },
1153
+ '一张 pack 是一套具名策略:工作区绑到哪个 pack,就按哪套开关生效;未绑定按默认。',
1154
+ ),
1155
+ h(
1156
+ 'div',
1157
+ { style: { ...S.bar, marginTop: '10px', marginBottom: '0' } },
1158
+ h('span', { style: S.hint }, '打开设置时默认显示'),
1159
+ h(
1160
+ 'div',
1161
+ { style: { flex: '0 0 auto', minWidth: '120px' } },
1162
+ h(Picker, {
1163
+ value: defaultTab,
1164
+ options: TABS.map((entry) => ({ value: entry.value, label: entry.label })),
1165
+ onChange: (next) => run([{ op: 'defaultTab', value: next }]),
1166
+ }),
1167
+ ),
1168
+ ),
1169
+ h(
1170
+ 'div',
1171
+ { style: { marginTop: '10px' } },
1172
+ h(Segmented, { value: current, onChange: setTab, options: TABS }),
1173
+ ),
1174
+ state.error ? h('div', { style: { ...S.error, marginTop: '8px' } }, state.error) : null,
1175
+ h(Warnings, { warnings: data.warnings }),
1176
+ // 「显示官方技能」只影响列表:分组页要它才看得见官方技能,分包页要靠它
1177
+ // 才能给官方技能设开关。工作区页是绑定表,与列不列官方项无关,因此不显示。
1178
+ current === 'workspace'
1179
+ ? null
1180
+ : h(SettingRow, {
1181
+ label: '显示官方技能',
1182
+ hint: '官方 @deepseek-ai 技能默认不上面板,但模型仍可调用;打开后可逐项关闭。',
1183
+ on: ui.showOfficialSkills === true,
1184
+ onChange: (value) => run([{ op: 'showOfficialSkills', value: value }]),
1185
+ }),
1186
+ current === 'groups'
1187
+ ? h(
1188
+ 'div',
1189
+ null,
1190
+ h(
1191
+ 'div',
1192
+ { style: { marginTop: '12px' } },
1193
+ h(SettingRow, {
1194
+ label: '在输入框右侧显示入口',
1195
+ hint: '关掉后会话输入框右侧不再出现 Skills chip;pack 绑定仍可在「工作区」页操作。立刻生效。',
1196
+ on: ui.showChip !== false,
1197
+ onChange: (value) =>
1198
+ run([{ op: 'showChip', value: value }]).then((ok) => {
1199
+ if (!ok) return
1200
+ if (env.onChipChange) env.onChipChange(value)
1201
+ }),
1202
+ }),
1203
+ ),
1204
+ h(GroupsPage, { data: data, run: run }),
1205
+ )
1206
+ : null,
1207
+ current === 'packs' ? h(PackSettings, { data: data, run: run }) : null,
1208
+ current === 'workspace' ? h(WorkspacePage, { data: data, run: run, host: host }) : null,
1209
+ )
1210
+ }
1211
+
1212
+ /**
1213
+ * 「分组」页:只做结构,不做策略。
1214
+ *
1215
+ * 按分组折叠展示成员,展开后在行内直接改归属;开关不在这里——它属于 pack,
1216
+ * 由「分包」页按 pack 设置。一页只做一件事,否则同一个开关会有两个入口。
1217
+ *
1218
+ * 顺序是「新建 → 搜索 → 列表」:新建入口在列表底下时,用户要滚到最底才看得见。
1219
+ * 搜索同时匹配分组名——搜「三维」多半是想找那一组,而不是名字里恰好含这两个
1220
+ * 字的项,所以组名命中时整组保留。
1221
+ */
1222
+ function GroupsPage(props) {
1223
+ const data = props.data
1224
+ const run = props.run
1225
+ const [query, setQuery] = useState('')
1226
+ const [collapsed, setCollapsed] = useState({})
1227
+ const [editing, setEditing] = useState(null)
1228
+ /** 批量归组的目标分组;`''` 表示未分组。 */
1229
+ const [bulkGroup, setBulkGroup] = useState('')
1230
+
1231
+ const groups = data.groups.groups
1232
+ const needle = query.trim().toLowerCase()
1233
+
1234
+ const buckets = useMemo(() => {
1235
+ const matchRow = (row) =>
1236
+ needle === '' ? true : (row.name + ' ' + row.description).toLowerCase().indexOf(needle) >= 0
1237
+ const byId = new Map()
1238
+ const loose = []
1239
+ for (const row of data.skills) {
1240
+ const gid = row.groupId
1241
+ if (gid === null || gid === undefined || gid === '' || !groups.some((group) => group.id === gid)) {
1242
+ if (matchRow(row)) loose.push(row)
1243
+ continue
1244
+ }
1245
+ if (!byId.has(gid)) byId.set(gid, [])
1246
+ byId.get(gid).push(row)
1247
+ }
1248
+ const byName = (a, b) => a.name.localeCompare(b.name, 'zh')
1249
+ // 未分组恒在最上:新装的项先落这里,用户第一眼要看到的就是它。
1250
+ const out = [{ id: null, name: '未分组', rows: loose.sort(byName) }]
1251
+ for (const group of [...groups].sort((a, b) => a.name.localeCompare(b.name, 'zh'))) {
1252
+ const members = byId.get(group.id) || []
1253
+ const groupHit = needle !== '' && group.name.toLowerCase().indexOf(needle) >= 0
1254
+ out.push({
1255
+ id: group.id,
1256
+ name: group.name,
1257
+ rows: (groupHit ? members : members.filter(matchRow)).sort(byName),
1258
+ })
1259
+ }
1260
+ return out
1261
+ }, [data.skills, groups, needle])
1262
+
1263
+ const searching = needle !== ''
1264
+ const total = buckets.reduce((sum, bucket) => sum + bucket.rows.length, 0)
1265
+ /** 当前筛选命中的项(跨分组拍平),批量归组的对象。 */
1266
+ const matched = buckets.reduce((list, bucket) => list.concat(bucket.rows), [])
1267
+ const assignTo = (row, groupId) =>
1268
+ run([{ op: 'assign', name: row.name, groupId: groupId === '' ? null : groupId }])
1269
+ // 一次 apply 带上 N 条 assign,只发一条请求:逐个发在几十项时会被乱序返回的
1270
+ // 响应来回覆盖。
1271
+ const assignAll = () => {
1272
+ if (matched.length === 0) return
1273
+ run(
1274
+ matched.map((row) => ({
1275
+ op: 'assign',
1276
+ name: row.name,
1277
+ groupId: bulkGroup === '' ? null : bulkGroup,
1278
+ })),
1279
+ )
1280
+ }
1281
+
1282
+ return h(
1283
+ 'div',
1284
+ { style: { marginTop: '12px' } },
1285
+ h(
1286
+ 'div',
1287
+ { style: { ...S.hint, marginBottom: '6px' } },
1288
+ '点分组展开成员,在行内直接改归属。开关不在这里——去「分包」页按 pack 设置。',
1289
+ ),
1290
+ h(GroupCreator, { onCreate: (name) => run([{ op: 'createGroup', name: name }]) }),
1291
+ h(
1292
+ 'div',
1293
+ { style: S.bar },
1294
+ h('input', {
1295
+ style: S.input,
1296
+ placeholder: '搜索名字、描述或分组名',
1297
+ value: query,
1298
+ onChange: (event) => setQuery(event.target.value),
1299
+ }),
1300
+ h('span', { style: S.hint }, total + ' 项'),
1301
+ ),
1302
+ h(
1303
+ 'div',
1304
+ { style: { ...S.bar, marginTop: '0' } },
1305
+ h('span', { style: S.hint }, '把当前 ' + matched.length + ' 项归到'),
1306
+ h(
1307
+ 'div',
1308
+ { style: { flex: '0 0 auto', minWidth: '130px' } },
1309
+ h(Picker, {
1310
+ value: bulkGroup,
1311
+ options: [
1312
+ { value: '', label: '未分组' },
1313
+ ...groups.map((group) => ({ value: group.id, label: group.name })),
1314
+ ],
1315
+ onChange: (next) => setBulkGroup(next),
1316
+ }),
1317
+ ),
1318
+ h(
1319
+ 'button',
1320
+ {
1321
+ type: 'button',
1322
+ disabled: matched.length === 0,
1323
+ style: {
1324
+ ...S.segItem(false),
1325
+ border: '1px solid ' + T.border,
1326
+ borderRadius: '6px',
1327
+ opacity: matched.length === 0 ? 0.45 : 1,
1328
+ cursor: matched.length === 0 ? 'not-allowed' : 'pointer',
1329
+ },
1330
+ onClick: assignAll,
1331
+ },
1332
+ '应用',
1333
+ ),
1334
+ ),
1335
+ buckets.map((bucket) => {
1336
+ const key = bucket.id === null ? '__loose' : bucket.id
1337
+ // 组一多,全展开会把列表拉成长条:未分组默认展开,其余默认折叠。
1338
+ // 搜索时按「这一组有没有命中」决定展开,否则要么摊出一片「(空)」,
1339
+ // 要么把命中藏起来。
1340
+ const isCollapsed = searching
1341
+ ? bucket.rows.length === 0
1342
+ : collapsed[key] === undefined
1343
+ ? bucket.id !== null
1344
+ : collapsed[key] === true
1345
+ return h(
1346
+ 'div',
1347
+ { key: key, style: S.group },
1348
+ h(
1349
+ 'div',
1350
+ { style: S.groupHead, onClick: () => setCollapsed({ ...collapsed, [key]: !isCollapsed }) },
1351
+ h('span', { style: { opacity: 0.5, width: '12px' } }, isCollapsed ? '▸' : '▾'),
1352
+ // 未分组的 id 就是 null,与「没有在改名」共享同一个值,所以这里必须
1353
+ // 先判 bucket.id !== null,否则未分组一进来就被渲染成输入框。
1354
+ bucket.id !== null && editing === bucket.id
1355
+ ? h('input', {
1356
+ autoFocus: true,
1357
+ defaultValue: bucket.name,
1358
+ style: S.groupInput,
1359
+ onClick: (event) => event.stopPropagation(),
1360
+ onBlur: (event) => {
1361
+ const trimmed = String(event.target.value || '').trim()
1362
+ setEditing(null)
1363
+ if (trimmed === '' || trimmed === bucket.name) return
1364
+ run([{ op: 'renameGroup', groupId: bucket.id, name: trimmed }])
1365
+ },
1366
+ onKeyDown: (event) => {
1367
+ if (event.key === 'Enter') event.target.blur()
1368
+ if (event.key === 'Escape') {
1369
+ event.target.value = bucket.name
1370
+ event.target.blur()
1371
+ }
1372
+ },
1373
+ })
1374
+ : h('span', { style: S.groupName }, bucket.name),
1375
+ h('span', { style: S.badge }, bucket.rows.length + ' 项'),
1376
+ bucket.id !== null && editing !== bucket.id
1377
+ ? h(
1378
+ 'button',
1379
+ {
1380
+ type: 'button',
1381
+ style: S.reset,
1382
+ title: '重命名分组',
1383
+ onClick: (event) => {
1384
+ event.stopPropagation()
1385
+ setEditing(bucket.id)
1386
+ },
1387
+ },
1388
+ '✎',
1389
+ )
1390
+ : null,
1391
+ bucket.id !== null
1392
+ ? h(ConfirmButton, {
1393
+ title: '删除分组(成员回到未分组)',
1394
+ onConfirm: () => run([{ op: 'deleteGroup', groupId: bucket.id }]),
1395
+ })
1396
+ : null,
1397
+ ),
1398
+ isCollapsed
1399
+ ? null
1400
+ : bucket.rows.length === 0
1401
+ ? h('div', { style: { ...S.empty, paddingLeft: '22px' } }, '(空)')
1402
+ : bucket.rows.map((row) =>
1403
+ h(
1404
+ 'div',
1405
+ { key: row.name, style: S.row },
1406
+ h(
1407
+ 'div',
1408
+ { style: S.rowMain },
1409
+ h(
1410
+ 'div',
1411
+ { style: S.rowName },
1412
+ row.name,
1413
+ row.origin ? h('span', { style: S.origin }, row.origin) : null,
1414
+ ),
1415
+ h('div', { style: S.rowDesc, title: row.description }, row.description),
1416
+ ),
1417
+ row.official ? h('span', { style: S.badge }, '官方') : null,
1418
+ h(
1419
+ 'div',
1420
+ { style: { flex: '0 0 auto', minWidth: '128px' } },
1421
+ h(Picker, {
1422
+ value: bucket.id === null ? '' : bucket.id,
1423
+ options: [
1424
+ { value: '', label: '未分组' },
1425
+ ...groups.map((group) => ({ value: group.id, label: group.name })),
1426
+ ],
1427
+ onChange: (next) => assignTo(row, next),
1428
+ }),
1429
+ ),
1430
+ ),
1431
+ ),
1432
+ )
1433
+ }),
1434
+ )
1435
+ }
1436
+
1437
+ /** 「分包」页:包名册 → 包内开关,自上而下两块。工作区绑定已独立成一页。 */
1438
+ function PackSettings(props) {
1439
+ const data = props.data
1440
+ const run = props.run
1441
+ const packs = data.packs
1442
+ const defaultPack = defaultPackOf(packs)
1443
+ const [selected, setSelected] = useState(DEFAULT_PACK_ID)
1444
+ const [query, setQuery] = useState('')
1445
+ const [newName, setNewName] = useState('')
1446
+ const [editing, setEditing] = useState(null)
1447
+ const pack = packById(packs, selected)
1448
+ /** `default` 没有上一层,未记录直接显示成「开」,排序也按开算。 */
1449
+ const isDefault = pack === null || pack.id === DEFAULT_PACK_ID
1450
+ /** 已注册遮蔽但复验仍可见:同层被别的插件先占了,这条关不掉。 */
1451
+ const stuck = new Set(Array.isArray(data.stuck) ? data.stuck : [])
1452
+
1453
+ const items = useMemo(() => {
1454
+ const needle = query.trim().toLowerCase()
1455
+ // 命中组名等于命中整组:在分包页搜「前端」应该把该组的所有项都筛出来,
1456
+ // 而不是因为项名里没有「前端」两个字就报没结果。
1457
+ const hitGroups = new Set()
1458
+ if (needle !== '') {
1459
+ for (const group of data.groups.groups) {
1460
+ if (group.name.toLowerCase().indexOf(needle) >= 0) hitGroups.add(group.id)
1461
+ }
1462
+ }
1463
+ const visible =
1464
+ needle === ''
1465
+ ? [...data.skills]
1466
+ : data.skills.filter((row) => {
1467
+ if ((row.name + ' ' + row.description).toLowerCase().indexOf(needle) >= 0) return true
1468
+ const gid = groupIdOf(row)
1469
+ return gid !== null && hitGroups.has(gid)
1470
+ })
1471
+ // 顺序是「开 > 关 > 跟随(开) > 跟随(关)」:显式决定过的排在前面,
1472
+ // 跟随项再按它实际会落到开还是关分档。同档按名字排。
1473
+ // 「跟随」会落到哪:选中 pack 的组记录优先,其余由宿主给的 default 链答案决定。
1474
+ const landing = (row) => groupValue(pack, groupIdOf(row)) ?? row.openGlobal
1475
+ return visible.sort((a, b) => {
1476
+ const left = rankOf(itemValue(pack, a.name), isDefault, landing(a))
1477
+ const right = rankOf(itemValue(pack, b.name), isDefault, landing(b))
1478
+ if (left !== right) return left - right
1479
+ return a.name.localeCompare(b.name, 'zh')
1480
+ })
1481
+ }, [data.skills, data.groups.groups, data.groups.assignments, query, pack, defaultPack, isDefault])
1482
+
1483
+ /** 分组同样「开 > 关 > 跟随(开) > 跟随(关)」。 */
1484
+ const orderedGroups = useMemo(() => {
1485
+ return [...data.groups.groups].sort((a, b) => {
1486
+ // 选中 pack 没有组记录时,落在 default 的组记录上;再没有就是开。
1487
+ const left = rankOf(groupValue(pack, a.id), isDefault, groupValue(defaultPack, a.id) ?? true)
1488
+ const right = rankOf(groupValue(pack, b.id), isDefault, groupValue(defaultPack, b.id) ?? true)
1489
+ if (left !== right) return left - right
1490
+ return a.name.localeCompare(b.name, 'zh')
1491
+ })
1492
+ }, [data.groups.groups, pack, isDefault])
1493
+
1494
+ const groupCounts = useMemo(() => {
1495
+ const counts = {}
1496
+ for (const row of data.skills) {
1497
+ const gid = groupIdOf(row)
1498
+ if (gid === null) continue
1499
+ counts[gid] = (counts[gid] || 0) + 1
1500
+ }
1501
+ return counts
1502
+ }, [data.skills])
1503
+
1504
+ if (pack === null) return h('div', { style: S.hint }, '没有可用的 pack')
1505
+
1506
+ const createPack = () => {
1507
+ const trimmed = newName.trim()
1508
+ if (trimmed === '') return
1509
+ run([{ op: 'createPack', name: trimmed }])
1510
+ setNewName('')
1511
+ }
1512
+ const overrides = (entry) => Object.keys(entry.items).length + Object.keys(entry.groups).length
1513
+
1514
+ return h(
1515
+ 'div',
1516
+ { style: { marginTop: '12px' } },
1517
+
1518
+ // 1. pack 列表
1519
+ h(
1520
+ 'div',
1521
+ { style: S.block },
1522
+ h('div', { style: S.blockTitle }, 'pack'),
1523
+ packs.map((entry) =>
1524
+ h(
1525
+ 'div',
1526
+ {
1527
+ key: entry.id,
1528
+ style: {
1529
+ ...S.packRow,
1530
+ borderColor: entry.id === pack.id ? T.borderStrong : T.border,
1531
+ background: entry.id === pack.id ? 'rgba(127, 127, 127, 0.16)' : 'transparent',
1532
+ },
1533
+ onClick: () => setSelected(entry.id),
1534
+ },
1535
+ editing === entry.id
1536
+ ? h('input', {
1537
+ autoFocus: true,
1538
+ defaultValue: entry.name,
1539
+ style: S.groupInput,
1540
+ onClick: (event) => event.stopPropagation(),
1541
+ onBlur: (event) => {
1542
+ const trimmed = String(event.target.value || '').trim()
1543
+ setEditing(null)
1544
+ if (trimmed === '' || trimmed === entry.name) return
1545
+ run([{ op: 'renamePack', packId: entry.id, name: trimmed }])
1546
+ },
1547
+ onKeyDown: (event) => {
1548
+ if (event.key === 'Enter') event.target.blur()
1549
+ if (event.key === 'Escape') {
1550
+ event.target.value = entry.name
1551
+ event.target.blur()
1552
+ }
1553
+ },
1554
+ })
1555
+ : h('span', { style: S.groupName }, packLabel(entry)),
1556
+ h('span', { style: S.badge }, overrides(entry) + ' 条覆盖'),
1557
+ // `default` 没有改名与删除入口:它是求值链终点,删掉或改个名都会让
1558
+ // 「未绑定即默认」失去指代。
1559
+ entry.builtin
1560
+ ? null
1561
+ : h(
1562
+ 'button',
1563
+ {
1564
+ type: 'button',
1565
+ style: S.reset,
1566
+ title: '重命名 pack',
1567
+ onClick: (event) => {
1568
+ event.stopPropagation()
1569
+ setEditing(entry.id)
1570
+ },
1571
+ },
1572
+ '✎',
1573
+ ),
1574
+ entry.builtin
1575
+ ? null
1576
+ : h(ConfirmButton, {
1577
+ title: '删除 pack(指向它的绑定会变成悬空引用,按默认求值)',
1578
+ onConfirm: () => {
1579
+ run([{ op: 'deletePack', packId: entry.id }])
1580
+ if (entry.id === selected) setSelected(DEFAULT_PACK_ID)
1581
+ },
1582
+ }),
1583
+ ),
1584
+ ),
1585
+ h(
1586
+ 'div',
1587
+ { style: { display: 'flex', gap: '8px', marginTop: '8px', alignItems: 'center' } },
1588
+ h('input', {
1589
+ style: { ...S.input, flex: '0 0 200px' },
1590
+ placeholder: '新 pack 名',
1591
+ value: newName,
1592
+ onChange: (event) => setNewName(event.target.value),
1593
+ onKeyDown: (event) => {
1594
+ if (event.key === 'Enter') createPack()
1595
+ },
1596
+ }),
1597
+ h(
1598
+ 'button',
1599
+ {
1600
+ type: 'button',
1601
+ style: { ...S.segItem(false), border: '1px solid ' + T.border, borderRadius: '6px' },
1602
+ onClick: createPack,
1603
+ },
1604
+ '新建 pack',
1605
+ ),
1606
+ ),
1607
+ ),
1608
+
1609
+ // 2. 包内开关
1610
+ h(
1611
+ 'div',
1612
+ { style: S.block },
1613
+ h('div', { style: S.blockTitle }, '包内开关:' + packLabel(pack)),
1614
+ h(
1615
+ 'div',
1616
+ { style: { ...S.hint, marginBottom: '8px' } },
1617
+ '每个开关三态:开 / 关 / 跟随。「跟随」是独立的第三个选项——显式关掉与「这张 pack 不表态」是两件事。',
1618
+ ),
1619
+ data.groups.groups.length === 0
1620
+ ? h('div', { style: S.hint }, '还没有分组。到「分组」页新建,或直接在下面逐项设置。')
1621
+ : orderedGroups.map((group) => {
1622
+ const value = groupValue(pack, group.id)
1623
+ const inherited = groupValue(defaultPack, group.id) ?? true
1624
+ return h(
1625
+ 'div',
1626
+ {
1627
+ key: group.id,
1628
+ style: {
1629
+ display: 'flex',
1630
+ alignItems: 'center',
1631
+ gap: '10px',
1632
+ padding: '4px 0',
1633
+ borderTop: '1px solid ' + T.border,
1634
+ },
1635
+ },
1636
+ h('span', { style: { ...S.groupName, flex: '1 1 auto' } }, group.name),
1637
+ h('span', { style: S.badge }, (groupCounts[group.id] || 0) + ' 项'),
1638
+ value === null && !isDefault
1639
+ ? h('span', { style: { ...S.hint, whiteSpace: 'nowrap' } }, inherited ? '跟随(开)' : '跟随(关)')
1640
+ : null,
1641
+ h(TriState, {
1642
+ value: value,
1643
+ allowInherit: !isDefault,
1644
+ onChange: (next) => run([{ op: 'packGroup', packId: pack.id, groupId: group.id, value: next }]),
1645
+ }),
1646
+ )
1647
+ }),
1648
+ h('div', { style: { ...S.bar, marginTop: '10px' } },
1649
+ h('input', {
1650
+ style: S.input,
1651
+ placeholder: '搜索名字或描述',
1652
+ value: query,
1653
+ onChange: (event) => setQuery(event.target.value),
1654
+ }),
1655
+ h('span', { style: S.hint }, items.length + ' 项'),
1656
+ ),
1657
+ items.length === 0
1658
+ ? h('div', { style: S.empty }, '没有匹配的项')
1659
+ : items.map((row) => {
1660
+ const value = itemValue(pack, row.name)
1661
+ const gid = groupIdOf(row)
1662
+ const inherited = groupValue(pack, gid) ?? row.openGlobal
1663
+ return h(
1664
+ 'div',
1665
+ {
1666
+ key: row.name,
1667
+ style: {
1668
+ display: 'flex',
1669
+ alignItems: 'center',
1670
+ gap: '10px',
1671
+ padding: '4px 0',
1672
+ borderTop: '1px solid ' + T.border,
1673
+ },
1674
+ },
1675
+ h(
1676
+ 'div',
1677
+ { style: S.rowMain },
1678
+ h('div', { style: S.rowName }, row.name, row.origin
1679
+ ? h('span', { style: S.origin }, row.origin)
1680
+ : null),
1681
+ h('div', { style: S.rowDesc, title: row.description }, row.description),
1682
+ ),
1683
+ h('span', { style: S.badge }, gid === null ? '未分组' : groupNameOf(data.groups.groups, gid)),
1684
+ stuck.has(row.name)
1685
+ ? h(
1686
+ 'span',
1687
+ {
1688
+ style: { ...S.badge, color: T.error, borderColor: T.error },
1689
+ title: '同层已被别的插件先注册,关闭不生效',
1690
+ },
1691
+ '关不掉',
1692
+ )
1693
+ : null,
1694
+ row.open === null
1695
+ ? h(
1696
+ 'span',
1697
+ {
1698
+ style: { ...S.badge, color: T.dim },
1699
+ title: '清单不完整:这一行此刻没有生效状态,改动会在清单补齐后生效',
1700
+ },
1701
+ '未生效',
1702
+ )
1703
+ : null,
1704
+ value === null && !isDefault
1705
+ ? h('span', { style: { ...S.hint, whiteSpace: 'nowrap' } }, inherited ? '跟随(开)' : '跟随(关)')
1706
+ : null,
1707
+ h(TriState, {
1708
+ value: value,
1709
+ allowInherit: !isDefault,
1710
+ onChange: (next) => run([{ op: 'packItem', packId: pack.id, name: row.name, value: next }]),
1711
+ }),
1712
+ )
1713
+ }),
1714
+ ),
1715
+ )
1716
+ }
1717
+
1718
+ /**
1719
+ * 「工作区」页:会话 cwd 去重 + 手动添加的路径,每行一个 pack 下拉。
1720
+ *
1721
+ * 顺序是「说明 → 手动添加 → 列表」:手动添加是入口,放到列表底下等于没有。
1722
+ * 绑定保存时就会对所有 live 会话重新求值(`reapply`),不是只影响新会话:
1723
+ * 每个 Agent 按自己的 cwd 重新读绑定,改动落在下一个回合边界。
1724
+ */
1725
+ function WorkspacePage(props) {
1726
+ const data = props.data
1727
+ const run = props.run
1728
+ const host = props.host
1729
+ const [cwds, setCwds] = useState([])
1730
+ const [settled, setSettled] = useState(false)
1731
+ const [manual, setManual] = useState('')
1732
+
1733
+ useEffect(() => {
1734
+ let alive = true
1735
+ setSettled(false)
1736
+ listWorkspaceCwds(host, env.sessionId).then(
1737
+ (list) => {
1738
+ if (!alive) return
1739
+ setCwds(list)
1740
+ setSettled(true)
1741
+ },
1742
+ () => {
1743
+ if (!alive) return
1744
+ setCwds([])
1745
+ setSettled(true)
1746
+ },
1747
+ )
1748
+ return () => {
1749
+ alive = false
1750
+ }
1751
+ }, [host])
1752
+
1753
+ const extras = data.bindings && Array.isArray(data.bindings.extra) ? data.bindings.extra : []
1754
+ const paths = []
1755
+ const seen = {}
1756
+ for (const cwd of [...cwds, ...extras]) {
1757
+ if (typeof cwd !== 'string' || cwd === '') continue
1758
+ const key = normalizeCwd(cwd)
1759
+ if (key === null || seen[key] === true) continue
1760
+ seen[key] = true
1761
+ paths.push(cwd)
1762
+ }
1763
+
1764
+ const addManual = () => {
1765
+ const trimmed = manual.trim()
1766
+ if (trimmed === '') return
1767
+ run([{ op: 'addExtra', cwd: trimmed }])
1768
+ setManual('')
1769
+ }
1770
+
1771
+ return h(
1772
+ 'div',
1773
+ { style: { marginTop: '12px' } },
1774
+ h(
1775
+ 'div',
1776
+ { style: { ...S.hint, marginBottom: '8px' } },
1777
+ '保存后立即生效:该目录下已开着的会话也在下一个回合边界重新求值。未绑定即默认。',
1778
+ ),
1779
+ h(
1780
+ 'div',
1781
+ { style: { display: 'flex', gap: '8px', marginBottom: '8px', alignItems: 'center' } },
1782
+ h('input', {
1783
+ style: S.input,
1784
+ placeholder: '手动添加路径,例如 D:/work/repo',
1785
+ value: manual,
1786
+ onChange: (event) => setManual(event.target.value),
1787
+ onKeyDown: (event) => {
1788
+ if (event.key === 'Enter') addManual()
1789
+ },
1790
+ }),
1791
+ h(
1792
+ 'button',
1793
+ {
1794
+ type: 'button',
1795
+ style: { ...S.segItem(false), border: '1px solid ' + T.border, borderRadius: '6px' },
1796
+ onClick: addManual,
1797
+ },
1798
+ '添加',
1799
+ ),
1800
+ ),
1801
+ !settled
1802
+ ? h('div', { style: S.hint }, '读取会话列表…')
1803
+ : paths.length === 0
1804
+ ? h('div', { style: S.empty }, '没有可绑定的工作区。用上面的输入框手动添加。')
1805
+ : paths.map((cwd) => {
1806
+ const isExtra = extras.indexOf(cwd) >= 0
1807
+ const bound = bindingOf(data.bindings, cwd)
1808
+ // 内置 default 与「未绑定」等价(服务端把 bind default 当解绑):两者都显示成
1809
+ // 「默认」,否则下拉里会出现两个同名的默认项。悬空引用同样落到「默认」。
1810
+ const value =
1811
+ bound === null || bound === DEFAULT_PACK_ID || !hasPack(data.packs, bound) ? '' : bound
1812
+ return h(
1813
+ 'div',
1814
+ {
1815
+ key: cwd,
1816
+ style: {
1817
+ display: 'flex',
1818
+ alignItems: 'center',
1819
+ gap: '10px',
1820
+ padding: '6px 0',
1821
+ borderTop: '1px solid ' + T.border,
1822
+ },
1823
+ },
1824
+ h(
1825
+ 'div',
1826
+ { style: { flex: '1 1 auto', minWidth: 0 } },
1827
+ h('div', { style: S.path, title: cwd }, cwd),
1828
+ h('div', { style: S.rowDesc }, isExtra ? '手动添加' : '来自会话'),
1829
+ ),
1830
+ h(
1831
+ 'div',
1832
+ { style: { flex: '0 0 auto', minWidth: '150px' } },
1833
+ h(Picker, {
1834
+ value: value === '' ? '' : value,
1835
+ options: [
1836
+ { value: '', label: '默认' },
1837
+ // 内置 default 与「默认」等价(服务端把 bind default 当解绑),
1838
+ // 列出来只会让人以为有两个不同的选择。
1839
+ ...data.packs
1840
+ .filter((pack) => pack.id !== DEFAULT_PACK_ID)
1841
+ .map((pack) => ({ value: pack.id, label: pack.name })),
1842
+ ],
1843
+ onChange: (next) => run([{ op: 'bind', cwd: cwd, packId: next === '' ? null : next }]),
1844
+ }),
1845
+ ),
1846
+ isExtra
1847
+ ? h(ConfirmButton, {
1848
+ title: '从手动清单移除(不影响已有绑定)',
1849
+ onConfirm: () => run([{ op: 'removeExtra', cwd: cwd }]),
1850
+ })
1851
+ : null,
1852
+ )
1853
+ }),
1854
+ )
1855
+ }
1856
+
1857
+ // ---------- 注册 ----------
1858
+
1859
+ /** chip 上的 pack 名:绑到哪个 pack 就显示哪个,未绑定显示「默认」/`Default`。 */
1860
+ function refreshChipName(sessionId) {
1861
+ if (typeof sessionId !== 'string' || sessionId === '') {
1862
+ chipName.set(null)
1863
+ return
1864
+ }
1865
+ api.workspace(sessionId).then(
1866
+ (view) => {
1867
+ chipName.set(view && view.packId !== DEFAULT_PACK_ID ? view.packName : null)
1868
+ },
1869
+ () => {
1870
+ // 读不到就退回 Default:chip 本身不能因此消失。
1871
+ chipName.set(null)
1872
+ },
1873
+ )
1874
+ }
1875
+
1876
+ return {
1877
+ // `remote` 与 `remote.session` 都是独立服务名:只声明 `remote` 拿不到 session。
1878
+ // 声明是「服务在不在 ctx 上」的唯一开关;取用仍要 try/catch(ctx.get 对取不到的
1879
+ // 已声明服务会抛错)。缺了这两个,工作区清单只剩手动添加的路径。
1880
+ // `locale` 只用来判断 chip 的缺省文案该写「默认」还是 Default。
1881
+ inject: ['slots', 'remote', 'remote.session', 'locale'],
1882
+ apply(ctx) {
1883
+ // 兜底:client 模块的 apply 抛错会把 web boot 判成 entry failed,桌面端整个起不来。
1884
+ // 任何意外都只降级成「面板不出来」,绝不连坐宿主。
1885
+ try {
1886
+ const host = createHost(ctx)
1887
+
1888
+ // chip 的缺省文案随宿主语言走。只读一次:语言切换后刷新页面生效——
1889
+ // 为一个词多挂一条 locale/change 生命周期不值得。
1890
+ try {
1891
+ const locale = typeof ctx.get === 'function' ? ctx.get('locale') : null
1892
+ const snapshot = locale && typeof locale.getSnapshot === 'function' ? locale.getSnapshot() : null
1893
+ const active = snapshot && typeof snapshot.active === 'string' ? snapshot.active : ''
1894
+ chipFallback.set(/^zh/i.test(active) ? '默认' : 'Default')
1895
+ } catch {
1896
+ // 取不到就保持 Default,chip 照常显示。
1897
+ }
1898
+
1899
+ // 组件必须是稳定标识符:内联箭头每次渲染都是新类型,React 会整棵卸载重挂
1900
+ // (AGENTS.md 客户端红线 4)。apply 只跑一次,闭包里拿 host 是安全的。
1901
+ const SettingsSection = () => h(SettingsPanel, { host: host })
1902
+ // 设置页侧栏的图标:宿主给 `settings.section.icon` 留了槽位,按 section 的 id
1903
+ // 匹配。不注册就落到 `navIcon(id)` 的通用齿轮上,那一排分不清谁是谁。
1904
+ const SettingsIcon = (props) => h(ChipIcon, { size: props && props.size ? props.size : 16 })
1905
+ ctx.slots.inject('settings.section', () =>
1906
+ ctx.slots.register(
1907
+ { name: 'settings.section', id: PANEL_ID, order: 60, label: () => TITLE },
1908
+ SettingsSection,
1909
+ ),
1910
+ )
1911
+ ctx.slots.inject('settings.section.icon', () =>
1912
+ ctx.slots.register({ name: 'settings.section.icon', id: PANEL_ID }, SettingsIcon),
1913
+ )
1914
+
1915
+ // ---------- chip ----------
1916
+
1917
+ let chipOpen = false
1918
+ const chipListeners = new Set()
1919
+ const notifyChip = () => {
1920
+ for (const listener of chipListeners) listener()
1921
+ }
1922
+ const subscribeChip = (listener) => {
1923
+ chipListeners.add(listener)
1924
+ return () => chipListeners.delete(listener)
1925
+ }
1926
+ const readChipOpen = () => chipOpen
1927
+
1928
+ const SkillChip = (props) => {
1929
+ const sessionId = props && props.sessionId ? props.sessionId : undefined
1930
+ env.sessionId = sessionId
1931
+ const name = useSyncExternalStore(chipName.subscribe, chipName.read, chipName.read)
1932
+ const isOpen = useSyncExternalStore(subscribeChip, readChipOpen, readChipOpen)
1933
+ const fallback = useSyncExternalStore(chipFallback.subscribe, chipFallback.read, chipFallback.read)
1934
+ useEffect(() => {
1935
+ refreshChipName(sessionId)
1936
+ }, [sessionId])
1937
+ const label = name || fallback
1938
+ return h(
1939
+ React.Fragment,
1940
+ null,
1941
+ // 容器查询的规则随组件一起挂载:没有 `container-type` 的祖先时它不生效,
1942
+ // 结果就是一直显示文字——降级方向是「多显示」,不是「永远只有图标」。
1943
+ h('style', { key: 'chip-css' }, CHIP_CSS),
1944
+ h(
1945
+ 'button',
1946
+ {
1947
+ type: 'button',
1948
+ style: S.chip,
1949
+ title: '当前工作区绑定的 skill pack:' + label,
1950
+ onClick: () => {
1951
+ chipOpen = !isOpen
1952
+ notifyChip()
1953
+ },
1954
+ },
1955
+ h(ChipIcon),
1956
+ h('span', { key: 'chip-text', className: 'dsh-skill-pack-chip-text' }, label),
1957
+ ),
1958
+ )
1959
+ }
1960
+
1961
+ const ChipOverlay = () => {
1962
+ const isOpen = useSyncExternalStore(subscribeChip, readChipOpen, readChipOpen)
1963
+ return h(ChipModal, {
1964
+ open: isOpen,
1965
+ host: host,
1966
+ sessionId: env.sessionId,
1967
+ onClose: () => {
1968
+ chipOpen = false
1969
+ notifyChip()
1970
+ },
1971
+ })
1972
+ }
1973
+
1974
+ /**
1975
+ * `ui.showChip === false` 时不注册这个槽位。
1976
+ * 记住 disposer:拨回去要真的重挂,不能只读一次配置。
1977
+ */
1978
+ let chipDisposers = []
1979
+ const unmountChip = () => {
1980
+ for (const dispose of chipDisposers) {
1981
+ try {
1982
+ dispose()
1983
+ } catch {
1984
+ // 槽位可能已随视图卸载,忽略。
1985
+ }
1986
+ }
1987
+ chipDisposers = []
1988
+ }
1989
+ const registerChip = () => {
1990
+ if (chipDisposers.length > 0) return
1991
+ chipDisposers.push(
1992
+ ctx.slots.inject('conversation.input.right', () =>
1993
+ ctx.slots.register({ name: 'conversation.input.right', id: CHIP_ID, order: 42 }, SkillChip),
1994
+ ),
1995
+ )
1996
+ chipDisposers.push(
1997
+ ctx.slots.inject('shell.overlay', () =>
1998
+ ctx.slots.register({ name: 'shell.overlay', id: MODAL_ID, order: 60 }, ChipOverlay),
1999
+ ),
2000
+ )
2001
+ }
2002
+ env.onChipChange = (visible) => {
2003
+ if (visible) registerChip()
2004
+ else unmountChip()
2005
+ }
2006
+
2007
+ // 读一次 UI 开关决定挂不挂 chip。失败按缺省(显示)处理,不让入口整体缺失。
2008
+ api.global().then(
2009
+ (view) => {
2010
+ const ui = uiOf(view)
2011
+ const chip = !ui || ui.showChip !== false
2012
+ env.onChipChange(chip)
2013
+ },
2014
+ () => {
2015
+ env.onChipChange(true)
2016
+ },
2017
+ )
2018
+ } catch (error) {
2019
+ console.error('dsh-skill-pack: 客户端初始化失败', error)
2020
+ }
2021
+ },
2022
+ }
2023
+ },
2024
+ })