Files
makelore/src/components/layout/Sidebar.tsx
2026-09-07 10:06:38 +08:00

1245 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import {
BarChart3,
Bot,
Cpu,
ChevronRight,
Crown,
ExternalLink,
FolderKanban,
LogOut,
Plus,
Puzzle,
Settings as SettingsIcon,
UserCircle2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { DisclosureContent } from '@/components/ui/disclosure';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import { getAiHardwareOverview, type AiHardwareOverview } from '@/lib/ai-hardware';
import {
AI_HARDWARE_NAVIGATION_EVENT,
AI_HARDWARE_OVERVIEW_EVENT,
announceAiHardwareNavigation,
readAiHardwareAgentId,
} from '@/lib/ai-hardware-navigation';
import { getAiModuleForPath } from '@/lib/ai-modules';
import { invokeIpc } from '@/lib/api-client';
import { getAuthUserDisplayName } from '@/lib/auth-user-display';
import { WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT } from '@/lib/works-square-usage-events';
import { fetchWorksTokenUsage, type WorksTokenUsage } from '@/lib/works-square';
import { isWorksTokenUsageExhausted } from '@/lib/works-square-token-usage';
import { openWorksSquareSubscriptionUpgrade } from '@/lib/subscription-upgrade';
import { cn } from '@/lib/utils';
import { useCurrentUserProfile } from '@/hooks/use-current-user-profile';
import type { SidebarPeekSource } from './sidebar-peek';
import { ModuleSwitcher } from './ModuleSwitcher';
import { ImageWorkspaceSidebar } from './ImageWorkspaceSidebar';
import { SidebarUpdateButton } from './SidebarUpdateButton';
import { UserAvatar } from '@/components/profile/UserAvatar';
import { getAccountInitial } from '@/components/profile/user-avatar-utils';
import { UserProfileDialog } from '@/components/profile/UserProfileDialog';
import { useSettingsStore } from '@/stores/settings';
import { useAuthStore, type AuthUser } from '@/stores/auth';
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
import { useProviderStore } from '@/stores/providers';
import { isCanonicalCodingProjectId } from '@/lib/coding-projects';
import type { CodingProjectSummary } from '@/types/coding-project';
import { useProjectConfigStore } from '@/stores/project-config';
import type { ProjectType } from '../../../shared/project-config';
import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts';
import { toast } from 'sonner';
type OpenDialogResult = {
canceled: boolean;
filePaths: string[];
};
type TokenUsageState = {
status: 'idle' | 'loading' | 'loaded' | 'error';
usage: WorksTokenUsage | null;
error?: string;
};
type ProjectEntryError = {
project: CodingProjectSummary;
message: string;
};
type ProjectDirectoryMode = 'use-selected-directory' | 'create-child-directory';
type ProjectIdentityKind = ProjectIdentityChoice['kind'];
function getFolderName(pathValue: string): string {
const trimmed = pathValue.trim();
const withoutTrailingSeparators = trimmed.replace(/[\\/]+$/u, '');
return withoutTrailingSeparators.split(/[\\/]/u).filter(Boolean).at(-1) ?? trimmed;
}
function formatRemainingPercent(value: number | null | undefined): string {
if (typeof value !== 'number' || !Number.isFinite(value)) return '不限';
const clamped = Math.min(100, Math.max(0, value));
return `${Math.round(clamped)}%`;
}
function padTimePart(value: number): string {
return String(value).padStart(2, '0');
}
function formatRefreshTime(value: string | null | undefined): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return `${padTimePart(date.getMonth() + 1)}-${padTimePart(date.getDate())} ${padTimePart(date.getHours())}:${padTimePart(date.getMinutes())}`;
}
function getRefreshTimeLabel(value: string | null | undefined): string | null {
const formatted = formatRefreshTime(value);
return formatted ? `刷新时间 ${formatted}` : null;
}
function isTokenUsageExhausted(state: TokenUsageState): boolean {
if (state.status !== 'loaded' || !state.usage) return false;
return isWorksTokenUsageExhausted(state.usage);
}
function getSubscriptionPlanLabel(user: AuthUser | null, state: TokenUsageState): string {
if (!user) return '未登录';
const planName = state.usage?.plan_name?.trim();
if (planName) return planName;
const planCode = state.usage?.plan_code?.trim();
if (planCode) return planCode;
return '会员';
}
function isFreeSubscriptionPlan(state: TokenUsageState): boolean {
const planCode = state.usage?.plan_code?.trim().toLowerCase();
const planName = state.usage?.plan_name?.trim().toLowerCase();
return planCode === 'free' || planName === 'free';
}
function getSubscriptionMembershipLabel(user: AuthUser | null, state: TokenUsageState): string {
const levelLabel = getSubscriptionPlanLabel(user, state);
if (!user || isFreeSubscriptionPlan(state) || levelLabel === '会员') return levelLabel;
return `${levelLabel} 会员`;
}
type SidebarProps = {
workspaceLayout?: boolean;
sidebarPeekOpen?: boolean;
onSidebarPeekChange?: (open: boolean, source: SidebarPeekSource) => void;
};
export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSidebarPeekChange }: SidebarProps = {}) {
const { t } = useTranslation('common');
const sidebarPinnedCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const sidebarCollapsed = sidebarPinnedCollapsed && !sidebarPeekOpen;
const projects = useCodingWorkspaceStore((state) => state.projects);
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
const loadProjectList = useCodingWorkspaceStore((state) => state.load);
const setActiveProject = useCodingWorkspaceStore((state) => state.setActiveProject);
const removeProject = useCodingWorkspaceStore((state) => state.removeProject);
const createProject = useCodingWorkspaceStore((state) => state.createProject);
const projectConfigs = useProjectConfigStore((state) => state.configsByProjectId);
const loadProjectConfig = useProjectConfigStore((state) => state.load);
const removeProjectConfig = useProjectConfigStore((state) => state.remove);
const authUser = useAuthStore((state) => state.user);
const getValidAccessToken = useAuthStore((state) => state.getValidAccessToken);
const logout = useAuthStore((state) => state.logout);
const {
userProfile,
profileRequired,
profileSyncError,
syncProfileNow,
} = useCurrentUserProfile();
const refreshProviderSnapshot = useProviderStore((state) => state.refreshProviderSnapshot);
const [projectsOpen, setProjectsOpen] = useState(true);
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const [usageDrawerOpen, setUsageDrawerOpen] = useState(false);
const [tokenUsageState, setTokenUsageState] = useState<TokenUsageState>({ status: 'idle', usage: null });
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [newProjectSelectedPath, setNewProjectSelectedPath] = useState('');
const [newProjectDirectoryMode, setNewProjectDirectoryMode] = useState<ProjectDirectoryMode>('use-selected-directory');
const [newProjectType, setNewProjectType] = useState<ProjectType>('interactive_ai_app');
const [newProjectIdentityKind, setNewProjectIdentityKind] = useState<ProjectIdentityKind>('create');
const [newProjectIdentityProjectId, setNewProjectIdentityProjectId] = useState('');
const [createProjectError, setCreateProjectError] = useState<string | null>(null);
const [creatingProject, setCreatingProject] = useState(false);
const [projectEntryError, setProjectEntryError] = useState<ProjectEntryError | null>(null);
const [removeProjectCandidate, setRemoveProjectCandidate] = useState<CodingProjectSummary | null>(null);
const [robotOverview, setRobotOverview] = useState<AiHardwareOverview | null>(null);
const [robotOverviewLoading, setRobotOverviewLoading] = useState(false);
const [robotSelectedAgentId, setRobotSelectedAgentId] = useState<string | null>(() => readAiHardwareAgentId());
const accountButtonRef = useRef<HTMLButtonElement | null>(null);
const accountMenuRef = useRef<HTMLDivElement | null>(null);
const tokenUsageRequestIdRef = useRef(0);
const navigate = useNavigate();
const location = useLocation();
const activeModule = getAiModuleForPath(location.pathname);
const isProgrammingModule = activeModule === 'programming';
const isPaintingModule = activeModule === 'painting';
const isRobotModule = activeModule === 'robot';
const projectConfigPath = '/project-config';
const visibleProjects = projects;
const selectedProjectFolderName = getFolderName(newProjectSelectedPath);
const accountName = userProfile?.displayName?.trim() || getAuthUserDisplayName(authUser) || '未登录用户';
const accountFullName = accountName;
const accountInitial = getAccountInitial(accountName);
const accountAvatarUrl = userProfile?.avatarUrl ?? null;
const settingsLabel = t('sidebar.settings', '设置');
const memberLabel = getSubscriptionPlanLabel(authUser, tokenUsageState);
const membershipLabel = getSubscriptionMembershipLabel(authUser, tokenUsageState);
const tokenUsageExhausted = isTokenUsageExhausted(tokenUsageState);
const fiveHourRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.five_hour_refresh_at);
const weeklyRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.weekly_refresh_at);
const activeRobotAgentId = isRobotModule ? robotSelectedAgentId : null;
const openRobotRoute = useCallback((agentId: string | null = null, openCreate = false) => {
const params = new URLSearchParams();
if (agentId) params.set('agent', agentId);
if (openCreate) params.set('create', '1');
const search = params.toString();
navigate({ pathname: '/ai-hardware', search: search ? `?${search}` : '' });
announceAiHardwareNavigation({ agentId, openCreate });
}, [navigate]);
useEffect(() => {
if (!isProgrammingModule) return;
void loadProjectList().catch(() => undefined);
}, [isProgrammingModule, loadProjectList]);
useEffect(() => {
if (!isProgrammingModule) return;
for (const project of visibleProjects) {
void loadProjectConfig(project.id).catch(() => undefined);
}
}, [isProgrammingModule, loadProjectConfig, visibleProjects]);
useEffect(() => {
if (!isProgrammingModule) return;
void refreshProviderSnapshot().catch(() => undefined);
}, [isProgrammingModule, refreshProviderSnapshot]);
useEffect(() => {
if (!isRobotModule) {
setRobotOverview(null);
setRobotOverviewLoading(false);
return undefined;
}
let active = true;
const handleOverview = (event: Event) => {
const next = (event as CustomEvent<AiHardwareOverview>).detail;
if (!next || !Array.isArray(next.agents) || !Array.isArray(next.devices)) return;
setRobotOverview(next);
setRobotOverviewLoading(false);
};
window.addEventListener(AI_HARDWARE_OVERVIEW_EVENT, handleOverview);
setRobotOverviewLoading(true);
void getAiHardwareOverview()
.then((next) => {
if (!active) return;
setRobotOverview(next);
setRobotOverviewLoading(false);
})
.catch(() => {
if (!active) return;
setRobotOverviewLoading(false);
});
return () => {
active = false;
window.removeEventListener(AI_HARDWARE_OVERVIEW_EVENT, handleOverview);
};
}, [isRobotModule]);
useEffect(() => {
if (!isRobotModule) {
setRobotSelectedAgentId(null);
return undefined;
}
setRobotSelectedAgentId(readAiHardwareAgentId(location.search));
const handleNavigation = (event: Event) => {
const detail = (event as CustomEvent<{ agentId?: string | null; openCreate?: boolean }>).detail;
if (!detail?.openCreate && detail?.agentId !== undefined) setRobotSelectedAgentId(detail.agentId);
};
window.addEventListener(AI_HARDWARE_NAVIGATION_EVENT, handleNavigation);
return () => window.removeEventListener(AI_HARDWARE_NAVIGATION_EVENT, handleNavigation);
}, [isRobotModule, location.search]);
useEffect(() => {
if (profileRequired || profileSyncError) setProfileDialogOpen(true);
}, [profileRequired, profileSyncError]);
useEffect(() => {
if (!accountMenuOpen) return undefined;
const handleMouseDown = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (accountButtonRef.current?.contains(target)) return;
if (accountMenuRef.current?.contains(target)) return;
setAccountMenuOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setAccountMenuOpen(false);
}
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [accountMenuOpen]);
useEffect(() => {
if (!accountMenuOpen) {
setUsageDrawerOpen(false);
}
}, [accountMenuOpen]);
const refreshTokenUsage = useCallback(() => {
if (!authUser) {
tokenUsageRequestIdRef.current += 1;
setTokenUsageState({ status: 'idle', usage: null });
return;
}
const requestId = tokenUsageRequestIdRef.current + 1;
tokenUsageRequestIdRef.current = requestId;
setTokenUsageState((current) => ({ status: 'loading', usage: current.usage }));
void getValidAccessToken()
.then((accessToken) => {
if (!accessToken) {
throw new Error('请先登录后再查看用量');
}
return fetchWorksTokenUsage(accessToken);
})
.then((usage) => {
if (tokenUsageRequestIdRef.current === requestId) {
setTokenUsageState({ status: 'loaded', usage });
}
})
.catch((error) => {
if (tokenUsageRequestIdRef.current === requestId) {
setTokenUsageState({
status: 'error',
usage: null,
error: error instanceof Error ? error.message : '暂时无法获取套餐额度',
});
}
});
}, [authUser, getValidAccessToken]);
useEffect(() => {
refreshTokenUsage();
}, [refreshTokenUsage]);
useEffect(() => {
if (!authUser) return undefined;
const handleFocus = () => {
refreshTokenUsage();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refreshTokenUsage();
}
};
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [authUser, refreshTokenUsage]);
useEffect(() => {
if (authUser && accountMenuOpen) {
refreshTokenUsage();
}
}, [accountMenuOpen, authUser, refreshTokenUsage]);
useEffect(() => {
if (!authUser) return undefined;
const retryTimers = new Set<number>();
const handleTokenUsageStale = () => {
refreshTokenUsage();
const retryTimer = window.setTimeout(() => {
retryTimers.delete(retryTimer);
refreshTokenUsage();
}, 1500);
retryTimers.add(retryTimer);
};
window.addEventListener(WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT, handleTokenUsageStale);
return () => {
window.removeEventListener(WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT, handleTokenUsageStale);
for (const retryTimer of retryTimers) {
window.clearTimeout(retryTimer);
}
};
}, [authUser, refreshTokenUsage]);
const openCreateProject = () => {
setNewProjectName('');
setNewProjectSelectedPath('');
setNewProjectDirectoryMode('use-selected-directory');
setNewProjectType('interactive_ai_app');
setNewProjectIdentityKind('create');
setNewProjectIdentityProjectId('');
setCreateProjectError(null);
setCreateDialogOpen(true);
};
const closeCreateProject = () => {
if (creatingProject) return;
setCreateDialogOpen(false);
setNewProjectType('interactive_ai_app');
setNewProjectIdentityKind('create');
setNewProjectIdentityProjectId('');
setCreateProjectError(null);
};
const chooseProjectPath = async () => {
const result = await invokeIpc<OpenDialogResult>('dialog:open', {
properties: ['openDirectory'],
title: newProjectDirectoryMode === 'use-selected-directory'
? '选择要直接使用的项目文件夹'
: '选择项目保存位置',
});
if (result.canceled || !result.filePaths[0]) return;
setNewProjectSelectedPath(result.filePaths[0]);
setCreateProjectError(null);
};
const showProjectEntryError = (project: CodingProjectSummary, message: string) => {
setProjectEntryError({ project, message });
};
const enterProject = async (project: CodingProjectSummary) => {
const result = await loadProjectConfig(project.id);
if (result.status !== 'valid' || !result.config) {
showProjectEntryError(project, result.status === 'missing' ? '项目缺少必要的配置文件' : result.error ?? '项目配置无效');
return;
}
await setActiveProject(project.id);
navigate(result.config.initialized
? '/chat'
: projectConfigPath);
};
const readAndEnterProject = async (project: CodingProjectSummary) => {
setProjectEntryError(null);
try {
await enterProject(project);
} catch (error) {
showProjectEntryError(project, error instanceof Error ? error.message : String(error));
}
};
const confirmCreateProject = async () => {
const projectName = newProjectName.trim();
const selectedPath = newProjectSelectedPath.trim();
if (newProjectDirectoryMode === 'create-child-directory' && !projectName) {
setCreateProjectError('请输入项目名称');
return;
}
if (!selectedPath) {
setCreateProjectError('请选择项目路径');
return;
}
const boundProjectId = newProjectIdentityProjectId.trim();
if (newProjectIdentityKind === 'bind' && !isCanonicalCodingProjectId(boundProjectId)) {
setCreateProjectError('请输入有效的项目 ID小写 UUID。');
return;
}
setCreatingProject(true);
setCreateProjectError(null);
try {
const project = await createProject({
projectType: newProjectType,
identity: newProjectIdentityKind === 'create'
? { kind: 'create' }
: { kind: 'bind', projectId: boundProjectId },
...(newProjectDirectoryMode === 'create-child-directory'
? { parentPath: selectedPath, projectName }
: { projectPath: selectedPath }),
});
setCreateDialogOpen(false);
await enterProject(project);
} catch (error) {
setCreateProjectError(error instanceof Error ? error.message : String(error));
} finally {
setCreatingProject(false);
}
};
const openProject = async (project: CodingProjectSummary) => {
await readAndEnterProject(project);
};
const requestRemoveUnavailableProject = () => {
if (!projectEntryError) return;
setProjectEntryError(null);
setRemoveProjectCandidate(projectEntryError.project);
};
const handleRemoveUnavailableProject = async () => {
if (!removeProjectCandidate) return;
const project = removeProjectCandidate;
await removeProject(project.id);
removeProjectConfig(project.id);
setRemoveProjectCandidate(null);
toast.success(`已移除项目 ${project.name}`);
};
const navigateFromAccountMenu = (path: string) => {
setAccountMenuOpen(false);
setUsageDrawerOpen(false);
navigate(path);
};
const openProfileDialog = () => {
setAccountMenuOpen(false);
setUsageDrawerOpen(false);
setProfileDialogOpen(true);
void syncProfileNow().catch(() => undefined);
};
const openSubscriptionUpgrade = () => {
void openWorksSquareSubscriptionUpgrade().catch(() => undefined);
};
const handleAccountLogout = async () => {
setAccountMenuOpen(false);
setUsageDrawerOpen(false);
await logout();
navigate('/login');
};
const sidebarPeekPreview = sidebarPinnedCollapsed && sidebarPeekOpen;
return (
<aside
data-testid="sidebar"
aria-hidden={sidebarCollapsed}
onPointerEnter={() => onSidebarPeekChange?.(true, 'sidebar')}
onPointerLeave={() => onSidebarPeekChange?.(false, 'sidebar')}
className={cn(
'sidebar-glass-surface flex min-h-0 shrink-0 flex-col overflow-hidden border-r border-border/80 text-foreground',
sidebarCollapsed
? 'pointer-events-none w-0 border-r-0 opacity-0'
: workspaceLayout
? 'w-[256px] min-w-[256px] basis-[256px] opacity-100'
: 'w-64 opacity-100',
// Keep the collapsed preview in the same fixed layer while it opens
// and closes. This makes the hover state cover the titlebar and the
// full window height without reflowing the page underneath it.
sidebarPinnedCollapsed && 'fixed inset-y-0 left-0 z-[350]',
sidebarPeekPreview && 'sidebar-peek-surface pt-10 shadow-float',
)}
>
<div
className={cn(
'bg-transparent px-3 text-foreground',
isPaintingModule && !sidebarCollapsed ? 'pb-2 pt-10' : 'py-2',
sidebarCollapsed && 'px-2',
)}
>
<div className="min-w-0 w-full">
<ModuleSwitcher sidebarCollapsed={sidebarCollapsed} compact={!sidebarCollapsed} />
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto px-2 py-2">
{isPaintingModule ? (
<ImageWorkspaceSidebar sidebarCollapsed={sidebarCollapsed} />
) : isProgrammingModule ? (
<>
<button
type="button"
data-testid="sidebar-create-project"
className={cn(
'motion-press mb-1 flex min-h-9 w-full items-center gap-2 rounded-lg border-0 bg-transparent px-2 py-1.5 text-left text-sm font-medium text-foreground shadow-none transition-colors duration-100 hover:bg-surface-subtle',
sidebarCollapsed ? 'justify-center px-0' : '',
)}
aria-label="新建项目"
onClick={openCreateProject}
>
<Plus className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed && '新建项目'}
</button>
<div className="mt-3 grid gap-1">
<button
type="button"
data-testid="sidebar-nav-plugins"
aria-current={location.pathname === '/plugins' ? 'page' : undefined}
aria-label="插件"
onClick={() => navigate(activeProject ? '/plugins?scope=project' : '/plugins?scope=all')}
className={cn(
'motion-press flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-medium transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35',
location.pathname === '/plugins' ? 'bg-brand-selected text-foreground' : 'text-foreground hover:bg-surface-subtle',
sidebarCollapsed && 'justify-center px-0',
)}
>
<Puzzle className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed ? <span></span> : null}
</button>
</div>
<div className="mt-3">
<button
type="button"
onClick={() => setProjectsOpen(!projectsOpen)}
className={cn(
'motion-press flex min-h-8 w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs font-semibold text-muted-foreground hover:bg-surface-subtle hover:text-foreground',
sidebarCollapsed ? 'justify-center px-0' : '',
)}
>
<FolderKanban className="h-4 w-4 shrink-0" />
{!sidebarCollapsed && (
<>
<span className="min-w-0 flex-1 truncate"></span>
<span className="text-xs">{projectsOpen ? '收起' : '展开'}</span>
</>
)}
</button>
{!sidebarCollapsed ? (
<DisclosureContent open={projectsOpen} className="mt-1" innerClassName="space-y-1.5 px-1 py-1">
{visibleProjects.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
</div>
) : null}
{visibleProjects.map((project) => {
const projectConfig = projectConfigs[project.id];
const active = activeProject?.id === project.id;
return (
<button
type="button"
key={project.id}
data-testid={`sidebar-course-project-${project.id}`}
aria-label={`进入项目 ${project.name}`}
aria-current={active ? 'page' : undefined}
onClick={() => void openProject(project)}
className={cn(
'motion-press group/project flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-1',
active
? 'bg-brand-selected text-foreground shadow-soft ring-1 ring-brand/25'
: 'bg-transparent text-foreground hover:bg-surface-tertiary',
)}
>
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{project.name}</span>
<div className="flex shrink-0 -space-x-1.5" aria-label="项目智能体">
{projectConfig?.agents.map((agent) => (
<span
key={agent.id}
title={agent.name.trim() || agent.roleName}
className="h-5 w-5 overflow-hidden rounded-full border border-background bg-white"
>
<img
src={getAgentAvatarSrc(agent.avatarId, agent.avatarDataUrl)}
alt=""
className="h-full w-full object-cover [image-rendering:pixelated]"
/>
</span>
))}
</div>
<ChevronRight aria-hidden="true" className="h-3.5 w-3.5 shrink-0 -translate-x-0.5 text-muted-foreground opacity-50 transition-[opacity,transform,color] duration-200 ease-out group-hover/project:translate-x-0 group-hover/project:text-foreground group-hover/project:opacity-100 group-focus-visible/project:translate-x-0 group-focus-visible/project:text-foreground group-focus-visible/project:opacity-100" />
</button>
);
})}
</DisclosureContent>
) : null}
</div>
</>
) : (
<div data-testid="sidebar-robot-navigation" className="px-2 py-3">
<button
type="button"
aria-label="机器人工作台"
aria-current={location.pathname === '/ai-hardware' ? 'page' : undefined}
onClick={() => openRobotRoute()}
className={cn(
'flex min-h-10 w-full items-center gap-2 rounded-lg bg-brand-soft px-2 py-2 text-left text-sm font-semibold text-foreground',
sidebarCollapsed && 'justify-center px-0',
)}
>
<Cpu className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed ? <span></span> : null}
</button>
{!sidebarCollapsed ? (
<div className="mt-4 border-t border-border/60 pt-4">
<div className="flex items-center justify-between gap-2 px-2">
<span className="text-xs font-semibold tracking-wide text-muted-foreground"></span>
<button
type="button"
data-testid="sidebar-robot-create-agent"
aria-label="创建智能体"
title="创建智能体"
onClick={() => openRobotRoute(null, true)}
className="motion-press flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors duration-100 hover:bg-surface-subtle hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35"
>
<Plus className="h-4 w-4" />
</button>
</div>
<div data-testid="sidebar-robot-agents" className="mt-2 space-y-1">
{robotOverviewLoading ? (
<p role="status" className="px-2 py-2 text-xs text-muted-foreground"></p>
) : robotOverview ? (
robotOverview.agents.length > 0 ? robotOverview.agents.map((agent) => {
const active = activeRobotAgentId === agent.id;
const deviceCount = robotOverview.devices.filter((device) => device.agent_id === agent.id).length;
return (
<button
key={agent.id}
type="button"
data-testid={`sidebar-robot-agent-${agent.id}`}
aria-label={`进入智能体 ${agent.name}`}
aria-current={active ? 'page' : undefined}
onClick={() => openRobotRoute(agent.id)}
className={cn(
'motion-press flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35',
active ? 'bg-brand-selected text-foreground' : 'text-foreground hover:bg-surface-tertiary',
)}
>
<Bot className={cn('h-4 w-4 shrink-0', active ? 'text-brand' : 'text-muted-foreground')} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{agent.name}</span>
<span className="block text-[11px] tabular-nums text-muted-foreground">{deviceCount} </span>
</span>
</button>
);
}) : (
<p className="px-2 py-2 text-xs leading-5 text-muted-foreground"> + </p>
)
) : (
<p className="px-2 py-2 text-xs leading-5 text-muted-foreground"></p>
)}
</div>
</div>
) : null}
</div>
)}
</div>
{isProgrammingModule && createDialogOpen ? (
<Dialog
open={createDialogOpen}
onOpenChange={(open) => {
if (!open) closeCreateProject();
}}
>
<DialogContent
className="max-h-[calc(100vh-2rem)] max-w-xl overflow-y-auto p-5"
onInteractOutside={(event) => {
if (creatingProject) event.preventDefault();
}}
>
<DialogHeader className="flex-row items-start justify-between gap-3 space-y-0">
<div>
<DialogTitle className="text-xl"></DialogTitle>
<DialogDescription className="mt-1 text-xs">使</DialogDescription>
</div>
<button
type="button"
className="rounded-md border border-foreground/15 px-2 py-1 text-xs font-semibold hover:bg-white"
onClick={closeCreateProject}
disabled={creatingProject}
>
</button>
</DialogHeader>
<form
className="mt-4 space-y-3"
onSubmit={(event) => {
event.preventDefault();
void confirmCreateProject();
}}
>
<fieldset className="space-y-2">
<legend className="text-xs font-semibold"></legend>
<div className="grid gap-2 sm:grid-cols-2">
<label
className={cn(
'cursor-pointer rounded-xl border border-foreground/10 bg-white p-3 text-sm font-semibold',
newProjectIdentityKind === 'create' ? 'ring-2 ring-brand/35 shadow-float' : 'hover:shadow-float',
)}
>
<input
type="radio"
name="project-identity"
value="create"
aria-label="创建新的项目 ID"
checked={newProjectIdentityKind === 'create'}
onChange={() => {
setNewProjectIdentityKind('create');
setCreateProjectError(null);
}}
disabled={creatingProject}
/>
<span className="ml-2"> ID</span>
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground">
UUID
</span>
</label>
<label
className={cn(
'cursor-pointer rounded-xl border border-foreground/10 bg-white p-3 text-sm font-semibold',
newProjectIdentityKind === 'bind' ? 'ring-2 ring-brand/35 shadow-float' : 'hover:shadow-float',
)}
>
<input
type="radio"
name="project-identity"
value="bind"
aria-label="绑定已有项目 ID"
checked={newProjectIdentityKind === 'bind'}
onChange={() => {
setNewProjectIdentityKind('bind');
setCreateProjectError(null);
}}
disabled={creatingProject}
/>
<span className="ml-2"> ID</span>
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground">
UUID ID
</span>
</label>
</div>
{newProjectIdentityKind === 'bind' ? (
<div className="space-y-2 rounded-xl border border-foreground/10 bg-surface-subtle p-3">
<label className="block text-xs font-semibold" htmlFor="new-project-identity-id">
ID
</label>
<input
id="new-project-identity-id"
value={newProjectIdentityProjectId}
onChange={(event) => {
setNewProjectIdentityProjectId(event.target.value);
setCreateProjectError(null);
}}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
autoComplete="off"
spellCheck={false}
className="w-full rounded-md border border-foreground/15 bg-white px-3 py-2 font-mono text-sm font-medium outline-none focus:ring-2 focus:ring-brand/20"
disabled={creatingProject}
/>
<p className="text-[11px] font-medium leading-5 text-muted-foreground">
ID ID
</p>
</div>
) : null}
</fieldset>
<fieldset className="space-y-2">
<legend className="text-xs font-semibold"></legend>
<div className="grid gap-2 sm:grid-cols-2">
{([
['interactive_ai_app', '交互式 AI 应用', '创建可交互、可发布的 AI 应用项目;进入对话后可用项目初始化 Skill 生成工程骨架。'],
['custom', '自定义项目', '只创建项目空间,不配置默认发布方式。'],
] as const).map(([value, title, description]) => (
<label
key={value}
className={cn(
'min-h-28 cursor-pointer rounded-xl bg-white p-3 shadow-soft',
newProjectType === value
? 'ring-2 ring-brand/35 shadow-float'
: 'ring-1 ring-foreground/10 hover:shadow-float',
)}
>
<span className="flex items-center gap-2 text-sm font-semibold">
<input
type="radio"
name="project-type"
value={value}
aria-label={title}
checked={newProjectType === value}
onChange={() => {
setNewProjectType(value);
setCreateProjectError(null);
}}
disabled={creatingProject}
/>
{title}
</span>
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground [text-wrap:pretty]">
{description}
</span>
</label>
))}
</div>
</fieldset>
<fieldset className="space-y-2">
<legend className="text-xs font-semibold"></legend>
<div className="grid gap-2 sm:grid-cols-2">
<label
className={cn(
'cursor-pointer rounded-md border border-foreground/15 px-3 py-2 text-xs font-semibold',
newProjectDirectoryMode === 'use-selected-directory' ? 'bg-brand-soft' : 'bg-white',
)}
>
<input
type="radio"
name="project-directory-mode"
value="use-selected-directory"
checked={newProjectDirectoryMode === 'use-selected-directory'}
onChange={() => {
setNewProjectDirectoryMode('use-selected-directory');
setCreateProjectError(null);
}}
className="mr-2 align-middle"
disabled={creatingProject}
/>
使
</label>
<label
className={cn(
'cursor-pointer rounded-md border border-foreground/15 px-3 py-2 text-xs font-semibold',
newProjectDirectoryMode === 'create-child-directory' ? 'bg-brand-soft' : 'bg-white',
)}
>
<input
type="radio"
name="project-directory-mode"
value="create-child-directory"
checked={newProjectDirectoryMode === 'create-child-directory'}
onChange={() => {
setNewProjectDirectoryMode('create-child-directory');
setCreateProjectError(null);
}}
className="mr-2 align-middle"
disabled={creatingProject}
/>
</label>
</div>
</fieldset>
{newProjectDirectoryMode === 'create-child-directory' ? (
<div className="space-y-2">
<label className="block text-xs font-semibold" htmlFor="new-project-name">
</label>
<input
id="new-project-name"
value={newProjectName}
onChange={(event) => {
setNewProjectName(event.target.value);
setCreateProjectError(null);
}}
className="w-full rounded-md border border-foreground/15 bg-white px-3 py-2 text-sm font-medium outline-none focus:ring-2 focus:ring-brand/20"
autoFocus
disabled={creatingProject}
/>
</div>
) : null}
<label className="block text-xs font-semibold" htmlFor="new-project-path">
</label>
<div className="flex gap-2">
<input
id="new-project-path"
value={newProjectSelectedPath}
readOnly
className="min-w-0 flex-1 rounded-md border border-foreground/15 bg-white px-3 py-2 text-sm font-medium outline-none"
/>
<Button
type="button"
variant="outline"
className="shrink-0 border border-foreground/15 bg-white text-foreground"
onClick={() => void chooseProjectPath()}
disabled={creatingProject}
>
</Button>
</div>
<p className="text-[11px] font-medium text-muted-foreground">
{newProjectDirectoryMode === 'use-selected-directory'
? selectedProjectFolderName
? `将直接接入此文件夹,项目名称使用“${selectedProjectFolderName}”,不会重命名目录。`
: '将直接接入所选文件夹,不会创建下级目录。'
: '确认后会在所选位置下创建与项目名称相同的文件夹。'}
</p>
{createProjectError ? (
<div className="rounded-md border border-foreground/15 bg-accent-soft px-3 py-2 text-xs font-semibold">
{createProjectError}
</div>
) : null}
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
className="border border-foreground/15 bg-white text-foreground"
onClick={closeCreateProject}
disabled={creatingProject}
>
</Button>
<Button
type="submit"
className="border border-foreground/15 bg-brand-soft text-foreground"
disabled={creatingProject
|| !newProjectSelectedPath.trim()
|| (newProjectDirectoryMode === 'create-child-directory' && !newProjectName.trim())}
>
</Button>
</div>
</form>
</DialogContent>
</Dialog>
) : null}
{isProgrammingModule && projectEntryError && typeof document !== 'undefined'
? createPortal(
<div
data-testid="project-config-error-overlay"
className="fixed inset-0 z-[1000] flex min-h-screen items-center justify-center bg-foreground/15 p-4 sm:p-6"
>
<div
role="alertdialog"
aria-modal="true"
data-testid="project-config-error"
className="glass-surface w-full max-w-md rounded-2xl border border-border/80 bg-background p-5 text-foreground shadow-float sm:p-6"
>
<h2 className="text-xl font-semibold"></h2>
<p className="mt-2 text-sm font-medium leading-6 text-muted-foreground">
</p>
<pre className="mt-3 max-h-32 overflow-auto rounded-md border border-foreground/15 bg-white p-2 text-xs font-medium">
{projectEntryError.message}
</pre>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="outline"
className="border border-foreground/15 bg-accent-soft text-foreground"
onClick={requestRemoveUnavailableProject}
>
</Button>
<Button
type="button"
className="border border-foreground/15 bg-brand-soft text-foreground"
onClick={() => setProjectEntryError(null)}
>
</Button>
</div>
</div>
</div>,
document.body,
)
: null}
{isProgrammingModule ? (
<ConfirmDialog
open={Boolean(removeProjectCandidate)}
title={`移除项目“${removeProjectCandidate?.name ?? ''}”?`}
message="这只会从 Makelore 的项目列表中移除登记,不会删除磁盘中的项目文件。"
confirmLabel="确认移除"
cancelLabel="取消"
variant="destructive"
onCancel={() => setRemoveProjectCandidate(null)}
onConfirm={handleRemoveUnavailableProject}
onError={(error) => toast.error(error instanceof Error ? error.message : String(error))}
/>
) : null}
<UserProfileDialog
open={profileDialogOpen}
required={profileRequired}
syncError={profileSyncError}
onOpenChange={setProfileDialogOpen}
/>
<div className="mt-auto px-2 pb-2 pt-1">
<div className="relative">
<div className={cn(
'flex items-center gap-2',
sidebarCollapsed && 'flex-col-reverse',
)}>
<button
ref={accountButtonRef}
type="button"
data-testid="sidebar-member-menu-trigger"
aria-label={`打开会员菜单:${accountName}`}
aria-haspopup="menu"
aria-expanded={accountMenuOpen}
onClick={() => setAccountMenuOpen((open) => !open)}
className={cn(
'motion-press flex min-h-9 min-w-0 flex-1 items-center gap-2 rounded-lg border-0 bg-transparent px-2 py-1 text-left text-sm font-medium text-foreground shadow-none transition-[background-color,color,transform] hover:bg-surface-subtle',
accountMenuOpen && 'bg-background text-foreground',
sidebarCollapsed && 'min-h-9 w-full justify-center px-0',
)}
>
<UserAvatar
src={accountAvatarUrl}
initial={accountInitial}
testId="sidebar-account-avatar"
className="h-7 w-7 shrink-0"
/>
{!sidebarCollapsed && (
<>
<span className="min-w-0 flex-1">
<span className="block truncate">{accountName}</span>
</span>
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0', accountMenuOpen && 'rotate-90')} />
</>
)}
</button>
<SidebarUpdateButton collapsed={sidebarCollapsed} />
</div>
<DisclosureContent
ref={accountMenuRef}
open={accountMenuOpen}
role="menu"
data-testid="sidebar-account-menu"
className={cn(
'glass-surface z-50 max-w-[calc(100vw-1rem)] rounded-2xl border border-border/80 p-1 text-foreground shadow-float',
sidebarCollapsed
? 'fixed bottom-[72px] left-2 w-64'
: 'absolute bottom-[calc(100%+0.5rem)] left-0 w-full',
)}
innerClassName="max-h-[calc(100vh-7rem)] overflow-y-auto"
>
<div className="px-1 py-1">
<div className="flex items-center gap-2">
<UserAvatar
src={accountAvatarUrl}
initial={accountInitial}
testId="sidebar-account-menu-avatar"
className="h-7 w-7 shrink-0"
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{accountFullName}</p>
<p className="mt-0.5 text-[10px] font-medium text-muted-foreground">{membershipLabel}</p>
</div>
</div>
</div>
<div className="mt-1 grid gap-0.5">
<button
type="button"
role="menuitem"
data-testid="sidebar-account-usage-menuitem"
aria-expanded={usageDrawerOpen}
aria-controls="sidebar-account-usage-drawer"
className="flex min-h-8 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium transition-colors duration-100 hover:bg-surface-subtle"
onClick={() => setUsageDrawerOpen((open) => !open)}
>
<BarChart3 className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate"></span>
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground', usageDrawerOpen && 'rotate-90')} />
</button>
<DisclosureContent
open={usageDrawerOpen}
id="sidebar-account-usage-drawer"
data-testid="sidebar-account-usage-drawer"
className="ml-5 rounded-md bg-surface-subtle/70 px-1 py-0.5 text-xs font-medium text-muted-foreground"
innerClassName="grid gap-0.5"
>
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
<span></span>
<span className="text-foreground">{memberLabel}</span>
</div>
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
<span>5 </span>
<span className="text-foreground">
{formatRemainingPercent(tokenUsageState.usage?.five_hour_remaining_percent)}
</span>
</div>
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
<span>1 </span>
<span className="text-foreground">
{formatRemainingPercent(tokenUsageState.usage?.weekly_remaining_percent)}
</span>
</div>
{fiveHourRefreshLabel ? (
<div data-testid="sidebar-five-hour-refresh-at" className="px-1 text-[10px] font-semibold text-muted-foreground">
{fiveHourRefreshLabel}
</div>
) : null}
{weeklyRefreshLabel ? (
<div data-testid="sidebar-weekly-refresh-at" className="px-1 text-[10px] font-semibold text-muted-foreground">
{weeklyRefreshLabel}
</div>
) : null}
{tokenUsageExhausted ? (
<div className="mt-1 rounded-md border border-accent/50 bg-background p-1.5 text-foreground">
<p className="text-[11px] leading-4 text-muted-foreground">使</p>
<button
type="button"
data-testid="sidebar-account-upgrade-button"
className="mt-1.5 flex min-h-8 w-full items-center justify-center gap-1.5 rounded-md border border-foreground/15 bg-accent-soft px-2 py-1.5 text-xs font-semibold text-foreground shadow-soft transition-colors duration-100 hover:bg-background"
onClick={openSubscriptionUpgrade}
>
<Crown className="h-3.5 w-3.5" />
<span></span>
<ExternalLink className="h-3.5 w-3.5" />
</button>
</div>
) : null}
</DisclosureContent>
<button
type="button"
role="menuitem"
className="flex min-h-8 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium transition-colors duration-100 hover:bg-surface-subtle"
onClick={openProfileDialog}
>
<UserCircle2 className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate"></span>
</button>
<button
type="button"
role="menuitem"
data-testid="sidebar-nav-settings"
className="flex min-h-8 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium transition-colors duration-100 hover:bg-surface-subtle"
onClick={() => navigateFromAccountMenu('/settings')}
>
<SettingsIcon className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{settingsLabel}</span>
</button>
</div>
<div className="mt-1 pt-1">
<button
type="button"
role="menuitem"
className="flex min-h-8 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium text-accent-strong transition-colors duration-100 hover:bg-accent-soft hover:text-foreground"
onClick={() => void handleAccountLogout()}
>
<LogOut className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">退</span>
</button>
</div>
</DisclosureContent>
</div>
</div>
</aside>
);
}