Skip to content
Open
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
15 changes: 12 additions & 3 deletions docs/shell-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,20 @@ 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,
and only the exact current page is selected.
focus main content on selection. Header pages never become companion launchers.
Independent header pages retain exact destination selection. Messages stays
selected for registered sidebar/default-placement pages, including non-primary
pages reached through search or links; their sidebar selection and route remain
exact. Its tab labels the active child panel, and activating Messages from a
child returns to the existing conversation view. More pages indicates this
containing area with `aria-current="true"` (not `page`). Me and Settings remain
separate; missing/unregistered destinations do not borrow Messages selection.
The shell owns one joined Panel around navigation and page content, with a
16px outer gutter (8px on narrow screens). Nested Panels keep their opaque
fill and clipping but drop individual borders, radii, and shadows. Layout
Expand Down
36 changes: 34 additions & 2 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | undefined>(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" ||
Expand Down Expand Up @@ -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."}
</p>
{(!route.state.ingress || route.state.retryable) && (
<Button type="button" onClick={route.retry}>
<Button
type="button"
onClick={(event) => {
const focused =
event.currentTarget === document.activeElement;
// Retry removes the control and can briefly unmount the shell.
if (focused)
document
.getElementById("main-content")
?.focus({ preventScroll: true });
route.retry();
retryFocus.current = focused
? services.navigation.snapshot().attempt.id
: undefined;
}}
>
Retry navigation
</Button>
)}
Expand Down
54 changes: 32 additions & 22 deletions src/app/navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,30 +283,40 @@ it("fails a completed ordinary-member read without exposing Membership", async (
expect(screen.queryByRole("button", { name: "Membership" })).toBeNull();
expect(screen.queryByRole("heading", { name: "Membership" })).toBeNull();
});
it("Retry navigation starts a fresh permission read after a failed roster", async () => {
const fixture = membershipFixture();
fixture.fail();
const current = restoreMembership();
await screen.findByRole("button", { name: "Retry navigation" });
const before = fixture.rosterReads.length;
expect(before).toBeGreaterThan(0);
const release = fixture.hold();
try {
fireEvent.click(screen.getByRole("button", { name: "Retry navigation" }));
it.each([true, false])(
"Retry navigation refreshes permission and hands off only its own focus (focused: %s)",
async (focused) => {
const fixture = membershipFixture();
fixture.fail();
const current = restoreMembership();
const retry = await screen.findByRole("button", {
name: "Retry navigation",
});
const main = screen.getByRole("main");
const elsewhere = screen.getByRole("link", { name: "Skip to content" });
(focused ? retry : elsewhere).focus();
const before = fixture.rosterReads.length;
expect(before).toBeGreaterThan(0);
const release = fixture.hold();
try {
fireEvent.click(retry);
expect(focused ? main : elsewhere).toHaveFocus();
await waitFor(() =>
expect(fixture.rosterReads.length).toBeGreaterThan(before),
);
expectPending(current);
expect(focused ? main : elsewhere).toHaveFocus();
} finally {
await act(async () => release());
}
expect(
await screen.findByRole("heading", { name: "Membership" }),
).toBeVisible();
await waitFor(() =>
expect(fixture.rosterReads.length).toBeGreaterThan(before),
expect(current.navigation.snapshot().status).toBe("opened"),
);
expectPending(current);
} finally {
await act(async () => release());
}
expect(
await screen.findByRole("heading", { name: "Membership" }),
).toBeVisible();
await waitFor(() =>
expect(current.navigation.snapshot().status).toBe("opened"),
);
});
},
);

it("reconnects the selected Membership community after its transport fails", async () => {
const fixture = membershipFixture();
Expand Down
116 changes: 116 additions & 0 deletions src/app/shell/AppShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,3 +446,119 @@ it("uses chrome tabs with manual keyboard selection, page linkage, and isolated
error.mockRestore();
}
});

it.each([false, true])(
"keeps Messages selected for a child without losing exact navigation (compact: %s)",
async (compact) => {
vi.stubGlobal("matchMedia", (media: string) => ({
media,
matches: compact,
addEventListener() {},
removeEventListener() {},
}));
const current = createServices();
services = current;
const onSelect = vi.fn();
const entries: RegisteredPage[] = [
{ key: "buzz.me/me", title: "Me", placement: "topbar" },
{ key: "buzz.channels/channels", title: "Channels", placement: "topbar" },
{ key: "buzz.inbox/inbox", title: "Inbox", placement: "sidebar" },
{ key: "example/top", title: "Top", placement: "topbar" },
{ key: "example/tool", title: "Tool", placement: "toolbar" },
].map((entry) => ({
...entry,
placement: entry.placement as "topbar" | "sidebar" | "toolbar",
id: entry.key.slice(entry.key.indexOf("/") + 1),
pluginId: entry.key.slice(0, entry.key.indexOf("/")),
revision: "one",
primary: true,
component: () => null,
}));
const shell = (selected: string, pages = entries) => (
<ToastProvider>
<AppShell
pages={pages}
selected={selected}
navigationAttempt=""
onSelect={onSelect}
tone="default"
communities={current.communities}
accountActions={current.accountActions}
>
<p>Child content</p>
</AppShell>
</ToastProvider>
);
const { rerender } = render(shell("buzz.inbox/inbox"));
if (compact)
await userEvent.click(
screen.getByRole("button", { name: "Show navigation" }),
);
const inbox = within(
screen.getByRole("navigation", { name: "Pages" }),
).getByRole("button", { name: "Inbox" });
expect(inbox).toHaveAttribute("aria-current", "page");
if (compact)
await userEvent.click(screen.getByRole("button", { name: "More pages" }));
const nav = screen.getByRole("navigation", {
name: compact ? "Header pages" : "Topbar pages",
});
const role = compact ? "button" : "tab";
const messages = within(nav).getByRole(role, { name: "Messages" });
if (compact) {
expect(messages).toHaveAttribute("aria-current", "true");
expect(screen.queryByRole("tabpanel")).not.toBeInTheDocument();
await userEvent.click(messages);
expect(onSelect).toHaveBeenCalledExactlyOnceWith(
"buzz.channels/channels",
);
expect(document.getElementById("main-content")).toHaveFocus();
return;
}
expect(messages).toHaveAttribute("aria-selected", "true");
const panel = screen.getByRole("tabpanel", { name: "Messages" });
expect(panel).toHaveAttribute("id", messages.getAttribute("aria-controls"));
expect(panel).toHaveAttribute("aria-labelledby", messages.id);
expect(panel).toHaveTextContent("Child content");
await userEvent.click(messages);
expect(onSelect).toHaveBeenCalledExactlyOnceWith("buzz.channels/channels", {
focus: "preserve",
});
expect(messages).toHaveFocus();
onSelect.mockClear();
rerender(shell("buzz.channels/channels"));
await userEvent.click(messages);
expect(onSelect).not.toHaveBeenCalled();
rerender(shell("example/top"));
expect(within(nav).getByRole("tab", { name: "Top" })).toHaveAttribute(
"aria-selected",
"true",
);
rerender(shell("example/tool"));
expect(
within(nav).queryByRole("tab", { selected: true }),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Tool" })).toHaveAttribute(
"aria-current",
"page",
);
rerender(shell("buzz.me/me"));
expect(within(nav).getByRole("tab", { name: "Me" })).toHaveAttribute(
"aria-selected",
"true",
);
rerender(shell("settings"));
expect(
within(nav).queryByRole("tab", { selected: true }),
).not.toBeInTheDocument();
rerender(
shell(
"buzz.inbox/inbox",
entries.filter((entry) => entry.key !== "buzz.inbox/inbox"),
),
);
expect(
within(nav).queryByRole("tab", { selected: true }),
).not.toBeInTheDocument();
},
);
18 changes: 12 additions & 6 deletions src/app/shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type { OpenTarget } from "../../features/navigation/targets";
import { CommunityRail } from "../../features/communities/CommunityRail";
import { ProfileButton } from "./ProfileButton";
import { PageSearch, type SearchServices } from "./PageSearch";
import { orderPages } from "./presentation";
import { headerPageSelection, orderPages } from "./presentation";
import { PageNavigation } from "./PageNavigation";
import type { NavigationOpenOptions } from "../../features/navigation/controller";
import {
Expand Down Expand Up @@ -157,7 +157,11 @@ export function AppShell({
[pages],
);
const topbarPages = headerPages.filter((page) => page.placement === "topbar");
const selectedTopbarPage = topbarPages.some((page) => page.key === selected);
const selectedHeader = headerPageSelection(pages, selected);
const selectedTopbarPage = topbarPages.some(
(page) => page.key === selectedHeader,
);
const panelKey = selectedTopbarPage ? selectedHeader : selected;
const toolbarPages = headerPages.filter(
(page) => page.placement === "toolbar",
);
Expand Down Expand Up @@ -292,7 +296,8 @@ export function AppShell({
>
<PageNavigation
pages={topbarPages}
selected={selected}
selected={selectedHeader}
currentPage={selected}
onSelect={onSelect}
placement="topbar"
/>
Expand Down Expand Up @@ -357,7 +362,8 @@ export function AppShell({
>
<PageNavigation
pages={headerPages}
selected={selected}
selected={selectedHeader}
currentPage={selected}
onSelect={(key) => {
setOverflowOpen(false);
onSelect(key);
Expand Down Expand Up @@ -430,11 +436,11 @@ export function AppShell({
>
<PanelFrame companion={companion}>
<div
id={`shell-page-${selected}`}
id={`shell-page-${panelKey}`}
{...(!compactPages && selectedTopbarPage
? {
role: "tabpanel",
"aria-labelledby": `shell-page-${selected}-tab`,
"aria-labelledby": `shell-page-${panelKey}-tab`,
tabIndex: 0,
"data-focus-inset": "",
}
Expand Down
8 changes: 8 additions & 0 deletions src/app/shell/PageNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import { pagePresentation } from "./presentation";
export function PageNavigation({
pages,
selected,
currentPage = selected,
onSelect,
placement = "sidebar",
}: {
pages: readonly RegisteredPage[];
selected: string;
currentPage?: string;
onSelect(key: string, options?: Pick<NavigationOpenOptions, "focus">): void;
placement?: "sidebar" | "topbar" | "toolbar";
}) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading