fix(B02): show installed recommendation actions and remove duplicate navigation

This commit is contained in:
Codex committed 2026-10-21 13:56:37 +09:00
1 parent 9f1e583e23
commit 14a1354c95
7 files changed
+196 -25

No files matched your search

+1
View File
@@ -10,6 +10,7 @@
"test:capability-slash:ui": "node scripts/test-capability-slash-ui.mjs", "test:capability-slash:ui": "node scripts/test-capability-slash-ui.mjs",
"test:recommended-skills": "esbuild scripts/recommended-skills-backend-regression.ts --bundle --platform=node --format=esm --external:esbuild --outfile=.tmp/recommended-skills-backend-regression.mjs --log-level=warning && node .tmp/recommended-skills-backend-regression.mjs", "test:recommended-skills": "esbuild scripts/recommended-skills-backend-regression.ts --bundle --platform=node --format=esm --external:esbuild --outfile=.tmp/recommended-skills-backend-regression.mjs --log-level=warning && node .tmp/recommended-skills-backend-regression.mjs",
"test:recommended-skills:ui": "node scripts/test-recommended-skills-ui.mjs", "test:recommended-skills:ui": "node scripts/test-recommended-skills-ui.mjs",
"test:recommended-skills:dom": "node scripts/test-recommended-skills-dom.mjs",
"test:recommended-skills:app": "node scripts/verify-recommended-skills-app.cjs", "test:recommended-skills:app": "node scripts/verify-recommended-skills-app.cjs",
"test:recommended-skills:package": "node scripts/test-recommended-archify-package.mjs", "test:recommended-skills:package": "node scripts/test-recommended-archify-package.mjs",
"test:composer-capabilities": "node scripts/run-composer-capabilities-regression.mjs", "test:composer-capabilities": "node scripts/run-composer-capabilities-regression.mjs",
+110
View File
@@ -0,0 +1,110 @@
/** Offline real React DOM regression. Not a browser layout/pixel or native Electron test.
* Usage: YCODE_TEST_DOM_MODULE=/path/to/node_modules/jsdom node scripts/test-recommended-skills-dom.mjs
* --build-only creates a browser fixture without loading jsdom or Electron. No dependency installation. */
import fs from 'node:fs'
import path from 'node:path'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const base = path.resolve('.tmp'); fs.mkdirSync(base, { recursive: true })
const output = fs.mkdtempSync(path.join(base, 'recommended-skills-dom-'))
const source = path.resolve('src/renderer/src/capabilities/CapabilityCenter.tsx')
fs.writeFileSync(path.join(output, 'fixture.tsx'), `import React from 'react';import{createRoot}from'react-dom/client';import SkillsPage from${JSON.stringify(source)};const root=createRoot(document.getElementById('root'));window.__mount=()=>root.render(<SkillsPage initialTab="skills" onBackToChat={()=>{}} cwd="/controlled/workspace" onUseSkill={s=>window.__uses.push(s)}/>);window.__unmount=()=>root.unmount();window.__mount();`)
await require('esbuild').build({ entryPoints: [path.join(output, 'fixture.tsx')], outfile: path.join(output, 'fixture.js'), bundle: true, platform: 'browser', format: 'iife', jsx: 'automatic', loader: { '.svg': 'dataurl', '.png': 'dataurl', '.jpg': 'dataurl', '.woff': 'dataurl', '.woff2': 'dataurl', '.ttf': 'dataurl' }, logLevel: 'error' })
if (process.argv.includes('--build-only')) { console.log(JSON.stringify({ result: 'BUILD_ONLY', output, limits: 'Compiled real React/CSS only; no UI execution or visual verification.' })); process.exit(0) }
let JSDOM
try { ({ JSDOM } = require(process.env.YCODE_TEST_DOM_MODULE || process.env.YCODE_JSDOM_MODULE || 'jsdom')) } catch { throw Error('Provide an already-installed jsdom through YCODE_TEST_DOM_MODULE; this test never installs dependencies.') }
const rec = { id:'archify', name:'Archify', description:'Controlled recommendation', author:'fixture', license:'MIT', repository:'fixture/archify', version:'3', commit:'a'.repeat(40), packagePath:'archify.zip', sha256:'b'.repeat(64), expectedBytes:100, containsScripts:true }
const current = { id:'user:rec', name:rec.name, description:rec.description, version:rec.version, path:'/controlled/archify', scope:'user', enabled:true, userInvocable:true, disableModelInvocation:false, source:'imported', userModified:false, origin:{recommendationId:rec.id, repository:rec.repository, commit:rec.commit, packagePath:rec.packagePath, archiveSha256:rec.sha256, version:rec.version, installedAt:1} }
const checks = [], errors = []
let installed=[], failEnable=false, failCatalog=false, catalog=[rec], changed=new Set(), progressListeners=new Set(), progress=null, calls=[], sequence=0, holdInstall=null
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { url:'https://fixture.invalid', runScripts:'outside-only', pretendToBeVisual:true })
const w=dom.window, d=w.document
w.addEventListener('error', e=>errors.push(e.message))
w.__uses=[]
w.fetch=()=>{ throw Error('Network is forbidden in this fixture') }
w.matchMedia=()=>({ matches:false, addEventListener(){}, removeEventListener(){} })
w.ResizeObserver=class{observe(){} unobserve(){} disconnect(){}}
const notify=()=>{ for(const listener of changed) listener() }
const emit=(phase, operationId='foreign')=>{progress={phase,operationId,recommendationId:rec.id,receivedBytes:0,totalBytes:100,updatedAt:++sequence};for(const listener of progressListeners)listener(progress)}
w.ycode={skills:{
list:async()=>installed, recommendations:async()=>{if(failCatalog)throw Error('controlled catalog failure'); return catalog},
onChanged:fn=>{changed.add(fn);return()=>changed.delete(fn)}, onInstallProgress:fn=>{progressListeners.add(fn);return()=>progressListeners.delete(fn)}, installState:async()=>progress,
read:async()=>({skill:current,content:'Controlled content',files:[]}),
setEnabled:async(id,enabled)=>{calls.push(['enable',id]);if(failEnable)throw Error('controlled enable failure');installed=installed.map(s=>s.id===id?{...s,enabled}:s);notify()},
installRecommended:async(id,options)=>{calls.push(['install',id,options]);emit('downloading',options.operationId);await new Promise(resolve=>{holdInstall=resolve});installed=[current];emit('installed',options.operationId);notify();return current},
cancelInstall:async()=>({cancelled:true}),remove:async()=>{},export:async()=>null,importPath:async()=>null
},resources:{openExternal:async()=>{throw Error('External navigation forbidden')}}}
const pause=()=>new Promise(resolve=>setTimeout(resolve,15))
const wait=async(fn,label='condition')=>{for(let i=0;i<150;i++){if(fn())return;await pause()}throw Error('Timed out: '+label)}
const check=(label,value)=>{assert.ok(value,label); checks.push(label)}
const card=()=>d.querySelector('.yc-skill-recommendation-card')
const button=label=>d.querySelector(`[aria-label="${label}"]`)
const click=async(el)=>{assert.ok(el,'click target exists');el.click();await pause()}
const set=async(skills,state)=>{installed=skills;notify();await wait(()=>card()?.dataset.recommendationState===state,state);await pause()}
const close=async()=>{if(button('关闭技能详情'))await click(button('关闭技能详情'))}
try {
w.eval(fs.readFileSync(path.join(output,'fixture.js'),'utf8'))
await wait(()=>card(),'initial render')
check('uninstalled uses add slot',!!card().querySelector('.sm-add-btn'))
check('normal footer removed from DOM',!d.querySelector('.yc-skill-recommendation-footer')&&!d.body.textContent.includes('查看已安装技能'))
await set([current],'current')
check('current uses installed CardFrame',!!card().querySelector('.sm-card--installed'))
check('current removes Plus/add DOM',!card().querySelector('.sm-add-btn'))
check('current use occupies status slot exactly once',card().querySelectorAll('.sm-status .sm-use-btn').length===1&&!card().querySelector('.yc-skill-recommendation-meta button'))
await click(card().querySelector('.sm-use-btn'))
check('use invokes one draft callback without installation',w.__uses.length===1&&!calls.some(c=>c[0]==='install'))
check('use does not open install detail',!d.querySelector('[role=dialog]'))
check('top nav count reflects installed list',button('已安装技能').textContent.includes('(1)'))
await click(button('已安装技能'));await wait(()=>d.querySelector('[data-skill-id="user:rec"]'))
await click(button('返回专家中心'));await wait(()=>card())
check('reopen retains current state',card().dataset.recommendationState==='current')
await set([{...current,enabled:false}],'current');failEnable=true
await click(card().querySelector('.sm-use-btn'));await wait(()=>d.body.textContent.includes('controlled enable failure'))
check('enable failure prevents draft invocation',w.__uses.length===1)
failEnable=false;await click(card().querySelector('.sm-use-btn'));await wait(()=>w.__uses.length===2)
check('disabled skill enabled before callback',w.__uses.at(-1).enabled===true&&calls.filter(c=>c[0]==='enable').length===2)
await set([{...current,userInvocable:false}],'current')
check('non-invocable current cannot use or reinstall',card().querySelector('.sm-use-btn')?.disabled&&!card().querySelector('.sm-add-btn'))
for(const [name,skill,state,label] of [
['outdated',{...current,origin:{...current.origin,version:'2'}},'outdated','更新 Archify'],
['modified',{...current,userModified:true},'modified','替换 Archify'],
['broken',{...current,error:'bad document'},'outdated','修复 Archify'],
['same-name local',{...current,origin:undefined,userModified:true},'conflict','替换 Archify'],
['broken same-name local',{...current,origin:undefined,error:'bad local'},'conflict','替换 Archify'],
['different source',{...current,origin:{...current.origin,repository:'other/source'}},'outdated','更新 Archify']
]) {
await set([skill],state);check(name+' installed identity distinction',!!card().querySelector('.sm-card--installed')===(state!=='conflict'));check(name+' not represented as first add',!card().querySelector('.sm-add-btn')&&!!button(label));const before=calls.length
await click(button(label));check(name+' requires explicit confirmation',!!button('确认安装推荐技能')&&calls.length===before);await close()
}
await set([{...current,id:'workspace:rec',scope:'workspace'}],'none')
check('workspace is not treated as managed current',!!card().querySelector('.sm-add-btn')&&!card().querySelector('.sm-card--installed'))
await set([],'none');await click(button('安装 Archify'))
const staleConfirm=button('确认安装推荐技能');const beforeExternal=calls.filter(c=>c[0]==='install').length
await set([current],'current');staleConfirm.click();await pause()
check('external current while confirming prevents duplicate installation',calls.filter(c=>c[0]==='install').length===beforeExternal);await close()
await set([],'none');await click(button('安装 Archify'))
check('first install confirms provenance and scripts',d.querySelector('[role=dialog]').textContent.includes('fixture/archify')&&d.querySelector('[role=dialog]').textContent.includes('不会执行仓库中的任何安装命令'))
const confirm=button('确认安装推荐技能');confirm.click();confirm.click();await wait(()=>holdInstall)
check('duplicate confirmation issues only one request',calls.filter(c=>c[0]==='install').length===1)
check('real host progress rendered',d.querySelector('[data-install-phase="downloading"]'))
holdInstall();await wait(()=>card()?.dataset.recommendationState==='current');await close()
check('install success automatically switches action DOM',!card().querySelector('.sm-add-btn')&&!!card().querySelector('.sm-use-btn'))
await set([],'none');check('external uninstall restores add',!!card().querySelector('.sm-add-btn'))
emit('downloading');await wait(()=>d.querySelector('[data-foreign-install=true]'))
check('external install blocks new add',card().querySelector('.sm-add-btn').disabled)
emit('installed');await wait(()=>!d.querySelector('[data-foreign-install=true]'))
check('external terminal progress clears block',!card().querySelector('.sm-add-btn').disabled)
// Unmount/remount via existing navigation reloads catalog, without touching real storage.
failCatalog=true;await click(button('已安装技能'));await click(button('返回专家中心'));await wait(()=>d.querySelector('.yc-skill-recommendation-unavailable'))
check('unavailable has no duplicate installed entry',!d.body.textContent.includes('查看已安装技能')&&!!button('已安装技能'))
failCatalog=false;await click(d.querySelector('.yc-skill-recommendation-unavailable button'));await wait(()=>card())
check('catalog retry recovers in place',!!card())
catalog=[];await click(button('已安装技能'));await click(button('返回专家中心'));await wait(()=>d.querySelector('.yc-skill-recommendation-unavailable'))
check('empty catalog has no footer',!d.querySelector('.yc-skill-recommendation-footer')&&!d.body.textContent.includes('查看已安装技能'))
for(const tab of ['SkillHub','套件']) {await click([...d.querySelectorAll('[role=tab]')].find(e=>e.textContent===tab));check(tab+' has only top installed navigation',!d.body.textContent.includes('查看已安装技能')&&!!button('已安装技能'))}
check('no uncaught renderer errors',errors.length===0)
const report={result:'PASS',checks,calls,errors,limits:'Real React in offline jsdom; callback boundary tested, not production composer dispatch. No native Chromium, computed layout, screenshot, theme/hover/focus pixels, model SDK, actual install or network.'}
fs.writeFileSync(path.join(output,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify({result:'PASS',checks:checks.length,output}))
} catch(error) {fs.writeFileSync(path.join(output,'failure.json'),JSON.stringify({error:String(error),stack:error.stack,checks,errors,html:d.body.innerHTML},null,2));console.error('Evidence:',output);throw error}
finally {w.__unmount?.();w.close()}
+44 -10
View File
@@ -17,7 +17,7 @@ if (!electron) {
const env = { ...process.env } const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE delete env.ELECTRON_RUN_AS_NODE
delete env.ELECTRON_RENDERER_URL delete env.ELECTRON_RENDERER_URL
const child = spawn(electronModule, ['--no-sandbox', '--disable-gpu-sandbox', path.resolve('scripts/test-recommended-skills-ui.mjs'), output], { stdio: 'inherit', windowsHide: true, env }) const child = spawn(electronModule, [path.resolve('scripts/test-recommended-skills-ui.mjs'), output], { stdio: 'inherit', windowsHide: true, env })
const timer = setTimeout(() => child.kill(), 120000) const timer = setTimeout(() => child.kill(), 120000)
process.exitCode = await new Promise(resolve => child.on('exit', code => { clearTimeout(timer); resolve(code ?? 1) })) process.exitCode = await new Promise(resolve => child.on('exit', code => { clearTimeout(timer); resolve(code ?? 1) }))
} else { } else {
@@ -28,7 +28,7 @@ if (!electron) {
const userData = path.join(output, 'data') const userData = path.join(output, 'data')
fs.mkdirSync(userData, { recursive: true }) fs.mkdirSync(userData, { recursive: true })
app.setPath('userData', userData) app.setPath('userData', userData)
const checks = [], errors = [], calls = [], screenshots = [] const checks = [], errors = [], calls = [], screenshots = [], actionStyles = []
const recommendation = { const recommendation = {
id: 'archify', id: 'archify',
@@ -197,6 +197,29 @@ createRoot(document.getElementById('root')).render(
screenshots.push(name + '.png') screenshots.push(name + '.png')
} }
const verifyCurrentActionStyles = async theme => {
const selector = '[data-recommendation-id=archify] .sm-use-btn'
const inspect = () => read(`(()=>{const b=document.querySelector(${JSON.stringify(selector)});const s=getComputedStyle(b);return{width:s.width,height:s.height,background:s.backgroundColor,color:s.color,outline:s.outline,focused:document.activeElement===b,hover:b.matches(':hover')}})()`)
win.webContents.sendInputEvent({type:'mouseMove',x:1,y:1})
await read(`document.activeElement?.blur()`); await pause(200)
const normal = await inspect()
check(theme + ' use action default background is transparent', normal.background, value => value === 'rgba(0, 0, 0, 0)' || value === 'transparent')
const point = await read(`(()=>{const r=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();return{x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)}})()`)
win.webContents.sendInputEvent({type:'mouseMove',...point});await pause(200)
const hover = await inspect()
check(theme + ' pointer enters use action', hover.hover, true)
check(theme + ' hover has a distinct background', hover.background !== normal.background, true)
await capture('installed-use-' + theme + '-hover')
win.webContents.sendInputEvent({type:'mouseMove',x:1,y:1})
win.webContents.sendInputEvent({type:'keyDown',keyCode:'Tab'});win.webContents.sendInputEvent({type:'keyUp',keyCode:'Tab'})
await read(`document.querySelector(${JSON.stringify(selector)}).focus()`);await pause(200)
const focus = await inspect()
check(theme + ' use action remains focusable with stable 24px slot', focus.focused && focus.width === '24px' && focus.height === '24px', true)
await capture('installed-use-' + theme + '-focus')
actionStyles.push({theme,normal,hover,focus})
await read(`document.activeElement?.blur()`)
}
try { try {
await win.loadFile(path.join(output, 'index.html')) await win.loadFile(path.join(output, 'index.html'))
await wait('!!document.querySelector(".yc-skills-page")') await wait('!!document.querySelector(".yc-skills-page")')
@@ -229,7 +252,11 @@ createRoot(document.getElementById('root')).render(
check('a first install is not an overwrite', await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').every(c=>c[2]?.overwrite!==true))`), true) check('a first install is not an overwrite', await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').every(c=>c[2]?.overwrite!==true))`), true)
await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="current"') await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="current"')
check('the card reports the recommendation as installed', await read('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState'), 'current') check('the card reports the recommendation as installed', await read('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState'), 'current')
check('the installed skill is not offered for download again', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), true) check('the installed skill has no add button', await read('!!document.querySelector("[data-recommendation-id=archify] .sm-add-btn")'), false)
check('the installed skill uses the recovered installed card', await read('!!document.querySelector("[data-recommendation-id=archify] .sm-card--installed")'), true)
check('the installed action uses the original action slot', await read('!!document.querySelector("[data-recommendation-id=archify] .sm-status .sm-use-btn")'), true)
check('no duplicate under-card use action remains', await read('!!document.querySelector(".yc-skill-recommendation-meta button")'), false)
check('the duplicate footer is absent', await read('!!document.querySelector(".yc-skill-recommendation-footer")'), false)
check('the detail remains the same DOM instance throughout installation', await read('window.__detailBeforeInstall===document.querySelector("[role=dialog]")'), true) check('the detail remains the same DOM instance throughout installation', await read('window.__detailBeforeInstall===document.querySelector("[role=dialog]")'), true)
check('the detail offers use after installation', await read('document.querySelector("[role=dialog] .sm-skill-detail-action-row")?.textContent.includes("去使用")'), true) check('the detail offers use after installation', await read('document.querySelector("[role=dialog] .sm-skill-detail-action-row")?.textContent.includes("去使用")'), true)
check('the success notification also offers use', await read('document.querySelector(".yc-skill-install-success")?.textContent.includes("去使用")'), true) check('the success notification also offers use', await read('document.querySelector(".yc-skill-install-success")?.textContent.includes("去使用")'), true)
@@ -237,8 +264,13 @@ createRoot(document.getElementById('root')).render(
await capture('installed-detail') await capture('installed-detail')
await click('[role=dialog] .sm-skill-detail-close') await click('[role=dialog] .sm-skill-detail-close')
const installedActionStyle = await read(`(()=>{const b=document.querySelector('[data-recommendation-id=archify] .sm-use-btn');const s=getComputedStyle(b);const svg=b.querySelector('svg');return {width:s.width,height:s.height,svgWidth:getComputedStyle(svg).width,svgHeight:getComputedStyle(svg).height}})()`)
check('installed action uses recovered 24px slot and 16px icon', installedActionStyle, {width:'24px',height:'24px',svgWidth:'16px',svgHeight:'16px'})
await capture('installed-recommendation-light')
await verifyCurrentActionStyles('light')
// The installed list stays reachable and the skill can be used. // The installed list stays reachable and the skill can be used.
await click('.yc-skill-recommendation-footer button') await click('[aria-label="已安装技能"]')
await wait('document.querySelectorAll(".sm-card").length===1') await wait('document.querySelectorAll(".sm-card").length===1')
check('the installed skill appears in the installed list', await read('document.querySelector(".sm-card-name")?.textContent'), 'Archify') check('the installed skill appears in the installed list', await read('document.querySelector(".sm-card-name")?.textContent'), 'Archify')
await click('[aria-label="返回专家中心"]') await click('[aria-label="返回专家中心"]')
@@ -247,7 +279,7 @@ createRoot(document.getElementById('root')).render(
// A local skill with the same name requires an explicit overwrite. // A local skill with the same name requires an explicit overwrite.
await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([localSkill])})`) await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([localSkill])})`)
await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="conflict"') await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="conflict"')
await click('[aria-label="安装 Archify"]') await click('[aria-label="替换 Archify"]')
await wait('!!document.querySelector(".yc-skill-detail-shell")') await wait('!!document.querySelector(".yc-skill-detail-shell")')
check('a same-name local skill requires an explicit replacement', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("替换")'), true) check('a same-name local skill requires an explicit replacement', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("替换")'), true)
check('the replacement notice mentions the local modification', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("本地内容已被修改")'), true) check('the replacement notice mentions the local modification', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("本地内容已被修改")'), true)
@@ -295,8 +327,8 @@ createRoot(document.getElementById('root')).render(
await click('.yc-skill-install-success [aria-label="使用推荐技能 Archify"]') await click('.yc-skill-install-success [aria-label="使用推荐技能 Archify"]')
check('the success notification invokes the same use callback', await read('window.__qa.uses.length'), 1) check('the success notification invokes the same use callback', await read('window.__qa.uses.length'), 1)
await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([{ ...installedRecommendation, enabled: false }])})`) await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([{ ...installedRecommendation, enabled: false }])})`)
await wait('document.querySelector(".yc-skill-recommendation-meta")?.textContent.includes("启用后使用")') await wait('document.querySelector("[data-recommendation-id=archify] .sm-use-btn")?.title.includes("启用")')
await click('.yc-skill-recommendation-meta [aria-label="使用推荐技能 Archify"]') await click('[data-recommendation-id=archify] .sm-use-btn')
await wait('window.__qa.uses.length===2') await wait('window.__qa.uses.length===2')
check('disabled recommendation is enabled before use', await read('window.__qa.uses.at(-1).enabled'), true) check('disabled recommendation is enabled before use', await read('window.__qa.uses.at(-1).enabled'), true)
await read('window.ycode.skills.qaDelay(0)'); await read('window.ycode.skills.qaSetInstalled([])') await read('window.ycode.skills.qaDelay(0)'); await read('window.ycode.skills.qaSetInstalled([])')
@@ -308,9 +340,10 @@ createRoot(document.getElementById('root')).render(
await read(`location.reload()`) await read(`location.reload()`)
await wait('!!document.querySelector(".yc-skill-recommendation-unavailable")') await wait('!!document.querySelector(".yc-skill-recommendation-unavailable")')
check('an empty catalog is reported as unavailable, not as a maintenance page', await read('!!document.querySelector("[data-capability-maintenance]")'), false) check('an empty catalog is reported as unavailable, not as a maintenance page', await read('!!document.querySelector("[data-capability-maintenance]")'), false)
check('the unavailable state still offers the installed list', await read('!!document.querySelector(".yc-skill-recommendation-unavailable button")'), true) check('the unavailable state keeps top installed navigation', await read('!!document.querySelector("[aria-label=已安装技能]")'), true)
check('the unavailable state has no duplicate installed entry', await read('[...document.querySelectorAll(".yc-skill-recommendation-unavailable button")].some(e=>e.textContent.includes("查看已安装技能"))'), false)
await read(`window.ycode.skills.qaFailRecommendations(false)`) await read(`window.ycode.skills.qaFailRecommendations(false)`)
await read(`location.reload()`) await click('.yc-skill-recommendation-unavailable button')
await wait('!!document.querySelector("[data-recommendation-id=archify]")') await wait('!!document.querySelector("[data-recommendation-id=archify]")')
// An install owned by another window blocks a new install here. // An install owned by another window blocks a new install here.
@@ -354,6 +387,7 @@ createRoot(document.getElementById('root')).render(
check('the dark layout keeps the recommendation card inside the viewport', await read(`(()=>{const c=document.querySelector('[data-recommendation-id=archify] .sm-card');if(!c)return false;const r=c.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth+1})()`), true) check('the dark layout keeps the recommendation card inside the viewport', await read(`(()=>{const c=document.querySelector('[data-recommendation-id=archify] .sm-card');if(!c)return false;const r=c.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth+1})()`), true)
check('the dark layout does not overflow the document', overflow.scrollWidth <= overflow.innerWidth + 1, true) check('the dark layout does not overflow the document', overflow.scrollWidth <= overflow.innerWidth + 1, true)
await capture('recommendation-dark') await capture('recommendation-dark')
await verifyCurrentActionStyles('dark')
win.setSize(720, 900) win.setSize(720, 900)
await pause(400) await pause(400)
check('the recommendation card stays inside the narrow viewport', await read('(()=>{const c=document.querySelector("[data-recommendation-id=archify] .sm-card");if(!c)return false;const r=c.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth+1})()'), true) check('the recommendation card stays inside the narrow viewport', await read('(()=>{const c=document.querySelector("[data-recommendation-id=archify] .sm-card");if(!c)return false;const r=c.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth+1})()'), true)
@@ -365,7 +399,7 @@ createRoot(document.getElementById('root')).render(
check('no renderer errors', errors, []) check('no renderer errors', errors, [])
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({ fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({
result: 'PASS', checks, calls, screenshots, result: 'PASS', checks, calls, screenshots, actionStyles,
limits: 'Controlled host in isolated Electron; the shipped page and styles are used. No network, no real skill storage, no real install.' limits: 'Controlled host in isolated Electron; the shipped page and styles are used. No network, no real skill storage, no real install.'
}, null, 2)) }, null, 2))
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, output })) console.log(JSON.stringify({ result: 'PASS', checks: checks.length, output }))
@@ -11,7 +11,6 @@ export interface RecommendedSkillsProps {
installed: SkillRecord[] installed: SkillRecord[]
onUseSkill?(skill: SkillRecord): void onUseSkill?(skill: SkillRecord): void
onInstalledChange(): void onInstalledChange(): void
onViewInstalled(): void
} }
type InstallPhase = SkillInstallProgress['phase'] | 'idle' type InstallPhase = SkillInstallProgress['phase'] | 'idle'
@@ -34,6 +33,7 @@ const Card = CardFrame as ComponentType<{
sourceLabel?: string sourceLabel?: string
iconUrl?: string iconUrl?: string
disabled?: boolean disabled?: boolean
installed?: boolean
onOpen?(): void onOpen?(): void
actions?: React.ReactNode actions?: React.ReactNode
className?: string className?: string
@@ -110,9 +110,10 @@ function InstalledOverview({ skill, cwd }: { skill?: SkillRecord; cwd?: string }
</details> </details>
} }
export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChange, onViewInstalled }: RecommendedSkillsProps): React.JSX.Element { export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChange }: RecommendedSkillsProps): React.JSX.Element {
const [recommendations, setRecommendations] = useState<RecommendedSkill[]>([]) const [recommendations, setRecommendations] = useState<RecommendedSkill[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [catalogRevision, setCatalogRevision] = useState(0)
const [unavailable, setUnavailable] = useState('') const [unavailable, setUnavailable] = useState('')
const [view, setView] = useState<InstallView | null>(null) const [view, setView] = useState<InstallView | null>(null)
const [detail, setDetail] = useState<RecommendedSkill | null>(null) const [detail, setDetail] = useState<RecommendedSkill | null>(null)
@@ -130,6 +131,7 @@ export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChang
useEffect(() => { useEffect(() => {
let alive = true let alive = true
setLoading(true); setUnavailable('')
if (typeof window.ycode.skills.recommendations !== 'function') { setUnavailable('当前版本未提供推荐技能目录'); setLoading(false); return } if (typeof window.ycode.skills.recommendations !== 'function') { setUnavailable('当前版本未提供推荐技能目录'); setLoading(false); return }
void window.ycode.skills.recommendations().then(values => { void window.ycode.skills.recommendations().then(values => {
if (!alive) return if (!alive) return
@@ -138,7 +140,7 @@ export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChang
}).catch(reason => { if (alive) setUnavailable(String(reason.message ?? reason)) }) }).catch(reason => { if (alive) setUnavailable(String(reason.message ?? reason)) })
.finally(() => { if (alive) setLoading(false) }) .finally(() => { if (alive) setLoading(false) })
return () => { alive = false } return () => { alive = false }
}, []) }, [catalogRevision])
useEffect(() => { useEffect(() => {
if (!notice) return if (!notice) return
const timer = window.setTimeout(() => setNotice(null), 6000) const timer = window.setTimeout(() => setNotice(null), 6000)
@@ -181,7 +183,7 @@ export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChang
const begin = (recommendation: RecommendedSkill): void => { const begin = (recommendation: RecommendedSkill): void => {
setDetail(recommendation) setDetail(recommendation)
if (running || submitting.current) return if (running || submitting.current || matchInstalled(recommendation, installed).state === 'current') return
setError('') setError('')
const next: InstallView = { recommendation, operationId: operationId(), phase: 'idle', receivedBytes: 0, totalBytes: recommendation.expectedBytes, overwrite: false } const next: InstallView = { recommendation, operationId: operationId(), phase: 'idle', receivedBytes: 0, totalBytes: recommendation.expectedBytes, overwrite: false }
active.current = next; setView(next) active.current = next; setView(next)
@@ -189,6 +191,8 @@ export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChang
const run = async (overwrite: boolean): Promise<void> => { const run = async (overwrite: boolean): Promise<void> => {
const current = active.current const current = active.current
if (!current || current.phase !== 'idle' || submitting.current || foreign && isRunning(foreign.phase)) return if (!current || current.phase !== 'idle' || submitting.current || foreign && isRunning(foreign.phase)) return
// An external install can finish while this confirmation is open.
if (matchInstalled(current.recommendation, installed).state === 'current') { active.current = null; setView(null); return }
const operation = current.operationId const operation = current.operationId
submitting.current = true submitting.current = true
setError('') setError('')
@@ -244,7 +248,7 @@ export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChang
onClick={event => { event.stopPropagation(); void useSkill(skill) }}><UseSkillIcon />{enabling ? '正在启用…' : skill.enabled ? '去使用' : '启用后使用'}</button> onClick={event => { event.stopPropagation(); void useSkill(skill) }}><UseSkillIcon />{enabling ? '正在启用…' : skill.enabled ? '去使用' : '启用后使用'}</button>
if (loading) return <div className="sm-empty" role="status">加载中…</div> if (loading) return <div className="sm-empty" role="status">加载中…</div>
if (unavailable) return <div className="sm-empty yc-skill-recommendation-unavailable"><p>推荐技能目录暂不可用</p><p>{unavailable}</p><button type="button" className="wb-button wb-button--secondary wb-button--medium" onClick={onViewInstalled}>查看已安装技能</button></div> if (unavailable) return <div className="sm-empty yc-skill-recommendation-unavailable"><p>推荐技能目录暂不可用</p><p>{unavailable}</p><button type="button" className="wb-button wb-button--secondary wb-button--medium" onClick={() => setCatalogRevision(value => value + 1)}>重试加载推荐技能</button></div>
return <> return <>
{error && !detail && <p className="yc-skill-error" role="alert">{error}</p>} {error && !detail && <p className="yc-skill-error" role="alert">{error}</p>}
@@ -253,20 +257,36 @@ export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChang
<div className="sm-grid yc-skill-recommendation-grid">{matches.map(({ recommendation, match }) => { <div className="sm-grid yc-skill-recommendation-grid">{matches.map(({ recommendation, match }) => {
const operation = operationFor(recommendation) const operation = operationFor(recommendation)
const pending = !!operation && isRunning(operation.phase) const pending = !!operation && isRunning(operation.phase)
const status = pending ? PHASE_LABEL[operation.phase] : match.state === 'current' ? '已安装' : match.state === 'modified' ? '已安装(本地已修改)' : match.state === 'outdated' ? '已安装(版本不同)' : match.state === 'conflict' ? '已存在同名本地技能' : '未安装' const isInstalled = !!match.skill && match.state !== 'conflict'
const status = pending ? PHASE_LABEL[operation.phase] : isInstalled && match.skill?.error ? '已安装(损坏)' : match.state === 'current'
? !match.skill?.enabled ? '已安装(已禁用)' : !match.skill.userInvocable ? '已安装(仅模型调用)' : '已安装'
: match.state === 'modified' ? '已安装(本地已修改)' : match.state === 'outdated' ? '已安装(版本不同)' : match.state === 'conflict' ? '已存在同名本地技能' : '未安装'
const installLabel = match.state === 'conflict' ? '替换' : match.skill?.error ? '修复' : match.state === 'outdated' ? '更新' : match.skill ? '替换' : '安装'
const useTitle = !match.skill?.userInvocable ? '此技能仅供模型调用' : !onUseSkill ? '当前无法使用技能' : enabling ? '正在启用…' : running ? '当前有推荐技能正在安装' : match.skill.enabled ? '去使用' : '启用后使用'
return <div key={recommendation.id} className="yc-skill-card-keyboard yc-skill-recommendation-card" data-recommendation-id={recommendation.id} data-recommendation-state={match.state} data-install-phase={operation?.phase} role="button" tabIndex={0} aria-label={`查看 ${recommendation.name}`} return <div key={recommendation.id} className="yc-skill-card-keyboard yc-skill-recommendation-card" data-recommendation-id={recommendation.id} data-recommendation-state={match.state} data-install-phase={operation?.phase} role="button" tabIndex={0} aria-label={`查看 ${recommendation.name}`}
onKeyDown={event => { if (event.target === event.currentTarget && ['Enter', ' '].includes(event.key)) { event.preventDefault(); setDetail(recommendation) } }}> onKeyDown={event => { if (event.target === event.currentTarget && ['Enter', ' '].includes(event.key)) { event.preventDefault(); setDetail(recommendation) } }}>
<Card title={recommendation.name} description={recommendation.description} iconUrl={skillAvatarUrl(recommendation.id)} sourceLabel={status} onOpen={() => setDetail(recommendation)} actions={<div className="sm-status" onClick={event => event.stopPropagation()}><button type="button" className={'sm-add-btn' + (pending ? ' sm-add-btn--loading' : '')} aria-label={`安装 ${recommendation.name}`} disabled={running || match.state === 'current'} title={running ? '当前有推荐技能正在安装' : undefined} onClick={() => begin(recommendation)}>{pending ? <span className="yc-skill-recommendation-spinner" aria-hidden /> : <PlusIcon />}</button></div>} /> <Card title={recommendation.name} description={recommendation.description} iconUrl={skillAvatarUrl(recommendation.id)} sourceLabel={status}
<p className="yc-skill-recommendation-meta"><span>{recommendation.author}</span><span>v{recommendation.version}</span><span>{recommendation.license}</span>{foreign && <span className="yc-skill-recommendation-busy">其他窗口正在安装</span>}{useButton(recommendation, match.skill)}</p> installed={isInstalled} disabled={isInstalled && !match.skill?.enabled} onOpen={() => setDetail(recommendation)}
actions={<div className="sm-status" onClick={event => event.stopPropagation()}>
{match.state === 'current' && !pending ? <button type="button" className="sm-use-btn"
aria-label={`使用推荐技能 ${recommendation.name}`} title={useTitle}
disabled={enabling || running || !match.skill?.userInvocable || !onUseSkill}
onClick={() => { if (match.skill) void useSkill(match.skill) }}><UseSkillIcon /></button>
: <button type="button" className={(match.skill ? 'yc-skill-recommendation-replace-btn' : 'sm-add-btn') + (pending ? ' sm-add-btn--loading' : '')}
aria-label={`${installLabel} ${recommendation.name}`} disabled={running}
title={running ? '当前有推荐技能正在安装' : `${installLabel}推荐版本`}
onClick={() => begin(recommendation)}>{pending ? <span className="yc-skill-recommendation-spinner" aria-hidden /> : match.skill ? installLabel : <PlusIcon />}</button>}
</div>} />
<p className="yc-skill-recommendation-meta"><span>{recommendation.author}</span><span>v{recommendation.version}</span><span>{recommendation.license}</span>{foreign && <span className="yc-skill-recommendation-busy">其他窗口正在安装</span>}</p>
</div> </div>
})}</div> })}</div>
<div className="yc-skill-recommendation-footer"><button type="button" className="wb-button wb-button--secondary wb-button--medium" onClick={onViewInstalled}>查看已安装技能</button></div>
{detail && (() => { {detail && (() => {
const match = matchInstalled(detail, installed) const match = matchInstalled(detail, installed)
const operation = operationFor(detail) const operation = operationFor(detail)
const pending = !!operation && isRunning(operation.phase) const pending = !!operation && isRunning(operation.phase)
const confirming = operation?.phase === 'idle' const confirming = operation?.phase === 'idle'
const canInstall = match.state !== 'current' const canInstall = match.state !== 'current'
const installLabel = match.state === 'conflict' ? '替换' : match.skill?.error ? '修复' : match.state === 'outdated' ? '更新' : match.skill ? '替换' : '安装'
return <SkillDetailShell title={detail.name} description={detail.description} ariaLabel={`推荐技能详情 ${detail.name}`} recommendationId={detail.id} return <SkillDetailShell title={detail.name} description={detail.description} ariaLabel={`推荐技能详情 ${detail.name}`} recommendationId={detail.id}
avatar={<SkillAvatar name={detail.name} recommendationId={detail.id} size={68} />} avatar={<SkillAvatar name={detail.name} recommendationId={detail.id} size={68} />}
metadata={<><span>v{detail.version}</span><span>{detail.license}</span><span>{detail.author}</span>{detail.containsScripts && <span>包含脚本</span>}</>} metadata={<><span>v{detail.version}</span><span>{detail.license}</span><span>{detail.author}</span>{detail.containsScripts && <span>包含脚本</span>}</>}
@@ -274,7 +294,7 @@ export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChang
actions={<> actions={<>
{pending ? <><button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" disabled><span className="yc-skill-recommendation-spinner" />{PHASE_LABEL[operation.phase]}</button><button type="button" className="sm-skill-detail-btn" disabled={operation.phase === 'installing' || cancelPending} onClick={() => void cancel()}>{cancelPending ? '正在请求取消…' : '取消安装'}</button></> {pending ? <><button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" disabled><span className="yc-skill-recommendation-spinner" />{PHASE_LABEL[operation.phase]}</button><button type="button" className="sm-skill-detail-btn" disabled={operation.phase === 'installing' || cancelPending} onClick={() => void cancel()}>{cancelPending ? '正在请求取消…' : '取消安装'}</button></>
: confirming ? <><button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" aria-label="确认安装推荐技能" onClick={() => void run(!!match.skill)}>{match.skill ? '替换并安装' : '确认安装'}</button><button type="button" className="sm-skill-detail-btn" onClick={() => { active.current = null; setView(null) }}>返回详情</button></> : confirming ? <><button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" aria-label="确认安装推荐技能" onClick={() => void run(!!match.skill)}>{match.skill ? '替换并安装' : '确认安装'}</button><button type="button" className="sm-skill-detail-btn" onClick={() => { active.current = null; setView(null) }}>返回详情</button></>
: <>{useButton(detail, match.skill, true)}{canInstall && <button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" aria-label={`安装推荐版本 ${detail.name}`} disabled={running} onClick={() => begin(detail)}>{operation?.phase === 'failed' || operation?.phase === 'cancelled' ? '重试安装' : match.skill ? '安装推荐版本' : '安装'}</button>}</>} : <>{useButton(detail, match.skill, true)}{canInstall && <button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" aria-label={`${installLabel}推荐版本 ${detail.name}`} disabled={running} onClick={() => begin(detail)}>{operation?.phase === 'failed' || operation?.phase === 'cancelled' ? `重试${installLabel}` : match.skill ? `${installLabel}推荐版本` : '安装'}</button>}</>}
</>}> </>}>
{confirming && <InstallDisclosure recommendation={detail} installed={match.skill} />} {confirming && <InstallDisclosure recommendation={detail} installed={match.skill} />}
{operation && operation.phase !== 'idle' && <div className="yc-skill-inline-install" role="status" aria-live="polite" data-install-phase={operation.phase} data-operation-id={operation.operationId}> {operation && operation.phase !== 'idle' && <div className="yc-skill-inline-install" role="status" aria-live="polite" data-install-phase={operation.phase} data-operation-id={operation.operationId}>
+2 -2
View File
@@ -100,9 +100,9 @@ export default function SkillsPage({ cwd, onUseSkill, onCreateSkill, onFindSkill
{view === 'market' ? <><div className="sm-search-panel"><div className="sm-segment-bar"><div className="sm-segment-group" role="tablist" aria-label="技能来源">{['推荐', 'SkillHub', '套件'].map(value => <button key={value} role="tab" aria-selected={category === value} className={'sm-segment-item' + (category === value ? ' sm-segment-item--active' : '')} onClick={() => setCategory(value)}>{value}</button>)}</div></div> {view === 'market' ? <><div className="sm-search-panel"><div className="sm-segment-bar"><div className="sm-segment-group" role="tablist" aria-label="技能来源">{['推荐', 'SkillHub', '套件'].map(value => <button key={value} role="tab" aria-selected={category === value} className={'sm-segment-item' + (category === value ? ' sm-segment-item--active' : '')} onClick={() => setCategory(value)}>{value}</button>)}</div></div>
{category === '推荐' {category === '推荐'
? <section className="sm-installed-section yc-skill-recommendation-section"> ? <section className="sm-installed-section yc-skill-recommendation-section">
<RecommendedSkills cwd={cwd} installed={items} onUseSkill={onUseSkill} onInstalledChange={() => { void reload().catch(reason => { if (mounted.current) setError(String(reason.message ?? reason)) }) }} onViewInstalled={() => { setView('installed'); setQuery('') }} /> <RecommendedSkills cwd={cwd} installed={items} onUseSkill={onUseSkill} onInstalledChange={() => { void reload().catch(reason => { if (mounted.current) setError(String(reason.message ?? reason)) }) }} />
</section> </section>
: <div className="sm-empty yc-skill-market-unavailable"><SkillsIcon width={32} height={32} /><p>当前未连接{category}目录服务</p><p>你可以导入本地技能,或查看已安装技能。</p><Button onClick={() => { setView('installed'); setQuery('') }}>查看已安装技能</Button></div>} : <div className="sm-empty yc-skill-market-unavailable"><SkillsIcon width={32} height={32} /><p>当前未连接{category}目录服务</p><p>你可以导入本地技能,或通过顶部“已安装”查看现有技能。</p></div>}
</div></> : <section className="sm-installed-section"> </div></> : <section className="sm-installed-section">
{batch && <div className="sm-batch-toolbar"><div className="sm-batch-toolbar-left"><input type="checkbox" aria-label="全选技能" checked={matches.length > 0 && matches.every(item => selected.includes(item.id))} onChange={event => setSelected(event.target.checked ? matches.map(item => item.id) : [])} />已选择 {selected.length} 项</div><div className="sm-batch-toolbar-right"><Button disabled={busy || !selected.length} onClick={() => { void run(async () => { for (const item of items.filter(value => selected.includes(value.id) && !value.error)) await window.ycode.skills.setEnabled(item.id, true, cwd) }) }}>启用</Button><Button disabled={busy || !selected.length} onClick={() => { void run(async () => { for (const item of items.filter(value => selected.includes(value.id) && !value.error)) await window.ycode.skills.setEnabled(item.id, false, cwd) }) }}>禁用</Button><Button disabled={busy || !selected.some(id => items.some(item => item.id === id && item.scope === 'user'))} onClick={() => setPendingRemove(items.filter(item => selected.includes(item.id) && item.scope === 'user'))}>卸载</Button></div></div>} {batch && <div className="sm-batch-toolbar"><div className="sm-batch-toolbar-left"><input type="checkbox" aria-label="全选技能" checked={matches.length > 0 && matches.every(item => selected.includes(item.id))} onChange={event => setSelected(event.target.checked ? matches.map(item => item.id) : [])} />已选择 {selected.length} 项</div><div className="sm-batch-toolbar-right"><Button disabled={busy || !selected.length} onClick={() => { void run(async () => { for (const item of items.filter(value => selected.includes(value.id) && !value.error)) await window.ycode.skills.setEnabled(item.id, true, cwd) }) }}>启用</Button><Button disabled={busy || !selected.length} onClick={() => { void run(async () => { for (const item of items.filter(value => selected.includes(value.id) && !value.error)) await window.ycode.skills.setEnabled(item.id, false, cwd) }) }}>禁用</Button><Button disabled={busy || !selected.some(id => items.some(item => item.id === id && item.scope === 'user'))} onClick={() => setPendingRemove(items.filter(item => selected.includes(item.id) && item.scope === 'user'))}>卸载</Button></div></div>}
{loading ? <div className="sm-empty" role="status">加载中…</div> : !matches.length ? <div className="sm-empty">{query ? '没有找到相关技能' : '暂无已安装技能'}</div> : <div className="sm-installed-list">{(['workspace', 'user'] as const).map(scope => { const group = matches.filter(item => item.scope === scope); return !!group.length && <section key={scope} className="sm-installed-group"><div className="sm-installed-group-header"><div className="sm-installed-group-title"><span>{scope === 'workspace' ? '空间技能' : '自定义技能'}</span><span className="sm-installed-group-count">({group.length})</span></div></div><div className="sm-grid">{group.map(skill => <div className="yc-skill-card-keyboard" key={skill.id} data-skill-id={skill.id} role={batch ? undefined : 'button'} tabIndex={0} aria-label={`查看 ${skill.name}`} onKeyDown={event => { if (event.target === event.currentTarget && ['Enter', ' '].includes(event.key)) { event.preventDefault(); if (batch) toggleSelected(skill.id); else setSelectedId(skill.id) } }}> {loading ? <div className="sm-empty" role="status">加载中…</div> : !matches.length ? <div className="sm-empty">{query ? '没有找到相关技能' : '暂无已安装技能'}</div> : <div className="sm-installed-list">{(['workspace', 'user'] as const).map(scope => { const group = matches.filter(item => item.scope === scope); return !!group.length && <section key={scope} className="sm-installed-group"><div className="sm-installed-group-header"><div className="sm-installed-group-title"><span>{scope === 'workspace' ? '空间技能' : '自定义技能'}</span><span className="sm-installed-group-count">({group.length})</span></div></div><div className="sm-grid">{group.map(skill => <div className="yc-skill-card-keyboard" key={skill.id} data-skill-id={skill.id} role={batch ? undefined : 'button'} tabIndex={0} aria-label={`查看 ${skill.name}`} onKeyDown={event => { if (event.target === event.currentTarget && ['Enter', ' '].includes(event.key)) { event.preventDefault(); if (batch) toggleSelected(skill.id); else setSelectedId(skill.id) } }}>
@@ -11,6 +11,14 @@
.yc-skill-installed-overview>.markdown-body{margin-top:12px} .yc-skill-installed-overview>.markdown-body{margin-top:12px}
.yc-skill-notice.yc-skill-install-success{display:flex;align-items:center;gap:12px;pointer-events:auto} .yc-skill-notice.yc-skill-install-success{display:flex;align-items:center;gap:12px;pointer-events:auto}
.yc-skill-install-success .yc-skill-recommendation-link{display:inline-flex;align-items:center;gap:4px} .yc-skill-install-success .yc-skill-recommendation-link{display:inline-flex;align-items:center;gap:4px}
.yc-skill-recommendation-meta .yc-skill-recommendation-link{display:inline-flex;align-items:center;gap:4px}
.yc-skill-recommendation-link svg{width:14px;height:14px;flex:none} .yc-skill-recommendation-link svg{width:14px;height:14px;flex:none}
.yc-skill-recommendation-link:disabled{cursor:default;opacity:.5} .yc-skill-recommendation-link:disabled{cursor:default;opacity:.5}
/* Keep the recovered use icon/slot geometry; host theme tokens also cover dark mode. */
.yc-skill-recommendation-card .sm-use-btn{display:inline-flex;align-items:center;justify-content:center;flex:none;border-radius:var(--yc-radius-md);cursor:pointer;color:var(--wb-color-text-primary)}
.yc-skill-recommendation-card .sm-use-btn:hover:not(:disabled){background:var(--wb-palette-gray-5);color:var(--wb-color-text-primary)}
.yc-skill-recommendation-card .sm-use-btn:disabled{cursor:default;opacity:.5;background:transparent}
.yc-skill-recommendation-replace-btn{display:inline-flex;align-items:center;justify-content:center;min-width:var(--yc-size-10);height:var(--yc-size-6);padding:0 calc(var(--yc-space-4) / 2);border:0;border-radius:var(--yc-radius-md);background:var(--wb-bg-hover);color:var(--wb-color-text-primary);font:inherit;font-size:var(--yc-font-size-3);white-space:nowrap;cursor:pointer}
.yc-skill-recommendation-replace-btn:hover:not(:disabled){background:var(--wb-bg-hover-light)}
.yc-skill-recommendation-replace-btn:disabled{cursor:default;opacity:.5}
.yc-skill-recommendation-card .sm-use-btn:focus-visible,.yc-skill-recommendation-replace-btn:focus-visible{outline:var(--yc-space-1) solid var(--wb-border-focus);outline-offset:var(--yc-space-1)}
html[data-theme="dark"] .yc-skill-recommendation-card .sm-use-btn:hover:not(:disabled){background:var(--wb-bg-hover-light)}
@@ -14,10 +14,8 @@
.yc-skill-recommendation-card{position:relative} .yc-skill-recommendation-card{position:relative}
.yc-skill-recommendation-grid .yc-skill-recommendation-card>.sm-card{height:126px;box-sizing:border-box;padding-bottom:40px} .yc-skill-recommendation-grid .yc-skill-recommendation-card>.sm-card{height:126px;box-sizing:border-box;padding-bottom:40px}
.yc-skill-recommendation-meta{position:absolute;bottom:14px;left:20px;right:20px;display:flex;align-items:center;gap:8px;margin:0;color:var(--wb-color-text-tertiary);font-size:12px;line-height:20px} .yc-skill-recommendation-meta{position:absolute;bottom:14px;left:20px;right:20px;display:flex;align-items:center;gap:8px;margin:0;color:var(--wb-color-text-tertiary);font-size:12px;line-height:20px}
.yc-skill-recommendation-meta>.yc-skill-recommendation-link{margin-left:auto;white-space:nowrap}
.yc-skill-recommendation-meta>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .yc-skill-recommendation-meta>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.yc-skill-recommendation-busy{color:var(--wb-color-text-secondary)} .yc-skill-recommendation-busy{color:var(--wb-color-text-secondary)}
.yc-skill-recommendation-footer{display:flex;justify-content:flex-start;margin-top:4px}
.yc-skill-recommendation-progress{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding:12px 16px;border:1px solid var(--wb-border-default);border-radius:12px;background:var(--wb-bg-card);font-size:13px} .yc-skill-recommendation-progress{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding:12px 16px;border:1px solid var(--wb-border-default);border-radius:12px;background:var(--wb-bg-card);font-size:13px}
.yc-skill-recommendation-progress-text{color:var(--wb-color-text-primary);font-weight:500} .yc-skill-recommendation-progress-text{color:var(--wb-color-text-primary);font-weight:500}
.yc-skill-recommendation-progress .yc-skill-error{flex:1;min-width:0} .yc-skill-recommendation-progress .yc-skill-error{flex:1;min-width:0}