): 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/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/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/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);
});
From b3f8f8d8ced618f3046b80d042ec2f4d8187c559 Mon Sep 17 00:00:00 2001
From: Carl
<32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Date: Sat, 10 Oct 2026 16:09:00 -0700
Subject: [PATCH 2/4] Avoid per-keystroke setup in workflow draft replacement
test
Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
---
src/bundled/workflows/WorkflowsPage.test.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
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" }));
From b9ad8df8115bef14d229cf5582f4719822f6c448 Mon Sep 17 00:00:00 2001
From: Carl
<32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Date: Sun, 11 Oct 2026 09:58:06 -0700
Subject: [PATCH 3/4] Preserve keyboard focus when retry remounts navigation
Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
---
docs/shell-design.md | 5 ++-
src/app/App.tsx | 36 ++++++++++++++-
src/app/navigation.test.tsx | 54 ++++++++++++++---------
tests/browser/navigation-repairs.spec.mjs | 45 ++++++++++++++++---
4 files changed, 110 insertions(+), 30 deletions(-)
diff --git a/docs/shell-design.md b/docs/shell-design.md
index c233ef101..4c1c28757 100644
--- a/docs/shell-design.md
+++ b/docs/shell-design.md
@@ -72,7 +72,10 @@ semantic tokens, UI authoring rules and the local component reference.
tab. Each opening carries focus intent: topbar activation suppresses destination
autofocus, while explicit New conversation, sidebar and deep-link openings keep
their content focus. This intent is not saved in addresses or history; retry
- preserves it for the same opening. The selected page is a separately focusable,
+ preserves it for the same opening. A focused Retry navigation control hands
+ focus to main before retry removes it, restoring that focus if reconnect
+ temporarily remounts the shell. A new opening or focus elsewhere cancels this
+ handoff. The selected page is a separately focusable,
labelled tabpanel. Toolbar
and overflow destinations remain native buttons using `aria-current` and
focus main content on selection. Header pages never become companion launchers.
diff --git a/src/app/App.tsx b/src/app/App.tsx
index 3fe917608..553ecf506 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -9,7 +9,12 @@ import { AgentWakeNotice } from "../features/agents/AgentWakeNotice";
import { AgentUpdateReview } from "../bundled/agents/AgentUpdateReview";
import { SnapshotPreview } from "../bundled/agents/SnapshotPreview";
import { UpdateNotice } from "../features/updates/UpdateNotice";
-import { useEffect, useSyncExternalStore } from "react";
+import {
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useSyncExternalStore,
+} from "react";
import { registerAppShortcuts } from "./shortcuts";
import type { AppServices } from "./services";
import { Settings } from "./Settings";
@@ -82,6 +87,18 @@ function ConnectedApp({ services }: { services: AppServices }) {
);
const restoring = client.status === "loading" || !!connection.restoring;
const settings = route.target.kind === "settings";
+ const retryFocus = useRef(undefined);
+ useLayoutEffect(() => {
+ if (!retryFocus.current) return;
+ if (retryFocus.current === route.state.attempt.id && !settings && restoring)
+ return;
+ if (
+ retryFocus.current === route.state.attempt.id &&
+ document.activeElement === document.body
+ )
+ document.getElementById("main-content")?.focus({ preventScroll: true });
+ retryFocus.current = undefined;
+ }, [restoring, settings, route.state.attempt]);
const terminal =
startup === "recovery" ||
route.state.status === "failed" ||
@@ -262,7 +279,22 @@ function ConnectedApp({ services }: { services: AppServices }) {
: "The destination is unavailable or isn’t supported yet. Your target has been kept for retry."}
{(!route.state.ingress || route.state.retryable) && (
-