Files
agent-desktop/console/src/os/Launcher.tsx
T
Pine ae3ab5feb5 阶段5: 完整引入上游 OS Shell + Hub + Mailbox
- os/ 67 文件(DesktopOS/Dock/Launcher/MenuBar/MissionControl/NotificationCenter/AppStore/Window 系统 + os*Store/hooks)
- pages/Hub + api/modules/hub.ts + ImportHubModal(已有)
- Inbox 采用上游 Mailbox(approvals/messages 双 Tab + MailAccessControlDrawer/useMailPendingCount/mailDetail),保留云超服 Harvest 组件
- App.tsx 引入 OS Shell 分支(isOsPath→DesktopOSPage, Router 外)+ /hub/admin 路由 + BackendModeRouter + RuntimeAvailabilityGuard,保留云超服 AuthGuard/chatSocket/OPC 主题/vi-th-my-lo
- 双改合并:usePluginLoader(loadPawApp)/pluginMarket(isMarketPluginApp)/registry store(removeBySource)/hostExternals(removePlugin)/LanguageSwitcher(persistRemotely+ja-ru-pt-id)/BackendLoadingPage(override props)/ApprovalCard(reasoning)/auth.ts(mode)/auth/gate.ts
- SidebarSettingsPanel 加 Desktop Mode 入口(OS Shell 切换)
- locales 合并 os/hub/projectDirectory/inbox/account(zh/en/vi 用上游,lo/my/th 用 en 占位)
- 验证:tsc 0 错误、build 通过(1m23s)、阶段5 新增 35 文件 271 测试全过;全量回归 30 失败均预存在(stash 验证)
2026-09-04 10:36:41 +08:00

117 lines
3.5 KiB
TypeScript

/**
* Launcher.tsx — Start-menu overlay listing all registered apps in a grid
* with a search filter. Selecting an app opens its window and closes the
* launcher. Only apps whose route id resolves in the registry are shown.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
import { useOsWindows } from "./osWindowStore";
import type { OsAppDef } from "./osApps";
import { buttonRoleProps } from "./a11y";
import { useOsStyles } from "./useOsStyles";
import { useOsAppLauncher } from "./useOsAppLauncher";
interface LauncherProps {
/** Apps to show (already filtered to installed + available). */
apps: OsAppDef[];
}
export default function Launcher({ apps: source }: LauncherProps) {
const { styles } = useOsStyles();
const { t } = useTranslation();
// Actions only (referentially stable) — window updates never re-render
// the launcher grid.
const setLauncher = useOsWindows((s) => s.setLauncher);
const launchApp = useOsAppLauncher();
const [query, setQuery] = useState("");
const apps = useMemo(
() =>
source.filter((a) => {
const label = t(a.labelKey, a.fallback).toLowerCase();
return label.includes(query.toLowerCase());
}),
[source, query, t],
);
const launch = useCallback(
async (app: OsAppDef) => {
if (await launchApp(app.routeId)) {
setLauncher(false);
}
},
[launchApp, setLauncher],
);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setLauncher(false);
} else if (
event.key === "Enter" &&
event.target instanceof HTMLInputElement &&
!event.isComposing &&
apps[0]
) {
void launch(apps[0]);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [apps, launch, setLauncher]);
return (
<div
className={styles.launcher}
role="dialog"
aria-modal="true"
aria-label={t("os.launchpad", "Launchpad")}
onPointerDown={() => setLauncher(false)}
>
<div
className={styles.launcherSurface}
onPointerDown={(event) => event.stopPropagation()}
>
<div className={styles.launcherSearch}>
<Search size={17} aria-hidden />
<input
autoFocus
aria-label={t("common.search", "Search apps...")}
placeholder={t("common.search", "Search apps...")}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<div className={styles.launcherGrid}>
{apps.map((a) => {
const Icon = a.Icon;
const activate = () => void launch(a);
return (
<div
key={a.routeId}
className={styles.launcherItem}
onClick={activate}
{...buttonRoleProps(activate, t(a.labelKey, a.fallback))}
>
<div
className={styles.launcherIcon}
style={{ background: a.accent }}
>
<Icon size={27} />
</div>
<span>{t(a.labelKey, a.fallback)}</span>
</div>
);
})}
{apps.length === 0 && (
<div className={styles.launcherEmpty}>
{t("common.noData", "No apps")}
</div>
)}
</div>
</div>
</div>
);
}