Files
goodbuddy/src/renderer/src/SkillsSettingsSection.tsx
T

203 lines
6.1 KiB
TypeScript

import { Download, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
CapabilityAssignments,
CapabilitySnapshot,
RuntimeTarget
} from '../../shared/capability-contracts'
import { SettingsCategoryHeader } from './SettingsPrimitives'
export function SkillsSettingsSection(): React.JSX.Element {
const { t } = useTranslation('settingsSections')
const [snapshot, setSnapshot] = useState<CapabilitySnapshot>()
const [busy, setBusy] = useState<string>()
const [error, setError] = useState<string>()
useEffect(() => {
void window.goodbuddy.capabilities
.getSnapshot()
.then(setSnapshot)
.catch((reason: unknown) => {
setError(
reason instanceof Error
? reason.message
: t('skills.errors.readFailed')
)
})
}, [t])
const run = async (
key: string,
operation: () => Promise<CapabilitySnapshot>
): Promise<void> => {
setBusy(key)
setError(undefined)
try {
setSnapshot(await operation())
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: t('skills.errors.operationFailed')
)
} finally {
setBusy(undefined)
}
}
const updateAssignment = (
skillId: string,
assignments: CapabilityAssignments,
target: RuntimeTarget,
enabled: boolean
): void => {
const next = enabled
? [...assignments, target]
: assignments.filter((item) => item !== target)
void run(`assign:${skillId}`, () =>
window.goodbuddy.capabilities.setSkillAssignments(skillId, next)
)
}
return (
<>
<SettingsCategoryHeader
actions={
<>
<button
className="secondary-button"
disabled={Boolean(busy)}
onClick={() =>
void run('import', () =>
window.goodbuddy.capabilities.importSkill('directory')
)
}
type="button"
>
<Download aria-hidden="true" size={14} />
{t('skills.actions.importDirectory')}
</button>
<button
className="secondary-button"
disabled={Boolean(busy)}
onClick={() =>
void run('import', () =>
window.goodbuddy.capabilities.importSkill('zip')
)
}
type="button"
>
<Download aria-hidden="true" size={14} />
{t('skills.actions.importZip')}
</button>
</>
}
category="skills"
error={error}
headingId="skills-settings-heading"
/>
<section
aria-label={t('skills.listLabel')}
className="settings-section"
>
<p className="settings-notice">
{t('skills.notice')}
</p>
{!snapshot && !error && (
<p className="settings-empty">{t('skills.loading')}</p>
)}
<div className="capability-list">
{snapshot?.skills.map((skill) => (
<article className="capability-card" key={skill.id}>
<div className="capability-card__header">
<div>
<strong>{skill.name}</strong>
<small>
{skill.source === 'builtin'
? t('skills.source.builtin')
: t('skills.source.imported')}{' '}
· {skill.version ?? t('skills.versionMissing')}
</small>
</div>
</div>
<label className="toggle-row">
<input
aria-label={t('skills.enableAria', {
name: skill.name
})}
checked={skill.enabled}
disabled={Boolean(busy)}
onChange={(event) =>
void run(`toggle:${skill.id}`, () =>
window.goodbuddy.capabilities.setSkillEnabled(
skill.id,
event.target.checked
)
)
}
role="switch"
type="checkbox"
/>
<span>
{skill.enabled
? t('skills.enabled')
: t('skills.disabled')}
</span>
</label>
<p>{skill.description}</p>
<div className="capability-tags">
{skill.tags.map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
<div className="runtime-assignments">
<small>{t('skills.assignedTo')}</small>
{(['model', 'opencode', 'continue'] as RuntimeTarget[]).map(
(target) => (
<label key={target}>
<input
checked={skill.assignments.includes(target)}
disabled={Boolean(busy)}
onChange={(event) =>
updateAssignment(
skill.id,
skill.assignments,
target,
event.target.checked
)
}
type="checkbox"
/>
{t(`skills.runtimeLabels.${target}`)}
</label>
)
)}
{skill.source === 'imported' && (
<button
aria-label={t('skills.deleteAria', {
name: skill.name
})}
className="capability-remove"
disabled={Boolean(busy)}
onClick={() =>
void run(`remove:${skill.id}`, () =>
window.goodbuddy.capabilities.removeSkill(skill.id)
)
}
type="button"
>
<Trash2 size={13} />
{t('skills.actions.delete')}
</button>
)}
</div>
</article>
))}
</div>
</section>
</>
)
}