feat: add bundled OpenCode skills and refine module flow
This commit is contained in:
@@ -1,22 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { DisclosureContent } from '@/components/ui/disclosure';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { aiModules, getAiModuleForPath, type AiModuleId } from '@/lib/ai-modules';
|
||||
|
||||
const moduleToneClasses: Record<AiModuleId, { active: string }> = {
|
||||
programming: { active: 'bg-brand-soft' },
|
||||
painting: { active: 'bg-brand-soft' },
|
||||
learning: { active: 'bg-brand-soft' },
|
||||
robot: { active: 'bg-brand-soft' },
|
||||
};
|
||||
import { AI_MODULE_SELECTION_PATH, aiModules, getAiModuleForPath } from '@/lib/ai-modules';
|
||||
|
||||
export function ModuleSwitcher({ sidebarCollapsed, compact = false }: { sidebarCollapsed: boolean; compact?: boolean }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const switcherRef = useRef<HTMLDivElement | null>(null);
|
||||
const activeModuleId = getAiModuleForPath(location.pathname);
|
||||
const activeModule = aiModules.find((module) => module.id === activeModuleId) ?? aiModules[0];
|
||||
const getModuleLabel = (module: typeof aiModules[number]) => module.switcherLabel
|
||||
@@ -24,88 +12,24 @@ export function ModuleSwitcher({ sidebarCollapsed, compact = false }: { sidebarC
|
||||
const activeModuleLabel = getModuleLabel(activeModule);
|
||||
const compactTrigger = compact && !sidebarCollapsed;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && !switcherRef.current?.contains(target)) setOpen(false);
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={switcherRef} data-testid="sidebar-module-switcher" className="relative">
|
||||
<div data-testid="sidebar-module-switcher" className="relative">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sidebar-module-switcher-trigger"
|
||||
aria-label={`切换模块:${activeModuleLabel}`}
|
||||
aria-expanded={open}
|
||||
aria-controls="sidebar-module-switcher-menu"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
aria-label="返回首页"
|
||||
title="返回首页"
|
||||
onClick={() => navigate(AI_MODULE_SELECTION_PATH)}
|
||||
className={cn(
|
||||
'motion-press items-center bg-transparent text-left font-semibold text-foreground transition-[background-color,color,transform] hover:bg-surface-subtle active:scale-[0.985]',
|
||||
compactTrigger
|
||||
? 'inline-flex min-h-9 w-fit translate-y-2 justify-start gap-1 rounded-xl px-2 text-xs'
|
||||
: 'flex min-h-11 w-full justify-between gap-2 rounded-2xl px-3 py-2 text-sm',
|
||||
open && 'bg-surface-subtle',
|
||||
? 'inline-flex min-h-9 w-fit translate-y-2 justify-start rounded-xl p-2 text-xs'
|
||||
: 'flex min-h-11 w-full justify-between gap-2 rounded-2xl p-3 text-sm',
|
||||
sidebarCollapsed ? 'justify-center px-0' : '',
|
||||
)}
|
||||
>
|
||||
{!sidebarCollapsed ? <span className="min-w-0 whitespace-nowrap">{activeModuleLabel}</span> : null}
|
||||
{!sidebarCollapsed ? <ChevronDown className={cn(compactTrigger ? 'h-3.5 w-3.5' : 'h-4 w-4', 'shrink-0 transition-transform duration-200 ease-out', open && 'rotate-180')} /> : null}
|
||||
</button>
|
||||
|
||||
<DisclosureContent
|
||||
open={open}
|
||||
id="sidebar-module-switcher-menu"
|
||||
role="menu"
|
||||
data-testid="sidebar-module-switcher-menu"
|
||||
className={cn(
|
||||
'absolute top-[calc(100%+0.5rem)] z-50 rounded-2xl border border-border/80 bg-background p-2 text-foreground shadow-float',
|
||||
sidebarCollapsed ? 'left-0 w-64' : compactTrigger ? 'right-0 min-w-56' : 'inset-x-0',
|
||||
)}
|
||||
innerClassName="grid gap-1.5"
|
||||
>
|
||||
<div className="px-2 py-1 text-[11px] font-semibold text-muted-foreground">选择模块</div>
|
||||
{aiModules.map((module) => {
|
||||
const active = module.id === activeModuleId;
|
||||
const tone = moduleToneClasses[module.id];
|
||||
const moduleLabel = getModuleLabel(module);
|
||||
return (
|
||||
<button
|
||||
key={module.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid={`sidebar-module-${module.id}`}
|
||||
aria-label={`${moduleLabel}${module.enabled ? '' : ',即将上线'}`}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
disabled={!module.enabled}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
if (module.route) navigate(module.route);
|
||||
}}
|
||||
className={cn(
|
||||
'flex min-h-9 w-full items-center rounded-md border px-2 py-1.5 text-left text-xs font-semibold text-foreground transition-[background-color,color,transform,opacity] hover:bg-background active:scale-[0.985]',
|
||||
active ? `border-brand/20 ${tone.active} shadow-soft` : 'border-transparent',
|
||||
!module.enabled && 'cursor-not-allowed border-foreground/15 bg-surface-subtle text-muted-foreground/70 opacity-70 hover:bg-surface-subtle',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 whitespace-nowrap">
|
||||
<span className="block whitespace-nowrap">{moduleLabel}</span>
|
||||
{!module.enabled ? <span className="mt-0.5 block text-[10px] font-medium opacity-80">{module.subtitle}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</DisclosureContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { agentAvatarOptions } from '@/lib/agent-avatars';
|
||||
import { formatModelRefLabel, type ConfiguredModelOption } from '@/lib/model-options';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ProjectAgentConfig } from '../../../shared/project-config';
|
||||
import { DEFAULT_PROJECT_AGENT_SKILL_IDS } from '../../../shared/opencode-skills';
|
||||
|
||||
export type AgentCreationSkillOption = {
|
||||
id: string;
|
||||
@@ -81,6 +82,7 @@ export function AgentCreationDialog({
|
||||
}: AgentCreationDialogProps) {
|
||||
const editing = Boolean(agent);
|
||||
const [input, setInput] = useState<AgentCreationInput>(() => createInitialInput(modelOptions, agent));
|
||||
const defaultSkillsAppliedRef = useRef(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [skillQuery, setSkillQuery] = useState('');
|
||||
const [avatarPickerOpen, setAvatarPickerOpen] = useState(false);
|
||||
@@ -88,11 +90,25 @@ export function AgentCreationDialog({
|
||||
const avatarUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open) {
|
||||
defaultSkillsAppliedRef.current = false;
|
||||
return;
|
||||
}
|
||||
defaultSkillsAppliedRef.current = editing;
|
||||
setInput(createInitialInput(modelOptions, agent));
|
||||
setSkillQuery('');
|
||||
setAvatarPickerOpen(false);
|
||||
}, [agent, modelOptions, open]);
|
||||
}, [agent, editing, modelOptions, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || editing || defaultSkillsAppliedRef.current) return;
|
||||
const defaultSkillIds = DEFAULT_PROJECT_AGENT_SKILL_IDS.filter((skillId) => skills.some((skill) => skill.id === skillId));
|
||||
if (defaultSkillIds.length === 0) return;
|
||||
setInput((current) => current.skillIds.length > 0
|
||||
? current
|
||||
: { ...current, skillIds: [...defaultSkillIds] });
|
||||
defaultSkillsAppliedRef.current = true;
|
||||
}, [editing, open, skills]);
|
||||
|
||||
const handleAvatarFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
|
||||
Reference in New Issue
Block a user