mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-28 15:53:37 +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.
|
||||
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 { 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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user