Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,13 @@
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid rgba(232, 93, 117, 0.18);
border-top-color: #e85d75;
border: 3px solid rgba(176, 71, 89, 0.18);
border-top-color: #b04759;
animation: octop-boot-spin 0.85s linear infinite;
}
html[data-theme="dark"] #octop-boot-ring {
border-color: rgba(240, 139, 154, 0.22);
border-top-color: #f08b9a;
border-color: rgba(232, 137, 154, 0.22);
border-top-color: #e8899a;
}
.octop-boot-logo {
position: absolute;
Expand Down
4 changes: 2 additions & 2 deletions dashboard/public/offline.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
font-size: 22px;
font-weight: 600;
margin-bottom: 10px;
color: #f08b9a;
color: #e8899a;
}
p {
font-size: 14px;
Expand All @@ -54,7 +54,7 @@
padding: 10px 28px;
border-radius: 999px;
border: none;
background: #e85d75;
background: #b04759;
color: #fff;
font-size: 14px;
font-weight: 500;
Expand Down
159 changes: 159 additions & 0 deletions dashboard/src/components/AgentSelector.a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { OctopAgent } from "../context/AgentContext";

/**
* A11Y-9 regression guard.
*
* The agent picker showed a row of chips with no tablist semantics, so the
* grouping was invisible to assistive tech and every chip was its own tab stop.
*/

const state = {
agents: [] as OctopAgent[],
activeAgentId: "writer" as string | null,
loading: false,
};

const setActiveAgent = vi.fn((id: string | null) => {
state.activeAgentId = id;
});

vi.mock("../context/AgentContext", () => ({
useAgent: () => ({
get agents() {
return state.agents;
},
get activeAgentId() {
return state.activeAgentId;
},
get loading() {
return state.loading;
},
setActiveAgent,
}),
}));

import AgentSelector from "./AgentSelector";

function fakeAgent(
partial: Pick<OctopAgent, "agent_id" | "name"> & Partial<OctopAgent>,
): OctopAgent {
return {
id: 1,
description: null,
persona_mbti: null,
default_model: null,
system_prompt: null,
template_name: null,
state: "running",
last_error: null,
icon: null,
icon_name: null,
icon_url: null,
color: "#2563eb",
config: {},
is_owner: true,
kind: "expert",
...partial,
};
}

const a = fakeAgent({ agent_id: "a", name: "Alpha" });
const b = fakeAgent({ agent_id: "b", name: "Beta" });
const c = fakeAgent({ agent_id: "c", name: "Gamma" });

function setup(activeId = "a") {
state.agents = [a, b, c];
state.activeAgentId = activeId;
return render(<AgentSelector />);
}

describe("AgentSelector tablist (A11Y-9)", () => {
beforeEach(() => {
setActiveAgent.mockClear();
});

it("exposes the picker as a tablist of tabs", () => {
setup();
expect(screen.getByRole("tablist")).toBeInTheDocument();
expect(screen.getAllByRole("tab")).toHaveLength(3);
});

it("marks exactly one tab selected", () => {
setup("b");
const selected = screen
.getAllByRole("tab")
.filter((el) => el.getAttribute("aria-selected") === "true");
expect(selected).toHaveLength(1);
expect(selected[0]).toHaveAccessibleName(/Beta/);
});

it("keeps only the selected tab in the tab order (roving tabindex)", () => {
setup("b");
const tabs = screen.getAllByRole("tab");
const focusable = tabs.filter((el) => el.getAttribute("tabindex") === "0");
expect(focusable).toHaveLength(1);
expect(focusable[0]).toHaveAccessibleName(/Beta/);
});

it("moves selection with ArrowRight and wraps at the end", async () => {
setup("a");
const [alpha] = screen.getAllByRole("tab");
alpha.focus();

await userEvent.keyboard("{ArrowRight}");
expect(setActiveAgent).toHaveBeenLastCalledWith("b");

await userEvent.keyboard("{ArrowRight}");
expect(setActiveAgent).toHaveBeenLastCalledWith("c");

// Wrap: c -> a
await userEvent.keyboard("{ArrowRight}");
expect(setActiveAgent).toHaveBeenLastCalledWith("a");
});

it("supports Home and End", async () => {
setup("b");
const tabs = screen.getAllByRole("tab");
tabs[1].focus();

await userEvent.keyboard("{End}");
expect(setActiveAgent).toHaveBeenLastCalledWith("c");

await userEvent.keyboard("{Home}");
expect(setActiveAgent).toHaveBeenLastCalledWith("a");
});

it("names each tab with its agent AND its state", () => {
// The dot is colour-only, so the state must reach the accessible name.
state.agents = [
fakeAgent({ agent_id: "ok", name: "Healthy", state: "running" }),
fakeAgent({ agent_id: "bad", name: "Broken", state: "failed" }),
];
state.activeAgentId = "ok";
render(<AgentSelector />);

// The i18n mock in test/setup.ts resolves t(key, fallback) to the fallback,
// so the state reads as the raw value here. The contract under test is that
// the state is part of the name at all — without it the dot's colour is the
// only signal, which is what a deuteranope cannot read.
const running = screen.getByRole("tab", { name: /Healthy.*running/ });
const failed = screen.getByRole("tab", { name: /Broken.*failed/ });
expect(running).toBeInTheDocument();
expect(failed).toBeInTheDocument();
// And the two are distinguishable by name alone, not just by dot colour.
expect(running.getAttribute("aria-label")).not.toBe(
failed.getAttribute("aria-label"),
);
});

it("hides the state dot from assistive tech (name carries it instead)", () => {
setup();
const tab = screen.getAllByRole("tab")[0];
const dot = tab.querySelector("[data-state]");
expect(dot).not.toBeNull();
expect(dot).toHaveAttribute("aria-hidden");
});
});
64 changes: 62 additions & 2 deletions dashboard/src/components/AgentSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,25 @@ function AgentChip({
const title = disconnected
? `${agent.name} · ${t("agentSelector.disconnected")}`
: agent.description ?? agent.name;
// The state dot is a bare coloured span. Without this it is announced as
// nothing and its colour is the only carrier of state — which for the
// red/green running-vs-failed pair is exactly what a deuteranope cannot read.
const stateLabel = disconnected
? t("agentSelector.stateDisconnected", "Disconnected")
: t(`agentSelector.state.${agent.state}`, agent.state);

return (
<button
type="button"
tabIndex={measure ? -1 : undefined}
role="tab"
aria-selected={measure ? undefined : active}
// Roving tabindex: one stop for the whole tablist, arrows move between
// tabs, so Tab does not walk through every chip.
tabIndex={measure ? -1 : active ? 0 : -1}
aria-label={`${agent.name} · ${stateLabel}`}
data-chip-id={measure ? agent.agent_id : undefined}
data-group-key={measure ? groupKey : undefined}
data-agent-id={measure ? undefined : agent.agent_id}
className={`${active ? styles.chipActive : styles.chip}${
disconnected ? ` ${styles.chipDisconnected}` : ""
}`}
Expand All @@ -122,6 +135,7 @@ function AgentChip({
<span
className={styles.stateDot}
data-state={disconnected ? "failed" : agent.state}
aria-hidden
/>
</button>
);
Expand Down Expand Up @@ -212,6 +226,46 @@ export default function AgentSelector({
const measureRef = useRef<HTMLDivElement>(null);
const [hiddenIds, setHiddenIds] = useState<string[]>([]);

// Roving focus across the tablist. Arrow keys move selection, matching the
// automatic-activation behaviour of an ARIA tablist; Home/End jump to the
// ends. Only the selected tab is in the tab order, so Tab leaves the group.
const handleTablistKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const keys = [
"ArrowRight",
"ArrowLeft",
"ArrowDown",
"ArrowUp",
"Home",
"End",
];
if (!keys.includes(e.key)) return;
const chips = Array.from(
e.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]'),
);
if (chips.length === 0) return;
const current = chips.findIndex((c) => c === document.activeElement);
let next: number;
switch (e.key) {
case "Home":
next = 0;
break;
case "End":
next = chips.length - 1;
break;
case "ArrowRight":
case "ArrowDown":
next = current < 0 ? 0 : (current + 1) % chips.length;
break;
default:
next = current <= 0 ? chips.length - 1 : current - 1;
}
e.preventDefault();
chips[next]?.focus();
// Select as focus moves, so the tablist behaves like antd's Tabs.
const id = chips[next]?.getAttribute("data-agent-id");
if (id) setActiveAgent(id);
};

useEffect(() => {
if (loading) return;
const next = nextPickerSelection(activeAgentId, agents, selectable);
Expand Down Expand Up @@ -366,7 +420,13 @@ export default function AgentSelector({
<ChevronDown size={12} aria-hidden />
</button>
</div>
<div data-testid="agent-selector-chips" className={styles.track}>
<div
data-testid="agent-selector-chips"
className={styles.track}
role="tablist"
aria-label={heading}
onKeyDown={handleTablistKeyDown}
>
{visibleGroups.map((group) => renderGroup(group, false))}
</div>
{showMore ? (
Expand Down
22 changes: 22 additions & 0 deletions dashboard/src/layouts/MainLayout/index.module.less
Original file line number Diff line number Diff line change
@@ -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;
}
36 changes: 36 additions & 0 deletions dashboard/src/layouts/MainLayout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -34,6 +37,9 @@ const { Content } = Layout;

const SIDEBAR_COLLAPSED_KEY = "octop:sidebar:collapsed";

/** Focus target for the skip link. antd's Content renders <main>. */
export const MAIN_CONTENT_ID = "octop-main-content";

function getSavedCollapsed(): boolean {
try {
const saved = localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
Expand All @@ -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";
Expand All @@ -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 = (
<a
href={`#${MAIN_CONTENT_ID}`}
className={styles.skipLink}
onClick={(e) => {
// 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")}
</a>
);

const [collapsed, setCollapsed] = useState(() => getSavedCollapsed());
const [chatSidebarOpen, setChatSidebarOpen] = useChatSidebarOpen();
const [workbenchMounted, setWorkbenchMounted] = useState(() => onWorkbench);
Expand Down Expand Up @@ -196,6 +226,8 @@ export default function MainLayout() {
overflow: "hidden",
}}
>
{skipLink}

{/* Mobile overlay backdrop */}
{isMobile && !collapsed && (
<div
Expand Down Expand Up @@ -285,6 +317,10 @@ export default function MainLayout() {
}}
>
<Content
id={MAIN_CONTENT_ID}
// Focus target for the skip link; -1 keeps it out of the tab
// order while still being focusable programmatically.
tabIndex={-1}
className="page-container"
style={{
background: isChatRoute
Expand Down
Loading