mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
f8ba65636c
* test(control-ui): add --operator-scopes flag to the mock dev server
* feat(control-ui): simplified settings experience for non-admin operators
Non-admin browsers previously saw every settings page, many of which
dead-ended or rendered enabled controls whose RPCs fail with
'missing scope: operator.admin'.
- config.schema drops from operator.admin to operator.read: the schema is a
static document describing options whose values are already readable via
read-scoped config.get; admin-only schema only broke read-only settings
rendering (Automation/Infrastructure/AI Agents/Communications showed
'Schema unavailable. Use Raw.').
- Settings sidebar and settings search hide admin-only routes (custodian,
labs, updates, automation, infrastructure, mcp, security, secrets,
cloud-workers, communications, ai-agents, model-setup) for non-admin
viewers; legacy gateways without advertised scopes keep the full UI.
- Channels, Devices, Worktrees, Memory Import, Profile gate their mutation
controls on actual scopes with 'Browsing only…' notices instead of
enabled-but-failing buttons; Devices no longer fires device.pair.list /
exec.approvals.get without the scopes to call them (kills the two red
error callouts on page load).
- Scope-upgrade banner: dismissing it in the guidance phase (no in-app
upgrade path) now hides it fully instead of leaving a permanent chip.
- Config write coordinator surfaces scope refusals as a visible
admin-required error instead of silently resolving false.
* test(control-ui): advertise config.schema in the mock dev gateway
ensureSchemaLoaded now checks method advertisement + scope before loading
the schema; the mock harness must advertise config.schema like a real
gateway does or schema-driven settings pages render empty in the mock.
* fix(control-ui): close the worktree create draft on scope downgrade
* perf(doctor): isolate memory health artifact
Doctor lint loaded the broad Memory Core API barrel only to register health checks and read isolated check IDs. That synchronously pulled the full memory public graph into the first lint run, consuming most of the 120-second test budget.
Load a dedicated doctor-health public artifact instead and verify it is packaged. The bisect boundary was 9de3ca5fc9 (#125571); because that commit only adds upgrade-test assets, it exposed a pre-existing runner-sensitive cost rather than introducing the expensive import path.
* test(control-ui): restore device lifecycle test boundary
* perf(control-ui): lazy-load settings sidebar
* fix(ui): recheck access after confirmations
* fix(control-ui): gate presence-driven device reloads on pairing access
The presence connectivity-change path still called device.pair.list without
operator.pairing, the same invariant the pair-event and poller paths already
guard; a limited browser got a doomed RPC on every connectivity change.
* fix(control-ui): fail open on schema loads for legacy scope-less gateways
canCallGatewayMethod hardened to strict advertisement+scope checks (#125478),
which made the new ensureSchemaLoaded gate silently skip config.schema for
legacy hellos without advertised scopes or a method list. Schema loads now
skip only on a definitive denial (method advertised absent, or advertised
scopes without operator.read), reusing the fail-open hasOperatorReadAccess
semantics the rest of the non-admin UI uses; regression test pins the
legacy snapshot path.
* test(control-ui): split schema-access coverage into its own file
runtime-config-capability.test.ts crossed the max-lines cap; the legacy
fail-open regression and its denial counterpart move to a colocated
schema-access test file.
* fix(scripts): keep mapped Vitest lanes at their measured no-output floor
The codex extension shard legitimately works in silence beyond 300s under
the default reporter (measured 61s import + 293s testing at ~95% CPU); the
CI-wide OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=300000 env override shrank the
lane below that and the watchdog killed healthy runs, flipping with
incidental flake output (#125825). Per-config entries in
VITEST_CONFIG_NO_OUTPUT_TIMEOUT_MS now act as measured silence floors: a
global env value may widen a mapped lane's window but no longer shrinks it;
unmapped configs and the explicit '0' disable keep env verbatim. Adds the
codex extension lane to the map at the extra-long tier (same class as the
discord entry from #123025).
479 lines
17 KiB
TypeScript
479 lines
17 KiB
TypeScript
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
|
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";
|
|
|
|
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.
|
|
// Workboard is plugin-owned and enters the zone through its Control UI descriptor.
|
|
export const SIDEBAR_NAV_ROUTES = [
|
|
"dashboards",
|
|
"usage",
|
|
"cron",
|
|
"tasks",
|
|
"sessions",
|
|
"activity",
|
|
"plugins",
|
|
"apps",
|
|
"portals",
|
|
] as const satisfies readonly NavigationRouteId[];
|
|
|
|
// `route:workboard` shipped in browser and synced preferences before Workboard
|
|
// became plugin-owned. Keep it as a placement slot, but not a customizable core route.
|
|
const PERSISTED_SIDEBAR_ROUTES = ["workboard", ...SIDEBAR_NAV_ROUTES] as const;
|
|
|
|
// 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 PersistedSidebarRoute = (typeof PERSISTED_SIDEBAR_ROUTES)[number];
|
|
|
|
export function isPersistedSidebarRoute(value: unknown): value is PersistedSidebarRoute {
|
|
return PERSISTED_SIDEBAR_ROUTES.includes(value as PersistedSidebarRoute);
|
|
}
|
|
|
|
export type SidebarZoneEntry =
|
|
| { type: "route"; route: PersistedSidebarRoute }
|
|
| { 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 isPersistedSidebarRoute(route) ? { type: "route", route } : 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.
|
|
const SETTINGS_NAVIGATION_GROUPS = [
|
|
{ labelKey: null, routes: ["custodian", "profile", "appearance", "notifications"] },
|
|
{
|
|
labelKey: "nav.settingsGroupConnections",
|
|
routes: ["connection", "channels", "communications", "talk", "devices", "cloud-workers"],
|
|
},
|
|
{
|
|
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[];
|
|
|
|
const NON_ADMIN_SETTINGS_NAVIGATION_GROUPS = [
|
|
{ labelKey: null, routes: ["profile", "appearance", "notifications"] },
|
|
{
|
|
labelKey: "nav.settingsGroupConnections",
|
|
routes: ["connection", "channels", "talk", "devices"],
|
|
},
|
|
{
|
|
labelKey: "nav.settingsGroupAgents",
|
|
routes: ["agents", "model-providers", "memory"],
|
|
},
|
|
{ labelKey: "nav.settingsGroupSecurity", routes: ["approvals"] },
|
|
{
|
|
labelKey: "nav.settingsGroupSystem",
|
|
routes: ["advanced", "debug", "logs", "about"],
|
|
},
|
|
] as const satisfies readonly SettingsNavigationGroup[];
|
|
|
|
export function isSettingsNavigationRouteVisible(
|
|
routeId: NavigationRouteId,
|
|
canAdmin: boolean,
|
|
): boolean {
|
|
return (
|
|
canAdmin ||
|
|
NON_ADMIN_SETTINGS_NAVIGATION_GROUPS.some((group) =>
|
|
group.routes.some((candidate) => candidate === routeId),
|
|
)
|
|
);
|
|
}
|
|
|
|
export function visibleSettingsNavigationGroups(
|
|
canAdmin: boolean,
|
|
): readonly SettingsNavigationGroup[] {
|
|
return canAdmin ? SETTINGS_NAVIGATION_GROUPS : NON_ADMIN_SETTINGS_NAVIGATION_GROUPS;
|
|
}
|
|
|
|
// 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",
|
|
portals: "monitor",
|
|
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",
|
|
"cloud-workers": "server",
|
|
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" },
|
|
portals: { titleKey: "tabs.portals", subtitleKey: "subtitles.portals" },
|
|
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" },
|
|
"cloud-workers": {
|
|
titleKey: "tabs.cloudWorkers",
|
|
subtitleKey: "subtitles.cloudWorkers",
|
|
},
|
|
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);
|
|
}
|