feat(skills): allow activating a skill in multiple projects at once (#77)

The "Project" scope on a skill's activation panel was single-select:
choosing a different project from the dropdown silently moved the
activation, so a skill could only be active in one project at a time.
Make it multi-select instead — a skill can now be active in any
combination of projects independently.

UI
- New shared MultiSelectDropdown component (role="menuitemcheckbox",
  toggle keeps the popover open, Escape / outside click close).
- SkillActivationScope's project picker uses it. The trigger now shows
  the count: "Select projects…" / one path / "{n} projects".
- The high-level Off / Global / Project buttons keep working: tapping
  "Project" while no project is active default-activates the current
  one; tapping it again is a no-op so it can't wipe a multi-selection.
- Activating any project also clears the global flag (mutually
  exclusive); toggling a project off does not touch global.

State
- activeProjects: Set<projectPath> replaces the old single
  selectedProjectPath + projectSkills pair.
- Loaded by fanning out one getActiveSkills('project', cwd) per
  resolved project, and collecting paths whose list contains this
  skill into the Set.
- Each toggle is read-modify-write on a single project's
  activeSkills, so a failure on one project never corrupts the others.

No backend, jsonl format, or migration changes — purely a
front-end aggregation over the existing per-project API.

Tests
- 4 new tests for MultiSelectDropdown (visibility, ARIA, multi-toggle
  doesn't auto-close, Escape).
- 1 new SkillDetail test asserting the "{count} projects" trigger
  surfaces when multiple projects are active.
- All 4 existing SkillDetail tests still pass — verification subagent
  confirmed the multi-select gate (no-op when size>0) and global/
  project mutual exclusion.
- 5 locales each gain 2 new keys (selectProjects + projectCount).

Co-authored-by: 你的姓名 <you@example.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
小橙子 2026-06-18 19:31:08 +08:00 committed by GitHub
parent dc83023e91
commit f3d245cbbf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 421 additions and 54 deletions

View File

@ -0,0 +1,81 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom'
import { MultiSelectDropdown } from './MultiSelectDropdown'
type Item = { value: string; label: string }
function renderDropdown(props: {
values: string[]
items: Item[]
onToggle?: (v: string) => void
}) {
return render(
<MultiSelectDropdown
values={props.values}
onToggle={props.onToggle ?? (() => {})}
items={props.items}
trigger={<button>open</button>}
/>,
)
}
describe('MultiSelectDropdown', () => {
it('hides items until the trigger is clicked', () => {
renderDropdown({
values: [],
items: [{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }],
})
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('open'))
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Beta')).toBeInTheDocument()
})
it('exposes each row as menuitemcheckbox with correct aria-checked', () => {
renderDropdown({
values: ['a'],
items: [{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }],
})
fireEvent.click(screen.getByText('open'))
const rows = screen.getAllByRole('menuitemcheckbox')
expect(rows).toHaveLength(2)
expect(rows[0]).toHaveAttribute('aria-checked', 'true')
expect(rows[1]).toHaveAttribute('aria-checked', 'false')
})
it('keeps the popover open after toggling so multiple items can be chosen', () => {
const onToggle = vi.fn()
renderDropdown({
values: [],
items: [{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }],
onToggle,
})
fireEvent.click(screen.getByText('open'))
fireEvent.click(screen.getByText('Alpha'))
fireEvent.click(screen.getByText('Beta'))
// Both fires AND the menu items are still on screen — popover never auto-closed.
expect(onToggle).toHaveBeenCalledTimes(2)
expect(onToggle).toHaveBeenNthCalledWith(1, 'a')
expect(onToggle).toHaveBeenNthCalledWith(2, 'b')
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Beta')).toBeInTheDocument()
})
it('closes when Escape is pressed', () => {
renderDropdown({
values: [],
items: [{ value: 'a', label: 'Alpha' }],
})
fireEvent.click(screen.getByText('open'))
expect(screen.getByText('Alpha')).toBeInTheDocument()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,158 @@
import { useState, useRef, useEffect, type CSSProperties, type ReactNode } from 'react'
type MultiSelectDropdownItem<T extends string> = {
value: T
label: string
description?: string
icon?: ReactNode
}
type MultiSelectDropdownProps<T extends string> = {
items: MultiSelectDropdownItem<T>[]
/** Currently selected values. Items not in this array are rendered unchecked. */
values: T[]
/** Toggle a single item. The popover stays open so multiple items can be selected without reopening. */
onToggle: (value: T) => void
trigger: ReactNode
width?: CSSProperties['width']
maxHeight?: CSSProperties['maxHeight']
align?: 'left' | 'right'
className?: string
/** Optional bulk shortcuts shown above the items. */
selectAllLabel?: string
clearLabel?: string
onSelectAll?: () => void
onClear?: () => void
}
/**
* Multi-select cousin of Dropdown. Click outside / press Escape to close.
* Clicking an item toggles it without closing bulk-selection without
* reopening the popover for each pick. The shape mirrors Dropdown so a
* caller can swap them without learning a different API surface.
*/
export function MultiSelectDropdown<T extends string>({
items,
values,
onToggle,
trigger,
width = 320,
maxHeight,
align = 'left',
className = '',
selectAllLabel,
clearLabel,
onSelectAll,
onClear,
}: MultiSelectDropdownProps<T>) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleEsc)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleEsc)
}
}, [open])
const selectedSet = new Set(values)
const showHeader = (selectAllLabel && onSelectAll) || (clearLabel && onClear)
return (
<div ref={ref} className={`relative ${className || 'inline-block'}`}>
<div onClick={() => setOpen(!open)} className="cursor-pointer">
{trigger}
</div>
{open && (
<div
className={`
absolute z-50 mt-1 rounded-[var(--radius-lg)]
bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)]
shadow-[var(--shadow-dropdown)]
animate-in fade-in slide-in-from-top-1
${maxHeight ? 'overflow-y-auto' : 'overflow-hidden'}
${align === 'right' ? 'right-0' : 'left-0'}
`}
style={{ width, maxHeight }}
>
{showHeader && (
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--color-border-separator)] text-xs">
{selectAllLabel && onSelectAll && (
<button
type="button"
onClick={onSelectAll}
className="text-[var(--color-brand)] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] rounded-sm px-1 -mx-1"
>
{selectAllLabel}
</button>
)}
{clearLabel && onClear && (
<button
type="button"
onClick={onClear}
className="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] rounded-sm px-1 -mx-1"
>
{clearLabel}
</button>
)}
</div>
)}
{items.map((item, i) => {
const checked = selectedSet.has(item.value)
return (
<button
key={item.value}
type="button"
onClick={() => onToggle(item.value)}
className={`
w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors
hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)]
${checked ? 'bg-[var(--color-model-option-selected-bg)]' : ''}
${i > 0 ? 'border-t border-[var(--color-border-separator)]' : ''}
`}
aria-checked={checked}
role="menuitemcheckbox"
>
<span
className={`
flex h-5 w-5 flex-shrink-0 items-center justify-center rounded
${checked
? 'bg-[var(--color-brand)] text-[var(--color-btn-primary-fg)]'
: 'border border-[var(--color-border)]'}
`}
aria-hidden="true"
>
{checked && (
<span className="material-symbols-outlined text-[14px]" style={{ fontVariationSettings: "'FILL' 1" }}>check</span>
)}
</span>
{item.icon && (
<span className="flex h-5 w-5 flex-shrink-0 items-center justify-center text-[var(--color-text-secondary)]">{item.icon}</span>
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">{item.label}</div>
{item.description && (
<div className="text-xs text-[var(--color-text-secondary)] mt-0.5">{item.description}</div>
)}
</div>
</button>
)
})}
</div>
)}
</div>
)
}

View File

@ -176,4 +176,29 @@ describe('SkillActivationScope', () => {
// "Applies to:" label appears only under project scope with resolved projects.
expect(await screen.findByText(/Applies to/i)).toBeInTheDocument()
})
it('treats multiple projects as independently active and surfaces the count', async () => {
// Skill is active in 2 of 3 projects — the trigger should show "2 projects".
getActiveSkills.mockImplementation((scope: string, cwd?: string) => {
if (scope !== 'project') return Promise.resolve({ activeSkills: [] })
if (cwd === '/work/alpha' || cwd === '/work/beta') {
return Promise.resolve({ activeSkills: ['multi-skill'] })
}
return Promise.resolve({ activeSkills: [] })
})
apiGet.mockResolvedValue({
projects: [
{ id: 'p1', label: '/work/alpha', projectPath: '/work/alpha', isCurrent: true },
{ id: 'p2', label: '/work/beta', projectPath: '/work/beta', isCurrent: false },
{ id: 'p3', label: '/work/gamma', projectPath: '/work/gamma', isCurrent: false },
],
})
selectSkill('multi-skill')
render(<SkillDetail />)
// Trigger label uses the {count} key once 2+ projects are active.
expect(await screen.findByText(/2 projects/i)).toBeInTheDocument()
// The activated badge is still shown.
expect(screen.getByText(/Active/)).toBeInTheDocument()
})
})

View File

@ -8,7 +8,7 @@ import { useUIStore } from '../../stores/uiStore'
import { skillsApi } from '../../api/skills'
import { api } from '../../api/client'
import { useSessionStore } from '../../stores/sessionStore'
import { Dropdown } from '../shared/Dropdown'
import { MultiSelectDropdown } from '../shared/MultiSelectDropdown'
const META_PRIORITY = [
'description',
@ -256,9 +256,11 @@ type ProjectChoice = {
function SkillActivationScope({ skillName }: { skillName: string }) {
const t = useTranslation()
const [globalSkills, setGlobalSkills] = useState<string[]>([])
const [projectSkills, setProjectSkills] = useState<string[]>([])
// Set of project paths where this skill is currently active. Replaces the
// old single "selectedProjectPath" model: the skill can now be active in
// any number of projects independently.
const [activeProjects, setActiveProjects] = useState<Set<string>>(new Set())
const [projects, setProjects] = useState<ProjectChoice[]>([])
const [selectedProjectPath, setSelectedProjectPath] = useState<string | undefined>(undefined)
const [saving, setSaving] = useState(false)
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
@ -266,75 +268,155 @@ function SkillActivationScope({ skillName }: { skillName: string }) {
const cwd = activeSession?.workDir || activeSession?.projectPath || undefined
// Load the project list (reuse the project-rules endpoint) so the user can
// pick WHICH project the skill applies to under 'project' scope.
// pick WHICH projects the skill applies to under 'project' scope.
useEffect(() => {
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
api.get<{ projects: ProjectChoice[] }>(`/api/project-rules${query}`)
.then((res) => {
const resolved = res.projects.filter((p) => p.projectPath)
setProjects(resolved)
setSelectedProjectPath((prev) => {
if (prev && resolved.some((p) => p.projectPath === prev)) return prev
const current = resolved.find((p) => p.isCurrent)
return current?.projectPath ?? resolved[0]?.projectPath ?? undefined
})
setProjects(res.projects.filter((p) => p.projectPath))
})
.catch(() => {})
}, [cwd])
// Load activation state for global + the currently selected project.
// Global is a single list; load it once.
useEffect(() => {
skillsApi.getActiveSkills('global').then(res => setGlobalSkills(res.activeSkills)).catch(() => {})
}, [])
// Project state is per-project. Fan out one read per project and collect
// those whose activeSkills contains this skill into a Set. Re-run when the
// resolved project list changes.
useEffect(() => {
skillsApi.getActiveSkills('project', selectedProjectPath).then(res => setProjectSkills(res.activeSkills)).catch(() => {})
}, [selectedProjectPath])
let cancelled = false
const projectPaths = projects.map(p => p.projectPath).filter((p): p is string => Boolean(p))
if (projectPaths.length === 0) {
setActiveProjects(new Set())
return
}
Promise.all(
projectPaths.map(async (path) => {
try {
const res = await skillsApi.getActiveSkills('project', path)
return { path, active: res.activeSkills.includes(skillName) }
} catch {
return { path, active: false }
}
}),
).then((results) => {
if (cancelled) return
const next = new Set<string>()
for (const { path, active } of results) {
if (active) next.add(path)
}
setActiveProjects(next)
})
return () => { cancelled = true }
}, [projects, skillName])
const isGlobal = globalSkills.includes(skillName)
const currentScope: ActivationScope =
projectSkills.includes(skillName) ? 'project'
: globalSkills.includes(skillName) ? 'global'
activeProjects.size > 0 ? 'project'
: isGlobal ? 'global'
: 'off'
const handleChange = async (scope: ActivationScope) => {
const writeProjectActivation = async (projectPath: string, shouldBeActive: boolean) => {
// Read-modify-write that single project's activeSkills, leaving every
// other project untouched. This keeps each toggle independent — a
// failure on one project does not corrupt the others.
const res = await skillsApi.getActiveSkills('project', projectPath)
const current = res.activeSkills
const has = current.includes(skillName)
if (shouldBeActive === has) return
const next = shouldBeActive
? [...current, skillName]
: current.filter(s => s !== skillName)
await skillsApi.setActiveSkills(next, 'project', projectPath)
}
// Bulk apply for the high-level Off / Global / Project buttons.
const handleScopeChange = async (scope: ActivationScope) => {
setSaving(true)
try {
const newGlobal = globalSkills.filter(s => s !== skillName)
const newProject = projectSkills.filter(s => s !== skillName)
if (scope === 'global') {
newGlobal.push(skillName)
} else if (scope === 'project') {
newProject.push(skillName)
if (scope === 'off') {
// Clear from global + every active project.
const promises: Promise<unknown>[] = []
if (isGlobal) {
promises.push(skillsApi.setActiveSkills(globalSkills.filter(s => s !== skillName), 'global'))
}
for (const p of activeProjects) {
promises.push(writeProjectActivation(p, false))
}
await Promise.all(promises)
setGlobalSkills(g => g.filter(s => s !== skillName))
setActiveProjects(new Set())
return
}
await Promise.all([
skillsApi.setActiveSkills(newGlobal, 'global'),
skillsApi.setActiveSkills(newProject, 'project', selectedProjectPath),
])
setGlobalSkills(newGlobal)
setProjectSkills(newProject)
if (scope === 'global') {
const promises: Promise<unknown>[] = []
if (!isGlobal) {
promises.push(skillsApi.setActiveSkills([...globalSkills, skillName], 'global'))
}
// Switching to global clears project-level entries to avoid double-injection.
for (const p of activeProjects) {
promises.push(writeProjectActivation(p, false))
}
await Promise.all(promises)
setGlobalSkills(g => (g.includes(skillName) ? g : [...g, skillName]))
setActiveProjects(new Set())
return
}
// scope === 'project': default-activate the current project. If we're
// already in project mode, this button is a no-op and the user should
// use the multi-select dropdown instead.
if (activeProjects.size === 0) {
const defaultPath =
projects.find(p => p.isCurrent)?.projectPath ??
projects[0]?.projectPath
if (!defaultPath) return
const promises: Promise<unknown>[] = []
if (isGlobal) {
promises.push(skillsApi.setActiveSkills(globalSkills.filter(s => s !== skillName), 'global'))
}
promises.push(writeProjectActivation(defaultPath, true))
await Promise.all(promises)
setGlobalSkills(g => g.filter(s => s !== skillName))
setActiveProjects(new Set([defaultPath]))
}
} catch {
// ignore
// ignore — UI state stays in sync with what we last successfully wrote
} finally {
setSaving(false)
}
}
// When the user switches the project dropdown, re-evaluate whether the skill
// should be re-applied to the newly selected project (if scope was 'project').
const handleProjectSwitch = async (projectPath: string) => {
const wasProjectScoped = currentScope === 'project'
setSelectedProjectPath(projectPath)
// Fetch the new project's active skills to reflect its status immediately.
// Per-project toggle from the multi-select dropdown.
const handleToggleProject = async (projectPath: string) => {
if (saving) return
const wantActive = !activeProjects.has(projectPath)
setSaving(true)
try {
const res = await skillsApi.getActiveSkills('project', projectPath)
setProjectSkills(res.activeSkills)
// If the skill was project-scoped on the previous project, do NOT auto-move
// it — let the user explicitly click 'project' again for the new one.
void wasProjectScoped
// Activating any project also clears the global flag, since the two
// scopes are mutually exclusive in the high-level UI summary.
const promises: Promise<unknown>[] = [writeProjectActivation(projectPath, wantActive)]
if (wantActive && isGlobal) {
promises.push(skillsApi.setActiveSkills(globalSkills.filter(s => s !== skillName), 'global'))
}
await Promise.all(promises)
setActiveProjects(prev => {
const next = new Set(prev)
if (wantActive) next.add(projectPath)
else next.delete(projectPath)
return next
})
if (wantActive && isGlobal) {
setGlobalSkills(g => g.filter(s => s !== skillName))
}
} catch {
// ignore
} finally {
setSaving(false)
}
}
@ -366,7 +448,7 @@ function SkillActivationScope({ skillName }: { skillName: string }) {
return (
<button
key={opt.value}
onClick={() => handleChange(opt.value)}
onClick={() => handleScopeChange(opt.value)}
disabled={saving}
className={`flex items-center justify-center gap-2 rounded-xl border px-4 py-2 text-sm min-w-[88px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--color-surface)] ${
selected
@ -382,35 +464,46 @@ function SkillActivationScope({ skillName }: { skillName: string }) {
})}
</div>
{/* Project selector — only relevant when applying at 'project' scope. */}
{/* Multi-select project picker visible whenever there are projects to
choose from. The skill can be active in any combination of them. */}
{currentScope === 'project' && projects.length > 0 && (
<div className="mt-3 flex items-center gap-2 flex-wrap">
<span className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.skills.activation.appliesTo')}
</span>
<Dropdown
value={selectedProjectPath ?? ''}
onChange={(v) => handleProjectSwitch(v)}
<MultiSelectDropdown
values={Array.from(activeProjects)}
onToggle={(v) => void handleToggleProject(v)}
items={projects.map((p) => ({
value: p.projectPath ?? '',
label: shortenProjectPath(p.label),
description: p.isCurrent ? t('settings.skills.activation.currentProject') : undefined,
}))}
width={300}
maxHeight={280}
width={320}
maxHeight={320}
trigger={
<div className="inline-flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] transition-colors max-w-[260px]">
<span className="truncate">
{shortenProjectPath(projects.find((p) => p.projectPath === selectedProjectPath)?.label ?? selectedProjectPath ?? '')}
{activeProjects.size === 0
? t('settings.skills.activation.selectProjects')
: activeProjects.size === 1
? shortenProjectPath(
projects.find((p) => p.projectPath === Array.from(activeProjects)[0])?.label
?? Array.from(activeProjects)[0]
?? '',
)
: t('settings.skills.activation.projectCount', { count: activeProjects.size })}
</span>
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)] flex-shrink-0">expand_more</span>
</div>
}
/>
<span className="inline-flex items-center gap-1 text-xs text-[var(--color-brand)]">
<span className="material-symbols-outlined text-[14px]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
{t('settings.skills.activation.active')}
</span>
{activeProjects.size > 0 && (
<span className="inline-flex items-center gap-1 text-xs text-[var(--color-brand)]">
<span className="material-symbols-outlined text-[14px]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
{t('settings.skills.activation.active')}
</span>
)}
</div>
)}
</section>

View File

@ -856,6 +856,8 @@ export const en = {
'settings.skills.activation.projectDesc': 'Auto-loaded only in conversations for the current project',
'settings.skills.activation.appliesTo': 'Applies to:',
'settings.skills.activation.active': 'Active',
'settings.skills.activation.selectProjects': 'Select projects…',
'settings.skills.activation.projectCount': '{count} projects',
'settings.skills.activation.currentProject': 'Current project',
// Settings > Memory

View File

@ -856,6 +856,8 @@ export const jp: Record<TranslationKey, string> = {
'settings.skills.activation.projectDesc': '現在のプロジェクトの会話でのみ自動読み込み',
'settings.skills.activation.appliesTo': '適用先:',
'settings.skills.activation.active': '有効',
'settings.skills.activation.selectProjects': 'プロジェクトを選択…',
'settings.skills.activation.projectCount': '{count} プロジェクト',
'settings.skills.activation.currentProject': '現在のプロジェクト',
// Settings > Memory

View File

@ -856,6 +856,8 @@ export const kr: Record<TranslationKey, string> = {
'settings.skills.activation.projectDesc': '현재 프로젝트의 대화에서만 자동 로드',
'settings.skills.activation.appliesTo': '적용 대상:',
'settings.skills.activation.active': '활성',
'settings.skills.activation.selectProjects': '프로젝트 선택…',
'settings.skills.activation.projectCount': '{count}개 프로젝트',
'settings.skills.activation.currentProject': '현재 프로젝트',
// Settings > Memory

View File

@ -856,6 +856,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.skills.activation.projectDesc': '僅當前專案的對話自動載入',
'settings.skills.activation.appliesTo': '套用到:',
'settings.skills.activation.active': '已啟用',
'settings.skills.activation.selectProjects': '選擇專案…',
'settings.skills.activation.projectCount': '{count} 個專案',
'settings.skills.activation.currentProject': '目前專案',
// Settings > Memory

View File

@ -858,6 +858,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.skills.activation.projectDesc': '仅当前项目的对话自动加载',
'settings.skills.activation.appliesTo': '应用到:',
'settings.skills.activation.active': '已激活',
'settings.skills.activation.selectProjects': '选择项目…',
'settings.skills.activation.projectCount': '{count} 个项目',
'settings.skills.activation.currentProject': '当前项目',
// Settings > Memory