mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-15 07:04:01 -06:00
ea06d72e85
* feat(secrets): add gateway store settings * perf(control-ui): trim secrets startup copy * perf(control-ui): reduce secrets startup payload * fix(secrets): harden store mutation refresh * perf(control-ui): meet secrets startup budget * test(control-ui): update secrets navigation copy * fix(ui): pluralize secret-detection count and drop duplicated dialog hint * chore(protocol): regenerate gateway clients and SDK baseline after rebase * fix(gateway): merge secrets store methods after project RPCs in advertised order * chore: leave changelog to release generation * test(gateway): retain desktop launch train coverage
429 lines
15 KiB
TypeScript
429 lines
15 KiB
TypeScript
import { isValidWorkboardBoardId } from "@openclaw/workboard-contract";
|
|
// Control UI app navigation defines sidebar and settings presentation metadata.
|
|
import type { RouteId } from "./app-route-paths.ts";
|
|
import type { IconName } from "./components/icons.ts";
|
|
import { i18n, t } from "./i18n/index.ts";
|
|
import { normalizeLowercaseStringOrEmpty } from "./lib/string-coerce.ts";
|
|
|
|
export type NavigationRouteId = RouteId;
|
|
|
|
type NavigationItem = {
|
|
[TRouteId in NavigationRouteId]: IconName;
|
|
};
|
|
|
|
// The sidebar shows a small user-customizable ordered zone; every other nav route
|
|
// lives in the collapsed "More" section. Chat is reachable through the session
|
|
// list and Settings/Docs live in the sidebar footer, so neither is listed here.
|
|
// Skills and Skill Workshop are tabs inside the Plugins hub, not sidebar items.
|
|
// Worktrees is a tab of the Sessions hub, so it is not listed either.
|
|
export const SIDEBAR_NAV_ROUTES = [
|
|
"workboard",
|
|
"dashboards",
|
|
"usage",
|
|
"cron",
|
|
"tasks",
|
|
"sessions",
|
|
"activity",
|
|
"plugins",
|
|
"apps",
|
|
] as const satisfies readonly NavigationRouteId[];
|
|
|
|
// Routes presented as tabs of the Plugins hub. The sidebar highlights the
|
|
// Plugins entry for all of them, mirroring how config covers settings routes.
|
|
const PLUGINS_HUB_ROUTES: ReadonlySet<NavigationRouteId> = new Set([
|
|
"plugins",
|
|
"skills",
|
|
"skill-workshop",
|
|
]);
|
|
|
|
export function isPluginsHubRoute(routeId: NavigationRouteId): boolean {
|
|
return PLUGINS_HUB_ROUTES.has(routeId);
|
|
}
|
|
|
|
// Worktrees renders as a tab of the Sessions hub; the sidebar highlights the
|
|
// Sessions entry for both routes, mirroring the Plugins hub behavior.
|
|
const SESSIONS_HUB_ROUTES: ReadonlySet<NavigationRouteId> = new Set(["sessions", "worktrees"]);
|
|
|
|
export function isSessionsHubRoute(routeId: NavigationRouteId): boolean {
|
|
return SESSIONS_HUB_ROUTES.has(routeId);
|
|
}
|
|
|
|
export type SidebarNavRoute = (typeof SIDEBAR_NAV_ROUTES)[number];
|
|
|
|
export type SidebarZoneEntry =
|
|
| { type: "route"; route: SidebarNavRoute }
|
|
| { type: "workboard"; boardId: string }
|
|
| { type: "session"; key: string };
|
|
|
|
// Keep the highest-value operational destinations visible on first use. Users
|
|
// can still replace this route set through the customize menu.
|
|
export const DEFAULT_SIDEBAR_ENTRIES = ["cron", "plugins"].map((route) =>
|
|
serializeSidebarEntry({ type: "route", route: route as SidebarNavRoute }),
|
|
);
|
|
|
|
/**
|
|
* Parse the compact persisted representation used by browser and synced prefs.
|
|
*/
|
|
export function parseSidebarEntry(value: unknown): SidebarZoneEntry | null {
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
if (value.startsWith("route:")) {
|
|
const route = value.slice("route:".length);
|
|
return SIDEBAR_NAV_ROUTES.includes(route as SidebarNavRoute)
|
|
? { type: "route", route: route as SidebarNavRoute }
|
|
: null;
|
|
}
|
|
if (value.startsWith("session:")) {
|
|
const key = value.slice("session:".length).trim();
|
|
return key ? { type: "session", key } : null;
|
|
}
|
|
if (value.startsWith("workboard:")) {
|
|
const boardId = value.slice("workboard:".length).trim();
|
|
return isValidWorkboardBoardId(boardId) ? { type: "workboard", boardId } : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function serializeSidebarEntry(entry: SidebarZoneEntry): string {
|
|
if (entry.type === "route") {
|
|
return `route:${entry.route}`;
|
|
}
|
|
return entry.type === "workboard" ? `workboard:${entry.boardId}` : `session:${entry.key}`;
|
|
}
|
|
|
|
/**
|
|
* Normalize a persisted sidebar-zone list. Returns null when the value is not a
|
|
* list; malformed and duplicate entries are dropped.
|
|
*/
|
|
export function normalizeSidebarEntries(value: unknown): string[] | null {
|
|
if (!Array.isArray(value)) {
|
|
return null;
|
|
}
|
|
const normalized: string[] = [];
|
|
for (const valueEntry of value) {
|
|
const parsed = parseSidebarEntry(valueEntry);
|
|
if (!parsed) {
|
|
continue;
|
|
}
|
|
const entry = serializeSidebarEntry(parsed);
|
|
if (!normalized.includes(entry)) {
|
|
normalized.push(entry);
|
|
}
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
export function sidebarMoreRoutes(entries: readonly string[]): SidebarNavRoute[] {
|
|
const visibleRoutes = new Set(
|
|
entries.flatMap((entry) => {
|
|
const parsed = parseSidebarEntry(entry);
|
|
return parsed?.type === "route" ? [parsed.route] : [];
|
|
}),
|
|
);
|
|
return SIDEBAR_NAV_ROUTES.filter((routeId) => !visibleRoutes.has(routeId));
|
|
}
|
|
|
|
type SettingsNavigationGroup = {
|
|
/** i18n key for the group heading; null renders the group without a label. */
|
|
labelKey: string | null;
|
|
routes: readonly NavigationRouteId[];
|
|
};
|
|
|
|
export type SettingsSearchBlock = {
|
|
routeId: RouteId;
|
|
label: string;
|
|
pathname?: string;
|
|
search?: string;
|
|
hash: string;
|
|
};
|
|
|
|
let settingsSearchSegmenterLocale = "";
|
|
let settingsSearchSegmenter: Intl.Segmenter | null = null;
|
|
|
|
function settingsSearchHasWordPrefix(value: string, query: string): boolean {
|
|
const locale = i18n.getLocale();
|
|
if (settingsSearchSegmenterLocale !== locale) {
|
|
settingsSearchSegmenterLocale = locale;
|
|
settingsSearchSegmenter =
|
|
typeof Intl !== "undefined" && "Segmenter" in Intl
|
|
? new Intl.Segmenter(locale, { granularity: "word" })
|
|
: null;
|
|
}
|
|
if (!settingsSearchSegmenter) {
|
|
return value.split(/[^\p{L}\p{N}]+/u).some((word) => word.startsWith(query));
|
|
}
|
|
for (const segment of settingsSearchSegmenter.segment(value)) {
|
|
if (segment.isWordLike !== false && segment.segment.startsWith(query)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function settingsSearchTextMatches(value: string, query: string): boolean {
|
|
const candidate = normalizeLowercaseStringOrEmpty(value).normalize("NFC");
|
|
const normalizedQuery = normalizeLowercaseStringOrEmpty(query).normalize("NFC");
|
|
if (!normalizedQuery) {
|
|
return false;
|
|
}
|
|
if (normalizedQuery.length > 2) {
|
|
return candidate.includes(normalizedQuery);
|
|
}
|
|
return settingsSearchHasWordPrefix(candidate, normalizedQuery);
|
|
}
|
|
|
|
// Grouping feeds the full-page settings sidebar (settings-sidebar.ts). Ordered
|
|
// by user attention: personal/look-and-feel first, system plumbing last.
|
|
// Management surfaces (sessions, worktrees, activity, memory import) are
|
|
// workspace destinations, not settings; model setup is a subpage of Models.
|
|
export const SETTINGS_NAVIGATION_GROUPS = [
|
|
{ labelKey: null, routes: ["custodian", "profile", "appearance", "notifications"] },
|
|
{
|
|
labelKey: "nav.settingsGroupConnections",
|
|
routes: ["connection", "channels", "communications", "talk", "devices"],
|
|
},
|
|
{
|
|
labelKey: "nav.settingsGroupAgents",
|
|
routes: ["agents", "labs", "model-providers", "mcp", "memory", "automation"],
|
|
},
|
|
{
|
|
labelKey: "nav.settingsGroupSecurity",
|
|
routes: ["security", "secrets", "approvals"],
|
|
},
|
|
{
|
|
labelKey: "nav.settingsGroupSystem",
|
|
routes: ["infrastructure", "advanced", "debug", "logs", "updates", "about"],
|
|
},
|
|
] as const satisfies readonly SettingsNavigationGroup[];
|
|
|
|
// Settings subpages render with settings chrome but stay out of the sidebar.
|
|
// Subpages with a visible owner keep that owner selected so users retain
|
|
// location context while completing the nested flow.
|
|
const SETTINGS_SUBPAGE_ROUTES: readonly NavigationRouteId[] = [
|
|
"ai-agents",
|
|
"model-setup",
|
|
"lobsterdex",
|
|
];
|
|
export const SETTINGS_SEARCHABLE_SUBPAGE_ROUTES: readonly NavigationRouteId[] = ["ai-agents"];
|
|
const SETTINGS_SUBPAGE_OWNER_ROUTES: Partial<
|
|
Readonly<Record<NavigationRouteId, NavigationRouteId>>
|
|
> = {
|
|
"ai-agents": "agents",
|
|
"model-setup": "model-providers",
|
|
};
|
|
|
|
const SETTINGS_NAVIGATION_ROUTES: ReadonlySet<NavigationRouteId> = new Set([
|
|
...SETTINGS_NAVIGATION_GROUPS.flatMap((group) => group.routes),
|
|
...SETTINGS_SUBPAGE_ROUTES,
|
|
]);
|
|
|
|
const NAVIGATION_ICONS: NavigationItem = {
|
|
agents: "bot",
|
|
activity: "activity",
|
|
apps: "layoutGrid",
|
|
approvals: "badgeCheck",
|
|
workboard: "kanban",
|
|
worktrees: "folder",
|
|
channels: "link",
|
|
connection: "radio",
|
|
sessions: "fileText",
|
|
usage: "coins",
|
|
cron: "calendarClock",
|
|
tasks: "listChecks",
|
|
skills: "zap",
|
|
plugins: "puzzle",
|
|
"skill-workshop": "wrench",
|
|
devices: "monitorSmartphone",
|
|
chat: "messageSquare",
|
|
dashboard: "layoutDashboard",
|
|
dashboards: "layoutDashboard",
|
|
custodian: "lobster",
|
|
config: "settings",
|
|
profile: "circleUser",
|
|
communications: "send",
|
|
appearance: "palette",
|
|
lobsterdex: "bug",
|
|
automation: "terminal",
|
|
mcp: "wrench",
|
|
memory: "book",
|
|
talk: "mic",
|
|
infrastructure: "globe",
|
|
labs: "flaskConical",
|
|
updates: "download",
|
|
about: "fileText",
|
|
"ai-agents": "brain",
|
|
"model-setup": "spark",
|
|
"model-providers": "plug",
|
|
"memory-import": "download",
|
|
notifications: "bell",
|
|
security: "shieldCheck",
|
|
secrets: "key",
|
|
advanced: "fileCode",
|
|
debug: "bug",
|
|
logs: "scrollText",
|
|
plugin: "puzzle",
|
|
"new-session": "plus",
|
|
};
|
|
|
|
export function isSettingsNavigationRoute(routeId: NavigationRouteId): boolean {
|
|
return SETTINGS_NAVIGATION_ROUTES.has(routeId);
|
|
}
|
|
|
|
export function settingsNavigationOwnerRoute(routeId: NavigationRouteId): NavigationRouteId {
|
|
return SETTINGS_SUBPAGE_OWNER_ROUTES[routeId] ?? routeId;
|
|
}
|
|
|
|
export function navigationIconForRoute(routeId: NavigationRouteId): IconName {
|
|
return NAVIGATION_ICONS[routeId] ?? "folder";
|
|
}
|
|
|
|
export function scheduleRoutePreload<TRouteId extends string>(
|
|
timers: Map<EventTarget, ReturnType<typeof globalThis.setTimeout>>,
|
|
routeId: TRouteId,
|
|
event: Event,
|
|
preload: ((routeId: TRouteId) => Promise<void> | void) | undefined,
|
|
disabled = false,
|
|
immediate = false,
|
|
) {
|
|
if (disabled || !preload) {
|
|
return;
|
|
}
|
|
const target = event.currentTarget;
|
|
if (!target) {
|
|
return;
|
|
}
|
|
const start = () => {
|
|
timers.delete(target);
|
|
try {
|
|
void Promise.resolve(preload(routeId)).catch(() => undefined);
|
|
} catch {
|
|
// Preloading is opportunistic; navigation still handles real route errors.
|
|
}
|
|
};
|
|
if (immediate) {
|
|
cancelRoutePreload(timers, event);
|
|
start();
|
|
return;
|
|
}
|
|
if (!timers.has(target)) {
|
|
timers.set(target, globalThis.setTimeout(start, 50));
|
|
}
|
|
}
|
|
|
|
export function cancelRoutePreload(
|
|
timers: Map<EventTarget, ReturnType<typeof globalThis.setTimeout>>,
|
|
event: Event,
|
|
) {
|
|
const target = event.currentTarget;
|
|
if (!target) {
|
|
return;
|
|
}
|
|
const timer = timers.get(target);
|
|
if (timer !== undefined) {
|
|
globalThis.clearTimeout(timer);
|
|
timers.delete(target);
|
|
}
|
|
}
|
|
|
|
const NAVIGATION_COPY: Record<NavigationRouteId, { titleKey: string; subtitleKey: string }> = {
|
|
agents: { titleKey: "tabs.agents", subtitleKey: "subtitles.agents" },
|
|
activity: { titleKey: "tabs.activity", subtitleKey: "subtitles.activity" },
|
|
apps: { titleKey: "tabs.apps", subtitleKey: "subtitles.apps" },
|
|
approvals: { titleKey: "tabs.approvals", subtitleKey: "subtitles.approvals" },
|
|
workboard: { titleKey: "tabs.workboard", subtitleKey: "subtitles.workboard" },
|
|
worktrees: { titleKey: "tabs.worktrees", subtitleKey: "subtitles.worktrees" },
|
|
channels: { titleKey: "tabs.channels", subtitleKey: "subtitles.channels" },
|
|
connection: { titleKey: "tabs.connection", subtitleKey: "subtitles.connection" },
|
|
sessions: { titleKey: "tabs.sessions", subtitleKey: "subtitles.sessions" },
|
|
usage: { titleKey: "tabs.usage", subtitleKey: "subtitles.usage" },
|
|
cron: { titleKey: "tabs.cron", subtitleKey: "subtitles.cron" },
|
|
tasks: { titleKey: "tabs.tasks", subtitleKey: "subtitles.tasks" },
|
|
skills: { titleKey: "tabs.skills", subtitleKey: "subtitles.skills" },
|
|
plugins: { titleKey: "tabs.plugins", subtitleKey: "subtitles.plugins" },
|
|
"skill-workshop": {
|
|
titleKey: "tabs.skillWorkshop",
|
|
subtitleKey: "subtitles.skillWorkshop",
|
|
},
|
|
devices: { titleKey: "tabs.devices", subtitleKey: "subtitles.devices" },
|
|
chat: { titleKey: "tabs.chat", subtitleKey: "subtitles.chat" },
|
|
dashboard: { titleKey: "tabs.chat", subtitleKey: "subtitles.chat" },
|
|
dashboards: { titleKey: "tabs.dashboards", subtitleKey: "subtitles.dashboards" },
|
|
custodian: { titleKey: "tabs.custodian", subtitleKey: "subtitles.custodian" },
|
|
config: { titleKey: "nav.settings", subtitleKey: "subtitles.config" },
|
|
profile: { titleKey: "tabs.profile", subtitleKey: "subtitles.profile" },
|
|
communications: {
|
|
titleKey: "tabs.communications",
|
|
subtitleKey: "subtitles.communications",
|
|
},
|
|
appearance: { titleKey: "tabs.appearance", subtitleKey: "subtitles.appearance" },
|
|
lobsterdex: { titleKey: "tabs.lobsterdex", subtitleKey: "subtitles.lobsterdex" },
|
|
automation: { titleKey: "tabs.automation", subtitleKey: "subtitles.automation" },
|
|
mcp: { titleKey: "tabs.mcp", subtitleKey: "subtitles.mcp" },
|
|
memory: { titleKey: "tabs.memory", subtitleKey: "subtitles.memory" },
|
|
talk: { titleKey: "tabs.talk", subtitleKey: "subtitles.talk" },
|
|
infrastructure: { titleKey: "tabs.infrastructure", subtitleKey: "subtitles.infrastructure" },
|
|
labs: { titleKey: "tabs.labs", subtitleKey: "subtitles.labs" },
|
|
updates: { titleKey: "tabs.updates", subtitleKey: "subtitles.updates" },
|
|
about: { titleKey: "tabs.about", subtitleKey: "subtitles.about" },
|
|
"ai-agents": { titleKey: "tabs.aiAgents", subtitleKey: "subtitles.aiAgents" },
|
|
"model-setup": { titleKey: "tabs.modelSetup", subtitleKey: "subtitles.modelSetup" },
|
|
"model-providers": {
|
|
titleKey: "routeTitles.modelProviders",
|
|
subtitleKey: "subtitles.modelProviders",
|
|
},
|
|
"memory-import": { titleKey: "tabs.memoryImport", subtitleKey: "subtitles.memoryImport" },
|
|
notifications: {
|
|
titleKey: "routeTitles.notifications",
|
|
subtitleKey: "subtitles.notifications",
|
|
},
|
|
security: { titleKey: "tabs.security", subtitleKey: "subtitles.security" },
|
|
secrets: { titleKey: "tabs.secrets", subtitleKey: "secretsStore.hint" },
|
|
advanced: { titleKey: "routeTitles.advanced", subtitleKey: "subtitles.advanced" },
|
|
debug: { titleKey: "tabs.debug", subtitleKey: "subtitles.debug" },
|
|
logs: { titleKey: "tabs.logs", subtitleKey: "subtitles.logs" },
|
|
plugin: { titleKey: "tabs.plugin", subtitleKey: "subtitles.plugin" },
|
|
"new-session": { titleKey: "newSession.title", subtitleKey: "newSession.hint" },
|
|
};
|
|
|
|
export function titleForRoute(routeId: NavigationRouteId): string {
|
|
return t(NAVIGATION_COPY[routeId].titleKey);
|
|
}
|
|
|
|
/** Window/tab title, markers leftmost because tabs truncate from the right.
|
|
* Offline replaces the approval count (a stale queue is not actionable) and
|
|
* carries the pending-outbox total; titles already ending in the brand
|
|
* ("Ask OpenClaw") skip the suffix so it never reads "… OpenClaw — OpenClaw". */
|
|
export function formatDocumentTitle(options: {
|
|
context: string;
|
|
attentionCount?: number;
|
|
offline?: boolean;
|
|
queuedCount?: number;
|
|
}): string {
|
|
const base = options.context.endsWith("OpenClaw")
|
|
? options.context
|
|
: `${options.context} — OpenClaw`;
|
|
if (options.offline) {
|
|
const queued =
|
|
options.queuedCount && options.queuedCount > 0
|
|
? ` · ${t("connection.queuedCount", { count: String(options.queuedCount) })}`
|
|
: "";
|
|
return `(${t("common.offline")}${queued}) ${base}`;
|
|
}
|
|
if (options.attentionCount && options.attentionCount > 0) {
|
|
return `(${options.attentionCount}) ${base}`;
|
|
}
|
|
return base;
|
|
}
|
|
|
|
export function settingsNavigationLabelForRoute(routeId: NavigationRouteId): string {
|
|
if (routeId === "custodian") {
|
|
return t("nav.askOpenClaw");
|
|
}
|
|
return titleForRoute(routeId);
|
|
}
|
|
|
|
export function subtitleForRoute(routeId: NavigationRouteId): string {
|
|
return t(NAVIGATION_COPY[routeId].subtitleKey);
|
|
}
|