dsh-toolbox-ui 1.6.5 → 1.6.6

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/lib/client.js CHANGED
@@ -166,7 +166,12 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
166
166
  @keyframes mcpBlink{50%{opacity:.35}}
167
167
  .dsh-mcp-item-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
168
168
  .dsh-mcp-item-name{font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:.1px}
169
- .dsh-mcp-item-sub{font-size:10.5px;color:var(--mcp-mut);display:flex;gap:6px;align-items:center}
169
+ .dsh-mcp-item-sub{font-size:10.5px;color:var(--mcp-mut);display:flex;gap:6px;align-items:center;min-width:0;overflow:hidden;white-space:nowrap}
170
+ /* ⚠️ 子行必须有 min-width:0 + overflow:hidden:它在 flex 列里(.dsh-mcp-item-main 是
171
+ flex:1;min-width:0),只要里面放进任何**不收缩**的长文本(真机踩过:一整条根路径),
172
+ 它就会撑破 185px 的单元格并**没有省略号地**硬裁——显示成半截字,看起来像渲染坏了。
173
+ .dsh-mcp-item-name 早就有 ellipsis,子行此前漏了。 */
174
+ .dsh-mcp-item-sub>*{min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis}
170
175
  .dsh-mcp-badge{display:inline-flex;align-items:center;padding:1.5px 7px;border-radius:999px;font-size:10.5px;font-weight:500;line-height:16px;border:1px solid var(--mcp-line);color:var(--mcp-mut);background:var(--mcp-bg-1);corner-shape:round}
171
176
  .dsh-mcp-badge.dot::before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor;margin-right:5px;opacity:.85;corner-shape:round}
172
177
  .dsh-mcp-badge.t-http{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-state-business-tertiary)}
@@ -593,29 +598,30 @@ const sourceRank = (source) => SOURCE_RANK[source] ?? 999;
593
598
 
594
599
  function skillScopeOptions(rows) {
595
600
  const seen = new Map();
596
- for (const row of Array.isArray(rows) ? rows : []) {
601
+ for (const entry of Array.isArray(rows) ? rows : []) {
597
602
  // ⚠️ 去重键必须是**作用域身份(source + cwd)**,不是 source:两个工作区都会有
598
603
  // `project-agents`,只按 source 去重会把它们合成一项,还带上第一个的 root。
599
- for (const entry of [row, ...(Array.isArray(row?.disabled) ? row.disabled : [])]) {
600
- const source = entry?.source;
601
- if (typeof source !== 'string' || source === '' || seen.has(skillScopeIdentity(entry))) continue;
602
- seen.set(skillScopeIdentity(entry), {
603
- // 安装目标的身份与宿主一致:source + root(见 installTargetId)。
604
- id: installTargetId({ source, root: entry.root }),
605
- source,
606
- // 标签同样带项目名:筛选下拉里两行「项目 · .agents/skills」是没法选的。
607
- label: skillScopeLabel(entry),
608
- scope: entry.scope === 'project' ? 'project' : 'global',
609
- root: entry.root,
610
- cwd: entry.cwd,
611
- // wire 上的目标引用用的是 projectPath;这条退路上它等于行的 cwd。
612
- projectPath: entry.cwd,
613
- // ⚠️ writable 必须带下去:宿主在每一行上标了它(bundled 为 false),
614
- // 第一版把它丢了,于是「内置(只读)」既进了候选、还因为排在前面成了**默认**
615
- // 安装目标。注意它现在只是**显示用**:真正的可写与否由服务端判定(不接受载荷里的 writable)。
616
- writable: entry.writable !== false,
617
- });
618
- }
604
+ //
605
+ // 曾经这里还要展开 `row.disabled`(旧版把已停用技能放在**第二个数组**里)——
606
+ // 那个数组已经不存在了(停用是行上的派生态,见 SkillsManagerGateway.list)。
607
+ const source = entry?.source;
608
+ if (typeof source !== 'string' || source === '' || seen.has(skillScopeIdentity(entry))) continue;
609
+ seen.set(skillScopeIdentity(entry), {
610
+ // 安装目标的身份与宿主一致:source + root(见 installTargetId)。
611
+ id: installTargetId({ source, root: entry.root }),
612
+ source,
613
+ // 标签同样带项目名:筛选下拉里两行「项目 · .agents/skills」是没法选的。
614
+ label: skillScopeLabel(entry),
615
+ scope: entry.scope === 'project' ? 'project' : 'global',
616
+ root: entry.root,
617
+ cwd: entry.cwd,
618
+ // wire 上的目标引用用的是 projectPath;这条退路上它等于行的 cwd。
619
+ projectPath: entry.cwd,
620
+ // ⚠️ writable 必须带下去:宿主在每一行上标了它(bundled 为 false),
621
+ // 第一版把它丢了,于是「内置(只读)」既进了候选、还因为排在前面成了**默认**
622
+ // 安装目标。注意它现在只是**显示用**:真正的可写与否由服务端判定(不接受载荷里的 writable)。
623
+ writable: entry.writable !== false,
624
+ });
619
625
  }
620
626
  // 全局在前、项目在后(与宿主 rank 的语义一致:rank 小的先被采纳,用户级是默认视角)。
621
627
  return [...seen.values()].sort((left, right) => {
@@ -770,8 +776,8 @@ function skillTwinCounts(rows) {
770
776
  * 详情要渲染的那一行:**按身份从当前 rows 派生**。
771
777
  *
772
778
  * 为什么不是把选中的行对象存进 state:那样存的是"上一次取数时的那个对象"。面板里刚启停一个
773
- * 技能时列表会重新取数,而详情仍渲染旧对象——真机实测:磁盘上已经是 `SKILL.md.disabled`,
774
- * 详情却仍写「已启用」,点「刷新」也不变,只有重新选中那一行才对。派生之后这个状态无法存在。
779
+ * 技能时列表会重新取数,而详情仍渲染旧对象——真机实测:磁盘上的状态已经改了,详情却仍写旧值,
780
+ * 点「刷新」也不变,只有重新选中那一行才对。派生之后这个状态无法存在。
775
781
  * 选中项从列表里消失(被删掉/换了作用域)时返回 null,详情自然回到空态。
776
782
  */
777
783
  const selectedSkillRow = (rows, key) => (Array.isArray(rows) ? rows.find((row) => row?.key === key) ?? null : null)
@@ -792,8 +798,10 @@ const selectedSkillRow = (rows, key) => (Array.isArray(rows) ? rows.find((row) =
792
798
  * 谁生效由宿主的 providerOrder 决定,面板拿不到,但两个不同项目里的同名技能互不影响,
793
799
  * 标一份为胜者只是为了让提示有个主语,不会误导操作。
794
800
  *
795
- * 已停用的行不参与:它的文件是 `SKILL.md.disabled`,模型根本看不到它,说它"遮蔽/被遮蔽"
796
- * 都是假的。它只该出现在诊断里(见 skillDiagnostics)。
801
+ * 已停用的行不参与:两个调用面都关掉的技能,模型和用户都拿不到它,
802
+ * 说它"遮蔽/被遮蔽"都是假的。它只该出现在诊断里(见 skillDiagnostics)。
803
+ * ⚠️ 判据是 `enabled === false`——**不知道**(null)的行要参与:
804
+ * 它可能照样进了模型目录,把它排除掉才是漏报。
797
805
  *
798
806
  * @param {object[]} rows
799
807
  * @returns {object[]} 被遮蔽的行(附生效者信息)。
@@ -822,11 +830,9 @@ function shadowedSkills(rows) {
822
830
  /**
823
831
  * 汇总「用户需要知道但模型看不到」的条目。
824
832
  *
825
- * ⚠️ 输入只有**合并后的行**(已停用技能由宿主放进 `disabled`,面板合并进 rows 并打上
826
- * `enabled:false`)。上一版另外收 `disabled`/`illegal`/`blocked` 三个数组,而宿主只有一个
827
- * `disabled` 通道(`illegal` 从来没有生产者),调用方为了避免「同一条出现两次」只好传空数组
828
- * —— 于是这个函数**恒返回空**,整块诊断 UI 是死的(用例只测纯函数,看不见这件事)。
829
- * 现在它只从行推导,判据与行身份同源(`enabled === false` 就是「已停用」)。
833
+ * ⚠️ 输入只有**行**,判据与行身份同源:`enabled === false` 就是「两个调用面都关掉了」。
834
+ * 停用现在是 frontmatter 的两个字段,宿主照样把它报在目录里,所以这里不需要第二个数组,
835
+ * 也就不可能「同一条出现两次」或「整块 UI 恒为空」。
830
836
  *
831
837
  * 非法 frontmatter(宿主只 warn + skip)目前**没有**生产者,见 TODO row 21——
832
838
  * 在它落地之前,这里不假装能报。
@@ -838,14 +844,17 @@ function skillDiagnostics(rows) {
838
844
  }
839
845
 
840
846
  /**
841
- * detail 请求的参数:**送整条引用**(name + source + root + 项目路径)。
847
+ * detail 等动作请求的参数:**送整条引用**(source + root + 项目路径 + 磁盘条目 + 形态)。
842
848
  *
843
849
  * 为什么不能只送 name(或 name + cwd):宿主侧 `ctx.skills.get(name)` 按 rank 返回
844
- * **生效者**,而面板的模型是 (name, root) 精确操作(D8)——只送名字时,点「被遮蔽」
845
- * 那一行看到的是生效者的正文,用户以为自己在读被遮蔽的那份。现在宿主按引用定位到
846
- * 那个根再读文件,顺带连「已停用」的正文也读得出来(停用时路径是 `.disabled`)。
850
+ * **生效者**,而面板的模型是 (根, 条目) 精确操作(D8)——只送名字时,点「被遮蔽」
851
+ * 那一行看到的是生效者的正文,用户以为自己在读被遮蔽的那份。
852
+ *
853
+ * ⚠️ 送的是 **entry(磁盘条目名)而不是 name(技能名)**:宿主允许两者不一致
854
+ * (`dir-name/SKILL.md` 里写 `name: dir-alias` 照收),而定位必须按磁盘上的真实位置。
855
+ * name 只是显示用的身份,服务端从文件里自己读得出来。
847
856
  *
848
- * 单独成一个函数:这条不变量得有个能被直接断言的家,而不是埋在组件闭包里。
857
+ * 单独成一个函数:这条不变量得有个能被直接断言的家里,而不是埋在组件闭包里。
849
858
  */
850
859
  function skillDetailRequest(row) {
851
860
  return {
@@ -853,6 +862,8 @@ function skillDetailRequest(row) {
853
862
  source: row?.source,
854
863
  root: row?.root,
855
864
  projectPath: row?.cwd,
865
+ entry: row?.entry,
866
+ kind: row?.kind,
856
867
  };
857
868
  }
858
869
 
@@ -3199,7 +3210,7 @@ const McpTab = (props) => {
3199
3210
  )),
3200
3211
  );
3201
3212
 
3202
- // 能力层计数:技能的来自 SkillsPanel 回传的 list 结果(skills + disabled 之和)。
3213
+ // 能力层计数:技能的来自 SkillsPanel 回传的 list 结果(**行数**——已停用的技能也在列表里)。
3203
3214
  // MCP 面**不显示数字**——跨作用域总数外壳拿不到(list 只回答当前作用域那批),
3204
3215
  // 编一个出来比不显示更糟:用户会拿它当「一共有几个 MCP」。
3205
3216
  const abilityTabs = seg(
@@ -4099,12 +4110,23 @@ const SkillRow = ({ row, selected, onSelect, onToggle, busy, shadowedBy, twins }
4099
4110
  onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onSelect(row); } },
4100
4111
  },
4101
4112
  // 状态点沿用 MCP 那套语义:停用 = stopped(灰),可用 = connected(绿)。
4102
- h(Dot, { phase: row.enabled === false ? 'stopped' : 'connected' }),
4113
+ // 三态:读不出文件时(enabled === null)用 loading 的灰点——不假装知道它是哪一种。
4114
+ h(Dot, { phase: row.enabled === false ? 'stopped' : row.enabled === null ? 'loading' : 'connected' }),
4103
4115
  h('div', { className: 'dsh-mcp-item-main' },
4104
4116
  h('div', { className: 'dsh-mcp-item-name', title: row.name }, row.name),
4105
4117
  h('div', { className: 'dsh-mcp-item-sub' },
4106
4118
  // 作用域由**组头**承载,行内不再重复一条会被裁断的路径(这是分组的直接收益)。
4107
- row.modelInvocable === false ? h(Badge, { cls: 's-off' }, '仅用户可调用') : null,
4119
+ //
4120
+ // ⚠️ 两个调用面**各自**都可能被关掉,所以这里要按面各说一句——只标模型面的话,
4121
+ // 「仅模型可调用」(用户面关着)在列表上**看不出任何异常**,用户点进去才会发现
4122
+ // 自己没法用 /名字 调用它。顺序有讲究:停用是更强的结论(两个面都关了),先说它,
4123
+ // 否则停用的行会被说成「仅用户可调用」——用户会以为手动还能调用它,实际不能。
4124
+ row.enabled === false
4125
+ ? h(Badge, { cls: 's-off' }, row.migrated ? '旧机制停用' : '已停用')
4126
+ : [
4127
+ row.modelInvocable === false ? h(Badge, { key: 'no-model', cls: 's-off', title: '模型不会自动调用它' }, '模型不用') : null,
4128
+ row.userInvocable === false ? h(Badge, { key: 'no-user', cls: 's-off', title: '你不能用 /名字 手动调用它' }, '不可手调') : null,
4129
+ ],
4108
4130
  shadowedBy
4109
4131
  // 被遮蔽时说出**生效的那份在哪**:原先只说「被遮蔽」,用户找不到另一份。
4110
4132
  ? h(Badge, { cls: 's-err', title: '模型只会收到「' + skillScopeLabel(shadowedBy) + '」那一份' }, '被遮蔽')
@@ -4161,15 +4183,16 @@ function invocationUnknownReason(row) {
4161
4183
  }
4162
4184
 
4163
4185
  /** 管理模式的右侧详情:元信息 + 正文 + 启停/删除(与 MCP 面同一张详情卡片)。 */
4164
- const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove, onModelInvocable, tab = 'overview', onTab }) => {
4186
+ const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove, onModelInvocable, onUserInvocable, tab = 'overview', onTab }) => {
4165
4187
  if (!row) return h('div', { className: 'dsh-mcp-detail' }, h('div', { className: 'dsh-mcp-d-empty' },
4166
4188
  h('b', null, '选择左侧的一个技能'),
4167
4189
  h('span', null, '这里会显示它的位置与调用权限、SKILL.md 正文,以及遮蔽或校验失败时的诊断。')));
4168
4190
  const disabled = row.enabled === false;
4191
+ // 三态:true / false / null(读不到文件时不知道两个调用面是否都关掉了)。
4192
+ const enabledKnown = row.enabled === true || row.enabled === false;
4169
4193
  const readonly = row.writable === false;
4170
4194
  // 调用权限的**三态**:true / false / 未知。未知是真实的一态——宿主对 frontmatter 非法的技能
4171
- // 整条跳过,那时「能不能被模型调用」没有答案。此前它被渲染成「不可调用」,那是凭空的断言
4172
- // (已停用的行尤其明显:它们的权限只有我们读得到的那份文件能回答)。
4195
+ // 整条跳过,那时「能不能被模型调用」没有答案。此前它被渲染成「不可调用」,那是凭空的断言。
4173
4196
  const invocationKnown = row.modelInvocable === true || row.modelInvocable === false;
4174
4197
  const paneOff = (id) => detailPaneOff(tab, id);
4175
4198
  const tabBtn = (id, label, count) => detailTab(tab, onTab, id, label, count);
@@ -4199,11 +4222,13 @@ const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove,
4199
4222
  readonly ? h(Badge, { cls: 's-lock' }, '内置只读') : null,
4200
4223
  ),
4201
4224
  h('div', { className: 'dsh-mcp-d-meta' },
4202
- h(Badge, { cls: disabled ? 's-off' : shadowedBy ? 's-warn' : 's-on' }, disabled ? '已停用' : shadowedBy ? '被遮蔽' : '已启用'),
4225
+ h(Badge, { cls: disabled ? 's-off' : shadowedBy ? 's-warn' : 's-on' },
4226
+ disabled ? '已停用' : shadowedBy ? '被遮蔽' : enabledKnown ? '已启用' : '状态未知'),
4203
4227
  invocationKnown
4204
4228
  ? h(Badge, { cls: row.modelInvocable ? null : 's-off' }, row.modelInvocable ? '模型可调用' : '模型不可调用')
4205
4229
  : h(Badge, { cls: 's-off', title: invocationUnknownReason(row) }, '调用权限未知'),
4206
4230
  invocationKnown && !row.userInvocable ? h(Badge, { cls: 's-off' }, '用户不可调用') : null,
4231
+ row.migrated ? h(Badge, { cls: 's-warn', title: '这是旧版启停机制(改名成 .disabled)留下的条目;恢复后请用新的停用方式' }, '旧机制残留') : null,
4207
4232
  ),
4208
4233
  h('p', { className: 'dsh-mcp-d-desc' }, row.description || '(无说明)'),
4209
4234
  ),
@@ -4236,10 +4261,18 @@ const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove,
4236
4261
  h('div', { className: 'dsh-mcp-kv' },
4237
4262
  // 作用域与根目录合成一行:分组之后「哪一级」已由组头说过,这里只补完整路径。
4238
4263
  h('b', null, '位置'), h('span', null, (row.root || '(未报告)') + '(' + skillScopeLabel(row) + ')'),
4239
- h('b', null, '状态'), h('span', null, disabled ? '已停用(SKILL.md.disabled)' : '已启用'),
4240
- // 「模型自动调用」是**可操作**的那一项(issue #5:快速改 disable-model-invocation,
4241
- // 不必手工去翻 SKILL.md)。写在 SKILL.md 的 frontmatter 里,所以只在可写根上给开关;
4242
- // 权限读不出来时不装懂——开关禁用并写明未知。
4264
+ // 「停用」的落点是**文件内容**(frontmatter 的两个调用面字段),不是文件名——
4265
+ // 旧机制(改名 .disabled)只在迁移读取时才会出现在状态里,如实标出来。
4266
+ h('b', null, '状态'), h('span', null,
4267
+ row.migrated
4268
+ ? '旧机制停用(SKILL.md.disabled)——点「启用」把它改回来'
4269
+ : disabled
4270
+ ? '已停用(disable-model-invocation: true + user-invocable: false)'
4271
+ : enabledKnown ? '已启用' : '未知(读不到这个文件)'),
4272
+ // 两个调用面**各自可写**:宿主支持的就是两个面,而「已停用」只是「两个都关」这一种组合。
4273
+ // 只给一个面开关的话,用户在面板里拨不回「仅手动可调用」那一档——只能去编辑文件。
4274
+ // 写在 SKILL.md 的 frontmatter 里,所以只在可写根上给开关;权限读不出来时不装懂
4275
+ // ——开关禁用并写明未知。
4243
4276
  h('b', null, '模型自动调用'),
4244
4277
  h('span', null,
4245
4278
  h(Toggle, {
@@ -4249,14 +4282,29 @@ const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove,
4249
4282
  title: readonly
4250
4283
  ? '内置技能只读'
4251
4284
  : invocationKnown
4252
- ? '写入 SKILL.md 的 disable-model-invocation;宿主重新发现这条技能后才影响模型(重启 DSH 最稳妥)'
4285
+ ? '写入 SKILL.md 的 disable-model-invocation。宿主用文件监听盯技能根,改动通常不到一秒就生效(实测 100~300ms),不必重启'
4253
4286
  : invocationUnknownReason(row),
4254
4287
  onChange: () => onModelInvocable(row, row.modelInvocable !== true),
4255
4288
  }),
4256
4289
  ' ',
4257
4290
  invocationKnown ? (row.modelInvocable ? '允许' : '禁止') : '未知',
4258
4291
  ),
4259
- h('b', null, '用户可调用'), h('span', null, invocationKnown ? (row.userInvocable ? '是' : '否') : '未知'),
4292
+ h('b', null, '用户可调用'),
4293
+ h('span', null,
4294
+ h(Toggle, {
4295
+ on: row.userInvocable === true,
4296
+ disabled: busy || readonly || !invocationKnown,
4297
+ label: row.name + ':允许用户手动调用',
4298
+ title: readonly
4299
+ ? '内置技能只读'
4300
+ : invocationKnown
4301
+ ? '写入 SKILL.md 的 user-invocable:关掉后你就不能用 /名字 手动调用它(模型仍可自动调用)。与上面那个一起关掉就是「已停用」'
4302
+ : invocationUnknownReason(row),
4303
+ onChange: () => onUserInvocable(row, row.userInvocable !== true),
4304
+ }),
4305
+ ' ',
4306
+ invocationKnown ? (row.userInvocable ? '允许' : '禁止') : '未知',
4307
+ ),
4260
4308
  ),
4261
4309
  ),
4262
4310
  h('div', { className: 'dsh-mcp-card' + paneOff('text') },
@@ -4520,21 +4568,34 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4520
4568
  summaryRef.current?.({ state: 'down', count: null, error: result.error || '', scopeTabs: [] });
4521
4569
  return result;
4522
4570
  }
4523
- // 宿主的 `list` 返回的是**两个数组**(skills / disabled),不是带 enabled 字段的一坨。
4524
- // 停用态因此由「在哪一个数组里」表达——这里统一打上 enabled 标志,
4525
- // 让列表、筛选、开关三处读同一个字段,而不是各自去猜数组来源。
4526
- const next = [
4527
- ...skillRows(result.skills || []).map((row) => ({ ...row, enabled: true })),
4528
- ...skillRows(result.disabled || []).map((row) => ({ ...row, enabled: false })),
4529
- ];
4571
+ // 宿主返回的是**一个**数组:停用不再是「在另一个数组里」,而是行上的派生态
4572
+ // (enabled = 两个调用面是否都关掉,见 lib/skills-registry.js 的 toView)。
4573
+ // 旧版是两个数组、停用件由宿主扫盘补一份——那套做法的代价见 SkillsManagerGateway.list。
4574
+ const next = skillRows(result.skills || []);
4530
4575
  setRows(next);
4531
- // 计数是**两个数组之和**:已停用的技能也是技能,面板里看得见、也能被重新启用,
4532
- // 只数 skills 会让「能力层写着 8、列表里有 11 行」这种自相矛盾出现。
4576
+ // 计数就是行数:已停用的技能也是技能(面板里看得见、也点得动),
4577
+ // 只数可调用的会让「能力层写着 8、列表里有 11 行」这种自相矛盾出现。
4533
4578
  summaryRef.current?.({ state: 'ready', count: next.length, error: '', scopeTabs: skillScopeTabs(next) });
4534
4579
  return result;
4535
4580
  }, [call, workspaces]);
4536
4581
 
4537
4582
  useEffect(() => { void load(); }, [load]);
4583
+ // 切回这个标签页时重取一次列表:**宿主侧本来就已经自动发现**(它用 chokidar 监听技能根,
4584
+ // 新增/改名/删除技能无需重启即到达模型),所以面板缺的只是「知道自己该重取一次」。
4585
+ //
4586
+ // ⚠️ 为什么不是订阅 `skills/change` 做到推送式刷新:那个事件**不在**宿主的 Remote 转发
4587
+ // 白名单里,而白名单由 `dsh-api-remotes` 独占(`registerRemoteEvents` 只接受一个源,
4588
+ // 第二个注册直接抛 already registered)——第三方插件没有合法途径把它送到浏览器。
4589
+ // 这是契约没开这个口,不是我们偷懒;如实写在这里,免得下一轮有人又去试一遍。
4590
+ //
4591
+ // 也不轮询:技能变化远没有 MCP 连接状态那么频繁,而每次 list 都要扫全部技能根并逐个读文件
4592
+ // 解析 frontmatter。焦点重取的收益/成本比明显更好。
4593
+ useEffect(() => {
4594
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return undefined;
4595
+ const onVisibilityChange = () => { if (document.visibilityState !== 'hidden') void load(); };
4596
+ document.addEventListener('visibilitychange', onVisibilityChange);
4597
+ return () => document.removeEventListener('visibilitychange', onVisibilityChange);
4598
+ }, [load]);
4538
4599
  useEffect(() => {
4539
4600
  // 安装目标:问宿主「可以写到哪里」。拿不到(老宿主)就留空,由 skillTargetOptions 退回归发现集。
4540
4601
  void (async () => {
@@ -4578,9 +4639,7 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4578
4639
  // 而 importSkill 不传 writable 时宿主按可写处理 → 会直接写进内置目录)。
4579
4640
  const installTargets = scopeOptions.filter((option) => option.writable !== false);
4580
4641
  const shadowed = shadowedSkills(rows);
4581
- // ⚠️ 诊断只从**合并后的行**推导(已停用技能由宿主放进 disabled、面板合并进 rows)。
4582
- // 上一版这里传 `{skills: rows, disabled: []}`:宿主没有 illegal 通道,disabled 又被清空,
4583
- // 于是 `skillDiagnostics` 恒返回空 —— 整块诊断 UI 是死的(见函数上的注释)。
4642
+ // 诊断只从**行**推导(停用是两个调用面字段的派生态,见 lib/skills-registry.js 的 toView)。
4584
4643
  const diagnostics = skillDiagnostics(rows);
4585
4644
 
4586
4645
  // 加载某一行的正文。三态必须都落到 UI 上:失败没有终态,就会长得像「一直在加载」。
@@ -4617,12 +4676,11 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4617
4676
  }, [call, flash, load]);
4618
4677
 
4619
4678
  const toggleRow = useCallback(async (row, enable) => {
4620
- // 双向:MCP 面每行都有启用/停用开关,技能面必须同样能**恢复**已停用的技能
4621
- // (原先只有停用单向,已停用条目只能看、没有任何恢复入口)。
4679
+ // 双向:MCP 面每行都有启用/停用开关,技能面必须同样能**恢复**已停用的技能。
4622
4680
  const next = enable === undefined ? row.enabled === false : enable === true;
4623
- // 只送**引用**(source + root + 项目路径),不送 writable:可写与否由服务端判定,
4624
- // 客户端传什么不算数(否则「只读根不可写」只是客户端的礼貌)。
4625
- await act('setEnabled', { name: row.name, source: row.source, root: row.root, projectPath: row.cwd, enabled: next });
4681
+ // 只送**引用**(source + root + 项目路径 + 磁盘条目 + 形态),不送 writable:可写与否由服务端
4682
+ // 判定,客户端传什么不算数(否则「只读根不可写」只是客户端的礼貌)。
4683
+ await act('setEnabled', { ...skillDetailRequest(row), enabled: next });
4626
4684
  }, [act]);
4627
4685
 
4628
4686
  const toggleModelInvocable = useCallback(async (row, modelInvocable) => {
@@ -4630,7 +4688,15 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4630
4688
  // 与启停同一条纪律:只送引用,可写与否由服务端判定。
4631
4689
  // 重新取数在 act 里:行上的权限来自**磁盘上的那个文件**(宿主侧从文件解析),刷出来的就是
4632
4690
  // 真正写下去的值;被拒绝时文件没变,开关如实停在原位,而原因由 act 说出来。
4633
- await act('setModelInvocable', { name: row.name, source: row.source, root: row.root, projectPath: row.cwd, modelInvocable: modelInvocable === true });
4691
+ await act('setModelInvocable', { ...skillDetailRequest(row), modelInvocable: modelInvocable === true });
4692
+ }, [act]);
4693
+
4694
+ const toggleUserInvocable = useCallback(async (row, userInvocable) => {
4695
+ // 「用户可调用」的开关:写技能文件里的 user-invocable。
4696
+ // 它存在的理由与「停用」直接相关:停用 = 两个面都关,而只给一个面开关的话,
4697
+ // 用户在面板里拨不回「仅手动可调用」那一档(只能去编辑文件)。
4698
+ // 纪律与上面那个完全一致(只送引用、可写与否由服务端判定、失败要把原因说出来)。
4699
+ await act('setUserInvocable', { ...skillDetailRequest(row), userInvocable: userInvocable === true });
4634
4700
  }, [act]);
4635
4701
 
4636
4702
  const removeRow = useCallback(async (row) => {
@@ -4638,7 +4704,7 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4638
4704
  // 之前这个菜单项接的是 toggleRow —— 点删除只会把技能停用,还报成功。
4639
4705
  if (!confirm('确认删除技能「' + row.name + '」?' + (row.kind === 'bundle' ? '整个目录(含 scripts/references)' : '该文件') + '会从磁盘移除,不可撤销。')) return;
4640
4706
  // 删成功才清选中:失败时那一行还在,清掉选中只会让用户面对一个空详情、还不知道为什么。
4641
- if (await act('removeSkill', { name: row.name, source: row.source, root: row.root, projectPath: row.cwd })) setSelectedKey('');
4707
+ if (await act('removeSkill', skillDetailRequest(row))) setSelectedKey('');
4642
4708
  }, [act]);
4643
4709
 
4644
4710
  const confirmInstall = useCallback(async (target) => {
@@ -4744,8 +4810,13 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4744
4810
  h('div', { className: 'dsh-mcp-item-main' },
4745
4811
  h('div', { className: 'dsh-mcp-item-name' }, row.name),
4746
4812
  h('div', { className: 'dsh-mcp-item-sub' },
4747
- h(Badge, { cls: 's-off' }, '已停用'),
4748
- h('span', null, row.root || ''),
4813
+ // ⚠️ 这里以前还塞了一整条**根路径**(`row.root`)。真机实测:那串路径在
4814
+ // 侧栏 185px 的单元格里溢出(scrollWidth 237 > clientWidth 185),
4815
+ // 而且 `overflow:hidden` **没有省略号**,于是那行显示成「已」后面什么都没有
4816
+ // ——用户看到的是半截字,还以为是渲染坏了。
4817
+ // 路径本来就在**组头**的 title 里(组的身份就是 source + cwd),
4818
+ // 行内再放一遍既冗余又撑破布局。这里只说状态。
4819
+ h(Badge, { cls: 's-off' }, row.migrated ? '旧机制停用' : '已停用'),
4749
4820
  ),
4750
4821
  ),
4751
4822
  // 已停用条目也要能一键恢复:原先它们只能看不能动。
@@ -4774,6 +4845,7 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4774
4845
  onRetry: () => void loadBody(selected),
4775
4846
  onToggle: toggleRow,
4776
4847
  onModelInvocable: toggleModelInvocable,
4848
+ onUserInvocable: toggleUserInvocable,
4777
4849
  onRemove: removeRow,
4778
4850
  }),
4779
4851
  )
@@ -5078,6 +5150,7 @@ const MCP_CONTRIBUTION = {
5078
5150
  sk('detail', [skParam('payload', 'SkillPayload')], 'SkillDetailResult'),
5079
5151
  sk('setEnabled', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
5080
5152
  sk('setModelInvocable', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
5153
+ sk('setUserInvocable', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
5081
5154
  sk('removeSkill', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
5082
5155
  sk('importSkill', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
5083
5156
  sk('skillTargets', [skParam('payload', 'SkillPayload')], 'SkillTargetsResult'),