{showMore ? (
diff --git a/dashboard/src/layouts/MainLayout/index.module.less b/dashboard/src/layouts/MainLayout/index.module.less
new file mode 100644
index 0000000000..19af5fd175
--- /dev/null
+++ b/dashboard/src/layouts/MainLayout/index.module.less
@@ -0,0 +1,22 @@
+/* Skip link: visually hidden until focused. Inline styles cannot express
+ :focus-visible or ::before, so this one rule lives in a stylesheet. */
+.skipLink {
+ position: absolute;
+ top: -100px;
+ left: 8px;
+ z-index: 2000;
+ padding: 10px 16px;
+ border-radius: 8px;
+ background: var(--fn-bg-elevated, #fff);
+ color: var(--fn-text-primary, #111);
+ border: 1px solid var(--fn-border-focus, #b04759);
+ box-shadow: var(--fn-shadow-brand, 0 4px 14px rgba(176, 71, 89, 0.18));
+ font-size: 14px;
+ font-weight: 500;
+ text-decoration: none;
+ transition: top 0.15s ease;
+}
+
+.skipLink:focus {
+ top: 8px;
+}
diff --git a/dashboard/src/layouts/MainLayout/index.tsx b/dashboard/src/layouts/MainLayout/index.tsx
index dbf53d7370..fc7f2c2e14 100644
--- a/dashboard/src/layouts/MainLayout/index.tsx
+++ b/dashboard/src/layouts/MainLayout/index.tsx
@@ -26,6 +26,9 @@ import RequirePermission from "../../components/RequirePermission";
import { routeNeedsPermission } from "../../utils/permissions";
import { useLayoutMode } from "../../context/LayoutModeContext";
import { useDashboardPushToast } from "../../hooks/useDashboardPushToast";
+import { pageTitleFor } from "../pageTitle";
+import { useTranslation } from "react-i18next";
+import styles from "./index.module.less";
const Chat = lazy(() => import("../../pages/Chat"));
const WorkbenchPage = lazy(() => import("../../pages/Control/Workbench"));
@@ -34,6 +37,9 @@ const { Content } = Layout;
const SIDEBAR_COLLAPSED_KEY = "octop:sidebar:collapsed";
+/** Focus target for the skip link. antd's Content renders . */
+export const MAIN_CONTENT_ID = "octop-main-content";
+
function getSavedCollapsed(): boolean {
try {
const saved = localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
@@ -59,6 +65,7 @@ export default function MainLayout() {
const selectedKey = resolveSelectedKey(currentPath);
const isMobile = useIsMobile();
const { layoutMode } = useLayoutMode();
+ const { t } = useTranslation();
useDashboardPushToast();
useKeyboardOffset();
const isMinimalLayout = layoutMode === "minimal";
@@ -68,6 +75,29 @@ export default function MainLayout() {
(isMobile && MOBILE_FULLSCREEN_PATHS.has(currentPath));
const onWorkbench = isWorkbenchPath(currentPath);
+ // Reflect the current section in the tab title. The app name is set once in
+ // App.tsx; without this every tab, bookmark and history entry reads the same.
+ useEffect(() => {
+ document.title = pageTitleFor(t("app.pageTitle"), selectedKey, t);
+ }, [t, selectedKey]);
+
+ // First focusable element on the page: lets a keyboard user jump past the
+ // sidebar (which is ~19 nav items) straight to the content.
+ const skipLink = (
+ {
+ // Move focus as well as the scroll position, otherwise the next Tab
+ // continues from the link rather than from the content.
+ e.preventDefault();
+ document.getElementById(MAIN_CONTENT_ID)?.focus();
+ }}
+ >
+ {t("nav.skipToContent", "Skip to content")}
+
+ );
+
const [collapsed, setCollapsed] = useState(() => getSavedCollapsed());
const [chatSidebarOpen, setChatSidebarOpen] = useChatSidebarOpen();
const [workbenchMounted, setWorkbenchMounted] = useState(() => onWorkbench);
@@ -196,6 +226,8 @@ export default function MainLayout() {
overflow: "hidden",
}}
>
+ {skipLink}
+
{/* Mobile overlay backdrop */}
{isMobile && !collapsed && (
- {brandInner}
+
+ {brandInner}
+
+
-
-
-
-
- {navScrollBody}
-
+ {navScrollBody}
+
-
- {userFooter}
- {customizer}
+
+ {userFooter}
+ {customizer}
+
-
+
);
}
// Desktop: custom sidebar with icon-only collapsed mode.
// Right border is drawn by MainLayout's RailEdgeControl.
return (
-
- {brandInner}
-
-
-
- {navScrollBody}
-
+
+ {brandInner}
+
- {userFooter}
- {customizer}
-
+
+ {navScrollBody}
+
+
+ {userFooter}
+ {customizer}
+
+
);
}
diff --git a/dashboard/src/layouts/pageTitle.test.ts b/dashboard/src/layouts/pageTitle.test.ts
new file mode 100644
index 0000000000..284de55b31
--- /dev/null
+++ b/dashboard/src/layouts/pageTitle.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from "vitest";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { pageTitleFor, SECTION_KEYS_WITH_TITLES } from "./pageTitle";
+import en from "../locales/en.json";
+import zh from "../locales/zh.json";
+
+/**
+ * A11Y-11 regression guard.
+ *
+ * `document.title` was set once from the app name and never updated, so every
+ * tab, bookmark and history entry read identically and a screen-reader user
+ * navigating by tab title could not tell sections apart.
+ */
+
+/** Collect every `nav.*` key defined in a locale bundle. */
+function navKeys(bundle: unknown): Set {
+ const nav = (bundle as { nav?: Record }).nav ?? {};
+ return new Set(Object.keys(nav).map((k) => `nav.${k}`));
+}
+
+const EN_NAV = navKeys(en);
+const ZH_NAV = navKeys(zh);
+
+describe("pageTitleFor", () => {
+ it("prefixes the section name to the app name", () => {
+ const t = (key: string) => (key === "app.pageTitle" ? "Octop" : "Experts");
+ expect(pageTitleFor("Octop", "experts", t)).toBe("Experts - Octop");
+ });
+
+ it("uses the bare app name for an unknown section", () => {
+ const t = (key: string) => (key === "app.pageTitle" ? "Octop" : key);
+ expect(pageTitleFor("Octop", "not-a-section", t)).toBe("Octop");
+ expect(pageTitleFor("Octop", "", t)).toBe("Octop");
+ });
+
+ it("falls back rather than displaying a raw i18n key", () => {
+ // i18next returns the key itself when a translation is missing; showing
+ // "nav.chat - Octop" in a tab is worse than showing just the app name.
+ const t = (key: string) => key;
+ expect(pageTitleFor("Octop", "chat", t)).toBe("Octop");
+ });
+
+ it("every mapped section has a real key in BOTH locales", () => {
+ // Catches a typo like nav.adminSecurity (which does not exist; the sidebar
+ // uses nav.security) and a key added to en.json but not zh.json.
+ for (const section of SECTION_KEYS_WITH_TITLES) {
+ const labelKey = (() => {
+ const t = (key: string) => (EN_NAV.has(key) ? "LABEL" : key);
+ // Recover the key the map chose by calling with a sentinel.
+ const title = pageTitleFor("Octop", section, t);
+ return title === "Octop" ? null : title.replace(" - Octop", "");
+ })();
+ expect(labelKey, `section "${section}" resolves to a missing key`).toBe(
+ "LABEL",
+ );
+ }
+ });
+
+ it("keeps every sidebar entry covered by the title map", () => {
+ // The map must not rot as nav items are added. Read the sidebar source for
+ // its `labelKey` values and require each nav key to have a title entry.
+ const src = readFileSync(resolve(__dirname, "sidebarNav.tsx"), "utf8");
+ const items = [
+ ...src.matchAll(
+ /key: "([a-z-]+)",\s*\n\s*path: "[^"]+",\s*\n\s*icon: [^\n]+\n\s*labelKey: "([^"]+)"/g,
+ ),
+ ].map((m) => ({ key: m[1], labelKey: m[2] }));
+
+ expect(items.length).toBeGreaterThan(10);
+ for (const { key, labelKey } of items) {
+ expect(
+ SECTION_KEYS_WITH_TITLES,
+ `sidebar item "${key}" has no page title`,
+ ).toContain(key);
+ // And the title must use the SAME label the sidebar shows, so the tab and
+ // the nav entry can never disagree.
+ expect(EN_NAV.has(labelKey), `${labelKey} missing from en.json`).toBe(
+ true,
+ );
+ expect(ZH_NAV.has(labelKey), `${labelKey} missing from zh.json`).toBe(
+ true,
+ );
+ }
+ });
+});
diff --git a/dashboard/src/layouts/pageTitle.ts b/dashboard/src/layouts/pageTitle.ts
new file mode 100644
index 0000000000..68b89dd2d1
--- /dev/null
+++ b/dashboard/src/layouts/pageTitle.ts
@@ -0,0 +1,57 @@
+/**
+ * Mapping from a nav section key to its localized page-title key.
+ *
+ * `document.title` used to be set once, from the app name, and never updated —
+ * so every browser tab, bookmark, and history entry read identically regardless
+ * of the section, and a screen-reader user navigating by tab title had no way to
+ * tell pages apart. These are the SAME labels the sidebar uses, so the tab title
+ * and the visible nav entry can never disagree.
+ *
+ * Keys come from `resolveSelectedKey()` in `dashboard/src/routes`, which already
+ * maps every route path to one of `SIDEBAR_NAV_KEYS`.
+ */
+const SECTION_LABEL_KEYS: Record = {
+ chat: "nav.chat",
+ experts: "nav.experts",
+ tasks: "nav.tasks",
+ "token-usage": "nav.tokenUsage",
+ personalization: "nav.personalization",
+ channels: "nav.channels",
+ connectors: "nav.connectors",
+ "skill-packages": "nav.skillPackages",
+ "knowledge-bases": "nav.knowledgeBases",
+ bridge: "nav.bridge",
+ workbench: "nav.workbench",
+ "remote-desktop": "nav.remoteDesktop",
+ acp: "nav.acp",
+ "admin-users": "nav.adminUsers",
+ models: "nav.models",
+ "admin-storage": "nav.adminStorage",
+ "admin-plugins": "nav.adminPlugins",
+ "admin-security": "nav.security",
+ "admin-advanced": "nav.adminAdvanced",
+ "agent-config": "nav.agentConfig",
+};
+
+/**
+ * Build the document title for a section.
+ *
+ * Returns the bare app name when the key is unknown, so an unmapped or new
+ * route degrades to today's behaviour rather than showing a raw key.
+ */
+export function pageTitleFor(
+ appName: string,
+ sectionKey: string,
+ translate: (key: string) => string,
+): string {
+ const labelKey = SECTION_LABEL_KEYS[sectionKey];
+ if (!labelKey) return appName;
+ const section = translate(labelKey);
+ // If a label is missing the translator may return the key itself; showing
+ // "nav.chat - Octop" is worse than showing nothing, so fall back.
+ if (!section || section === labelKey) return appName;
+ return `${section} - ${appName}`;
+}
+
+/** Exposed for the test that keeps this in sync with the sidebar. */
+export const SECTION_KEYS_WITH_TITLES = Object.keys(SECTION_LABEL_KEYS);
diff --git a/dashboard/src/layouts/skipLink.a11y.test.ts b/dashboard/src/layouts/skipLink.a11y.test.ts
new file mode 100644
index 0000000000..2a3547166d
--- /dev/null
+++ b/dashboard/src/layouts/skipLink.a11y.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from "vitest";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import en from "../locales/en.json";
+import zh from "../locales/zh.json";
+
+/**
+ * A11Y-10 regression guard.
+ *
+ * Two separate gaps, both verified live before the fix:
+ * 1. No skip link — a keyboard user had to Tab through the whole sidebar
+ * (~19 items plus group headers) to reach page content on every navigation.
+ * 2. The sidebar was a plain
, so it exposed no navigation landmark and
+ * a screen reader user could not jump to or list the nav region.
+ *
+ * Asserted against source rather than by mounting. MainLayout pulls in the
+ * entire route table, ServiceRestart/Backup providers and a lazily-loaded Chat
+ * page; Sidebar reaches the preferences API. Mounting them in jsdom is slow and
+ * brittle, and for a landmark/attribute contract source is the honest check.
+ * (Same approach as MessageList's virtualised branch — see its test comment.)
+ */
+
+const MAIN_LAYOUT = readFileSync(
+ resolve(__dirname, "MainLayout/index.tsx"),
+ "utf8",
+);
+const SIDEBAR = readFileSync(resolve(__dirname, "Sidebar.tsx"), "utf8");
+
+const count = (haystack: string, needle: string) =>
+ haystack.split(needle).length - 1;
+
+describe("skip link (A11Y-10)", () => {
+ it("renders an anchor that targets the main content id", () => {
+ const id = MAIN_LAYOUT.match(
+ /export const MAIN_CONTENT_ID = "([^"]+)"/,
+ )?.[1];
+ expect(id).toBeTruthy();
+ // The link points at it...
+ expect(MAIN_LAYOUT).toContain("href={`#${MAIN_CONTENT_ID}`}");
+ // ...and the element carries it.
+ expect(MAIN_LAYOUT).toContain("id={MAIN_CONTENT_ID}");
+ });
+
+ it("makes main focusable so focus actually moves", () => {
+ // Without tabIndex={-1} the anchor would scroll but not move focus, and the
+ // next Tab would resume from the skip link instead of the content.
+ expect(MAIN_LAYOUT).toMatch(/tabIndex={-1}/);
+ expect(MAIN_LAYOUT).toContain(".focus()");
+ });
+
+ it("is the first focusable element in the DOM", () => {
+ // A skip link that appears after the sidebar is useless.
+ const skipAt = MAIN_LAYOUT.indexOf("{skipLink}");
+ const sidebarAt = MAIN_LAYOUT.indexOf(" {
+ expect(en.nav.skipToContent).toBeTruthy();
+ expect(zh.nav.skipToContent).toBeTruthy();
+ // Not left as an untranslated copy of the English string.
+ expect(zh.nav.skipToContent).not.toBe(en.nav.skipToContent);
+ });
+});
+
+describe("sidebar navigation landmark (A11Y-10)", () => {
+ it("wraps BOTH render paths in a labelled