perf: optimize app startup and background lifecycles

This commit is contained in:
inman
2026-08-19 00:56:15 +08:00
parent 31332f6d76
commit 927e13349b
121 changed files with 4359 additions and 1400 deletions

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import {
BarChart3,
Bot,
Cpu,
ChevronRight,
Crown,
@@ -19,6 +20,13 @@ 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';
@@ -159,6 +167,9 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
const [creatingProject, setCreatingProject] = useState(false);
const [projectEntryError, setProjectEntryError] = useState<ProjectEntryError | null>(null);
const [removeProjectCandidate, setRemoveProjectCandidate] = useState<OpencodeProject | 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);
@@ -168,6 +179,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
const isProgrammingModule = activeModule === 'programming';
const isPaintingModule = activeModule === 'painting';
const isLearningModule = activeModule === 'learning';
const isRobotModule = activeModule === 'robot';
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
const projectConfigPath = '/project-config';
const visibleProjects = opencodeProjects;
@@ -182,6 +194,16 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
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;
@@ -200,6 +222,54 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
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]);
@@ -455,7 +525,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
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 transition-[width,opacity,box-shadow] duration-300 ease-spring motion-reduce:transition-none',
'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
@@ -489,7 +559,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
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 hover:bg-surface-subtle active:scale-[0.985]',
'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="新建项目"
@@ -536,7 +606,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
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-[background-color,color,box-shadow,transform] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-1 active:scale-[0.985]',
'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',
@@ -574,8 +644,8 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
<button
type="button"
aria-label="机器人工作台"
aria-current="page"
onClick={() => navigate('/ai-hardware')}
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',
@@ -585,9 +655,55 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
{!sidebarCollapsed ? <span>机器人工作台</span> : null}
</button>
{!sidebarCollapsed ? (
<p className="mt-3 px-2 text-xs font-medium leading-5 text-muted-foreground">
管理机器人智能体、设备绑定与配置。
</p>
<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>
)}
@@ -639,7 +755,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
<label
key={value}
className={cn(
'min-h-28 cursor-pointer rounded-xl bg-white p-3 shadow-soft transition-[box-shadow,transform] active:scale-[0.96]',
'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',
@@ -796,7 +912,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
? 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 backdrop-blur-md sm:p-6"
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"
@@ -886,7 +1002,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
<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 transition-transform duration-200 ease-out', accountMenuOpen && 'rotate-90')} />
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0', accountMenuOpen && 'rotate-90')} />
</>
)}
</button>
@@ -928,12 +1044,12 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
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 hover:bg-surface-subtle active:scale-[0.985]"
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 transition-transform duration-200 ease-out', usageDrawerOpen && 'rotate-90')} />
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground', usageDrawerOpen && 'rotate-90')} />
</button>
<DisclosureContent
open={usageDrawerOpen}
@@ -974,7 +1090,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
<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-transform hover:bg-background active:scale-[0.985]"
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" />
@@ -987,7 +1103,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
<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 hover:bg-surface-subtle active:scale-[0.985]"
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" />
@@ -997,7 +1113,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
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 hover:bg-surface-subtle active:scale-[0.985]"
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" />
@@ -1009,7 +1125,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
<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 hover:bg-accent-soft hover:text-foreground active:scale-[0.985]"
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" />