@weibaohui/experts-management 0.1.4 → 0.3.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.
package/client/bundle.js CHANGED
@@ -8,17 +8,124 @@ window.__ModuleLoader__.load({
8
8
  var exports = module.exports
9
9
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" })
10
10
  var React = require("react")
11
+ /**
12
+ * @weibaohui/dsh-plugin-kit — client source(由消费者构建脚本内联进 bundle,
13
+ * 不经 loader 运行时加载)。对外暴露 PluginKit:
14
+ *
15
+ * PluginKit.substituteParams(template, params) — {{key}} 模板插值
16
+ * PluginKit.makeActionShareDialog(React, opts) — 返回 ActionShareDialog 组件
17
+ *
18
+ * ActionShareDialog props:
19
+ * title / hint / rows: [[label, value], ...] / initialPrompt
20
+ * run: async (prompt) => { jobId } — 发起执行
21
+ * poll: async (jobId) => { status, output, code }
22
+ * labels: { copy, copied, run, running, done, failed, outputLabel, close }
23
+ * onClose
24
+ *
25
+ * 全部样式内联(主题 token + 回退值),消费者无需自带 CSS。
26
+ */
27
+ var PluginKit = (function () {
28
+ function substituteParams(template, params) {
29
+ var out = String(template || '')
30
+ for (var key in (params || {})) out = out.split('{{' + key + '}}').join(String(params[key]))
31
+ return out
32
+ }
33
+
34
+ function makeActionShareDialog(React, options) {
35
+ options = options || {}
36
+ var h = React.createElement
37
+ var useState = React.useState
38
+ var useEffect = React.useEffect
39
+ var doFetch = options.fetch || (typeof fetch !== 'undefined' ? fetch : null)
40
+ var inputStyle = { width: '100%', minHeight: 190, resize: 'vertical', fontFamily: 'var(--dsw-font-family)', lineHeight: 1.6, fontSize: 12, background: 'var(--dsw-alias-bg-layer-2,transparent)', color: 'inherit', border: '1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3))', borderRadius: '8px', padding: '10px', boxSizing: 'border-box' }
41
+ var btnStyle = { background: 'transparent', color: 'inherit', border: '1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3))', borderRadius: '8px', padding: '5px 12px', fontSize: 13, cursor: 'pointer', font: 'inherit' }
42
+ var primaryStyle = Object.assign({}, btnStyle, { background: 'var(--dsw-alias-brand-primary,#4a7dff)', borderColor: 'var(--dsw-alias-brand-primary,#4a7dff)', color: '#fff' })
43
+
44
+ return function ActionShareDialog(props) {
45
+ var title = props.title
46
+ var hint = props.hint
47
+ var labels = props.labels || {}
48
+ var _p = useState(props.initialPrompt || '')
49
+ var prompt = _p[0]; var setPrompt = _p[1]
50
+ var _j = useState(null)
51
+ var job = _j[0]; var setJob = _j[1]
52
+ var _b = useState(false)
53
+ var busy = _b[0]; var setBusy = _b[1]
54
+ var _c = useState(false)
55
+ var copied = _c[0]; var setCopied = _c[1]
56
+ var _e = useState('')
57
+ var error = _e[0]; var setError = _e[1]
58
+ var _d = useState(false)
59
+ var dirty = _d[0]; var setDirty = _d[1]
60
+
61
+ // initialPrompt 异步到位(如宿主先要下发真实路径)时跟随刷新;用户编辑过则不打断
62
+ useEffect(function () {
63
+ if (!dirty) setPrompt(props.initialPrompt || '')
64
+ }, [props.initialPrompt])
65
+
66
+ useEffect(function () {
67
+ if (job === null || job.status !== 'running' || typeof props.poll !== 'function') return
68
+ var timer = setInterval(function () {
69
+ props.poll(job.jobId).then(function (d) {
70
+ setJob({ jobId: job.jobId, status: d.status, output: d.output || '', code: d.code !== undefined ? d.code : null, sessionId: d.sessionId })
71
+ }).catch(function () {})
72
+ }, 1500)
73
+ return function () { clearInterval(timer) }
74
+ }, [job !== null && job.jobId])
75
+
76
+ var doRun = function () {
77
+ if (typeof props.run !== 'function') return
78
+ setBusy(true); setError('')
79
+ props.run(prompt).then(function (r) {
80
+ setJob({ jobId: r.jobId, status: 'running', output: '', code: null })
81
+ }).catch(function (e) { setError(String(e && e.message)) }).finally(function () { setBusy(false) })
82
+ }
83
+ var copy = function () {
84
+ if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
85
+ navigator.clipboard.writeText(prompt).then(function () { setCopied(true); setTimeout(function () { setCopied(false) }, 1500) }).catch(function () {})
86
+ }
87
+ }
88
+ var statusText = job === null ? '' : job.status === 'running' ? (labels.running || 'running') : job.status === 'done' ? (labels.done || 'done') : (labels.failed || 'failed') + (job.code != null ? ' (' + job.code + ')' : '')
89
+
90
+ return h('div', { onClick: function (e) { if (e.target === e.currentTarget && props.onClose) props.onClose() }, style: { position: 'fixed', inset: 0, zIndex: 2147483000, background: 'rgba(0,0,0,.45)', display: 'flex', alignItems: 'center', justifyContent: 'center' } },
91
+ h('div', { style: { width: 'min(640px,92vw)', maxHeight: '86vh', overflow: 'auto', background: 'var(--dsw-alias-bg-layer-1,#fff)', border: '1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3))', borderRadius: '16px', padding: '20px', display: 'flex', flexDirection: 'column', gap: 12, color: 'var(--dsw-alias-label-primary,inherit)', font: 'var(--dsw-font-family,inherit)' } },
92
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 10 } },
93
+ h('div', { style: { fontSize: 17, fontWeight: 600 } }, title || ''),
94
+ h('button', { onClick: props.onClose, style: Object.assign({}, btnStyle, { marginLeft: 'auto', width: 28, height: 28, padding: 0, borderRadius: 28 }) }, '✕')),
95
+ hint ? h('div', { style: { fontSize: 12, opacity: .7 } }, hint) : null,
96
+ (props.rows || []).length > 0 ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 13 } },
97
+ props.rows.map(function (r, i) {
98
+ return r[1] ? h('div', { key: i }, h('b', null, r[0] + ':'), h('span', null, r[1])) : null
99
+ })) : null,
100
+ h('textarea', { value: prompt, onChange: function (e) { setDirty(true); setPrompt(e.target.value) }, spellCheck: false, style: inputStyle }),
101
+ error !== '' ? h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-state-error,#c75050)' } }, error) : null,
102
+ job !== null ? h('div', null,
103
+ h('div', { style: { fontSize: 12, opacity: .7, margin: '4px 0' } }, (labels.outputLabel || 'Output') + ' · ' + statusText),
104
+ h('pre', { style: { maxHeight: 220, margin: 0, overflow: 'auto', whiteSpace: 'pre-wrap', fontSize: 12, background: 'var(--dsw-alias-bg-layer-2,transparent)', border: '1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.2))', borderRadius: '8px', padding: '8px' } }, job.output || '…')) : null,
105
+ h('div', { style: { display: 'flex', gap: 8 } },
106
+ h('button', { onClick: copy, style: btnStyle }, copied ? (labels.copied || 'Copied') : (labels.copy || 'Copy')),
107
+ h('button', { onClick: doRun, disabled: busy || (job !== null && job.status === 'running'), style: primaryStyle }, job !== null && job.status === 'running' ? (labels.running || 'Running…') : (labels.run || 'Run')))))
108
+ }
109
+ }
110
+
111
+ return { substituteParams: substituteParams, makeActionShareDialog: makeActionShareDialog }
112
+ })()
113
+
11
114
  /**
12
115
  * dsh-plugin-experts-management - Browser half.
13
116
  *
14
- * One React app for every surface (sidebar overlay + settings section):
15
- * expert management page (installed / market views + detail modal + market
117
+ * One React app for every surface (settings section; the former sidebar
118
+ * fullscreen-overlay entry was retired pair with dsh-settings-ui):
119
+ * expert management page (mine / built-in views + detail modal + built-in
16
120
  * sync settings). Plus the composer integration:
17
121
  * - an `expert` input-trigger source on `/` (candidates from the plugin's own
18
122
  * HTTP API; pick inserts the literal `/expert-<name> ` token whose send the
19
123
  * host's user-explicit gesture boundary turns into the expert prompt), and
20
- * - a `+专家` button in the composer tool row (`conversation.input.left`)
21
- * that opens exactly that source via the per-session `toggleSource`.
124
+ * - a `+ 专家` button in the composer tool row (`conversation.input.left`)
125
+ * that opens the plugin's own searchable picker popover (the host slash menu
126
+ * filters only by a typed query, which a button click cannot provide); the
127
+ * pick is written into the draft through the same scoped
128
+ * `slash/input-insert-text` event the host menu uses.
22
129
  *
23
130
  * All interactive controls are host primitives
24
131
  * (@deepseek-ai/dsh-client-ui-primitives); all colors come from the ui-theme
@@ -73,13 +180,13 @@ window.__ModuleLoader__.load({
73
180
  const NS = 'expertsManagement'
74
181
 
75
182
  const ZH = {
76
- title: '专家市场',
183
+ title: '专家管理',
77
184
  close: '关闭',
78
- tabInstalled: '已安装',
79
- tabMarket: '市场',
185
+ tabMine: '我的',
186
+ tabBuiltin: '内置',
80
187
  searchPlaceholder: '搜索专家名称、职业、描述…',
81
- installedEmpty: '用户库还没有专家。去「市场」页浏览并安装。',
82
- marketEmpty: '市场为空。请在设置中同步市场仓库。',
188
+ mineEmpty: '用户库还没有专家。去「内置」页浏览并安装。',
189
+ builtinEmpty: '内置专家为空。请在设置中同步内置仓库。',
83
190
  expertTypeAgent: '专家',
84
191
  expertTypeTeam: '团队',
85
192
  sourceLabel: '来源',
@@ -88,6 +195,34 @@ window.__ModuleLoader__.load({
88
195
  install: '安装',
89
196
  installing: '安装中…',
90
197
  installedDone: '已安装到用户库',
198
+ shareBtn: '分享',
199
+ shareTitle: '分享专家到官方仓库',
200
+ shareHint: 'AI 将读取本机令牌,fork 官方仓库 → 建分支 → 提交该专家目录 → 创建 PR。确认或修改提示词后,复制到当前会话发送执行。',
201
+ shareParamName: '专家名',
202
+ shareParamVersion: '版本',
203
+ shareParamDir: '本机目录',
204
+ copyPrompt: '复制提示词',
205
+ copied: '已复制',
206
+ runBtn: '执行',
207
+ running: '执行中…',
208
+ runDone: '完成',
209
+ runFailed: '失败',
210
+ outputLabel: '执行输出',
211
+ editMeta: '编辑资料',
212
+ displayName: '显示名',
213
+ professionLabel: '职业',
214
+ displayDescription: '描述',
215
+ defaultInitPrompt: '默认开场',
216
+ tagsLabel: '标签',
217
+ edit: '编辑',
218
+ save: '保存',
219
+ cancel: '取消',
220
+ saved: '已保存',
221
+ attachSkill: '添加技能',
222
+ detach: '移除',
223
+ detachConfirm: '仅移除专家的技能副本,不影响技能库本体。确认移除?',
224
+ uploadAvatar: '更换头像',
225
+ uploading: '上传中…',
91
226
  overwrite: '覆盖安装',
92
227
  remove: '删除',
93
228
  removing: '删除中…',
@@ -107,10 +242,10 @@ window.__ModuleLoader__.load({
107
242
  filesLabel: '文件',
108
243
  versionLabel: '版本',
109
244
  sourceReadonly: '只读来源(可在 NTD 中管理,或安装到用户库)',
110
- marketSettings: '市场设置',
245
+ builtinSettings: '内置设置',
111
246
  syncNow: '立即同步',
112
247
  syncing: '同步中,可能需要一分钟…',
113
- syncDoneUpdated: '同步完成,市场已更新',
248
+ syncDoneUpdated: '同步完成,内置已更新',
114
249
  syncDoneLatest: '已是最新版本',
115
250
  firstCloneDone: '首次克隆完成',
116
251
  repoUrlLabel: '仓库地址',
@@ -133,18 +268,22 @@ window.__ModuleLoader__.load({
133
268
  noDescription: '暂无描述',
134
269
  avatarLoadFailed: '头像加载失败',
135
270
  menuGroup: '专家',
136
- pickExpert: '+专家',
271
+ pickExpert: '+ 专家',
137
272
  pickExpertTitle: '选择一位专家,以该专家的身份执行本条任务',
273
+ pickerLoading: '正在加载专家目录…',
274
+ pickerEmpty: '没有匹配的专家',
275
+ pickerTabAgents: '专家',
276
+ pickerTabTeams: '专家团',
138
277
  }
139
278
 
140
279
  const EN = {
141
- title: 'Expert Market',
280
+ title: 'Expert Management',
142
281
  close: 'Close',
143
- tabInstalled: 'Installed',
144
- tabMarket: 'Market',
282
+ tabMine: 'Mine',
283
+ tabBuiltin: 'Built-in',
145
284
  searchPlaceholder: 'Search experts by name, profession, description…',
146
- installedEmpty: 'No experts in the user library yet. Browse the Market tab and install one.',
147
- marketEmpty: 'Market is empty. Sync the market repo in settings.',
285
+ mineEmpty: 'No experts in the user library yet. Browse the Built-in tab and install one.',
286
+ builtinEmpty: 'Built-in experts are empty. Sync the built-in repo in settings.',
148
287
  expertTypeAgent: 'Expert',
149
288
  expertTypeTeam: 'Team',
150
289
  sourceLabel: 'Source',
@@ -153,6 +292,34 @@ window.__ModuleLoader__.load({
153
292
  install: 'Install',
154
293
  installing: 'Installing…',
155
294
  installedDone: 'Installed to the user library',
295
+ shareBtn: 'Share',
296
+ shareTitle: 'Share expert to the official repo',
297
+ shareHint: 'AI will read the local token, fork the official repo → create a branch → commit the expert directory → open a PR. Review or edit the prompt, then copy it into the conversation to run.',
298
+ shareParamName: 'Expert',
299
+ shareParamVersion: 'Version',
300
+ shareParamDir: 'Local dir',
301
+ copyPrompt: 'Copy prompt',
302
+ copied: 'Copied',
303
+ runBtn: 'Run',
304
+ running: 'Running…',
305
+ runDone: 'Done',
306
+ runFailed: 'Failed',
307
+ outputLabel: 'Output',
308
+ editMeta: 'Edit profile',
309
+ displayName: 'Display name',
310
+ professionLabel: 'Profession',
311
+ displayDescription: 'Description',
312
+ defaultInitPrompt: 'Default opener',
313
+ tagsLabel: 'Tags',
314
+ edit: 'Edit',
315
+ save: 'Save',
316
+ cancel: 'Cancel',
317
+ saved: 'Saved',
318
+ attachSkill: 'Add skill',
319
+ detach: 'Remove',
320
+ detachConfirm: "Only the expert's copy is removed — the skill library is untouched. Remove?",
321
+ uploadAvatar: 'Change avatar',
322
+ uploading: 'Uploading…',
156
323
  overwrite: 'Overwrite install',
157
324
  remove: 'Delete',
158
325
  removing: 'Deleting…',
@@ -172,10 +339,10 @@ window.__ModuleLoader__.load({
172
339
  filesLabel: 'Files',
173
340
  versionLabel: 'Version',
174
341
  sourceReadonly: 'Read-only source (manage in NTD, or install into the user library)',
175
- marketSettings: 'Market settings',
342
+ builtinSettings: 'Built-in settings',
176
343
  syncNow: 'Sync now',
177
344
  syncing: 'Syncing, may take a minute…',
178
- syncDoneUpdated: 'Sync complete, market updated',
345
+ syncDoneUpdated: 'Sync complete, built-in updated',
179
346
  syncDoneLatest: 'Already up to date',
180
347
  firstCloneDone: 'First clone done',
181
348
  repoUrlLabel: 'Repository URL',
@@ -200,11 +367,15 @@ window.__ModuleLoader__.load({
200
367
  menuGroup: 'Experts',
201
368
  pickExpert: '+ Expert',
202
369
  pickExpertTitle: 'Pick an expert to handle this message in their persona',
370
+ pickerLoading: 'Loading experts…',
371
+ pickerEmpty: 'No matching experts',
372
+ pickerTabAgents: 'Experts',
373
+ pickerTabTeams: 'Teams',
203
374
  }
204
375
 
205
376
  // ── Styles ───────────────────────────────────────────────────────────────
206
377
 
207
- const STYLE = `
378
+ const STYLE = `<style>
208
379
  .exp-page{position:relative;display:flex;flex-direction:column;gap:14px;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);font-size:var(--dsw-font-sm-14,14px)}
209
380
  .exp-toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
210
381
  .exp-tabs{display:flex;gap:4px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:3px}
@@ -248,17 +419,32 @@ window.__ModuleLoader__.load({
248
419
  .exp-member-avatar{width:32px;height:32px;border-radius:8px;object-fit:cover;background:var(--dsw-alias-bg-layer-3)}
249
420
  .exp-skill-row{display:flex;flex-direction:column;gap:2px;padding:8px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}
250
421
  .exp-skill-row:last-child{border-bottom:0}
251
- .exp-settings{display:flex;flex-direction:column;gap:10px;padding:14px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:14px}
252
422
  .exp-form-row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
253
423
  .exp-form-row label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dsw-alias-label-secondary);flex:1;min-width:160px}
254
- .exp-input{background:var(--dsw-alias-specific-input-major);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 10px;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;width:100%}
424
+ .exp-flash{font-size:12px;color:var(--dsw-alias-state-positive,#3aa76d)}.exp-input{background:var(--dsw-alias-specific-input-major);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 10px;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;width:100%}
255
425
  .exp-status-line{display:flex;gap:14px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-secondary)}
256
426
  .exp-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary);border-radius:10px;padding:8px 18px;font-size:13px;z-index:80;box-shadow:0 8px 24px rgba(0,0,0,.25)}
257
427
  .exp-checkline{display:flex;gap:6px;align-items:center;font-size:13px;color:var(--dsw-alias-label-secondary)}
258
428
  .exp-checkline input{accent-color:var(--dsw-alias-brand-primary)}
259
429
  .exp-chip{display:inline-flex;align-items:center;gap:4px;height:26px;padding:0 9px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer;white-space:nowrap}
260
430
  .exp-chip:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
261
- `
431
+ .exp-picker-backdrop{position:fixed;inset:0;z-index:2147483200}
432
+ .exp-picker{position:fixed;z-index:2147483201;width:400px;max-width:92vw;max-height:360px;display:flex;flex-direction:column;gap:4px;padding:6px;background:var(--dsw-specific-menu);border:1px solid var(--dsw-alias-border-inverted);border-radius:12px;box-shadow:var(--dsw-shadow-lv3)}
433
+ .exp-picker-input{flex:none}
434
+ .exp-picker-tabs{flex:none;align-self:flex-start}
435
+ .exp-picker-list{display:flex;flex-direction:column;min-height:40px;overflow-y:auto}
436
+ .exp-picker-row{display:flex;align-items:center;gap:8px;width:100%;min-height:36px;padding:6px 10px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);text-align:left;cursor:pointer;font:inherit;font-size:13px}
437
+ .exp-picker-row[data-active="true"]{background:var(--dsw-alias-interactive-bg-hover)}
438
+ .exp-picker-icon{width:18px;flex:none;text-align:center}
439
+ .exp-picker-name{flex:none;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
440
+ .exp-picker-literal{flex:none;max-width:32%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:11px}
441
+ .exp-picker-desc{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:12px}
442
+ .exp-picker-empty{padding:12px 10px;text-align:center;color:var(--dsw-alias-label-dimmed);font-size:13px}
443
+ .exp-overlay{position:fixed;inset:0;z-index:2147483000;background:var(--dsw-alias-bg-base);overflow:auto;padding:20px 26px}
444
+ .exp-head{display:flex;align-items:center;gap:10px;margin-bottom:4px}
445
+ .exp-title{font-weight:600;font-size:16px}
446
+ .exp-spacer{flex:1}
447
+ </style>`
262
448
 
263
449
  // ── Helpers ──────────────────────────────────────────────────────────────
264
450
 
@@ -306,17 +492,51 @@ window.__ModuleLoader__.load({
306
492
  const rosterListeners = new Set()
307
493
  const ROSTER_TTL = 60_000
308
494
 
495
+ /**
496
+ * Roster row mapping (pure, testable). The host slash menu renders only
497
+ * name + description per row, so the human-readable displayName is prefixed
498
+ * into the description — expert teams (👥) and single experts both show
499
+ * their real name next to the `expert-<id>` literal. The picker popover
500
+ * renders `displayName` as the primary label and `plainDescription` as the
501
+ * secondary line instead. Rows come out sorted by displayName(中文按拼音),
502
+ * the picker splits them into 专家/专家团 tabs by `team`.
503
+ */
504
+ function toRosterRows(mine, builtin) {
505
+ const rows = [...(Array.isArray(mine) ? mine : []), ...(Array.isArray(builtin) ? builtin : [])].map((e) => {
506
+ const displayName = e.displayName || e.name
507
+ const desc = e.description || e.profession || ''
508
+ return {
509
+ name: `expert-${e.name}`,
510
+ displayName,
511
+ description: displayName && displayName !== e.name ? (desc === '' ? displayName : `${displayName} · ${desc}`) : desc,
512
+ plainDescription: desc,
513
+ team: e.expertType === 'team',
514
+ icon: e.expertType === 'team' ? '👥' : '🧑‍💼',
515
+ }
516
+ })
517
+ rows.sort((a, b) => String(a.displayName).localeCompare(String(b.displayName), 'zh'))
518
+ return rows
519
+ }
520
+
521
+ /** Picker tab split (pure, testable): single experts vs expert teams. */
522
+ function splitRosterByType(rows) {
523
+ const agents = [], teams = []
524
+ for (const r of Array.isArray(rows) ? rows : []) (r.team ? teams : agents).push(r)
525
+ return { agents, teams }
526
+ }
527
+
528
+ /** Picker filter predicate (pure): match the displayed fields, not the `expert-` prefix. */
529
+ function pickerRowMatch(r, lower) {
530
+ return matchExpert({ name: r.name.replace(/^expert-/, ''), displayName: r.displayName, description: r.plainDescription }, lower)
531
+ }
532
+
309
533
  async function fetchRoster(force) {
310
534
  if (!force && expertRoster !== null && Date.now() - expertRosterAt < ROSTER_TTL) return expertRoster
311
535
  try {
312
536
  const data = await fetchJson(API)
313
- const installed = Array.isArray(data.installed) ? data.installed : []
314
- const market = (Array.isArray(data.market) ? data.market : []).filter((e) => !e.installed)
315
- expertRoster = [...installed, ...market].map((e) => ({
316
- name: `expert-${e.name}`,
317
- description: e.description || e.profession || '',
318
- icon: e.expertType === 'team' ? '👥' : '🧑‍💼',
319
- }))
537
+ const mine = Array.isArray(data.mine) ? data.mine : []
538
+ const builtin = (Array.isArray(data.builtin) ? data.builtin : []).filter((e) => !e.installed)
539
+ expertRoster = toRosterRows(mine, builtin)
320
540
  expertRosterAt = Date.now()
321
541
  for (const listener of [...rosterListeners]) { try { listener() } catch {} }
322
542
  } catch { /* 菜单失败静默:候选组保持 pending/缺席 */ }
@@ -356,6 +576,10 @@ window.__ModuleLoader__.load({
356
576
  * end (host toggleCommandMenu 同款调用形状)。The standard kit exposes no
357
577
  * caret, so the pick replaces a collapsed span at draft end — picks die
358
578
  * quietly on span-CAS if the draft moved since the click.
579
+ *
580
+ * NOTE: the + 专家 button no longer uses this — the host menu cannot offer
581
+ * a search box, so the button opens ExpertPicker instead. Kept for the
582
+ * contract tests and as the documented toggleSource path.
359
583
  */
360
584
  function openTriggerSource(composerScope, sessionId, input, sourceName) {
361
585
  const inputTriggers = composerScope && composerScope.inputTriggers
@@ -378,6 +602,115 @@ window.__ModuleLoader__.load({
378
602
  return true
379
603
  }
380
604
 
605
+ /**
606
+ * Insert `text` at the end of the session draft through the same scoped
607
+ * event the host slash menu executes (`slash/input-insert-text`). The span
608
+ * CAS uses the freshest input snapshot handed to the slot props — while the
609
+ * picker popover is open the composer draft cannot move (focus is in the
610
+ * picker), so the splice applies; a stale snapshot quietly no-ops, same as
611
+ * the host menu's span-CAS.
612
+ */
613
+ function insertComposerText(scope, sessionId, input, text) {
614
+ const sessions = scope && scope.sessions
615
+ if (!sessions) return false
616
+ let actx
617
+ try { actx = sessions.scope(sessionId) } catch { return false }
618
+ if (actx === undefined || actx === null || typeof actx.bail !== 'function') return false
619
+ const draft = (input && input.draft) || ''
620
+ const at = draft.length
621
+ try {
622
+ return actx.bail(actx, 'slash/input-insert-text', {
623
+ text,
624
+ span: { start: at, end: at, draftRev: (input && input.draftRev) || 0 },
625
+ }) === true
626
+ } catch { return false }
627
+ }
628
+
629
+ /** Best-effort refocus of the composer textarea after the picker closes. */
630
+ function refocusComposer() {
631
+ try {
632
+ const card = document.querySelector('[data-composer-card]')
633
+ const ta = card && card.querySelector('textarea')
634
+ if (ta && typeof ta.focus === 'function') ta.focus()
635
+ } catch {}
636
+ }
637
+
638
+ /** Picker popover list cap — beyond this the search input is the filter. */
639
+ const PICKER_ROW_CAP = 200
640
+
641
+ /**
642
+ * + 专家 picker:锚定在按钮上方、自带搜索框的候选浮层(portal 到 body)。
643
+ * 宿主斜杠菜单靠「输入的 query」过滤,按钮打开的菜单没有输入载体——候选
644
+ * 太多时无从筛选,所以浮层自带搜索框。专家/专家团分两个 tab(tab 标签上的
645
+ * 计数跟随当前搜索过滤),各 tab 内按显示名排序(toRosterRows 已排好)。
646
+ * 键盘 ↑/↓/Enter/Esc,鼠标 hover+点击;tab 按钮 mousedown 不抢输入框焦点。
647
+ */
648
+ function ExpertPicker(props) {
649
+ const t = props.t
650
+ const [query, setQuery] = useState('')
651
+ const [tab, setTab] = useState('agent') // 'agent' | 'team'
652
+ const [active, setActive] = useState(0)
653
+ const inputRef = useRef(null)
654
+ const listRef = useRef(null)
655
+ useEffect(() => { try { if (inputRef.current) inputRef.current.focus() } catch {} }, [])
656
+ useEffect(() => {
657
+ const onKey = (e) => { if (e.key === 'Escape' && !(e.isComposing === true)) props.onClose() }
658
+ try { document.addEventListener('keydown', onKey) } catch {}
659
+ return () => { try { document.removeEventListener('keydown', onKey) } catch {} }
660
+ }, [])
661
+ const lower = query.trim().toLowerCase()
662
+ const { agents, teams } = splitRosterByType(props.rows)
663
+ const filteredAgents = lower === '' ? agents : agents.filter((r) => pickerRowMatch(r, lower))
664
+ const filteredTeams = lower === '' ? teams : teams.filter((r) => pickerRowMatch(r, lower))
665
+ const shown = (tab === 'team' ? filteredTeams : filteredAgents).slice(0, PICKER_ROW_CAP)
666
+ useEffect(() => { setActive(0) }, [lower, tab, props.rows])
667
+ useEffect(() => {
668
+ const list = listRef.current
669
+ const el = list && list.children[active]
670
+ if (el && typeof el.scrollIntoView === 'function') { try { el.scrollIntoView({ block: 'nearest' }) } catch {} }
671
+ }, [active])
672
+ const onKeyDown = (e) => {
673
+ // IME 组词中的按键不触发选择(回车是选定拼音候选,不是 pick)
674
+ if (e.nativeEvent && e.nativeEvent.isComposing === true) return
675
+ if (e.key === 'ArrowDown') { e.preventDefault(); setActive((i) => Math.min(i + 1, shown.length - 1)) }
676
+ else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)) }
677
+ else if (e.key === 'Enter') { e.preventDefault(); const row = shown[active]; if (row) props.onPick(row) }
678
+ }
679
+ const width = 400
680
+ const winW = typeof window !== 'undefined' ? window.innerWidth : 800
681
+ const winH = typeof window !== 'undefined' ? window.innerHeight : 600
682
+ const left = Math.max(8, Math.min(props.anchor.left, winW - width - 8))
683
+ const bottom = Math.max(8, winH - props.anchor.top + 6)
684
+ const tabBtn = (key, labelKey, count) => h('button', {
685
+ type: 'button', role: 'tab', className: 'exp-tab', 'data-on': tab === key, 'aria-selected': tab === key,
686
+ onMouseDown: (e) => { e.preventDefault(); setTab(key) }, onClick: () => setTab(key),
687
+ }, `${t(labelKey)} (${count})`)
688
+ return h('div', { className: 'exp-picker-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) props.onClose() } },
689
+ h('div', { className: 'exp-picker', style: { left, bottom, width }, role: 'dialog', 'aria-label': t('pickExpertTitle') },
690
+ h('input', {
691
+ ref: inputRef, className: 'exp-input exp-picker-input', value: query,
692
+ placeholder: t('searchPlaceholder'), onChange: (e) => setQuery(e.target.value), onKeyDown,
693
+ }),
694
+ h('div', { className: 'exp-tabs exp-picker-tabs', role: 'tablist' },
695
+ tabBtn('agent', 'pickerTabAgents', filteredAgents.length),
696
+ tabBtn('team', 'pickerTabTeams', filteredTeams.length)),
697
+ h('div', { className: 'exp-picker-list', ref: listRef, role: 'listbox' },
698
+ props.rows === null
699
+ ? h('div', { className: 'exp-picker-empty' }, t('pickerLoading'))
700
+ : shown.length === 0
701
+ ? h('div', { className: 'exp-picker-empty' }, t('pickerEmpty'))
702
+ : shown.map((row, i) => h('button', {
703
+ key: row.name, type: 'button', role: 'option', 'aria-selected': i === active,
704
+ className: 'exp-picker-row', 'data-active': i === active,
705
+ onMouseEnter: () => setActive(i),
706
+ onMouseDown: (e) => { e.preventDefault(); props.onPick(row) },
707
+ },
708
+ row.icon ? h('span', { className: 'exp-picker-icon', 'aria-hidden': 'true' }, row.icon) : null,
709
+ h('span', { className: 'exp-picker-name' }, row.displayName || row.name),
710
+ row.displayName && row.displayName !== row.name ? h('span', { className: 'exp-picker-literal' }, row.name) : null,
711
+ h('span', { className: 'exp-picker-desc' }, row.plainDescription || ''))))))
712
+ }
713
+
381
714
  // ── Small components ─────────────────────────────────────────────────────
382
715
 
383
716
  function Avatar({ expert, size }) {
@@ -452,18 +785,136 @@ window.__ModuleLoader__.load({
452
785
  : h('div', { className: 'exp-member-avatar', title: t('avatarLoadFailed') }, '👤')
453
786
  }
454
787
 
455
- function DetailModal({ name, source, t, onClose, onInstalled, onDeleted }) {
788
+ let shareDialogComponent = null
789
+ function getShareDialogComponent() {
790
+ if (shareDialogComponent === null) shareDialogComponent = PluginKit.makeActionShareDialog(__React)
791
+ return shareDialogComponent
792
+ }
793
+
794
+ /** 专家分享提示词:提交到 ntd-resource 的 experts/ 子树(与技能分享同管线、同 token)。 */
795
+ const EXPERT_SHARE_PROMPT = [
796
+ '请把本地专家「{{expertName}}」{{version}}打包提交到 GitCode 官方仓库 weibaohui/ntd-resource 的 experts/ 子树,作为一个 PR 供维护者审核。',
797
+ '',
798
+ '## 关键信息',
799
+ '- 专家目录:{{resourceDir}}(~ 表示当前用户家目录,执行前先展开为绝对路径)',
800
+ '- 官方仓库:weibaohui/ntd-resource(GitCode,API base = https://api.gitcode.com)',
801
+ '- PAT 位置:{{settingsFile}} 中 skills-management.market 段的 token 字段(由技能市场设置面板保存,与技能分享同源)。',
802
+ '',
803
+ '## 执行步骤(严格按顺序)',
804
+ '1. 读取 PAT:读取 {{settingsFile}},定位 skills-management.market 段下的 token 字段。token 是敏感凭据,读取后不要把明文打印到输出、日志或最终结果里。',
805
+ '2. 展开专家目录为绝对路径,遍历该目录(含 agents/、skills/、avatars/ 子目录与 .codebuddy-plugin/plugin.json),收集每个文件的「相对该目录的路径」与内容;跳过 .git 一类同步元数据。',
806
+ '3. 把第 1 步读到的 token 作为 HTTP 认证令牌(bearer),按顺序调用 GitCode API:',
807
+ ' a. 验证用户:`GET https://api.gitcode.com/api/v5/user`,拿到 login 字段——后续所有 URL 里的 {owner} 一律用它。',
808
+ ' b. fork:`POST https://api.gitcode.com/api/v5/repos/weibaohui/ntd-resource/forks`;若返回 409/422 表示已 fork,视为成功。',
809
+ ' c. 建分支:`POST https://api.gitcode.com/api/v5/repos/{owner}/ntd-resource/branches`,JSON body 为 {"branch_name":"experts/{{expertName}}-<unix 时间戳>","refs":"main"}。',
810
+ ' d. 写文件:对第 2 步收集的每个文件,`POST https://api.gitcode.com/api/v5/repos/{owner}/ntd-resource/contents/experts/{{该文件相对专家目录的路径}}`。**必须**用 experts/ 前缀,不能写到仓库根目录。表单字段 content=<文件字节的 base64>、message="贡献专家 {{expertName}} {{version}}"、branch=<步骤 c 的分支名>。',
811
+ ' e. 创建 PR:`POST https://api.gitcode.com/api/v5/repos/weibaohui/ntd-resource/pulls`,JSON body 为 {"title":"[专家] {{expertName}} {{version}}","body":"专家目录 {{resourceDir}} 的文件清单与用途简介","head":"{owner}:{branch}","base":"main"}。',
812
+ '4. 完成后,最终输出 PR 的网页链接(响应里的 web_url 字段)。',
813
+ '',
814
+ '## 注意',
815
+ '- token 是敏感凭据,任何输出里都不要回显其明文。',
816
+ '- 如果任一步骤失败,先检查错误信息,不要盲目重试;若 token 失效,提示用户到技能市场的 ⚙ 设置面板重新填写。',
817
+ '- 全程与最终汇报都使用中文。',
818
+ ].join('\n')
819
+
820
+ function DetailModal({ name, source, t, onClose, onInstalled, onDeleted, onToast }) {
456
821
  const [detail, setDetail] = useState(null)
457
822
  const [error, setError] = useState('')
458
823
  const [busy, setBusy] = useState(false)
459
824
  const [agentMd, setAgentMd] = useState(null)
825
+ const detailUrl = () => `${API}/detail?name=${encodeURIComponent(name)}${source ? `&source=${encodeURIComponent(source)}` : ''}`
826
+ const loadDetail = () => fetchJson(detailUrl())
827
+ .then((d) => setDetail(d))
828
+ .catch((e) => setError(String(e && e.message)))
829
+ useEffect(() => { loadDetail() }, [name, source])
830
+ // ── 编辑态(v0.3,仅 dsh 用户库专家)──
831
+ const [editingMd, setEditingMd] = useState(null) // {agent, content} | null
832
+ const [metaEdit, setMetaEdit] = useState(false)
833
+ const [metaForm, setMetaForm] = useState(null)
834
+ const [skillsEdit, setSkillsEdit] = useState(false)
835
+ const [availSkills, setAvailSkills] = useState(null)
836
+ const [saving, setSaving] = useState(false)
837
+ const [savedFlash, setSavedFlash] = useState('')
838
+ const [avatarBusy, setAvatarBusy] = useState(false)
839
+ const [shareOpen, setShareOpen] = useState(false)
840
+ const [shareSettingsFile, setShareSettingsFile] = useState('')
841
+ const avatarInputRef = useRef(null)
460
842
  useEffect(() => {
461
- let live = true
462
- fetchJson(`${API}/detail?name=${encodeURIComponent(name)}${source ? `&source=${encodeURIComponent(source)}` : ''}`)
463
- .then((d) => { if (live) setDetail(d) })
464
- .catch((e) => { if (live) setError(String(e && e.message)) })
465
- return () => { live = false }
466
- }, [name, source])
843
+ if (shareOpen === false) return
844
+ fetchJson(`${API}/share/status`).then((d) => setShareSettingsFile(d.settingsFile || '')).catch(() => setShareSettingsFile(''))
845
+ }, [shareOpen])
846
+ const flash = (text) => { setSavedFlash(text); setTimeout(() => setSavedFlash(''), 1600) }
847
+ const startEditMd = (agentName) => {
848
+ fetchJson(`${API}/agent-md?name=${encodeURIComponent(name)}${source ? `&source=${encodeURIComponent(source)}` : ''}&agent=${encodeURIComponent(agentName)}`)
849
+ .then((r) => setEditingMd({ agent: agentName, content: r.content }))
850
+ .catch((e) => setError(String(e && e.message)))
851
+ }
852
+ const saveMd = async () => {
853
+ setSaving(true); setError('')
854
+ try {
855
+ await fetchJson(`${API}/agent-md`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, agent: editingMd.agent, content: editingMd.content }) })
856
+ setEditingMd(null); flash(t('saved')); loadDetail()
857
+ } catch (e) { setError(String(e && e.message)) } finally { setSaving(false) }
858
+ }
859
+ const startMetaEdit = () => {
860
+ const p = (detail && detail.pluginJson) || {}
861
+ const loc = (v) => ({ zh: (v && v.zh) || '', en: (v && v.en) || '' })
862
+ const tags = Array.isArray(p.tags) ? p.tags : []
863
+ setMetaForm({
864
+ displayName: loc(p.displayName), profession: loc(p.profession),
865
+ displayDescription: loc(p.displayDescription), defaultInitPrompt: loc(p.defaultInitPrompt),
866
+ tagsZh: tags.map((x) => x.zh || '').filter(Boolean).join(','),
867
+ tagsEn: tags.map((x) => x.en || '').filter(Boolean).join(','),
868
+ quickPrompts: Array.isArray(p.quickPrompts) ? p.quickPrompts : [],
869
+ })
870
+ setMetaEdit(true)
871
+ }
872
+ const saveMeta = async () => {
873
+ setSaving(true); setError('')
874
+ const splitList = (v) => String(v || '').split(/[,,]/).map((x) => x.trim()).filter(Boolean)
875
+ const body = { metadata: {
876
+ displayName: metaForm.displayName, profession: metaForm.profession,
877
+ displayDescription: metaForm.displayDescription, defaultInitPrompt: metaForm.defaultInitPrompt,
878
+ tags: (function () {
879
+ const zhList = splitList(metaForm.tagsZh); const enList = splitList(metaForm.tagsEn)
880
+ const len = Math.max(zhList.length, enList.length)
881
+ return Array.from({ length: len }, (_, i) => ({ zh: zhList[i] || '', en: enList[i] || '' })).filter((t) => t.zh !== '' || t.en !== '')
882
+ })(),
883
+ quickPrompts: metaForm.quickPrompts,
884
+ } }
885
+ try {
886
+ await fetchJson(`${API}/metadata`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, ...body }) })
887
+ setMetaEdit(false); flash(t('saved')); loadDetail()
888
+ } catch (e) { setError(String(e && e.message)) } finally { setSaving(false) }
889
+ }
890
+ const detachSkill = (skillName) => {
891
+ if (!window.confirm(t('detachConfirm'))) return
892
+ fetchJson(`${API}/expert-skills`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, detach: [skillName] }) })
893
+ .then(() => { flash(t('saved')); loadDetail() })
894
+ .catch((e) => setError(String(e && e.message)))
895
+ }
896
+ const openSkillsEdit = () => {
897
+ setSkillsEdit(true)
898
+ if (availSkills === null) {
899
+ fetchJson(`${API}/available-skills`).then((d) => setAvailSkills(d.skills || [])).catch((e) => setError(String(e && e.message)))
900
+ }
901
+ }
902
+ const attachSkill = (skillName) => {
903
+ fetchJson(`${API}/expert-skills`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, attach: [skillName] }) })
904
+ .then(() => { flash(t('saved')); loadDetail() })
905
+ .catch((e) => setError(String(e && e.message)))
906
+ }
907
+ const onAvatarFile = async (e) => {
908
+ const f = e.target.files && e.target.files[0]
909
+ e.target.value = ''
910
+ if (!f) return
911
+ setAvatarBusy(true); setError('')
912
+ try {
913
+ const buf = new Uint8Array(await f.arrayBuffer())
914
+ await fetchJson(`${API}/avatar?name=${encodeURIComponent(name)}`, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: buf })
915
+ flash(t('saved')); loadDetail()
916
+ } catch (ex) { setError(String(ex && ex.message)) } finally { setAvatarBusy(false) }
917
+ }
467
918
  const loadAgentMd = () => {
468
919
  fetchJson(`${API}/agent-md?name=${encodeURIComponent(name)}${source ? `&source=${encodeURIComponent(source)}` : ''}`)
469
920
  .then((r) => setAgentMd(r.content))
@@ -505,6 +956,24 @@ window.__ModuleLoader__.load({
505
956
  kv(t('filesLabel'), `${detail.fileCount} / ${formatSize(detail.totalSize)}`),
506
957
  kv(t('dirLabel'), detail.dir)),
507
958
  detail.readOnly ? h('div', { className: 'exp-kv' }, h('span', { style: { color: 'var(--dsw-alias-label-tertiary)' } }, t('sourceReadonly'))) : null,
959
+ detail.source === 'dsh' ? h('div', { className: 'exp-form-row' },
960
+ h('button', { className: 'exp-btn', disabled: busy || saving || avatarBusy, onClick: startMetaEdit }, t('editMeta')),
961
+ h('button', { className: 'exp-btn', disabled: busy || saving || avatarBusy, onClick: () => { if (avatarInputRef.current) avatarInputRef.current.click() } }, avatarBusy ? t('uploading') : t('uploadAvatar')),
962
+ savedFlash ? h('span', { className: 'exp-flash' }, savedFlash) : null,
963
+ h('input', { ref: avatarInputRef, type: 'file', accept: 'image/png,image/jpeg,image/gif,image/webp', style: { display: 'none' }, onChange: onAvatarFile })) : null,
964
+ metaEdit && metaForm ? h('div', { className: 'exp-section' },
965
+ h('div', { className: 'exp-section-title' }, t('editMeta')),
966
+ ...['displayName', 'profession', 'displayDescription', 'defaultInitPrompt'].map((key) => h('div', { key, style: { marginBottom: '8px' } },
967
+ h('div', { className: 'exp-section-title', style: { margin: '4px 0' } }, t(key === 'profession' ? 'professionLabel' : key === 'tags' ? 'tagsLabel' : key)),
968
+ h('input', { className: 'exp-input', value: metaForm[key].zh, placeholder: 'zh', onChange: (e) => setMetaForm({ ...metaForm, [key]: { ...metaForm[key], zh: e.target.value } }), style: { marginBottom: '4px', width: '100%' } }),
969
+ h('input', { className: 'exp-input', value: metaForm[key].en, placeholder: 'en', onChange: (e) => setMetaForm({ ...metaForm, [key]: { ...metaForm[key], en: e.target.value } }), style: { width: '100%' } }))),
970
+ h('div', { style: { marginBottom: '8px' } },
971
+ h('div', { className: 'exp-section-title', style: { margin: '4px 0' } }, t('tagsLabel')),
972
+ h('input', { className: 'exp-input', value: metaForm.tagsZh, placeholder: 'zh,逗号分隔', onChange: (e) => setMetaForm({ ...metaForm, tagsZh: e.target.value }), style: { marginBottom: '4px', width: '100%' } }),
973
+ h('input', { className: 'exp-input', value: metaForm.tagsEn, placeholder: 'en, comma separated', onChange: (e) => setMetaForm({ ...metaForm, tagsEn: e.target.value }), style: { width: '100%' } })),
974
+ h('div', { className: 'exp-form-row' },
975
+ h('button', { className: 'exp-btn', 'data-primary': 'true', disabled: saving, onClick: saveMeta }, saving ? '…' : t('save')),
976
+ h('button', { className: 'exp-btn', disabled: saving, onClick: () => setMetaEdit(false) }, t('cancel')))) : null,
508
977
  (detail.quickPromptsZh || []).length > 0 ? h('div', { className: 'exp-section' },
509
978
  h('div', { className: 'exp-section-title' }, t('quickPrompts')),
510
979
  ...detail.quickPromptsZh.slice(0, 5).map((q, i) => h('div', { className: 'exp-kv', key: i }, '• ', q))) : null,
@@ -520,13 +989,33 @@ window.__ModuleLoader__.load({
520
989
  h('div', { className: 'exp-section-title' }, t('skills')),
521
990
  ...detail.skillMeta.map((s) => h('div', { className: 'exp-skill-row', key: s.skillName },
522
991
  h('div', { style: { fontSize: 13 } }, `${s.emoji ? s.emoji + ' ' : ''}${s.skillName}`),
523
- h('div', { className: 'exp-profession' }, s.descriptionZh || s.descriptionEn || s.description || '')))) : null,
992
+ h('div', { className: 'exp-profession' }, s.descriptionZh || s.descriptionEn || s.description || ''),
993
+ detail.source === 'dsh' ? h('button', { className: 'exp-btn', style: { marginLeft: 'auto' }, onClick: () => detachSkill(s.skillName) }, t('detach')) : null)),
994
+ detail.source === 'dsh' ? h('div', { className: 'exp-form-row' },
995
+ skillsEdit ? null : h('button', { className: 'exp-btn', onClick: openSkillsEdit }, t('attachSkill'))) : null,
996
+ detail.source === 'dsh' && skillsEdit ? h('div', { className: 'exp-form-row' },
997
+ availSkills === null ? h('span', { className: 'exp-profession' }, '…')
998
+ : availSkills.length === 0 ? h('span', { className: 'exp-profession' }, '—')
999
+ : availSkills.map((sk) => h('button', { key: sk.name, className: 'exp-btn', title: sk.description, onClick: () => attachSkill(sk.name) }, `+ ${sk.name}`))) : null) : null,
524
1000
  (detail.agentFiles || []).length > 0 ? h('div', { className: 'exp-section' },
525
1001
  h('div', { className: 'exp-section-title' }, t('agents')),
526
1002
  ...detail.agentFiles.map((a) => h('div', { className: 'exp-skill-row', key: a.relPath },
527
1003
  h('div', { style: { fontSize: 13 } }, `${a.emoji ? a.emoji + ' ' : ''}${a.name}${a.name === detail.leadAgentFile ? ' ★' : ''}`),
528
- h('div', { className: 'exp-profession' }, a.description || '')))) : null,
1004
+ h('div', { className: 'exp-profession' }, a.description || ''),
1005
+ detail.source === 'dsh' ? h('button', { className: 'exp-btn', style: { marginLeft: 'auto' }, onClick: () => startEditMd(a.name) }, t('edit')) : null))) : null,
1006
+ editingMd !== null ? h('div', { className: 'exp-section' },
1007
+ h('div', { className: 'exp-section-title' }, `${t('edit')} · ${editingMd.agent}`),
1008
+ h('textarea', {
1009
+ className: 'exp-input', value: editingMd.content,
1010
+ onChange: (e) => setEditingMd({ ...editingMd, content: e.target.value }),
1011
+ spellCheck: false,
1012
+ style: { width: '100%', minHeight: '260px', fontFamily: 'ui-monospace,monospace', fontSize: '12px', lineHeight: 1.6, whiteSpace: 'pre-wrap', background: 'var(--dsw-alias-bg-layer-2,transparent)', color: 'inherit', border: '1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3))', borderRadius: '8px', padding: '10px', boxSizing: 'border-box' },
1013
+ }),
1014
+ h('div', { className: 'exp-form-row' },
1015
+ h('button', { className: 'exp-btn', 'data-primary': 'true', disabled: saving, onClick: saveMd }, saving ? '…' : t('save')),
1016
+ h('button', { className: 'exp-btn', disabled: saving, onClick: () => setEditingMd(null) }, t('cancel')))) : null,
529
1017
  h('div', { className: 'exp-form-row' },
1018
+ h('button', { className: 'exp-btn', onClick: () => setShareOpen(true) }, t('shareBtn')),
530
1019
  h('button', { className: 'exp-btn', onClick: () => (agentMd === null ? loadAgentMd() : setAgentMd(null)) }, agentMd === null ? t('viewAgentMd') : t('hideAgentMd')),
531
1020
  detail.source !== 'dsh'
532
1021
  ? h('button', { className: 'exp-btn', 'data-primary': 'true', disabled: busy, onClick: () => install(false) }, busy ? t('installing') : t('install'))
@@ -538,19 +1027,28 @@ window.__ModuleLoader__.load({
538
1027
  detail.plugin ? h('details', null,
539
1028
  h('summary', { style: { cursor: 'pointer', fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } }, t('pluginJson')),
540
1029
  h('pre', { className: 'exp-pre' }, JSON.stringify(detail.plugin, null, 2))) : null,
541
- ) : null))
1030
+ ) : null,
1031
+ shareOpen ? h(getShareDialogComponent(), {
1032
+ title: t('shareTitle'), hint: t('shareHint'),
1033
+ rows: [[t('shareParamName'), detail.name], [t('shareParamVersion'), detail.version || '1.0.0'], [t('shareParamDir'), detail.dir]],
1034
+ initialPrompt: PluginKit.substituteParams(EXPERT_SHARE_PROMPT, { expertName: detail.name, version: detail.version || '1.0.0', resourceDir: detail.dir, settingsFile: shareSettingsFile }),
1035
+ labels: { copy: t('copyPrompt'), copied: t('copied'), run: t('runBtn'), running: t('running'), done: t('runDone'), failed: t('runFailed'), outputLabel: t('outputLabel') },
1036
+ run: (prompt) => fetchJson(`${API}/share/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, dir: detail.dir }) }),
1037
+ poll: (id) => fetchJson(`${API}/share/run?id=${encodeURIComponent(id)}`),
1038
+ onClose: () => setShareOpen(false),
1039
+ }) : null))
542
1040
  }
543
1041
 
544
- function MarketSettingsCard({ t, onToast, onSynced }) {
1042
+ function BuiltinSettingsDialog({ t, onClose, onToast, onSynced }) {
545
1043
  const [status, setStatus] = useState(null)
546
1044
  const [form, setForm] = useState(null)
547
1045
  const [busy, setBusy] = useState(false)
548
- const load = () => fetchJson(`${API}/market/status`).then((s) => { setStatus(s); setForm((f) => f ?? { url: s.url, branch: s.branch, repoDir: s.dir, token: '', autoSync: s.autoSync, syncOnStartup: s.syncOnStartup }) })
1046
+ const load = () => fetchJson(`${API}/builtin/status`).then((s) => { setStatus(s); setForm((f) => f ?? { url: s.url, branch: s.branch, repoDir: s.dir, token: '', autoSync: s.autoSync, syncOnStartup: s.syncOnStartup }) })
549
1047
  useEffect(() => { load() }, [])
550
1048
  const sync = async () => {
551
1049
  setBusy(true)
552
1050
  try {
553
- const r = await fetchJson(`${API}/market/sync`, { method: 'POST' })
1051
+ const r = await fetchJson(`${API}/builtin/sync`, { method: 'POST' })
554
1052
  onToast(r.isFirstClone ? t('firstCloneDone') : r.hasUpdates ? t('syncDoneUpdated') : t('syncDoneLatest'))
555
1053
  await load()
556
1054
  onSynced()
@@ -564,51 +1062,72 @@ window.__ModuleLoader__.load({
564
1062
  const dirText = (form.repoDir || '').trim()
565
1063
  if (dirText !== '' && dirText !== (status && status.dir)) patch.repoDir = dirText
566
1064
  if (typeof form.token === 'string' && form.token !== '') patch.token = form.token
567
- if (form.token === null) patch.token = null
568
- await fetchJson(`${API}/market/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) })
1065
+ await fetchJson(`${API}/builtin/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) })
569
1066
  setForm((f) => ({ ...f, token: '' }))
570
1067
  onToast(t('saved'))
571
1068
  await load()
572
1069
  } catch (e) { onToast(String(e && e.message)) }
573
1070
  setBusy(false)
574
1071
  }
575
- const field = (label, value, onChange, type) => h('label', null, label,
576
- h('input', { className: 'exp-input', type: type || 'text', value: value ?? '', onChange: (e) => onChange(e.target.value) }))
577
- if (status === null) return null
578
- return h('div', { className: 'exp-settings' },
579
- h('div', { className: 'exp-status-line' },
580
- h('b', null, t('marketSettings')),
581
- h('span', null, `${t('repoDirLabel')}: ${status.dir}`),
582
- h('span', null, `${t('lastSyncLabel')}: ${status.lastSyncAt ? formatTime(status.lastSyncAt) : t('never')}`),
583
- status.localCommit ? h('span', null, `${t('localCommitLabel')}: ${String(status.localCommit).slice(0, 8)}`) : null,
584
- status.remoteCommit ? h('span', null, `${t('remoteCommitLabel')}: ${String(status.remoteCommit).slice(0, 8)}`, status.needsUpdate ? Badge({ kind: 'type', children: t('needsUpdateTag') }) : null) : null,
585
- !status.gitAvailable ? h('span', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, t('gitMissing')) : null,
586
- status.sparsePaths ? h('span', null, `sparse: ${(status.sparsePaths || []).join(', ')}`) : null),
587
- h('div', { className: 'exp-form-row' },
588
- field(t('repoUrlLabel'), form.url, (v) => setForm({ ...form, url: v })),
589
- field(t('branchLabel'), form.branch, (v) => setForm({ ...form, branch: v }))),
590
- h('div', { className: 'exp-form-row' },
591
- field(t('repoDirLabel'), form.repoDir, (v) => setForm({ ...form, repoDir: v })),
592
- field(`${t('tokenLabel')}${status.hasToken ? ` (${t('tokenConfigured')})` : ''}`, form.token ?? '', (v) => setForm({ ...form, token: v }), 'password')),
593
- h('div', { className: 'exp-form-row' },
594
- h('label', { className: 'exp-checkline' },
595
- h('input', { type: 'checkbox', checked: !!form.autoSync, onChange: (e) => setForm({ ...form, autoSync: e.target.checked }) }), t('autoSyncLabel')),
596
- h('label', { className: 'exp-checkline' },
597
- h('input', { type: 'checkbox', checked: !!form.syncOnStartup, onChange: (e) => setForm({ ...form, syncOnStartup: e.target.checked }) }), t('syncOnStartupLabel')),
598
- h('span', { style: { flex: 1 } }),
599
- status.hasToken ? h('button', { className: 'exp-btn', disabled: busy, onClick: () => setForm({ ...form, token: null }) }, t('clearToken')) : null,
600
- h('button', { className: 'exp-btn', disabled: busy || status.syncing, onClick: sync }, busy || status.syncing ? t('syncing') : t('syncNow')),
601
- h(prim('Button'), { onClick: save, disabled: busy }, t('save'))))
1072
+ const clearToken = async () => {
1073
+ setBusy(true)
1074
+ try {
1075
+ await fetchJson(`${API}/builtin/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: null }) })
1076
+ onToast(t('saved'))
1077
+ await load()
1078
+ } catch (e) { onToast(String(e && e.message)) }
1079
+ setBusy(false)
1080
+ }
1081
+ // 布局逐行对齐技能市场的市场设置弹窗:状态区 = 左标签/右值成行;
1082
+ // 输入区 = 全宽堆叠、placeholder 即标签;底部 = 右对齐 保存 + 立即同步(主按钮)
1083
+ const short = (c) => (c ? String(c).slice(0, 8) : '-')
1084
+ const row = (label, value) => h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '3px 0' } },
1085
+ h('span', { className: 'exp-profession' }, label), h('span', { style: { wordBreak: 'break-all', textAlign: 'right', fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, value))
1086
+ return h('div', { className: 'exp-modal-backdrop', onClick: (e) => { if (e.target === e.currentTarget) onClose() } },
1087
+ h('div', { className: 'exp-modal', style: { maxWidth: 640 } },
1088
+ h('div', { className: 'exp-modal-head' },
1089
+ h('span', { className: 'exp-title' }, t('builtinSettings')),
1090
+ h('button', { className: 'exp-btn exp-modal-close', onClick: onClose }, t('close'))),
1091
+ status === null ? h('div', { className: 'exp-empty' }, '…')
1092
+ : h('div', { style: { display: 'contents' } },
1093
+ !status.gitAvailable ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 12 } }, t('gitMissing')) : null,
1094
+ h('div', null,
1095
+ row(t('repoUrlLabel'), status.url),
1096
+ row(t('branchLabel'), status.branch),
1097
+ row(t('localCommitLabel'), short(status.localCommit)),
1098
+ status.remoteCommit ? h('div', { style: { display: 'flex', justifyContent: 'flex-end' } },
1099
+ status.needsUpdate ? Badge({ kind: 'type', children: t('needsUpdateTag') }) : null) : null,
1100
+ row(t('remoteCommitLabel'), short(status.remoteCommit)),
1101
+ row(t('lastSyncLabel'), status.lastSyncAt ? formatTime(status.lastSyncAt) : t('never')),
1102
+ row(t('repoDirLabel'), status.dir),
1103
+ status.sparsePaths ? row('sparse', (status.sparsePaths || []).join(', ')) : null),
1104
+ h('div', { style: { display: 'flex', gap: 16, flexWrap: 'wrap' } },
1105
+ h('label', { className: 'exp-checkline' },
1106
+ h('input', { type: 'checkbox', checked: !!form.autoSync, onChange: (e) => setForm({ ...form, autoSync: e.target.checked }) }), t('autoSyncLabel')),
1107
+ h('label', { className: 'exp-checkline' },
1108
+ h('input', { type: 'checkbox', checked: !!form.syncOnStartup, onChange: (e) => setForm({ ...form, syncOnStartup: e.target.checked }) }), t('syncOnStartupLabel'))),
1109
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
1110
+ h('input', { className: 'exp-input', value: form.url ?? '', placeholder: t('repoUrlLabel'), onChange: (e) => setForm({ ...form, url: e.target.value }), style: { width: '100%', boxSizing: 'border-box' } }),
1111
+ h('input', { className: 'exp-input', value: form.branch ?? '', placeholder: t('branchLabel'), onChange: (e) => setForm({ ...form, branch: e.target.value }), style: { width: '100%', boxSizing: 'border-box' } }),
1112
+ h('input', { className: 'exp-input', value: form.repoDir ?? '', placeholder: t('repoDirLabel'), onChange: (e) => setForm({ ...form, repoDir: e.target.value }), style: { width: '100%', boxSizing: 'border-box' } }),
1113
+ h('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
1114
+ h('input', { className: 'exp-input', type: 'password', value: form.token ?? '', onChange: (e) => setForm({ ...form, token: e.target.value }),
1115
+ placeholder: status.hasToken ? `${t('tokenLabel')} · ${t('tokenConfigured')}` : t('tokenLabel'), style: { flex: 1 } }),
1116
+ status.hasToken ? h('button', { className: 'exp-btn', disabled: busy, onClick: clearToken }, t('clearToken')) : null)),
1117
+ h('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 } },
1118
+ h('button', { className: 'exp-btn', disabled: busy, onClick: save }, t('save')),
1119
+ h('button', { className: 'exp-btn', 'data-primary': 'true', disabled: busy || status.syncing, onClick: sync }, busy || status.syncing ? t('syncing') : t('syncNow'))))))
602
1120
  }
603
1121
 
604
1122
  // ── Page ─────────────────────────────────────────────────────────────────
605
1123
 
606
- function ExpertsPage({ t, embedded }) {
607
- const [tab, setTab] = useState('market')
1124
+ function ExpertsPage({ t, embedded, onClose }) {
1125
+ const [tab, setTab] = useState('builtin')
608
1126
  const [data, setData] = useState(null)
609
1127
  const [error, setError] = useState('')
610
1128
  const [search, setSearch] = useState('')
611
1129
  const [selected, setSelected] = useState(null) // {name, source}
1130
+ const [settingsOpen, setSettingsOpen] = useState(false)
612
1131
  const [busyName, setBusyName] = useState(null)
613
1132
  const [toast, setToast] = useState(null)
614
1133
  const showToast = (text) => { setToast(text); setTimeout(() => setToast(null), 2600) }
@@ -617,7 +1136,7 @@ window.__ModuleLoader__.load({
617
1136
  const rows = useMemo(() => {
618
1137
  if (!data) return []
619
1138
  const lower = search.trim().toLowerCase()
620
- const list = tab === 'installed' ? data.installed : data.market
1139
+ const list = tab === 'mine' ? data.mine : data.builtin
621
1140
  return list.filter((e) => matchExpert(e, lower))
622
1141
  }, [data, tab, search])
623
1142
  const install = async (row) => {
@@ -647,16 +1166,20 @@ window.__ModuleLoader__.load({
647
1166
  } catch (e) { showToast(String(e && e.message)) }
648
1167
  setBusyName(null)
649
1168
  }
650
- return h('div', { className: 'exp-page' },
1169
+ return h('div', { className: 'exp-page' + (embedded ? '' : ' exp-overlay') },
1170
+ !embedded ? h('div', { className: 'exp-head' },
1171
+ h('span', { className: 'exp-title' }, t('title')),
1172
+ h('span', { className: 'exp-spacer' }),
1173
+ h('button', { className: 'exp-btn', onClick: () => { if (onClose) onClose() } }, t('close'))) : null,
651
1174
  h('div', { className: 'exp-toolbar' },
652
1175
  h('div', { className: 'exp-tabs' },
653
- h('button', { className: 'exp-tab', 'data-on': tab === 'installed', onClick: () => setTab('installed') }, `${t('tabInstalled')}${data ? ` (${data.installed.length})` : ''}`),
654
- h('button', { className: 'exp-tab', 'data-on': tab === 'market', onClick: () => setTab('market') }, `${t('tabMarket')}${data ? ` (${data.market.length})` : ''}`)),
1176
+ h('button', { className: 'exp-tab', 'data-on': tab === 'mine', onClick: () => setTab('mine') }, `${t('tabMine')}${data ? ` (${data.mine.length})` : ''}`),
1177
+ h('button', { className: 'exp-tab', 'data-on': tab === 'builtin', onClick: () => setTab('builtin') }, `${t('tabBuiltin')}${data ? ` (${data.builtin.length})` : ''}`)),
655
1178
  h('input', { className: 'exp-input exp-search', placeholder: t('searchPlaceholder'), value: search, onChange: (e) => setSearch(e.target.value) }),
656
- h('span', { className: 'exp-count' }, `${rows.length}`)),
657
- tab === 'market' ? h(MarketSettingsCard, { t, onToast: showToast, onSynced: reload }) : null,
1179
+ h('span', { className: 'exp-count' }, `${rows.length}`),
1180
+ tab === 'builtin' ? h('button', { className: 'exp-btn', title: t('builtinSettings'), onClick: () => setSettingsOpen(true) }, t('builtinSettings')) : null),
658
1181
  error !== '' ? h('div', { className: 'exp-empty' }, `${t('loadFailed')}: ${error}`) : null,
659
- data !== null && rows.length === 0 ? h('div', { className: 'exp-empty' }, tab === 'installed' ? t('installedEmpty') : t('marketEmpty')) : null,
1182
+ data !== null && rows.length === 0 ? h('div', { className: 'exp-empty' }, tab === 'mine' ? t('mineEmpty') : t('builtinEmpty')) : null,
660
1183
  rows.length > 0
661
1184
  ? h(PagedGrid, {
662
1185
  items: rows,
@@ -668,6 +1191,9 @@ window.__ModuleLoader__.load({
668
1191
  onInstalled: () => { setSelected(null); showToast(t('installedDone')); reload() },
669
1192
  onDeleted: () => { setSelected(null); showToast(t('removedDone')); reload() },
670
1193
  }) : null,
1194
+ settingsOpen ? h(BuiltinSettingsDialog, {
1195
+ t, onClose: () => setSettingsOpen(false), onToast: showToast, onSynced: reload,
1196
+ }) : null,
671
1197
  toast !== null ? h('div', { className: 'exp-toast' }, toast) : null)
672
1198
  }
673
1199
 
@@ -679,6 +1205,7 @@ window.__ModuleLoader__.load({
679
1205
  __internals: {
680
1206
  NS, ZH, EN, matchExpert, formatSize, formatTime, avatarUrl,
681
1207
  EXPERT_SOURCE_NAME, makeExpertSource, openTriggerSource, fetchRoster,
1208
+ toRosterRows, insertComposerText, splitRosterByType, pickerRowMatch,
682
1209
  },
683
1210
  /** Test/host helper: mount a standalone page into any container. */
684
1211
  __boot(container, opts = {}) {
@@ -730,20 +1257,6 @@ window.__ModuleLoader__.load({
730
1257
  })
731
1258
  }
732
1259
  } catch (e) { try { console.error('[experts-management] composer inject:', e) } catch {} }
733
- ctx.effect(() => {
734
- try {
735
- ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
736
- name: 'sidebar.footer.action',
737
- id: CLIENT_NAME,
738
- order: 60,
739
- locale: NS,
740
- label: () => t('title'),
741
- inject: () => ({ t }),
742
- }, function FooterSlot(apiProps) {
743
- return h(FooterSlotComponent, { __t: t, wide: apiProps && apiProps.wide })
744
- }))
745
- } catch (e) { (globalThis.__expErrors = globalThis.__expErrors || []).push('footer:' + (e && e.message)); throw e }
746
- }, 'experts-management: sidebar footer action')
747
1260
  ctx.effect(() => {
748
1261
  try {
749
1262
  ctx.slots.inject('settings.section', () => ctx.slots.register({
@@ -769,8 +1282,8 @@ window.__ModuleLoader__.load({
769
1282
  inject: () => ({ t }),
770
1283
  }, function ExpertButtonSlot(apiProps) {
771
1284
  return h(ComposerButtonSlot, {
772
- __t: t, icon: '🧑‍💼', label: t('pickExpert'), title: t('pickExpertTitle'),
773
- source: EXPERT_SOURCE_NAME, composerScopeRef: () => composerScope,
1285
+ __t: t, label: t('pickExpert'), title: t('pickExpertTitle'),
1286
+ composerScopeRef: () => composerScope,
774
1287
  sessionId: apiProps && apiProps.sessionId, input: apiProps && apiProps.input,
775
1288
  })
776
1289
  }))
@@ -779,34 +1292,46 @@ window.__ModuleLoader__.load({
779
1292
  },
780
1293
  }
781
1294
 
782
- /** Footer slot entry: the button, and — when open — the whole experts page
783
- * portaled to <body> as a fullscreen overlay (same pattern as the skills
784
- * market footer entry). */
785
- function FooterSlotComponent(props) {
786
- const t = props.__t
787
- const [open, setOpen] = useState(false)
788
- useEffect(ensureStyles, [])
789
- if (!open) {
790
- return h('button', { className: 'exp-btn', onClick: () => setOpen(true), title: t('title') }, t('title'))
791
- }
792
- const page = h(ExpertsPage, { t, embedded: false, onClose: () => setOpen(false) })
793
- if (RDP && typeof RDP.createPortal === 'function') return RDP.createPortal(page, document.body)
794
- return page
795
- }
796
-
797
- /** Composer tool-row button: opens one registered '/' source over the
798
- * session's trigger controller. Hidden while the inputTriggers/sessions
799
- * services are absent (plugin composed without the trigger pipeline). */
1295
+ /** Composer tool-row button: 加号+文字 chip,点击在按钮上方打开自带搜索的
1296
+ * 专家 picker 浮层;pick slash/input-insert-text 写入 `/expert-<name> `。
1297
+ * 浮层面板盖住按钮以外的区域,再点一次按钮会先落在背板上——天然形成开关切换。
1298
+ * inputTriggers/sessions 服务缺席时按钮隐藏(管理页不受影响)。 */
800
1299
  function ComposerButtonSlot(props) {
801
1300
  useEffect(ensureStyles, [])
1301
+ const [picker, setPicker] = useState(null) // {left, top} 锚点快照;null = 关闭
1302
+ const [rows, setRows] = useState(null) // null = 加载中
1303
+ const btnRef = useRef(null)
1304
+ const liveInput = useRef(props.input)
1305
+ liveInput.current = props.input
802
1306
  const composerScope = props.composerScopeRef ? props.composerScopeRef() : null
803
- const ready = !!(composerScope && composerScope.inputTriggers && composerScope.sessions && props.sessionId)
1307
+ const ready = !!(composerScope && composerScope.sessions && props.sessionId)
804
1308
  if (!ready) return null
1309
+ const close = () => setPicker(null)
1310
+ const open = () => {
1311
+ let anchor = { left: 16, top: 160 }
1312
+ try { if (btnRef.current) anchor = btnRef.current.getBoundingClientRect() } catch {}
1313
+ setPicker({ left: anchor.left, top: anchor.top })
1314
+ setRows(null)
1315
+ Promise.resolve(fetchRoster())
1316
+ .then((list) => setRows(Array.isArray(list) ? list : []))
1317
+ .catch(() => setRows([]))
1318
+ }
1319
+ const pick = (row) => {
1320
+ insertComposerText(composerScope, props.sessionId, liveInput.current, `/${row.name} `)
1321
+ close()
1322
+ refocusComposer()
1323
+ }
1324
+ const popover = picker !== null && RDP && typeof RDP.createPortal === 'function'
1325
+ ? RDP.createPortal(h(ExpertPicker, { t: props.__t, anchor: picker, rows, onClose: close, onPick: pick }), document.body)
1326
+ : null
805
1327
  return h('button', {
806
1328
  className: 'exp-chip',
1329
+ ref: btnRef,
807
1330
  title: props.title || props.label,
808
- onClick: () => { openTriggerSource(composerScope, props.sessionId, props.input, props.source) },
809
- }, `${props.icon || ''}${props.icon ? ' ' : ''}${props.label}`)
1331
+ 'aria-haspopup': 'dialog',
1332
+ 'aria-expanded': picker !== null,
1333
+ onClick: open,
1334
+ }, props.label, popover)
810
1335
  }
811
1336
 
812
1337
  /** Settings section slot entry: render the page directly in the host tree. */