): void;
placement?: "sidebar" | "topbar" | "toolbar";
}) {
@@ -28,6 +30,9 @@ export function PageNavigation({
variant="chrome"
value={pages.some((page) => page.key === selected) ? selected : null}
onValueChange={(key) => onSelect(key, { focus: "preserve" })}
+ onReselect={(key) => {
+ if (key !== currentPage) onSelect(key, { focus: "preserve" });
+ }}
items={pages.map((page) => {
const Badge = page.badge;
return {
@@ -82,6 +87,9 @@ export function PageNavigation({
key={page.key}
onClick={select}
selected={selected === page.key}
+ aria-current={
+ selected === page.key && currentPage !== selected ? true : undefined
+ }
label={label}
title={label}
variant="row"
diff --git a/src/app/shell/presentation.test.ts b/src/app/shell/presentation.test.ts
index b79450e07..d75249604 100644
--- a/src/app/shell/presentation.test.ts
+++ b/src/app/shell/presentation.test.ts
@@ -7,7 +7,11 @@ import {
BrowserIcon,
ChatsCircleIcon,
} from "../../shared/design-system/icons/index";
-import { orderPages, pagePresentation } from "./presentation";
+import {
+ headerPageSelection,
+ orderPages,
+ pagePresentation,
+} from "./presentation";
function page(key: string, title: string, icon?: string): RegisteredPage {
const separator = key.indexOf("/");
@@ -133,3 +137,63 @@ describe("page mark precedence", () => {
expect(two).toHaveProperty("image", fox);
});
});
+
+describe("header area selection", () => {
+ const entries: RegisteredPage[] = [
+ { ...me, primary: true, placement: "topbar" },
+ { ...messages, primary: true, placement: "topbar" },
+ inbox,
+ { ...page("buzz.reminders/reminders", "Reminders"), placement: "sidebar" },
+ page("buzz.agents/agents", "Agents"),
+ page("buzz.workflows/workflows", "Workflows"),
+ { ...page("buzz.sessions/sessions", "Sessions"), primary: false },
+ page("example.sidebar/me", "External sidebar"),
+ {
+ ...page("example.header/main", "External header"),
+ primary: true,
+ placement: "topbar",
+ },
+ {
+ ...page("example.tools/main", "External toolbar"),
+ primary: true,
+ placement: "toolbar",
+ },
+ ];
+ test.each([
+ ["buzz.channels/channels", "buzz.channels/channels"],
+ ["buzz.inbox/inbox", "buzz.channels/channels"],
+ ["buzz.reminders/reminders", "buzz.channels/channels"],
+ ["buzz.agents/agents", "buzz.channels/channels"],
+ ["buzz.workflows/workflows", "buzz.channels/channels"],
+ ["buzz.sessions/sessions", "buzz.channels/channels"],
+ ["example.sidebar/me", "buzz.channels/channels"],
+ ["buzz.me/me", "buzz.me/me"],
+ ["settings", "settings"],
+ ["example.header/main", "example.header/main"],
+ ["example.tools/main", "example.tools/main"],
+ ["missing/page", "missing/page"],
+ ])("%s selects %s without changing the destination", (selected, expected) => {
+ expect(headerPageSelection(entries, selected)).toBe(expected);
+ });
+ test("never borrows an absent, non-primary or non-topbar Messages entry", () => {
+ const others = entries.filter((entry) => entry.key !== messages.key);
+ for (const registered of [
+ others,
+ [...others, { ...messages, placement: "topbar" as const }],
+ [
+ ...others,
+ { ...messages, primary: true, placement: "toolbar" as const },
+ ],
+ ]) {
+ expect(headerPageSelection(registered, inbox.key)).toBe(inbox.key);
+ }
+ });
+ test("Me stays separate even when its placement is omitted", () => {
+ expect(
+ headerPageSelection(
+ [...entries.filter((entry) => entry.key !== me.key), me],
+ me.key,
+ ),
+ ).toBe(me.key);
+ });
+});
diff --git a/src/app/shell/presentation.ts b/src/app/shell/presentation.ts
index 9df6e71c8..06cf4740d 100644
--- a/src/app/shell/presentation.ts
+++ b/src/app/shell/presentation.ts
@@ -70,3 +70,24 @@ export function pagePresentation(page: RegisteredPage): PagePresentation {
...(page.icon === undefined ? {} : { image: page.icon }),
};
}
+
+// Header selection describes the area; sidebar selection and routes stay exact.
+export function headerPageSelection(
+ pages: readonly RegisteredPage[],
+ selected: string,
+): string {
+ const page = pages.find((page) => page.key === selected);
+ if (
+ selected !== "buzz.me/me" &&
+ page &&
+ (page.placement === undefined || page.placement === "sidebar") &&
+ pages.some(
+ (page) =>
+ page.key === "buzz.channels/channels" &&
+ page.primary &&
+ page.placement === "topbar",
+ )
+ )
+ return "buzz.channels/channels";
+ return selected;
+}
diff --git a/src/bundled/channels/ChannelActivityPopover.tsx b/src/bundled/channels/ChannelActivityPopover.tsx
index d4fa9288e..72fd57ee9 100644
--- a/src/bundled/channels/ChannelActivityPopover.tsx
+++ b/src/bundled/channels/ChannelActivityPopover.tsx
@@ -196,6 +196,7 @@ export function ChannelActivityPopover({
const agentIds = activeAgents.join(",");
const [open, setOpen] = useState(false);
const triggerPointerType = useRef(null);
+ const pressedOpen = useRef(false);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!open || !activeAgents.length) return;
@@ -253,6 +254,16 @@ export function ChannelActivityPopover({
return;
}
}
+ // A queued hover dismissal must not close a newer keyboard/touch opening.
+ if (
+ !next &&
+ details.reason === "trigger-hover" &&
+ pressedOpen.current
+ ) {
+ details.cancel();
+ return;
+ }
+ pressedOpen.current = next && details.reason === "trigger-press";
setOpen(next);
if (next) setNow(Date.now());
if (next && items.length)
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index bb3dba36d..73c098047 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -1525,7 +1525,12 @@ function ChannelWorkspace({
channelId={current.id}
channelName={current.name}
autoFocus={
- !current.readOnly && !requestedMessage && !requestedThread
+ // The bound request arrives after the composer's first render.
+ (navigation?.focus ??
+ navigator?.snapshot().attempt.focus) !== "preserve" &&
+ !current.readOnly &&
+ !requestedMessage &&
+ !requestedThread
}
onOpenLink={openLink}
canOpenLink={canOpenLink}
diff --git a/src/bundled/workflows/WorkflowsPage.test.tsx b/src/bundled/workflows/WorkflowsPage.test.tsx
index abcf9077e..cdbf0fae3 100644
--- a/src/bundled/workflows/WorkflowsPage.test.tsx
+++ b/src/bundled/workflows/WorkflowsPage.test.tsx
@@ -361,7 +361,10 @@ it("discards form-only state on same-revision review and re-arms the next draft'
);
await user.click(screen.getByRole("combobox", { name: "Add condition" }));
await user.click(await screen.findByRole("option", { name: "Author" }));
- await user.type(screen.getByRole("textbox", { name: "Value" }), value);
+ // This checks draft replacement, not typing; populate the invalid fixture once.
+ fireEvent.change(screen.getByRole("textbox", { name: "Value" }), {
+ target: { value },
+ });
expect(warnsOnUnload()).toBe(true);
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Close inspector" }));
diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md
index 6e541540b..2531615c5 100644
--- a/src/shared/design-system/DESIGN.md
+++ b/src/shared/design-system/DESIGN.md
@@ -1405,6 +1405,12 @@ while focus stays on its tab. The host passes per-opening focus intent so page
autofocus does not override that handoff. Explicit content openings, including
New conversation, retain their destination focus. This is not a general route-tab
recipe: toolbar destinations and More pages overflow retain button semantics.
+Messages represents the containing area for registered sidebar/default-placement
+pages, while their sidebar selection and route remain exact. Its tab stays linked
+to the active child panel. The shell opts into Tabs' `onReselect` to return from a
+child to Messages without changing ordinary active-tab behavior elsewhere. More
+pages marks that containing area with `aria-current="true"`, reserving `page` for
+an exact destination. Settings and independent header pages stay separate.
The shell owns responsive overflow. Full-page `IconButton variant="chrome"` uses
`aria-current="page"` for selection; companion toggles continue to use `aria-expanded`.
diff --git a/src/shared/design-system/ui/Tabs.tsx b/src/shared/design-system/ui/Tabs.tsx
index 491472b7b..713a3951f 100644
--- a/src/shared/design-system/ui/Tabs.tsx
+++ b/src/shared/design-system/ui/Tabs.tsx
@@ -34,6 +34,7 @@ export function Tabs({
items,
label,
onValueChange,
+ onReselect,
trailingAction,
variant = "chrome",
showSelection = true,
@@ -47,6 +48,8 @@ export function Tabs({
items: readonly TabItem[];
label: string;
onValueChange: (value: Value) => void;
+ /** Opt in when activating the selected tab returns from a nested view. */
+ onReselect?: (value: Value) => void;
trailingAction?: ReactNode;
variant?: TabsVariant;
/** Hide the visual selection for a single-view header; tab semantics remain. */
@@ -91,6 +94,9 @@ export function Tabs({
{...(item.panelId
? { id: `${item.panelId}-tab`, "aria-controls": item.panelId }
: {})}
+ onClick={() => {
+ if (item.value === value) onReselect?.(item.value);
+ }}
onKeyDown={(event) => {
if (item.onClose && event.key === "Delete") {
event.preventDefault();
diff --git a/src/shared/design-system/ui/compositions.test.tsx b/src/shared/design-system/ui/compositions.test.tsx
index 5550e59ec..fb13332c3 100644
--- a/src/shared/design-system/ui/compositions.test.tsx
+++ b/src/shared/design-system/ui/compositions.test.tsx
@@ -580,3 +580,34 @@ test("compact footer spacing is opt-in and resets when leaving confirmation", ()
"default",
);
});
+
+test("selected tab reactivation is opt-in and distinct from value changes", async () => {
+ const user = userEvent.setup();
+ const change = vi.fn();
+ const reselect = vi.fn();
+ const props = {
+ label: "Area",
+ value: "one",
+ onValueChange: change,
+ items: [
+ { value: "one", label: "One" },
+ { value: "two", label: "Two" },
+ ],
+ };
+ const { rerender } = render();
+ const one = screen.getByRole("tab", { name: "One" });
+ await user.click(one);
+ expect(change).not.toHaveBeenCalled();
+ rerender();
+ await user.click(one);
+ await user.keyboard("{Enter} ");
+ expect(reselect).toHaveBeenCalledTimes(3);
+ expect(reselect).toHaveBeenLastCalledWith("one");
+ expect(change).not.toHaveBeenCalled();
+ await user.keyboard("{ArrowRight}");
+ expect(reselect).toHaveBeenCalledTimes(3);
+ expect(change).not.toHaveBeenCalled();
+ await user.keyboard("{Enter}");
+ expect(change).toHaveBeenCalledExactlyOnceWith("two");
+ expect(reselect).toHaveBeenCalledTimes(3);
+});
diff --git a/tests/browser/me-workspace.spec.mjs b/tests/browser/me-workspace.spec.mjs
index 20b05091d..a705ef7b4 100644
--- a/tests/browser/me-workspace.spec.mjs
+++ b/tests/browser/me-workspace.spec.mjs
@@ -156,8 +156,17 @@ test("Me sidebar shares working activity, keyboard preview and local panel navig
.getByRole("button", { name: "Close Agent Activity tab", exact: true })
.click();
await expect(alpha).toBeFocused();
+ await page.clock.install();
+ await page.clock.pauseAt(new Date());
+ // Cross the closed trigger, then reopen by keyboard before hover dismissal.
+ await alpha.hover();
await page.mouse.move(0, 0);
await alpha.press("Enter");
+ await expect(alpha).toHaveAttribute("aria-expanded", "true");
+ await expect(popup).toBeVisible();
+ // Run past the 150ms hover-close deadline, not a wall-clock wait.
+ await page.clock.runFor(200);
+ await page.clock.resume();
await expect(popup).toBeVisible();
const activityAction = popup.getByRole("button", {
name: /View .+ activity/,
diff --git a/tests/browser/navigation-repairs.spec.mjs b/tests/browser/navigation-repairs.spec.mjs
index 1db1c202e..bd75db14b 100644
--- a/tests/browser/navigation-repairs.spec.mjs
+++ b/tests/browser/navigation-repairs.spec.mjs
@@ -37,26 +37,61 @@ test("Back restores Personal space without inheriting the active community", asy
).toBeVisible();
});
-test("Retry navigation reconnects the failed target session", async ({
+test("keyboard Retry hands off focus while reconnecting a failed Messages tab opening", async ({
page,
app,
}) => {
let requests = 0;
+ let release;
+ const held = new Promise((resolve) => {
+ release = resolve;
+ });
await page.route("**/api/relay/primary/session", async (route) => {
requests++;
if (requests === 1) return route.fulfill({ json: {} });
+ await held;
return route.continue();
});
- await page.goto(app.origin);
- await openPage(page, "Messages", { connected: false });
+ // Start outside Messages so its header activation owns preserve-focus intent.
+ await page.goto(
+ `${app.origin}/#buzz=${encodeURIComponent(JSON.stringify({ version: 1, kind: "settings" }))}`,
+ );
+ const messages = page.getByRole("tab", { name: "Messages", exact: true });
+ await messages.focus();
+ await messages.press("Enter");
await expect(
page.getByRole("heading", { name: "This destination couldn’t open" }),
).toBeVisible();
- await button(page, "Retry navigation").click();
- await expect.poll(() => requests, { timeout: 1500 }).toBe(2);
+ const visit = await entry(page);
+ const retry = button(page, "Retry navigation");
+ const main = page.getByRole("main");
+ try {
+ await retry.focus();
+ await retry.press("Enter");
+ await expect.poll(() => requests, { timeout: 1500 }).toBe(2);
+ await expect(retry).toHaveCount(0);
+ await expect(main).toBeFocused();
+ } finally {
+ release();
+ }
await expect(
page.getByRole("textbox", { name: "Message #Alpha", exact: true }),
).toBeVisible();
+ await expect(main).toBeFocused();
+ // Resolving Messages chooses its conversation in the same visit.
+ expect(await entry(page)).toEqual({
+ id: visit.id,
+ target: {
+ version: 1,
+ kind: "conversation",
+ channelId: "alpha",
+ scope: visit.target.scope,
+ },
+ });
+ await page.keyboard.press("Tab");
+ await expect(
+ page.getByRole("tabpanel", { name: "Messages", exact: true }),
+ ).toBeFocused();
});
test("Skip to content focuses main without adding a visit or dropping Back", async ({
diff --git a/tests/browser/page-placement.spec.mjs b/tests/browser/page-placement.spec.mjs
index 46bc07105..f5a906ec3 100644
--- a/tests/browser/page-placement.spec.mjs
+++ b/tests/browser/page-placement.spec.mjs
@@ -86,6 +86,7 @@ test("Me replaces the sidebar while Messages preserves its draft and history", a
await page.keyboard.press(activation);
await expect(messages).toHaveAttribute("aria-selected", "true");
await expect(composer).toHaveText("Keep this draft");
+ await expect(messages).toBeFocused();
}
await me.click();
const newComposer = page.getByRole("textbox", {
@@ -173,6 +174,68 @@ test("Me replaces the sidebar while Messages preserves its draft and history", a
await expect(
page.getByRole("complementary", { name: "Channel sidebar" }),
).toBeVisible();
+ // The real sidebar changes the route, not the containing Messages area.
+ const inbox = page
+ .getByRole("navigation", { name: "Pages" })
+ .getByRole("button", { name: "Inbox", exact: true });
+ for (const mode of ["light", "dark"]) {
+ await page.emulateMedia({ colorScheme: mode });
+ for (const activation of ["pointer", "Enter", "Space"]) {
+ await inbox.click();
+ await expect(inbox).toHaveAttribute("aria-current", "page");
+ await expect(messages).toHaveAttribute("aria-selected", "true");
+ await expect(
+ page.getByRole("tabpanel", { name: "Messages", exact: true }),
+ ).toHaveAttribute("id", await messages.getAttribute("aria-controls"));
+ await expect(page.locator("#main-content")).toBeFocused();
+ if (activation === "pointer") await messages.click();
+ else {
+ await messages.focus();
+ await messages.press(activation);
+ }
+ await expect(composer).toHaveText("Keep this draft");
+ await expect(messages).toBeFocused();
+ }
+ }
+ await inbox.click();
+ await page.reload();
+ await expect(inbox).toHaveAttribute("aria-current", "page");
+ await expect(messages).toHaveAttribute("aria-selected", "true");
+ await me.click();
+ await expect(me).toHaveAttribute("aria-selected", "true");
+ await page.goBack();
+ await expect(inbox).toHaveAttribute("aria-current", "page");
+ await expect(messages).toHaveAttribute("aria-selected", "true");
+ await page.goForward();
+ await expect(me).toHaveAttribute("aria-selected", "true");
+ await page.goBack();
+ await expect(inbox).toHaveAttribute("aria-current", "page");
+ for (const mode of ["light", "dark"]) {
+ await page.emulateMedia({ colorScheme: mode });
+ await page.setViewportSize({ width: 390, height: 950 });
+ await page.evaluate(() => {
+ document.documentElement.style.fontSize = "200%";
+ });
+ await page.getByRole("button", { name: "More pages", exact: true }).click();
+ const overflow = page.getByRole("navigation", { name: "Header pages" });
+ const area = overflow.getByRole("button", {
+ name: "Messages",
+ exact: true,
+ });
+ await expect(area).toHaveAttribute("aria-current", "true");
+ await expect(area).toBeVisible();
+ await page.keyboard.press("Escape");
+ await page.evaluate(() => {
+ document.documentElement.style.fontSize = "";
+ });
+ await page.setViewportSize({ width: 1440, height: 950 });
+ await expect(messages).toHaveAttribute("aria-selected", "true");
+ await page.screenshot({
+ path: test.info().outputPath(`messages-inbox-${mode}.png`),
+ });
+ }
+ await messages.click();
+ await expect(composer).toHaveText("Keep this draft");
await openPage(page, "Settings");
await expect(topbar.locator('[aria-selected="true"]')).toHaveCount(0);
});