From a9e753b26ec9fbadeda2213e7b8554667bd5472a Mon Sep 17 00:00:00 2001 From: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Date: Sat, 10 Oct 2026 10:59:20 -0700 Subject: [PATCH 1/4] Keep Messages selected across its sidebar destinations Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- docs/shell-design.md | 10 +- src/app/shell/AppShell.test.tsx | 116 ++++++++++++++++++ src/app/shell/AppShell.tsx | 18 ++- src/app/shell/PageNavigation.tsx | 8 ++ src/app/shell/presentation.test.ts | 66 +++++++++- src/app/shell/presentation.ts | 21 ++++ src/bundled/channels/ChannelsPage.tsx | 7 +- src/shared/design-system/DESIGN.md | 6 + src/shared/design-system/ui/Tabs.tsx | 6 + .../design-system/ui/compositions.test.tsx | 31 +++++ tests/browser/page-placement.spec.mjs | 63 ++++++++++ 11 files changed, 342 insertions(+), 10 deletions(-) diff --git a/docs/shell-design.md b/docs/shell-design.md index 782e42a46..c233ef101 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -75,8 +75,14 @@ semantic tokens, UI authoring rules and the local component reference. preserves it for the same opening. 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 diff --git a/src/app/shell/AppShell.test.tsx b/src/app/shell/AppShell.test.tsx index 2ac323069..9078124c5 100644 --- a/src/app/shell/AppShell.test.tsx +++ b/src/app/shell/AppShell.test.tsx @@ -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) => ( + + +

Child content

+
+
+ ); + 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(); + }, +); diff --git a/src/app/shell/AppShell.tsx b/src/app/shell/AppShell.tsx index d7112a4ed..3450928cc 100644 --- a/src/app/shell/AppShell.tsx +++ b/src/app/shell/AppShell.tsx @@ -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 { @@ -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", ); @@ -292,7 +296,8 @@ export function AppShell({ > @@ -357,7 +362,8 @@ export function AppShell({ > { setOverflowOpen(false); onSelect(key); @@ -430,11 +436,11 @@ export function AppShell({ >
): 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) && ( - )} diff --git a/src/app/navigation.test.tsx b/src/app/navigation.test.tsx index 81537ee75..ee48a5c6b 100644 --- a/src/app/navigation.test.tsx +++ b/src/app/navigation.test.tsx @@ -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(); 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 ({ From db46f0202b7fe93dd867b28040a2252606d9341e Mon Sep 17 00:00:00 2001 From: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Date: Sun, 11 Oct 2026 10:01:59 -0700 Subject: [PATCH 4/4] Keep keyboard activity previews open past stale hover dismissal Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- src/bundled/channels/ChannelActivityPopover.tsx | 11 +++++++++++ tests/browser/me-workspace.spec.mjs | 9 +++++++++ 2 files changed, 20 insertions(+) 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/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/,