mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-30 16:23:35 +08:00
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:
parent
dc83023e91
commit
f3d245cbbf
81
desktop/src/components/shared/MultiSelectDropdown.test.tsx
Normal file
81
desktop/src/components/shared/MultiSelectDropdown.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
158
desktop/src/components/shared/MultiSelectDropdown.tsx
Normal file
158
desktop/src/components/shared/MultiSelectDropdown.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -176,4 +176,29 @@ describe('SkillActivationScope', () => {
|
|||||||
// "Applies to:" label appears only under project scope with resolved projects.
|
// "Applies to:" label appears only under project scope with resolved projects.
|
||||||
expect(await screen.findByText(/Applies to/i)).toBeInTheDocument()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { useUIStore } from '../../stores/uiStore'
|
|||||||
import { skillsApi } from '../../api/skills'
|
import { skillsApi } from '../../api/skills'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { Dropdown } from '../shared/Dropdown'
|
import { MultiSelectDropdown } from '../shared/MultiSelectDropdown'
|
||||||
|
|
||||||
const META_PRIORITY = [
|
const META_PRIORITY = [
|
||||||
'description',
|
'description',
|
||||||
@ -256,9 +256,11 @@ type ProjectChoice = {
|
|||||||
function SkillActivationScope({ skillName }: { skillName: string }) {
|
function SkillActivationScope({ skillName }: { skillName: string }) {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const [globalSkills, setGlobalSkills] = useState<string[]>([])
|
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 [projects, setProjects] = useState<ProjectChoice[]>([])
|
||||||
const [selectedProjectPath, setSelectedProjectPath] = useState<string | undefined>(undefined)
|
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const sessions = useSessionStore((s) => s.sessions)
|
const sessions = useSessionStore((s) => s.sessions)
|
||||||
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||||
@ -266,75 +268,155 @@ function SkillActivationScope({ skillName }: { skillName: string }) {
|
|||||||
const cwd = activeSession?.workDir || activeSession?.projectPath || undefined
|
const cwd = activeSession?.workDir || activeSession?.projectPath || undefined
|
||||||
|
|
||||||
// Load the project list (reuse the project-rules endpoint) so the user can
|
// 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(() => {
|
useEffect(() => {
|
||||||
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
|
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
|
||||||
api.get<{ projects: ProjectChoice[] }>(`/api/project-rules${query}`)
|
api.get<{ projects: ProjectChoice[] }>(`/api/project-rules${query}`)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const resolved = res.projects.filter((p) => p.projectPath)
|
setProjects(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
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [cwd])
|
}, [cwd])
|
||||||
|
|
||||||
// Load activation state for global + the currently selected project.
|
// Global is a single list; load it once.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
skillsApi.getActiveSkills('global').then(res => setGlobalSkills(res.activeSkills)).catch(() => {})
|
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(() => {
|
useEffect(() => {
|
||||||
skillsApi.getActiveSkills('project', selectedProjectPath).then(res => setProjectSkills(res.activeSkills)).catch(() => {})
|
let cancelled = false
|
||||||
}, [selectedProjectPath])
|
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 =
|
const currentScope: ActivationScope =
|
||||||
projectSkills.includes(skillName) ? 'project'
|
activeProjects.size > 0 ? 'project'
|
||||||
: globalSkills.includes(skillName) ? 'global'
|
: isGlobal ? 'global'
|
||||||
: 'off'
|
: '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)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const newGlobal = globalSkills.filter(s => s !== skillName)
|
if (scope === 'off') {
|
||||||
const newProject = projectSkills.filter(s => s !== skillName)
|
// Clear from global + every active project.
|
||||||
|
const promises: Promise<unknown>[] = []
|
||||||
if (scope === 'global') {
|
if (isGlobal) {
|
||||||
newGlobal.push(skillName)
|
promises.push(skillsApi.setActiveSkills(globalSkills.filter(s => s !== skillName), 'global'))
|
||||||
} else if (scope === 'project') {
|
}
|
||||||
newProject.push(skillName)
|
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([
|
if (scope === 'global') {
|
||||||
skillsApi.setActiveSkills(newGlobal, 'global'),
|
const promises: Promise<unknown>[] = []
|
||||||
skillsApi.setActiveSkills(newProject, 'project', selectedProjectPath),
|
if (!isGlobal) {
|
||||||
])
|
promises.push(skillsApi.setActiveSkills([...globalSkills, skillName], 'global'))
|
||||||
setGlobalSkills(newGlobal)
|
}
|
||||||
setProjectSkills(newProject)
|
// 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 {
|
} catch {
|
||||||
// ignore
|
// ignore — UI state stays in sync with what we last successfully wrote
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the user switches the project dropdown, re-evaluate whether the skill
|
// Per-project toggle from the multi-select dropdown.
|
||||||
// should be re-applied to the newly selected project (if scope was 'project').
|
const handleToggleProject = async (projectPath: string) => {
|
||||||
const handleProjectSwitch = async (projectPath: string) => {
|
if (saving) return
|
||||||
const wasProjectScoped = currentScope === 'project'
|
const wantActive = !activeProjects.has(projectPath)
|
||||||
setSelectedProjectPath(projectPath)
|
setSaving(true)
|
||||||
// Fetch the new project's active skills to reflect its status immediately.
|
|
||||||
try {
|
try {
|
||||||
const res = await skillsApi.getActiveSkills('project', projectPath)
|
// Activating any project also clears the global flag, since the two
|
||||||
setProjectSkills(res.activeSkills)
|
// scopes are mutually exclusive in the high-level UI summary.
|
||||||
// If the skill was project-scoped on the previous project, do NOT auto-move
|
const promises: Promise<unknown>[] = [writeProjectActivation(projectPath, wantActive)]
|
||||||
// it — let the user explicitly click 'project' again for the new one.
|
if (wantActive && isGlobal) {
|
||||||
void wasProjectScoped
|
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 {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -366,7 +448,7 @@ function SkillActivationScope({ skillName }: { skillName: string }) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
onClick={() => handleChange(opt.value)}
|
onClick={() => handleScopeChange(opt.value)}
|
||||||
disabled={saving}
|
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)] ${
|
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
|
selected
|
||||||
@ -382,35 +464,46 @@ function SkillActivationScope({ skillName }: { skillName: string }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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 && (
|
{currentScope === 'project' && projects.length > 0 && (
|
||||||
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xs text-[var(--color-text-tertiary)]">
|
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||||
{t('settings.skills.activation.appliesTo')}
|
{t('settings.skills.activation.appliesTo')}
|
||||||
</span>
|
</span>
|
||||||
<Dropdown
|
<MultiSelectDropdown
|
||||||
value={selectedProjectPath ?? ''}
|
values={Array.from(activeProjects)}
|
||||||
onChange={(v) => handleProjectSwitch(v)}
|
onToggle={(v) => void handleToggleProject(v)}
|
||||||
items={projects.map((p) => ({
|
items={projects.map((p) => ({
|
||||||
value: p.projectPath ?? '',
|
value: p.projectPath ?? '',
|
||||||
label: shortenProjectPath(p.label),
|
label: shortenProjectPath(p.label),
|
||||||
description: p.isCurrent ? t('settings.skills.activation.currentProject') : undefined,
|
description: p.isCurrent ? t('settings.skills.activation.currentProject') : undefined,
|
||||||
}))}
|
}))}
|
||||||
width={300}
|
width={320}
|
||||||
maxHeight={280}
|
maxHeight={320}
|
||||||
trigger={
|
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]">
|
<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">
|
<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>
|
||||||
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)] flex-shrink-0">expand_more</span>
|
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)] flex-shrink-0">expand_more</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="inline-flex items-center gap-1 text-xs text-[var(--color-brand)]">
|
{activeProjects.size > 0 && (
|
||||||
<span className="material-symbols-outlined text-[14px]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
<span className="inline-flex items-center gap-1 text-xs text-[var(--color-brand)]">
|
||||||
{t('settings.skills.activation.active')}
|
<span className="material-symbols-outlined text-[14px]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
||||||
</span>
|
{t('settings.skills.activation.active')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -856,6 +856,8 @@ export const en = {
|
|||||||
'settings.skills.activation.projectDesc': 'Auto-loaded only in conversations for the current project',
|
'settings.skills.activation.projectDesc': 'Auto-loaded only in conversations for the current project',
|
||||||
'settings.skills.activation.appliesTo': 'Applies to:',
|
'settings.skills.activation.appliesTo': 'Applies to:',
|
||||||
'settings.skills.activation.active': 'Active',
|
'settings.skills.activation.active': 'Active',
|
||||||
|
'settings.skills.activation.selectProjects': 'Select projects…',
|
||||||
|
'settings.skills.activation.projectCount': '{count} projects',
|
||||||
'settings.skills.activation.currentProject': 'Current project',
|
'settings.skills.activation.currentProject': 'Current project',
|
||||||
|
|
||||||
// Settings > Memory
|
// Settings > Memory
|
||||||
|
|||||||
@ -856,6 +856,8 @@ export const jp: Record<TranslationKey, string> = {
|
|||||||
'settings.skills.activation.projectDesc': '現在のプロジェクトの会話でのみ自動読み込み',
|
'settings.skills.activation.projectDesc': '現在のプロジェクトの会話でのみ自動読み込み',
|
||||||
'settings.skills.activation.appliesTo': '適用先:',
|
'settings.skills.activation.appliesTo': '適用先:',
|
||||||
'settings.skills.activation.active': '有効',
|
'settings.skills.activation.active': '有効',
|
||||||
|
'settings.skills.activation.selectProjects': 'プロジェクトを選択…',
|
||||||
|
'settings.skills.activation.projectCount': '{count} プロジェクト',
|
||||||
'settings.skills.activation.currentProject': '現在のプロジェクト',
|
'settings.skills.activation.currentProject': '現在のプロジェクト',
|
||||||
|
|
||||||
// Settings > Memory
|
// Settings > Memory
|
||||||
|
|||||||
@ -856,6 +856,8 @@ export const kr: Record<TranslationKey, string> = {
|
|||||||
'settings.skills.activation.projectDesc': '현재 프로젝트의 대화에서만 자동 로드',
|
'settings.skills.activation.projectDesc': '현재 프로젝트의 대화에서만 자동 로드',
|
||||||
'settings.skills.activation.appliesTo': '적용 대상:',
|
'settings.skills.activation.appliesTo': '적용 대상:',
|
||||||
'settings.skills.activation.active': '활성',
|
'settings.skills.activation.active': '활성',
|
||||||
|
'settings.skills.activation.selectProjects': '프로젝트 선택…',
|
||||||
|
'settings.skills.activation.projectCount': '{count}개 프로젝트',
|
||||||
'settings.skills.activation.currentProject': '현재 프로젝트',
|
'settings.skills.activation.currentProject': '현재 프로젝트',
|
||||||
|
|
||||||
// Settings > Memory
|
// Settings > Memory
|
||||||
|
|||||||
@ -856,6 +856,8 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.skills.activation.projectDesc': '僅當前專案的對話自動載入',
|
'settings.skills.activation.projectDesc': '僅當前專案的對話自動載入',
|
||||||
'settings.skills.activation.appliesTo': '套用到:',
|
'settings.skills.activation.appliesTo': '套用到:',
|
||||||
'settings.skills.activation.active': '已啟用',
|
'settings.skills.activation.active': '已啟用',
|
||||||
|
'settings.skills.activation.selectProjects': '選擇專案…',
|
||||||
|
'settings.skills.activation.projectCount': '{count} 個專案',
|
||||||
'settings.skills.activation.currentProject': '目前專案',
|
'settings.skills.activation.currentProject': '目前專案',
|
||||||
|
|
||||||
// Settings > Memory
|
// Settings > Memory
|
||||||
|
|||||||
@ -858,6 +858,8 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.skills.activation.projectDesc': '仅当前项目的对话自动加载',
|
'settings.skills.activation.projectDesc': '仅当前项目的对话自动加载',
|
||||||
'settings.skills.activation.appliesTo': '应用到:',
|
'settings.skills.activation.appliesTo': '应用到:',
|
||||||
'settings.skills.activation.active': '已激活',
|
'settings.skills.activation.active': '已激活',
|
||||||
|
'settings.skills.activation.selectProjects': '选择项目…',
|
||||||
|
'settings.skills.activation.projectCount': '{count} 个项目',
|
||||||
'settings.skills.activation.currentProject': '当前项目',
|
'settings.skills.activation.currentProject': '当前项目',
|
||||||
|
|
||||||
// Settings > Memory
|
// Settings > Memory
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user