From 4cce100dfc8df0b22f5f1d9dfbe0cf6bda35d76f Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 4 Nov 2026 16:19:03 +1100 Subject: [PATCH 01/12] fix(people): open profiles from Solo Share access and "N others" lists Solo Share access listed people with no route to their profile: names and "Managed by" were plain text, View profile was disabled and person rows had no Send message. ChannelWorkspace now builds the team dialog's three member link props once and passes them to the team Manage members dialog and to both Solo headers, the main-pane SoloHeading and the side-tab SoloToolbar. SoloToolbar forwards them to its header Share access button and its Chat actions dialog. The side-tab toolbar renders inside useConversationTabs, before canOpenLink and openLink are declared, so the props wrap them in arrows that read them only when called. A membership row's "N others" list showed plain names. Its entries now use the lead name's button, shared through a local profileName helper: each opens that member's profile (the agent profile for agents) and closes the list. "N others" moves into a small MembershipPreview component so it has a ref; a chosen entry focuses it before opening, so closing the profile returns focus there. Entries the host cannot open stay plain text, and the list takes the lead names' quiet .membershipName look. The sidebar and Solo-region Share access mounts and Huddle members still open the dialog without links; they live outside ChannelsPage. Tests: new RTL cases for the Solo header, Chat actions and side-tab paths and for the "N others" list, each failing before the fix; the join-burst journey now asserts 602 entry buttons and opens Pinky's profile from the list, returning focus to "601 others". Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- docs/buzz-help.md | 3 +- .../channels/ChannelsPage.solo.test.tsx | 104 ++++++++++- .../channels/ChannelsPage.tabs.test.tsx | 39 +++- src/bundled/channels/ChannelsPage.tsx | 22 ++- src/bundled/channels/SoloHeading.tsx | 18 +- src/features/messages/MembershipRow.tsx | 170 +++++++++++------- src/features/messages/Messages.module.css | 11 +- .../messages/membership-rows.test.tsx | 65 ++++++- tests/browser/membership.spec.mjs | 26 ++- 9 files changed, 375 insertions(+), 83 deletions(-) diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 444087eca7..2c2d1f4d74 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -258,7 +258,8 @@ revokes all of them, and a pairing unused for 30 days expires. conversation entries, even across long gaps or midnight. Large or mixed groups keep one name per action, such as “Pinky and 182 others were added.” Hover or activate “182 others” to see - the full, scrollable list of names. Messages and huddle entries split groups. + the full, scrollable list of names; choose a name to open that person's + profile. Messages and huddle entries split groups. - **Solo** organizes your chats with agents. Choose **New chat**, select or tag an agent, and send a message. Solo conversations continue at the chat's top diff --git a/src/bundled/channels/ChannelsPage.solo.test.tsx b/src/bundled/channels/ChannelsPage.solo.test.tsx index cdab5674a5..ebc53a5208 100644 --- a/src/bundled/channels/ChannelsPage.solo.test.tsx +++ b/src/bundled/channels/ChannelsPage.solo.test.tsx @@ -39,6 +39,7 @@ import { import { SoloWorkspaces } from "./SoloWorkspaces"; import type { RelaySession } from "../../features/relay/session"; import type { ReadTarget } from "../../features/relay/read-state-model"; +import type { RegisteredPanel } from "../../features/panels/service"; vi.mock( "../../features/messages/solo-model-picks-storage", @@ -393,6 +394,17 @@ function fixture({ } as unknown as Navigation; const archive: ArchiveShortcut = {}; const read: ReadShortcut = {}; + const profilePanel = { + id: "profile", + key: "fixture:profile", + pluginId: "fixture", + revision: "1", + title: "Profile", + matches: (target: string) => target.startsWith("nostr:npub"), + component: () =>

Profile content

, + } as RegisteredPanel; + // A stable snapshot, as useSyncExternalStore requires. + const available = [profilePanel]; const page = (sidebar = false) => ( {sidebar && ( @@ -410,9 +422,11 @@ function fixture({ available, + subscribe: () => () => {}, register() {}, - resolve: () => undefined, + resolve: (target) => + profilePanel.matches(target) ? profilePanel : undefined, requestOpen: () => false, handleOpenRequests: () => () => {}, }} @@ -1371,6 +1385,92 @@ it.each([ expect(screen.queryByText(/’s chat/)).toBeNull(); }); +it("opens a profile from the header's Share access and returns focus to it", async () => { + const person = "e".repeat(64); + const f = fixture({ people: [person] }); + // The dialog lists members once their names settle. + Object.assign(f.session.profiles, { ensure: async () => {} }); + f.mount(); + const user = userEvent.setup(); + const trigger = await screen.findByRole("button", { name: "Share access" }); + await user.click(trigger); + const dialog = await screen.findByRole("dialog", { name: /Share access/ }); + await user.click( + await within(dialog).findByRole("button", { + name: /^Open profile for Teammate/, + }), + ); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(await screen.findByText("Profile content")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Close Profile tab" })); + await waitFor(() => expect(trigger).toHaveFocus()); +}); + +it("offers the team's member actions from Chat actions → Share access", async () => { + const person = "e".repeat(64); + const dm = "22222222-2222-4222-8222-222222222222"; + const f = fixture({ people: [person], viewerNamed: true }); + const openDm = vi.fn(async () => dm); + Object.assign(f.session.profiles, { ensure: async () => {} }); + Object.assign(f.session, { + directMessages: { + ...f.session.directMessages, + available: true, + open: openDm, + }, + }); + f.mount(); + const user = userEvent.setup(); + const trigger = await screen.findByRole("button", { name: "Chat actions" }); + const share = async () => { + await user.click(trigger); + await user.click( + await screen.findByRole("menuitem", { name: "Share access" }), + ); + return within(await screen.findByRole("dialog", { name: /Share access/ })); + }; + const actions = async (dialog: ReturnType, name: string) => { + await user.click( + await dialog.findByRole("button", { name: `Actions for ${name}` }), + ); + return within(await screen.findByRole("menu")); + }; + let dialog = await share(); + // An agent and the viewer get no Send message, as in team channels. + for (const name of ["Scout", "Viewer"]) { + const menu = await actions(dialog, name); + expect( + menu.getByRole("menuitem", { name: "View profile" }), + ).not.toHaveAttribute("aria-disabled", "true"); + expect(menu.queryByRole("menuitem", { name: "Send message" })).toBeNull(); + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + } + await user.click( + (await actions(dialog, "Teammate")).getByRole("menuitem", { + name: "Send message", + }), + ); + expect(openDm).toHaveBeenCalledExactlyOnceWith( + [person], + expect.any(AbortSignal), + ); + await waitFor(() => + expect(f.open).toHaveBeenLastCalledWith(conversationTarget(f.scope, dm)), + ); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + dialog = await share(); + await user.click( + (await actions(dialog, "Teammate")).getByRole("menuitem", { + name: "View profile", + }), + ); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(await screen.findByText("Profile content")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Close Profile tab" })); + await waitFor(() => expect(trigger).toHaveFocus()); +}); + it("marks an archived chat once and keeps only its lifecycle actions", async () => { fixture({ archived: true }).mount(); await screen.findByRole("button", { name: "Chat actions" }); diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx index 7cc490691b..1021a0bd1f 100644 --- a/src/bundled/channels/ChannelsPage.tabs.test.tsx +++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx @@ -186,7 +186,16 @@ async function page( matches: (target) => target === "https://app.example/", component: appComponent ?? (() =>

App content

), }; - const available = [panel, app]; + const profile: RegisteredPanel = { + id: "profile", + key: "fixture:profile", + pluginId: "fixture", + revision: "1", + title: "Profile", + matches: (target) => target.startsWith("nostr:npub"), + component: () =>

Profile content

, + }; + const available = [panel, app, profile]; let handleOpen: ((request: PanelOpenRequest) => boolean) | undefined; const panels: Panels = { snapshot: () => available, @@ -937,6 +946,34 @@ it("gives a Solo chat's side tab its own header actions, opening it in the main expect(screen.queryByRole("button", { name: "Chat actions" })).toBeNull(); }); +it("opens a profile from a Solo side tab's Share access", async () => { + const h = await page("stream", { + open: vi.fn(async () => ({ status: "opened" as const })), + snapshot: () => ({ entry: { id: "visit" }, attempt: {} }), + } as unknown as Navigation); + const tabs = renderHook(() => useChannelTabState(h.session, "alpha")); + act(() => + tabs.result.current.setTabs((items) => [ + ...items, + { id: "conversation:gamma", kind: "conversation", channelId: "gamma" }, + ]), + ); + await h.user.click(await screen.findByRole("tab", { name: "Gamma" })); + await h.user.click( + await screen.findByRole("button", { name: "Share access" }), + ); + const dialog = await screen.findByRole("dialog", { name: /Share access/ }); + await h.user.click( + await within(dialog).findByRole("button", { name: /^Open profile for/ }), + ); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(await screen.findByText("Profile content")).toBeVisible(); + await h.user.click(screen.getByRole("button", { name: "Close Profile tab" })); + // The profile replaced Gamma in the pane, unmounting its toolbar. While a + // pane keeps tabs, closing one leaves focus with them. + expect(screen.getByRole("tab", { name: "Gamma" })).toHaveFocus(); +}); + it("keeps a side tab's failed move recoverable after switching tabs", async () => { const base = { status: "ready", diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index e8b83ebf5f..f1ef4078ef 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1206,6 +1206,18 @@ function ChannelWorkspace({ target: targetKey(fallback), }; }; + // Member lists open profiles and DMs through this page. Side-tab Solo + // toolbars render inside useConversationTabs, before canOpenLink and + // openLink are declared, so these read them only when called. + const memberLinks = { + canOpenLink: (target: string) => canOpenLink(target), + onOpenLink: (url: string, returnFocus?: HTMLElement) => + openLink(url, { returnFocus }), + onOpenConversation: + navigator && viewer + ? (id: string) => openLink(`buzz://channel/${encodeURIComponent(id)}`) + : undefined, + }; const secondary = useConversationTabs({ state: tabState, relay, @@ -1227,6 +1239,7 @@ function ChannelWorkspace({ channel={channel} navigator={navigator} signal={navigation?.signal} + {...memberLinks} onOpen={open} onLifecycle={(action, trigger) => handoff?.openLifecycle(channel, action, trigger, navigation?.signal) @@ -2159,14 +2172,8 @@ function ChannelWorkspace({ ? membersTrigger : membersHeaderTrigger } - canOpenLink={canOpenLink} {...(agents ? { agents } : {})} - onOpenLink={(url, returnFocus) => openLink(url, { returnFocus })} - onOpenConversation={ - navigator && viewer - ? (id) => openLink(`buzz://channel/${encodeURIComponent(id)}`) - : undefined - } + {...memberLinks} /> )} setConversion({ channelId: current.id, diff --git a/src/bundled/channels/SoloHeading.tsx b/src/bundled/channels/SoloHeading.tsx index 06f2b447d7..c24648bc39 100644 --- a/src/bundled/channels/SoloHeading.tsx +++ b/src/bundled/channels/SoloHeading.tsx @@ -8,6 +8,7 @@ import { useRef, useState, useSyncExternalStore, + type ComponentProps, type ReactNode, type RefObject, } from "react"; @@ -61,7 +62,10 @@ type SoloToolbarProps = { onLifecycle(action: ChannelLifecycleAction, trigger?: HTMLElement): void; /** Reports a move started here; see `useSoloMoveRecovery`. */ onMove(move: Promise): void; -}; +} & Pick< + ComponentProps, + "canOpenLink" | "onOpenLink" | "onOpenConversation" +>; /** The header of a conversation shown with the Solo treatment. */ export function SoloHeading(props: SoloToolbarProps) { @@ -81,6 +85,9 @@ export function SoloHeading(props: SoloToolbarProps) { owner={owner} navigator={props.navigator} signal={props.signal} + canOpenLink={props.canOpenLink} + onOpenLink={props.onOpenLink} + onOpenConversation={props.onOpenConversation} /> } actions={} @@ -103,6 +110,9 @@ export function SoloToolbar({ onConvert, onLifecycle, onMove, + canOpenLink, + onOpenLink, + onOpenConversation, membershipInHeader = false, }: SoloToolbarProps & { membershipInHeader?: boolean }) { const menus = useSyncExternalStore( @@ -150,6 +160,9 @@ export function SoloToolbar({ owner={owner} navigator={navigator} signal={signal} + canOpenLink={canOpenLink} + onOpenLink={onOpenLink} + onOpenConversation={onOpenConversation} /> )} {children} @@ -227,6 +240,9 @@ export function SoloToolbar({ signal={sharing.visit} trigger={actionsTrigger} close={() => setSharing(undefined)} + canOpenLink={canOpenLink} + onOpenLink={onOpenLink} + onOpenConversation={onOpenConversation} /> )} {creatingWorkspace && ( diff --git a/src/features/messages/MembershipRow.tsx b/src/features/messages/MembershipRow.tsx index 8d1cce696b..93d6b51c7c 100644 --- a/src/features/messages/MembershipRow.tsx +++ b/src/features/messages/MembershipRow.tsx @@ -1,6 +1,7 @@ import type { IdentityNameView } from "../identity-names/service"; import { useIdentityNames } from "../identity-names/react"; import { + PopoverClose, PopoverRoot, PopoverTrigger, PopoverPopup, @@ -8,7 +9,7 @@ import { } from "../../shared/design-system/ui/Popover"; import { Avatar } from "../../shared/design-system/ui/Avatar"; import referenceStyles from "../../shared/InlineReference.module.css"; -import { Fragment, memo } from "react"; +import { Fragment, memo, useRef, type RefObject } from "react"; import { profileTarget } from "../profiles/target"; import type { Profile } from "../relay/contracts"; import { membershipDescription, type TimelineRow } from "./membership-rows"; @@ -16,6 +17,98 @@ import { DayDivider } from "./MessageTimestamp"; import styles from "./Messages.module.css"; import { avatarMedia } from "../../shared/avatar-source"; +type ProfileLinks = { + viewer?: string | undefined; + agentPubkeys?: ReadonlySet | undefined; + onOpenLink?: ((url: string) => boolean) | undefined; + canOpenLink?: ((target: string) => boolean) | undefined; +}; + +/** A member's name as a button that opens their profile, or undefined where + * the host cannot open it. A system notice names people as quiet text, not + * mention chips. No profile target, so copies stay plain prose. */ +function profileName( + { viewer, agentPubkeys, onOpenLink, canOpenLink }: ProfileLinks, + id: string | undefined, + text: string, + /** Where the profile returns focus; the name itself unless given. */ + origin?: RefObject, +) { + const agent = !!id && !!agentPubkeys?.has(id); + const target = id && profileTarget(id, { agent }); + if (!target || !canOpenLink?.(target)) return undefined; + return ( + + ); +} + +/** "N others": every named member, each opening their profile. */ +function MembershipPreview({ + text, + label, + members, + links, +}: { + text: string; + label: string; + members: readonly { id: string; name: string }[]; + links: ProfileLinks; +}) { + // A chosen name closes with the list, so its profile returns focus here. + const trigger = useRef(null); + return ( + + + {text} + + } + /> + + + {label} ({members.length}) + +
+
    + {members.map((member) => { + const name = profileName(links, member.id, member.name, trigger); + return ( +
  • + {name ? : member.name} +
  • + ); + })} +
+
+
+
+ ); +} + /** Quiet, non-conversational activity. Avatars are decorative; prose owns the * label, and each named member opens their profile like a prose mention. */ export const MembershipRow = memo(function MembershipRow({ @@ -33,14 +126,10 @@ export const MembershipRow = memo(function MembershipRow({ row: TimelineRow; names?: IdentityNameView | undefined; profiles: ReadonlyMap; - viewer?: string | undefined; media(url: string, size?: "small"): string | undefined; - agentPubkeys?: ReadonlySet | undefined; day: boolean; resolveName?: (pubkey: string, fallback: string) => string; - onOpenLink?: ((url: string) => boolean) | undefined; - canOpenLink?: ((target: string) => boolean) | undefined; -}) { +} & ProfileLinks) { const globalName = useIdentityNames(names); const resolveName = scopedName ?? globalName; const { targets, parts, title } = membershipDescription( @@ -49,6 +138,7 @@ export const MembershipRow = memo(function MembershipRow({ viewer, resolveName, ); + const links = { viewer, agentPubkeys, onOpenLink, canOpenLink }; return (
{day && } @@ -85,67 +175,19 @@ export const MembershipRow = memo(function MembershipRow({

{parts.map((part, index) => { const key = `${index}:${part.id ?? ""}:${part.text}`; - if (part.preview) { - const { label, members } = part.preview; + if (part.preview) return ( - - - {part.text} - - } - /> - - - {label} ({members.length}) - -

-
    - {members.map((member) => ( -
  • {member.name}
  • - ))} -
-
- - + ); - } - const agent = !!part.id && !!agentPubkeys?.has(part.id); - const target = part.id && profileTarget(part.id, { agent }); - if (!target || !canOpenLink?.(target)) - return {part.text}; - // A system notice names people as quiet text, not mention chips. - // No profile target, so copies stay plain prose. return ( - + + {profileName(links, part.id, part.text) ?? part.text} + ); })}

diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index 69dd8eed33..02c5a3b580 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -2001,15 +2001,18 @@ html[data-keyboard-navigation] .text pre code:focus-visible { } /* A quiet log line: member names keep the caption color at rest, carry medium weight as their only resting cue, and take the inline-reference - color while hovered or keyboard-focused. The shared chip fill and ring stay. */ -.membership .membershipName { + color while hovered or keyboard-focused. The shared chip fill and ring stay. + The "N others" list renders in a portal, outside the row, so it is named too. */ +:is(.membership, .membershipPeople) .membershipName { --link-text: var(--text-subtle); /* Restated over the channel host's button reset. */ color: var(--link-text); font-weight: var(--type-weight-medium); } -.membership .membershipName:hover, -html[data-keyboard-navigation] .membership .membershipName:focus-visible { +:is(.membership, .membershipPeople) .membershipName:hover, +html[data-keyboard-navigation] + :is(.membership, .membershipPeople) + .membershipName:focus-visible { --link-text: var(--text-link); } .membershipPeople { diff --git a/src/features/messages/membership-rows.test.tsx b/src/features/messages/membership-rows.test.tsx index b74661c82f..e32540729b 100644 --- a/src/features/messages/membership-rows.test.tsx +++ b/src/features/messages/membership-rows.test.tsx @@ -2,7 +2,15 @@ import { assert, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import "@testing-library/jest-dom/vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { profileTarget } from "../profiles/target"; import type { ChannelMessage, MembershipChange } from "../relay/contracts"; import { @@ -374,6 +382,61 @@ it("member names open the subject's and the actor's profiles when the host can", expect(document.querySelector("[data-profile-target]")).toBeNull(); }); +it("listed members open profiles and return focus to the overflow control", async () => { + const unnamed = "d".repeat(64); + const group = membershipRows( + [pinky, brain, wes, unnamed].map((id, i) => + row(String(i), joined("adder", id)), + ), + )[0]; + assert.exists(group); + // The host returns focus to whatever held it when the profile opened. + const origins: (Element | null)[] = []; + const open = vi.fn((_target: string) => { + origins.push(document.activeElement); + return true; + }); + render( + undefined} + viewer={wes} + day={false} + onOpenLink={open} + canOpenLink={(target) => target !== profileTarget(unnamed)} + />, + ); + const user = userEvent.setup(); + const others = screen.getByRole("button", { name: "3 others" }); + const list = async () => { + await user.click(others); + return within( + await screen.findByRole("region", { name: "People who were added" }), + ); + }; + let people = await list(); + expect( + people + .getAllByRole("button") + .map((entry) => entry.getAttribute("aria-label")), + ).toEqual(["View Pinky profile", "View Brain profile", "View your profile"]); + // A member the host cannot open stays plain text. + expect(people.getByText("dddddddddd").closest("button")).toBeNull(); + await user.click(people.getByRole("button", { name: "View Brain profile" })); + expect(open).toHaveBeenLastCalledWith(profileTarget(brain, { agent: true })); + expect(origins.at(-1)).toBe(others); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + people = await list(); + people.getByRole("button", { name: "View Pinky profile" }).focus(); + await user.keyboard("{Enter}"); + expect(open).toHaveBeenLastCalledWith(profileTarget(pinky)); + expect(origins.at(-1)).toBe(others); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(open).toHaveBeenCalledTimes(2); +}); + it("omits only the owner's starting agents once history reaches the start", () => { const added = (id: string, actor: string, target: string) => row(id, { type: "member_joined", actor, target }); diff --git a/tests/browser/membership.spec.mjs b/tests/browser/membership.spec.mjs index 5cca30ae25..c4db0315f4 100644 --- a/tests/browser/membership.spec.mjs +++ b/tests/browser/membership.spec.mjs @@ -339,8 +339,14 @@ burstTest( exact: true, }); await expect(people.getByRole("listitem")).toHaveCount(602); - await expect(people.getByRole("listitem").first()).toHaveText("Pinky"); - await expect(people.getByRole("listitem").nth(1)).toHaveText("Brain"); + // Each listed member opens their profile, like the row's lead name. + await expect(people.getByRole("button")).toHaveCount(602); + await expect(people.getByRole("button").first()).toHaveAccessibleName( + "View Pinky profile", + ); + await expect(people.getByRole("button").nth(1)).toHaveAccessibleName( + "View Brain profile", + ); await people.hover(); await page.screenshot({ path: testInfo.outputPath("membership-people.png"), @@ -384,6 +390,22 @@ burstTest( }); await page.keyboard.press("Escape"); await expect(popup).toHaveCount(0); + // Choosing a member closes the list; their profile returns focus to it. + await page.mouse.move(0, 0); + await others.hover(); + await expect(popup).toBeVisible(); + await people.getByRole("button", { name: "View Pinky profile" }).click(); + await expect(popup).toHaveCount(0); + const profile = page.getByRole("complementary", { + name: "Profile", + exact: true, + }); + await expect(profile).toContainText("Pinky"); + await page + .getByRole("button", { name: "Close Pinky tab", exact: true }) + .click(); + await expect(profile).toHaveCount(0); + await expect(others).toBeFocused(); await page.screenshot({ path: testInfo.outputPath("join-burst.png") }); await testInfo.attach("join-burst", { From d47a31930710b3b38405e037039e32d3cfddd762 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 4 Nov 2026 16:37:01 +1100 Subject: [PATCH 02/12] fix(messages): resolve "N others" names only while the list is open The "N others" list built every member's profile button whenever its membership row rendered, even with the list closed: an npub encode plus the host's canOpenLink per member, about 3.75 ms per pass for a 602-member burst and repeated on each memo miss while the burst's profiles load. The list moves into a MembershipPeople component inside PopoverPopup, whose portal mounts it only while the list is open. The list's scrollport clipped its entries: each .link overhangs by space-half, so the hover fill lost 2px on the left and the 2px-offset keyboard ring was cut on the left of every entry and the top of the first. Following DESIGN.md "Focus recipes", the scrollport gains space-half of inline padding (offset by a matching negative margin, so names stay aligned with the title) and the entries' ring is inset with outline-offset: -2px. Member names now rest at currentColor instead of text-subtle. Row names still inherit the caption's text-subtle, while listed names take the popover's text-standard rather than reading dimmer than the plain entries around them. Hover and keyboard focus keep the inline-reference colour. The comment no longer says medium weight is the only resting cue; the .link underline stays. Tests: the "N others" RTL case asserts that a closed list asks the host about its lead name only, which fails against the inline map. The join-burst journey checks that a resting entry matches the list's colour, Tabs to the first name, asserts its drawn ring fits inside the scrollport and saves a focus screenshot. The colour and clip assertions fail before their fixes (rgb(82, 82, 82) vs rgb(15, 15, 15); 6px clipped). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- src/features/messages/MembershipRow.tsx | 36 +++++++++++++------ src/features/messages/Messages.module.css | 18 +++++++--- .../messages/membership-rows.test.tsx | 7 +++- tests/browser/membership.spec.mjs | 27 ++++++++++++++ 4 files changed, 72 insertions(+), 16 deletions(-) diff --git a/src/features/messages/MembershipRow.tsx b/src/features/messages/MembershipRow.tsx index 93d6b51c7c..089ae32341 100644 --- a/src/features/messages/MembershipRow.tsx +++ b/src/features/messages/MembershipRow.tsx @@ -93,22 +93,38 @@ function MembershipPreview({ // biome-ignore lint/a11y/noNoninteractiveTabindex: The bounded member list needs keyboard focus for native scrolling. tabIndex={0} > -
    - {members.map((member) => { - const name = profileName(links, member.id, member.name, trigger); - return ( -
  • - {name ? : member.name} -
  • - ); - })} -
+ ); } +/** The listed names. The portal mounts this only while the list is open, so a + * large burst resolves each member's profile target only when someone looks. */ +function MembershipPeople({ + members, + links, + trigger, +}: { + members: readonly { id: string; name: string }[]; + links: ProfileLinks; + trigger: RefObject; +}) { + return ( +
    + {members.map((member) => { + const name = profileName(links, member.id, member.name, trigger); + return ( +
  • + {name ? : member.name} +
  • + ); + })} +
+ ); +} + /** Quiet, non-conversational activity. Avatars are decorative; prose owns the * label, and each named member opens their profile like a prose mention. */ export const MembershipRow = memo(function MembershipRow({ diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index 02c5a3b580..143e452d35 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -1999,12 +1999,13 @@ html[data-keyboard-navigation] .text pre code:focus-visible { min-width: 0; overflow-wrap: anywhere; } -/* A quiet log line: member names keep the caption color at rest, carry - medium weight as their only resting cue, and take the inline-reference - color while hovered or keyboard-focused. The shared chip fill and ring stay. - The "N others" list renders in a portal, outside the row, so it is named too. */ +/* A quiet log line: member names rest in their surrounding text color (the + row's caption, the list's body), keep the link underline, add medium weight, + and take the inline-reference color while hovered or keyboard-focused. The + shared chip fill and ring stay. The "N others" list renders in a portal, + outside the row, so it is named too. */ :is(.membership, .membershipPeople) .membershipName { - --link-text: var(--text-subtle); + --link-text: currentColor; /* Restated over the channel host's button reset. */ color: var(--link-text); font-weight: var(--type-weight-medium); @@ -2020,8 +2021,15 @@ html[data-keyboard-navigation] overflow-y: auto; overscroll-behavior: contain; margin-top: var(--space-2); + /* Inline room in the scrollport for each name's hover overhang. */ + padding-inline: var(--space-half); + margin-inline: calc(-1 * var(--space-half)); @apply text-body-sm text-standard; } +/* The scrollport would clip an offset ring, so the names' ring is inset. */ +html[data-keyboard-navigation] .membershipPeople .membershipName:focus-visible { + outline-offset: -2px; +} .membershipPeople ul { list-style: none; margin: 0; diff --git a/src/features/messages/membership-rows.test.tsx b/src/features/messages/membership-rows.test.tsx index e32540729b..231efe24d0 100644 --- a/src/features/messages/membership-rows.test.tsx +++ b/src/features/messages/membership-rows.test.tsx @@ -396,6 +396,9 @@ it("listed members open profiles and return focus to the overflow control", asyn origins.push(document.activeElement); return true; }); + const canOpenLink = vi.fn( + (target: string) => target !== profileTarget(unnamed), + ); render( target !== profileTarget(unnamed)} + canOpenLink={canOpenLink} />, ); + // A closed list asks the host about its lead name only, not every member. + expect(canOpenLink.mock.calls).toEqual([[profileTarget(pinky)]]); const user = userEvent.setup(); const others = screen.getByRole("button", { name: "3 others" }); const list = async () => { diff --git a/tests/browser/membership.spec.mjs b/tests/browser/membership.spec.mjs index c4db0315f4..c7a0e889f8 100644 --- a/tests/browser/membership.spec.mjs +++ b/tests/browser/membership.spec.mjs @@ -360,6 +360,33 @@ burstTest( await others.press("Enter"); await expect(popup).toBeVisible(); await expect(people).toBeFocused(); + // Names rest in the list's own color; Tab reaches the first one, whose + // ring and fill fit inside the scrollport instead of being clipped. + const first = people.getByRole("button").first(); + await expect(first).toHaveCSS( + "color", + await people.evaluate((list) => getComputedStyle(list).color), + ); + await page.keyboard.press("Tab"); + await expect(first).toBeFocused(); + await expect(first).toHaveCSS("outline-style", "solid"); + const clipped = await first.evaluate((entry) => { + const list = entry.closest("section"); + const port = list.getBoundingClientRect(); + const box = entry.getBoundingClientRect(); + const style = getComputedStyle(entry); + const ring = + parseFloat(style.outlineOffset) + parseFloat(style.outlineWidth); + return Math.max( + port.left - (box.left - ring), + port.top - (box.top - ring), + box.right + ring - (port.left + list.clientWidth), + ); + }); + expect(clipped).toBeLessThanOrEqual(0); + await popup.screenshot({ + path: testInfo.outputPath("membership-people-focus.png"), + }); await people.press("End"); await expect(people.getByRole("listitem").last()).toBeInViewport(); await page.keyboard.press("Escape"); From b65b4694d4dec527d96f3565d250ba29ed4bbbd3 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 4 Nov 2026 17:40:45 +1100 Subject: [PATCH 03/12] fix(people): return focus to the Solo opener when its profile closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing a profile opened from Solo Share access, either the header button or Chat actions → Share access, left focus on the "Chat " section instead of the control that opened the dialog. Team Manage members already returns there. The main-pane SoloHeading is keyed on navigation.entryId. Since #784 (moved to SoloHeading in #1273) each visit remounts the header. That ends the visit's header dialogs, menus and toasts, which are scoped to its signal and don't close themselves on abort. The key stays. Opening the profile is a new visit, so the header remounts and detaches the opener that panelTrigger holds. afterClose then falls back to the history section. The dismissal's async return to the chat remounts the header again. This follows keptTabReturn. onClosePanel records a dismissal-scoped return when the panel's opener is detached and marked data-solo-opener. The visit layout effect consumes it only on this dismissal's matching return, and only while focus is still on the history section. It then focuses the same control in the remounted header. A Solo side tab's toolbar isn't keyed per visit. When its profile closes, the pane keeps the chat's tab and focus stays with it, so that outcome is unchanged. The RTL tests "opens a profile from the header's Share access and returns focus to it" and the Chat actions variant passed despite the bug. Their fixture's navigation is static (entryId "visit", open is a vi.fn), so no visit is ever applied. The header never remounted and the original trigger stayed connected. Their focus assertions are removed and the header test is renamed. A new solo-workspace.spec.mjs journey covers both openers across real navigation entries. It also asserts that opening the profile changes the entry. On 7ae9be938 its two focus assertions fail in Chromium and WebKit, and they pass with this change. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- src/bundled/channels/ChannelMembersDialog.tsx | 2 + .../channels/ChannelsPage.solo.test.tsx | 9 ++- src/bundled/channels/ChannelsPage.tsx | 42 +++++++++++--- src/bundled/channels/SoloHeading.tsx | 1 + tests/browser/solo-workspace.spec.mjs | 58 +++++++++++++++++++ 5 files changed, 100 insertions(+), 12 deletions(-) diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx index b9aa9626f3..9817e47f4e 100644 --- a/src/bundled/channels/ChannelMembersDialog.tsx +++ b/src/bundled/channels/ChannelMembersDialog.tsx @@ -458,6 +458,7 @@ export function ChannelMembersButton({ {conversationName !== undefined ? ( { +// This fixture's visit never changes, so the header never remounts here as it +// does for each visit in the app. solo-workspace.spec.mjs covers focus +// returning to the opener when the profile closes. +it("opens a profile from the header's Share access", async () => { const person = "e".repeat(64); const f = fixture({ people: [person] }); // The dialog lists members once their names settle. @@ -1402,8 +1405,6 @@ it("opens a profile from the header's Share access and returns focus to it", asy ); expect(screen.queryByRole("dialog")).toBeNull(); expect(await screen.findByText("Profile content")).toBeVisible(); - await user.click(screen.getByRole("button", { name: "Close Profile tab" })); - await waitFor(() => expect(trigger).toHaveFocus()); }); it("offers the team's member actions from Chat actions → Share access", async () => { @@ -1467,8 +1468,6 @@ it("offers the team's member actions from Chat actions → Share access", async ); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); expect(await screen.findByText("Profile content")).toBeVisible(); - await user.click(screen.getByRole("button", { name: "Close Profile tab" })); - await waitFor(() => expect(trigger).toHaveFocus()); }); it("marks an archived chat once and keeps only its lifecycle actions", async () => { diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index f1ef4078ef..e3b5af2ccd 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1206,6 +1206,14 @@ function ChannelWorkspace({ target: targetKey(fallback), }; }; + // The main Solo header remounts for each visit, ending that visit's dialogs + // and menus, so a panel opened from it outlives its opener. The same control + // in the header of the dismissal's return takes focus back instead. + const soloOpenerReturn = useRef<{ + from: string; + target: string; + control: string; + }>(undefined); // Member lists open profiles and DMs through this page. Side-tab Solo // toolbars render inside useConversationTabs, before canOpenLink and // openLink are declared, so these read them only when called. @@ -1295,13 +1303,17 @@ function ChannelWorkspace({ if (!historyPanel(entry)) return; const pane = entryPane(tabState.mainEntries, entry); const fallback = closeTarget(pane, remaining); - if (fallback) - dismissContent( - tabState.detailVisits(entry.channelId, entry.target), - fallback, - pane, - remaining, - ); + if (!fallback || !navigator) return; + const visits = tabState.detailVisits(entry.channelId, entry.target); + const from = navigator.snapshot().entry.id; + const control = panelTrigger.current?.isConnected + ? undefined + : panelTrigger.current?.dataset.soloOpener; + soloOpenerReturn.current = + control && visits.includes(from) + ? { from, target: targetKey(fallback), control } + : undefined; + dismissContent(visits, fallback, pane, remaining); }, onCloseThread: (tab, active, last) => { // X is explicit dismissal; Back/Forward never comes through this path. @@ -1514,6 +1526,22 @@ function ChannelWorkspace({ focusKept.current = kept.id; } } + const opener = soloOpenerReturn.current; + if (opener && navigation.entryId !== opener.from) { + soloOpenerReturn.current = undefined; + // Only while focus waits on the chat, where the emptied pane left it. + // The main header precedes any side tab's toolbar. + if ( + targetKey(navigation.target) === opener.target && + document.activeElement === historyContainer.current + ) + historyContainer.current + ?.closest("[data-solo-focus-pane]") + ?.querySelector( + `[data-solo-opener="${opener.control}"]`, + ) + ?.focus({ preventScroll: true }); + } if (navigation.focus === "preserve") setPreservedVisit(navigation); } const activity = handoff?.activityThread.current; diff --git a/src/bundled/channels/SoloHeading.tsx b/src/bundled/channels/SoloHeading.tsx index c24648bc39..b44b07013b 100644 --- a/src/bundled/channels/SoloHeading.tsx +++ b/src/bundled/channels/SoloHeading.tsx @@ -176,6 +176,7 @@ export function SoloToolbar({ { + const teammate = generateSecretKey(); + test.use({ sessionMembers: { [id]: [getPublicKey(teammate)] } }); + test("closing a profile opened from Share access returns focus to its opener", async ({ + page, + app, + }) => { + app.serveProfile(teammate, { name: "Sam Rivera" }); + await page.goto(app.origin); + await newChat(page); + await page + .getByRole("navigation", { name: "Solo conversations" }) + .locator(`[data-solo-channel-id="${id}"]`) + .click(); + const conversation = page.getByRole("article", { name: "Conversation" }); + const share = conversation.getByRole("button", { + name: "Share access", + exact: true, + }); + const actions = conversation.getByRole("button", { + name: "Chat actions", + exact: true, + }); + const dialog = page.getByRole("dialog", { + name: "Share access", + exact: true, + }); + const name = dialog.getByRole("button", { + name: /^Open profile for Sam Rivera/, + }); + const tab = page.getByRole("tab", { name: "Sam Rivera", exact: true }); + const entry = () => + page.evaluate(() => history.state.buzzNavigationV1.entry.id); + // The header button, a name chosen by keyboard and Escape on the tab. + await share.click(); + const visit = await entry(); + await name.press("Enter"); + await expect(dialog).toHaveCount(0); + await expect(tab).toBeFocused(); + expect(await entry()).not.toBe(visit); + await tab.press("Escape"); + await expect(tab).toHaveCount(0); + await expect(share).toBeFocused(); + // Chat actions, a name chosen by pointer and the tab's Close button. + await actions.click(); + await page + .getByRole("menuitem", { name: "Share access", exact: true }) + .click(); + await name.click(); + await expect(dialog).toHaveCount(0); + await expect(tab).toBeFocused(); + await closeProfile(page).click(); + await expect(tab).toHaveCount(0); + await expect(actions).toBeFocused(); + }); +}); // Real hover, menu hit targets and local-panel handoff cannot be proved in jsdom. test("Solo sidebar shares working activity, keyboard preview and local panel navigation", async ({ page, From 418b844fe89c3086944e782caaf731c32fdad23e Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 4 Nov 2026 18:11:41 +1100 Subject: [PATCH 04/12] fix(people): return Solo profile focus only to the opener that opened it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing a profile could focus a Share access or Chat actions control that never opened it. 9aba9a238 chose the control from panelTrigger when the profile closed. That ref lives as long as the session's ChannelWorkspace, and a detail visit re-applied by Back/Forward doesn't refresh it. Open a profile from a message author in Solo chat B, then Sam's profile from chat A's Share access. Back twice shows B's profile again, and Escape focused B's Share access instead of B's history section. openLink now records a Solo header's opener when it opens a panel, while the opener is still connected. The record keeps the opening's chat and target, plus the side tab whose toolbar held it. Every onClosePanel consumes the record. It applies only to the panel it opened, with the same chat and target, while it is still the panel's trigger and detached from its header. The dismissal's return keeps its existing gates. Side tabs change behaviour. 9aba9a238 left focus on a Solo side tab's own tab when a profile opened from its toolbar closed. Now the toolbar's Share access or Chat actions takes it back, as the main header's does. This follows onCloseThread: while tabs remain, an opener takes focus only from within their pane. The profile covers the chat's tab and unmounts its toolbar. On close the dock reselects and focuses the chat's tab. On the dismissal's return, and only while focus is still on that tab, the same control in the dock's remounted toolbar takes focus in place of focusKept's tab focus. No rule in docs/, AGENTS.md or the tab strip keeps focus on the tabs. The side-tab RTL comment that said so came from 764e4c261. The lookups stay apart. The main one searches [data-solo-focus-pane]. Side-tab toolbars live in the PanelDock, outside it, which the lookup comment now says instead of implying document order. The side lookup starts from the focused tab's [data-panel-workspace], which shows only its selected tab's toolbar. Tests: two solo-workspace.spec.mjs journeys. One restores Sam with Back then Forward and closes him, and focus returns to Share access. It then goes Back to Beta's author profile and closes it. On 9aba9a238 focus lands on Beta's Share access, failing the Chat Beta assertion in Chromium and WebKit. The other opens Beta in a side tab of team channel Gamma and closes profiles opened from its toolbar's Share access and Chat actions. On 9aba9a238 focus stays on the Beta tab, and both focus assertions fail in both engines. The side-tab RTL test drops its Gamma tab focus assertion, because its fixture profile isn't a history panel and its visit never changes. It now covers opening from both Share access and Chat actions → Share access. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- .../channels/ChannelsPage.tabs.test.tsx | 58 ++++---- src/bundled/channels/ChannelsPage.tsx | 83 ++++++++--- tests/browser/solo-workspace.spec.mjs | 137 +++++++++++++++++- 3 files changed, 229 insertions(+), 49 deletions(-) diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx index 1021a0bd1f..4c182b5726 100644 --- a/src/bundled/channels/ChannelsPage.tabs.test.tsx +++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx @@ -946,33 +946,37 @@ it("gives a Solo chat's side tab its own header actions, opening it in the main expect(screen.queryByRole("button", { name: "Chat actions" })).toBeNull(); }); -it("opens a profile from a Solo side tab's Share access", async () => { - const h = await page("stream", { - open: vi.fn(async () => ({ status: "opened" as const })), - snapshot: () => ({ entry: { id: "visit" }, attempt: {} }), - } as unknown as Navigation); - const tabs = renderHook(() => useChannelTabState(h.session, "alpha")); - act(() => - tabs.result.current.setTabs((items) => [ - ...items, - { id: "conversation:gamma", kind: "conversation", channelId: "gamma" }, - ]), - ); - await h.user.click(await screen.findByRole("tab", { name: "Gamma" })); - await h.user.click( - await screen.findByRole("button", { name: "Share access" }), - ); - const dialog = await screen.findByRole("dialog", { name: /Share access/ }); - await h.user.click( - await within(dialog).findByRole("button", { name: /^Open profile for/ }), - ); - expect(screen.queryByRole("dialog")).toBeNull(); - expect(await screen.findByText("Profile content")).toBeVisible(); - await h.user.click(screen.getByRole("button", { name: "Close Profile tab" })); - // The profile replaced Gamma in the pane, unmounting its toolbar. While a - // pane keeps tabs, closing one leaves focus with them. - expect(screen.getByRole("tab", { name: "Gamma" })).toHaveFocus(); -}); +// This fixture's profile isn't a history panel and its visit never changes, so +// closing it has no dismissal's return. solo-workspace.spec.mjs covers focus +// returning to the side tab's opener when the profile closes. +it.each(["Share access", "Chat actions"])( + "opens a profile from a Solo side tab's %s", + async (opener) => { + const h = await page("stream", { + open: vi.fn(async () => ({ status: "opened" as const })), + snapshot: () => ({ entry: { id: "visit" }, attempt: {} }), + } as unknown as Navigation); + const tabs = renderHook(() => useChannelTabState(h.session, "alpha")); + act(() => + tabs.result.current.setTabs((items) => [ + ...items, + { id: "conversation:gamma", kind: "conversation", channelId: "gamma" }, + ]), + ); + await h.user.click(await screen.findByRole("tab", { name: "Gamma" })); + await h.user.click(await screen.findByRole("button", { name: opener })); + if (opener === "Chat actions") + await h.user.click( + await screen.findByRole("menuitem", { name: "Share access" }), + ); + const dialog = await screen.findByRole("dialog", { name: /Share access/ }); + await h.user.click( + await within(dialog).findByRole("button", { name: /^Open profile for/ }), + ); + await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(await screen.findByText("Profile content")).toBeVisible(); + }, +); it("keeps a side tab's failed move recoverable after switching tabs", async () => { const base = { diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index e3b5af2ccd..e826604300 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1207,12 +1207,20 @@ function ChannelWorkspace({ }; }; // The main Solo header remounts for each visit, ending that visit's dialogs - // and menus, so a panel opened from it outlives its opener. The same control - // in the header of the dismissal's return takes focus back instead. + // and menus, and a side tab's toolbar unmounts while a panel covers its + // chat. A panel opened from either outlives its opener, so the same control + // in that chat's header takes focus back on the dismissal's return. + const soloOpener = useRef<{ + trigger: HTMLElement; + channelId: string; + target: string; + tab: string | undefined; + }>(undefined); const soloOpenerReturn = useRef<{ from: string; target: string; control: string; + tab: string | undefined; }>(undefined); // Member lists open profiles and DMs through this page. Side-tab Solo // toolbars render inside useConversationTabs, before canOpenLink and @@ -1300,18 +1308,26 @@ function ChannelWorkspace({ ); }, onClosePanel: (entry, remaining) => { + // Any close consumes the recorded opener. Only the panel it opened, while + // it is still that panel's trigger, returns focus to it. + const opener = soloOpener.current; + soloOpener.current = undefined; if (!historyPanel(entry)) return; const pane = entryPane(tabState.mainEntries, entry); const fallback = closeTarget(pane, remaining); if (!fallback || !navigator) return; const visits = tabState.detailVisits(entry.channelId, entry.target); const from = navigator.snapshot().entry.id; - const control = panelTrigger.current?.isConnected - ? undefined - : panelTrigger.current?.dataset.soloOpener; + const control = + opener?.channelId === entry.channelId && + opener.target === entry.target && + opener.trigger === panelTrigger.current && + !opener.trigger.isConnected + ? opener.trigger.dataset.soloOpener + : undefined; soloOpenerReturn.current = control && visits.includes(from) - ? { from, target: targetKey(fallback), control } + ? { from, target: targetKey(fallback), control, tab: opener?.tab } : undefined; dismissContent(visits, fallback, pane, remaining); }, @@ -1529,18 +1545,30 @@ function ChannelWorkspace({ const opener = soloOpenerReturn.current; if (opener && navigation.entryId !== opener.from) { soloOpenerReturn.current = undefined; - // Only while focus waits on the chat, where the emptied pane left it. - // The main header precedes any side tab's toolbar. - if ( - targetKey(navigation.target) === opener.target && - document.activeElement === historyContainer.current - ) - historyContainer.current - ?.closest("[data-solo-focus-pane]") - ?.querySelector( - `[data-solo-opener="${opener.control}"]`, - ) - ?.focus({ preventScroll: true }); + // Only while focus waits where the close left it: on the chat once the + // pane emptied, or on the chat's tab that its pane selected instead. + // Side-tab toolbars live in the PanelDock, outside the main pane, and + // the dock shows only its selected tab's toolbar. + const focused = document.activeElement; + const pane = opener.tab + ? focused instanceof HTMLElement && + focused.dataset.tabValue === opener.tab + ? focused.closest("[data-panel-workspace]") + : null + : focused === historyContainer.current + ? historyContainer.current?.closest("[data-solo-focus-pane]") + : null; + const control = + targetKey(navigation.target) === opener.target + ? pane?.querySelector( + `[data-solo-opener="${opener.control}"]`, + ) + : undefined; + if (control) { + control.focus({ preventScroll: true }); + // The opener, rather than its chat's tab, takes focus back. + focusKept.current = undefined; + } } if (navigation.focus === "preserve") setPreservedVisit(navigation); } @@ -1841,11 +1869,27 @@ function ChannelWorkspace({ const next = channelId && panelOpening(panels, channelId, url, rootId); if (next) { setSettings(undefined); - panelTrigger.current = + const trigger = returnFocus ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null); + panelTrigger.current = trigger; + // Record a Solo header's opener while it is connected to its header. + const header = trigger?.dataset.soloOpener + ? trigger.closest("[data-solo-focus-pane], [data-panel-workspace]") + : null; + soloOpener.current = + trigger && header + ? { + trigger, + channelId: next.channelId, + target: next.target, + tab: header.hasAttribute("data-panel-workspace") + ? tabState.getSelected() + : undefined, + } + : undefined; // Thread-origin details cover the live thread instead of retiring it. if (!fromThread && routedThread && !historyPanel(next)) select(channelId); @@ -1868,6 +1912,7 @@ function ChannelWorkspace({ // recreate openLink and re-render each memoized message row. tabState.setTabs, tabState.select, + tabState.getSelected, tabState.leaveVisit, panelTrigger, ], diff --git a/tests/browser/solo-workspace.spec.mjs b/tests/browser/solo-workspace.spec.mjs index 958e3f49ee..ba76a2c02c 100644 --- a/tests/browser/solo-workspace.spec.mjs +++ b/tests/browser/solo-workspace.spec.mjs @@ -233,11 +233,17 @@ test("Solo chat Activity opens the agent's profile locally, restores per convers await closeSettings(page); await expect(trigger).toHaveCount(0); }); -// Browser-only: opening and closing the profile are real navigation visits, -// and the Solo header remounts for each one. A static jsdom visit does not. +// Browser-only: opening and closing the profile are real navigation visits. +// The main Solo header remounts for each one, and focus returns to the opener +// on the close's return visit. A static jsdom visit has neither. test.describe("Share access profiles", () => { const teammate = generateSecretKey(); - test.use({ sessionMembers: { [id]: [getPublicKey(teammate)] } }); + test.use({ + sessionMembers: { + [id]: [getPublicKey(teammate)], + [other]: [getPublicKey(teammate)], + }, + }); test("closing a profile opened from Share access returns focus to its opener", async ({ page, app, @@ -290,6 +296,131 @@ test.describe("Share access profiles", () => { await expect(tab).toHaveCount(0); await expect(actions).toBeFocused(); }); + test("a profile restored by Back or Forward returns focus only to its own opener", async ({ + page, + app, + }) => { + app.serveProfile(teammate, { name: "Sam Rivera" }); + await page.goto(app.origin); + await newChat(page); + const sidebar = page.getByRole("navigation", { + name: "Solo conversations", + }); + const conversation = page.getByRole("article", { name: "Conversation" }); + const share = conversation.getByRole("button", { + name: "Share access", + exact: true, + }); + const sam = page.getByRole("tab", { name: "Sam Rivera", exact: true }); + const profile = page.getByRole("tab", { + name: "Fixture Reader", + exact: true, + }); + // Beta: the viewer's profile, opened from a message's author. + await sidebar.locator(`[data-solo-channel-id="${other}"]`).click(); + await conversation + .getByRole("button", { name: "View Fixture Reader profile", exact: true }) + .first() + .click(); + await expect(profile).toBeFocused(); + // Alpha: Sam's profile, opened from Share access. + await sidebar.locator(`[data-solo-channel-id="${id}"]`).click(); + await share.click(); + await page + .getByRole("dialog", { name: "Share access", exact: true }) + .getByRole("button", { name: /^Open profile for Sam Rivera/ }) + .press("Enter"); + await expect(sam).toBeFocused(); + // Back then Forward restores Sam; closing it still returns to Share access. + await page.goBack(); + await expect(sam).toHaveCount(0); + await page.goForward(); + await expect(sam).toBeVisible(); + await sam.press("Escape"); + await expect(sam).toHaveCount(0); + await expect(share).toBeFocused(); + // Back restores Beta's profile. Alpha's Share access never opened it, so + // Beta's does not take focus when it closes. + await page.goBack(); + await expect( + page.getByRole("region", { name: "Chat Beta", exact: true }), + ).toBeVisible(); + await expect(profile).toHaveAttribute("aria-selected", "true"); + await profile.press("Escape"); + await expect(profile).toHaveCount(0); + await expect( + page.getByRole("region", { name: "Chat Beta", exact: true }), + ).toBeFocused(); + }); + test("closing a profile opened from a Solo side tab returns focus to its opener", async ({ + page, + app, + }) => { + app.serveProfile(teammate, { name: "Sam Rivera" }); + await page.goto(app.origin); + await openPage(page, "Messages"); + await page.locator('[data-channel-id="gamma"]').click(); + await expect + .poll(() => + app.report.liveRequests.some((request) => + request.filters.some((filter) => filter["#h"]?.includes("gamma")), + ), + ) + .toBe(true); + // A team channel's link opens the Solo chat Beta in a side tab. + const link = app.append( + "primary", + "gamma", + `[Open Beta](buzz://channel/${other})`, + ); + await page + .locator(`[data-message-id="${link.id}"]`) + .getByRole("link", { name: "Open Chat: Beta", exact: true }) + .click(); + const beta = page.getByRole("tab", { name: "Beta", exact: true }); + await expect(beta).toHaveAttribute("aria-selected", "true"); + // The side tab's toolbar renders in its dock, outside the tab panel. + const dock = page.locator("[data-panel-workspace]"); + const share = dock.getByRole("button", { + name: "Share access", + exact: true, + }); + const actions = dock.getByRole("button", { + name: "Chat actions", + exact: true, + }); + const dialog = page.getByRole("dialog", { + name: "Share access", + exact: true, + }); + const name = dialog.getByRole("button", { + name: /^Open profile for Sam Rivera/, + }); + const tab = page.getByRole("tab", { name: "Sam Rivera", exact: true }); + // The toolbar button, a name chosen by keyboard and Escape on the tab. + await share.click(); + await name.press("Enter"); + await expect(dialog).toHaveCount(0); + await expect(tab).toBeFocused(); + await expect(share).toHaveCount(0); + await tab.press("Escape"); + await expect(tab).toHaveCount(0); + await expect(beta).toHaveAttribute("aria-selected", "true"); + await expect(share).toBeFocused(); + // Chat actions, a name chosen by pointer and the tab's Close button. + await actions.click(); + await page + .getByRole("menuitem", { name: "Share access", exact: true }) + .click(); + await name.click(); + await expect(dialog).toHaveCount(0); + await expect(tab).toBeFocused(); + await page + .getByRole("button", { name: "Close Sam Rivera tab", exact: true }) + .click(); + await expect(tab).toHaveCount(0); + await expect(actions).toBeFocused(); + }); }); // Real hover, menu hit targets and local-panel handoff cannot be proved in jsdom. test("Solo sidebar shares working activity, keyboard preview and local panel navigation", async ({ From 693bff3328267139fa2f9736bb87273ba2597320 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 4 Nov 2026 18:38:36 +1100 Subject: [PATCH 05/12] fix(people): keep the Solo opener through a profile's own openings dc922d455 records a Solo header's opener and lets only the closing panel it opened return focus. Two flows lost that return. An agent's profile opened from Share access can open its owner's ("Open owner profile"). The owner's profile replaces the agent's tab: the push records a visit, and applying it reopens the panel alone. Its close never matched the record's target, so focus went to the history section. 9aba9a238 returned it to Share access, like team Manage members. visitPanel now hands the record to a panel opened in place of the recorded one, in the same chat. Every close consumed the record, even of an unrelated tab. A Solo side tab's profile can sit beside a retained channel tool, such as Gamma's Canvas. Closing the Canvas cleared the record, and the profile's close left focus on the Beta tab. Now only a close matching the record's chat and target consumes it. The channel, target and trigger-identity checks still reject a stale record. The review's repro, closing the owner's tab and then the agent's, does not occur in either Solo host, since the owner's profile replaces the agent's. The review's clear-on-match fix alone leaves the nested step failing. serveProfile takes an optional owner key and signs a real NIP-OA attestation. The fixture's soloAgentNames carry a placeholder signature, so their profiles show no "Managed by". Tests (solo-workspace.spec.mjs, Chromium and WebKit): - The Share access journey opens Scout, an agent Sam manages, then Sam from Scout's profile, and closes Sam. Focus returns to Share access. It fails on dc922d455 and with only the clear-on-match change, and passes on 9aba9a238. - The side-tab journey opens Gamma's Canvas, then Sam from Beta's Share access, closes Canvas, then Sam. Focus returns to Share access. It fails on dc922d455 and with only the hand-over. - The Back/Forward journey reads the entry before Escape and polls for the dismissal's return before asserting Chat Beta stays focused. On 9aba9a238 that assertion now fails, with focus on Beta's Share access. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- src/bundled/channels/ChannelsPage.tsx | 20 +++++++++-- tests/browser/fixture.mjs | 20 +++++++++-- tests/browser/solo-workspace.spec.mjs | 51 ++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index e826604300..9121e87e7d 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1090,6 +1090,15 @@ function ChannelWorkspace({ target.detail.rootId === entry.rootId ) return; + // A panel opened in place of a Solo opener's panel, such as an agent's + // owner from its profile, takes over that opener. + const opener = soloOpener.current; + if ( + opener?.channelId === entry.channelId && + target.channelId === entry.channelId && + opener.target === target.detail?.target + ) + soloOpener.current = { ...opener, target: entry.target }; tabState.leaveVisit(); void navigator.open({ version: 1, @@ -1308,10 +1317,15 @@ function ChannelWorkspace({ ); }, onClosePanel: (entry, remaining) => { - // Any close consumes the recorded opener. Only the panel it opened, while - // it is still that panel's trigger, returns focus to it. + // Only closing the panel it opened consumes the recorded opener; a tab + // closed beside that panel leaves it. Only while it is still that + // panel's trigger does it take focus back. const opener = soloOpener.current; - soloOpener.current = undefined; + if ( + opener?.channelId === entry.channelId && + opener.target === entry.target + ) + soloOpener.current = undefined; if (!historyPanel(entry)) return; const pane = entryPane(tabState.mainEntries, entry); const fallback = closeTarget(pane, remaining); diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 1ec0cbbc53..f24b10bedb 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -2744,8 +2744,24 @@ export const test = base.extend({ return { event, plaintext, agent }; }, // Answer later kind-0 reads for this key; no live delivery is modeled. - serveProfile(key, body) { - const event = sign(0, [], JSON.stringify(body), key); + // An owner key adds its signed NIP-OA attestation. + serveProfile(key, body, owner) { + const digest = + owner && + createHash("sha256") + .update(`nostr:agent-auth:${getPublicKey(key)}:`) + .digest(); + const tags = digest + ? [ + [ + "auth", + getPublicKey(owner), + "", + bytesToHex(schnorr.sign(new Uint8Array(digest), owner)), + ], + ] + : []; + const event = sign(0, tags, JSON.stringify(body), key); servedProfiles.set(event.pubkey, event); return event; }, diff --git a/tests/browser/solo-workspace.spec.mjs b/tests/browser/solo-workspace.spec.mjs index ba76a2c02c..620786b3ee 100644 --- a/tests/browser/solo-workspace.spec.mjs +++ b/tests/browser/solo-workspace.spec.mjs @@ -238,9 +238,11 @@ test("Solo chat Activity opens the agent's profile locally, restores per convers // on the close's return visit. A static jsdom visit has neither. test.describe("Share access profiles", () => { const teammate = generateSecretKey(); + // An agent Sam manages, so its profile links to Sam's. + const scout = generateSecretKey(); test.use({ sessionMembers: { - [id]: [getPublicKey(teammate)], + [id]: [getPublicKey(teammate), getPublicKey(scout)], [other]: [getPublicKey(teammate)], }, }); @@ -249,6 +251,7 @@ test.describe("Share access profiles", () => { app, }) => { app.serveProfile(teammate, { name: "Sam Rivera" }); + app.serveProfile(scout, { name: "Scout", is_agent: true }, teammate); await page.goto(app.origin); await newChat(page); await page @@ -295,6 +298,26 @@ test.describe("Share access profiles", () => { await closeProfile(page).click(); await expect(tab).toHaveCount(0); await expect(actions).toBeFocused(); + // An agent's profile opens its owner's in its place. Closing the owner's + // still returns to Share access. + const agent = page.getByRole("tab", { name: "Scout", exact: true }); + await share.click(); + await dialog + .getByRole("button", { name: /^Open profile for Scout\b/ }) + .press("Enter"); + await expect(dialog).toHaveCount(0); + await expect(agent).toBeFocused(); + await agentProfile(page) + .getByRole("button", { + name: "Open owner profile: Sam Rivera", + exact: true, + }) + .click(); + await expect(tab).toBeFocused(); + await expect(agent).toHaveCount(0); + await tab.press("Escape"); + await expect(tab).toHaveCount(0); + await expect(share).toBeFocused(); }); test("a profile restored by Back or Forward returns focus only to its own opener", async ({ page, @@ -316,6 +339,8 @@ test.describe("Share access profiles", () => { name: "Fixture Reader", exact: true, }); + const entry = () => + page.evaluate(() => history.state.buzzNavigationV1.entry.id); // Beta: the viewer's profile, opened from a message's author. await sidebar.locator(`[data-solo-channel-id="${other}"]`).click(); await conversation @@ -346,8 +371,11 @@ test.describe("Share access profiles", () => { page.getByRole("region", { name: "Chat Beta", exact: true }), ).toBeVisible(); await expect(profile).toHaveAttribute("aria-selected", "true"); + const restored = await entry(); await profile.press("Escape"); await expect(profile).toHaveCount(0); + // The dismissal's return is where a stale opener would take focus. + await expect.poll(entry).not.toBe(restored); await expect( page.getByRole("region", { name: "Chat Beta", exact: true }), ).toBeFocused(); @@ -420,6 +448,27 @@ test.describe("Share access profiles", () => { .click(); await expect(tab).toHaveCount(0); await expect(actions).toBeFocused(); + // Gamma's Canvas closes beside the profile and leaves Share access to the + // profile's own close. + await page + .getByRole("button", { name: "Channel actions", exact: true }) + .click(); + await page + .getByRole("menuitem", { name: "View canvas", exact: true }) + .click(); + const canvas = page.getByRole("tab", { name: "Canvas", exact: true }); + await expect(canvas).toHaveAttribute("aria-selected", "true"); + await beta.click(); + await share.click(); + await name.press("Enter"); + await expect(tab).toBeFocused(); + await page + .getByRole("button", { name: "Close Canvas tab", exact: true }) + .click(); + await expect(canvas).toHaveCount(0); + await tab.press("Escape"); + await expect(tab).toHaveCount(0); + await expect(share).toBeFocused(); }); }); // Real hover, menu hit targets and local-panel handoff cannot be proved in jsdom. From 07a4aee29fb6305db08f00c4e4e3898db6aeb2e9 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 4 Nov 2026 19:05:55 +1100 Subject: [PATCH 06/12] fix(people): key the Solo opener on the visits it opened d7aa71060 records a Solo header's opener with its chat and profile target, so it can't tell two visits of the same profile apart. Wrong opener: open Sam from a message author (V1). With Sam open, open Scout from Share access (V2), then "Open owner profile: Sam Rivera" (V3). Go Back twice to V1 and press Escape. Focus went to Share access, though the author opened V1. Without the handover, author Sam, author Fixture Reader, Share access Sam, Back twice and Escape did the same. Lost opener: Share access opens Scout (V1), and the owner link opens Sam (V2), which moved the record to Sam. Go Back to V1 and press Escape. Focus landed on the history section instead of Share access. Closing Sam first, then going Back to Scout and pressing Escape, lost it too, because Sam's close consumed the whole record. The record now holds the navigation visits its opener produced, not a channel and target. openLink records the visit that shows the panel once open() returns, whether pushed or already current. Both host drivers publish pushes synchronously. visitPanel adds a visit pushed from one of the record's visits in the same chat, which covers the owner handover. onClosePanel returns focus only when the closing visit is in the record, together with the existing trigger-identity and detached checks. Each history-panel close removes the visits it dismisses and drops the record once none remain. A non-history close, such as Gamma's Canvas, leaves it. The channelId and target fields and the target-based handover go. Tests (solo-workspace.spec.mjs, Chromium and WebKit): - The Share access journey, after closing Sam, goes Back to Scout and presses Escape. It then reopens Scout, follows the owner link, goes Back to Scout and presses Escape. Both expect Share access focused. - The Back/Forward journey publishes a message from Sam in Alpha and runs both wrong-opener sequences. Each polls entry() past the dismissal's return, then expects Chat Alpha focused. A soft-assertion copy run against d7aa71060's ChannelsPage.tsx fails exactly these four steps in both engines and no others. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- src/bundled/channels/ChannelsPage.tsx | 70 ++++++++---------- tests/browser/solo-workspace.spec.mjs | 101 +++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 40 deletions(-) diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 9121e87e7d..364edd808f 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1090,15 +1090,7 @@ function ChannelWorkspace({ target.detail.rootId === entry.rootId ) return; - // A panel opened in place of a Solo opener's panel, such as an agent's - // owner from its profile, takes over that opener. - const opener = soloOpener.current; - if ( - opener?.channelId === entry.channelId && - target.channelId === entry.channelId && - opener.target === target.detail?.target - ) - soloOpener.current = { ...opener, target: entry.target }; + const from = navigator.snapshot().entry.id; tabState.leaveVisit(); void navigator.open({ version: 1, @@ -1110,6 +1102,11 @@ function ChannelWorkspace({ ...(entry.rootId ? { rootId: entry.rootId } : {}), }, }); + // A panel opened in place of a Solo opener's panel, such as an agent's + // owner from its profile, is that opener's visit too. + const opener = soloOpener.current; + if (target.channelId === entry.channelId && opener?.visits.includes(from)) + opener.visits.push(navigator.snapshot().entry.id); }, [navigator, tabState.leaveVisit], ); @@ -1218,12 +1215,12 @@ function ChannelWorkspace({ // The main Solo header remounts for each visit, ending that visit's dialogs // and menus, and a side tab's toolbar unmounts while a panel covers its // chat. A panel opened from either outlives its opener, so the same control - // in that chat's header takes focus back on the dismissal's return. + // in that chat's header takes focus back on the dismissal's return. Only a + // visit it opened does: Back can restore the same panel from another opener. const soloOpener = useRef<{ trigger: HTMLElement; - channelId: string; - target: string; tab: string | undefined; + visits: string[]; }>(undefined); const soloOpenerReturn = useRef<{ from: string; @@ -1317,32 +1314,30 @@ function ChannelWorkspace({ ); }, onClosePanel: (entry, remaining) => { - // Only closing the panel it opened consumes the recorded opener; a tab - // closed beside that panel leaves it. Only while it is still that - // panel's trigger does it take focus back. - const opener = soloOpener.current; - if ( - opener?.channelId === entry.channelId && - opener.target === entry.target - ) - soloOpener.current = undefined; if (!historyPanel(entry)) return; const pane = entryPane(tabState.mainEntries, entry); const fallback = closeTarget(pane, remaining); if (!fallback || !navigator) return; const visits = tabState.detailVisits(entry.channelId, entry.target); const from = navigator.snapshot().entry.id; + // The recorded opener takes focus back only from a visit it opened, and + // only while it is still that panel's trigger. The dismissal retires + // this panel's visits; once none of its own remain, it is forgotten. + const opener = soloOpener.current; const control = - opener?.channelId === entry.channelId && - opener.target === entry.target && + opener?.visits.includes(from) && + visits.includes(from) && opener.trigger === panelTrigger.current && !opener.trigger.isConnected ? opener.trigger.dataset.soloOpener : undefined; - soloOpenerReturn.current = - control && visits.includes(from) - ? { from, target: targetKey(fallback), control, tab: opener?.tab } - : undefined; + if (opener) { + opener.visits = opener.visits.filter((id) => !visits.includes(id)); + if (!opener.visits.length) soloOpener.current = undefined; + } + soloOpenerReturn.current = control + ? { from, target: targetKey(fallback), control, tab: opener?.tab } + : undefined; dismissContent(visits, fallback, pane, remaining); }, onCloseThread: (tab, active, last) => { @@ -1889,25 +1884,22 @@ function ChannelWorkspace({ ? document.activeElement : null); panelTrigger.current = trigger; - // Record a Solo header's opener while it is connected to its header. + // Find a Solo header's opener while it is connected to its header. const header = trigger?.dataset.soloOpener ? trigger.closest("[data-solo-focus-pane], [data-panel-workspace]") : null; - soloOpener.current = - trigger && header - ? { - trigger, - channelId: next.channelId, - target: next.target, - tab: header.hasAttribute("data-panel-workspace") - ? tabState.getSelected() - : undefined, - } - : undefined; + const tab = header?.hasAttribute("data-panel-workspace") + ? tabState.getSelected() + : undefined; // Thread-origin details cover the live thread instead of retiring it. if (!fromThread && routedThread && !historyPanel(next)) select(channelId); open(next, fromThread); + // The opener owns the visit now showing its panel, pushed or current. + soloOpener.current = + trigger && header && navigator && historyPanel(next) + ? { trigger, tab, visits: [navigator.snapshot().entry.id] } + : undefined; return true; } return false; diff --git a/tests/browser/solo-workspace.spec.mjs b/tests/browser/solo-workspace.spec.mjs index 620786b3ee..e5f81f0398 100644 --- a/tests/browser/solo-workspace.spec.mjs +++ b/tests/browser/solo-workspace.spec.mjs @@ -1,6 +1,11 @@ import { pauseClock } from "./clock.mjs"; import { pluginAction } from "./plugin-settings.mjs"; -import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools"; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, + verifyEvent, +} from "nostr-tools"; import { test, expect } from "./fixture.mjs"; import { closeSettings, @@ -318,12 +323,38 @@ test.describe("Share access profiles", () => { await tab.press("Escape"); await expect(tab).toHaveCount(0); await expect(share).toBeFocused(); + // The agent's visit stays in history. Back restores it, and closing it + // also returns to Share access. + await page.goBack(); + await expect(agent).toHaveAttribute("aria-selected", "true"); + await agent.press("Escape"); + await expect(agent).toHaveCount(0); + await expect(share).toBeFocused(); + // So does Back to it from the owner's visit, which stays in history. + await share.click(); + await dialog + .getByRole("button", { name: /^Open profile for Scout\b/ }) + .press("Enter"); + await expect(agent).toBeFocused(); + await agentProfile(page) + .getByRole("button", { + name: "Open owner profile: Sam Rivera", + exact: true, + }) + .click(); + await expect(tab).toBeFocused(); + await page.goBack(); + await expect(agent).toHaveAttribute("aria-selected", "true"); + await agent.press("Escape"); + await expect(agent).toHaveCount(0); + await expect(share).toBeFocused(); }); test("a profile restored by Back or Forward returns focus only to its own opener", async ({ page, app, }) => { app.serveProfile(teammate, { name: "Sam Rivera" }); + app.serveProfile(scout, { name: "Scout", is_agent: true }, teammate); await page.goto(app.origin); await newChat(page); const sidebar = page.getByRole("navigation", { @@ -379,6 +410,74 @@ test.describe("Share access profiles", () => { await expect( page.getByRole("region", { name: "Chat Beta", exact: true }), ).toBeFocused(); + // Alpha: Sam's profile, opened from his message's author. Share access + // later opens Sam's profile again, directly or through Scout's owner, but + // never the visit Back restores. + await sidebar.locator(`[data-solo-channel-id="${id}"]`).click(); + const chat = page.getByRole("region", { name: "Chat Alpha", exact: true }); + await expect(chat).toBeVisible(); + const message = finalizeEvent( + { + kind: 9, + created_at: Math.floor(Date.now() / 1000), + content: "Sam's message", + tags: [["h", id]], + }, + teammate, + ); + await expect(() => app.relay.publish("primary", message)).toPass(); + const author = conversation.getByRole("button", { + name: "View Sam Rivera profile", + exact: true, + }); + const dialog = page.getByRole("dialog", { + name: "Share access", + exact: true, + }); + const closeRestored = async () => { + await expect(sam).toHaveAttribute("aria-selected", "true"); + const restored = await entry(); + await sam.press("Escape"); + await expect(sam).toHaveCount(0); + await expect.poll(entry).not.toBe(restored); + await expect(chat).toBeFocused(); + }; + await author.click(); + await expect(sam).toBeFocused(); + await conversation + .getByRole("button", { name: "View Fixture Reader profile", exact: true }) + .first() + .click(); + await expect(profile).toBeFocused(); + await share.click(); + await dialog + .getByRole("button", { name: /^Open profile for Sam Rivera/ }) + .press("Enter"); + await expect(sam).toBeFocused(); + await page.goBack(); + await expect(profile).toHaveAttribute("aria-selected", "true"); + await page.goBack(); + await closeRestored(); + // Scout's owner link opens Sam in Scout's place, on Share access's behalf. + const scoutTab = page.getByRole("tab", { name: "Scout", exact: true }); + await author.click(); + await expect(sam).toBeFocused(); + await share.click(); + await dialog + .getByRole("button", { name: /^Open profile for Scout\b/ }) + .press("Enter"); + await expect(scoutTab).toBeFocused(); + await agentProfile(page) + .getByRole("button", { + name: "Open owner profile: Sam Rivera", + exact: true, + }) + .click(); + await expect(sam).toBeFocused(); + await page.goBack(); + await expect(scoutTab).toHaveAttribute("aria-selected", "true"); + await page.goBack(); + await closeRestored(); }); test("closing a profile opened from a Solo side tab returns focus to its opener", async ({ page, From 2bff4321a32c00be983fe53c5ab07b65ed14d753 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 4 Nov 2026 19:39:39 +1100 Subject: [PATCH 07/12] fix(people): return focus when Share access reopens the shown profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Choosing the profile that is already showing, from Solo Share access or Chat actions → Share access, lost focus to when that profile closed. Reopening it pushes no visit, so the main SoloHeading doesn't remount and the opener stays connected. onClosePanel recorded a return only for a detached opener, so it recorded none. afterClose focused the connected button, then the dismissal's return remounted the keyed header and removed it. The gap dates from 764e4c261. For a main-pane record, onClosePanel no longer requires a detached opener. The dismissal retires the current visit, so its return lands on another entry: the previous one, or a fresh replacement. SoloHeading's `${current.id}:${entryId}` key therefore always remounts the header. The return's layout effect, which runs in that commit, now accepts focus on as well as on the history section. The remount leaves it there when it removes the focused opener. Accepting leaves the earlier flows alone. In each journey the opener was detached at the close, so focus waited on the history section. When a tab remains beside the profile, such as a Solo thread, the tab strip focuses it at once, and a probe found it still focused after the return, with or without a reopen. The side-tab path keeps its detached check. Selecting the profile tab unmounts the toolbar, so the opener is detached even when Share access picks the profile already open in a side tab, which pushes no visit. A probe confirmed focus returns to Share access there. Comments now say what the record adopts: any visit pushed from one of its visits in the same chat, not only a panel opened in place of its own. A same-profile reopen takes over the current visit instead of owning one it pushed. Tests (solo-workspace.spec.mjs, Chromium and WebKit): - The Share access journey reopens Sam's profile while it shows, first from Share access, then from Chat actions → Share access. It checks entry() is unchanged, presses Escape, waits for the old header control to detach and expects the opener focused. A soft-assertion copy run against 40b3a492c's ChannelsPage.tsx fails exactly these two assertions in both engines, and passes with the fix. - The Back/Forward journey's negative checks, the Beta step and closeRestored, now wait for the old Share access button to detach instead of polling history.state. That detach happens in the commit whose layout effect consumes soloOpenerReturn. With these barriers the journey still fails on d7aa71060 (both closeRestored steps) and on 9aba9a238 (the Beta step too). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- src/bundled/channels/ChannelsPage.tsx | 27 +++++++++------- tests/browser/solo-workspace.spec.mjs | 46 +++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 364edd808f..4ec5b9c8b6 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1102,8 +1102,8 @@ function ChannelWorkspace({ ...(entry.rootId ? { rootId: entry.rootId } : {}), }, }); - // A panel opened in place of a Solo opener's panel, such as an agent's - // owner from its profile, is that opener's visit too. + // A Solo opener's record adopts any visit pushed from one of its visits + // in the same chat, such as an agent's owner opened from its profile. const opener = soloOpener.current; if (target.channelId === entry.channelId && opener?.visits.includes(from)) opener.visits.push(navigator.snapshot().entry.id); @@ -1214,9 +1214,9 @@ function ChannelWorkspace({ }; // The main Solo header remounts for each visit, ending that visit's dialogs // and menus, and a side tab's toolbar unmounts while a panel covers its - // chat. A panel opened from either outlives its opener, so the same control - // in that chat's header takes focus back on the dismissal's return. Only a - // visit it opened does: Back can restore the same panel from another opener. + // chat. Either way the opener is gone by the dismissal's return, so the same + // control in that chat's header takes focus back then. Only the record's + // visits do: Back can restore the same panel from another opener. const soloOpener = useRef<{ trigger: HTMLElement; tab: string | undefined; @@ -1320,15 +1320,17 @@ function ChannelWorkspace({ if (!fallback || !navigator) return; const visits = tabState.detailVisits(entry.channelId, entry.target); const from = navigator.snapshot().entry.id; - // The recorded opener takes focus back only from a visit it opened, and - // only while it is still that panel's trigger. The dismissal retires - // this panel's visits; once none of its own remain, it is forgotten. + // The recorded opener takes focus back only from one of its visits, and + // only while it is still that panel's trigger. A main header opener + // that reopened the panel already showing is still mounted, but the + // return remounts its header. The dismissal retires this panel's + // visits; once none of its own remain, it is forgotten. const opener = soloOpener.current; const control = opener?.visits.includes(from) && visits.includes(from) && opener.trigger === panelTrigger.current && - !opener.trigger.isConnected + (opener.tab === undefined || !opener.trigger.isConnected) ? opener.trigger.dataset.soloOpener : undefined; if (opener) { @@ -1556,6 +1558,8 @@ function ChannelWorkspace({ soloOpenerReturn.current = undefined; // Only while focus waits where the close left it: on the chat once the // pane emptied, or on the chat's tab that its pane selected instead. + // A main header opener that was still mounted took focus itself, and + // this commit's header remount removed it. // Side-tab toolbars live in the PanelDock, outside the main pane, and // the dock shows only its selected tab's toolbar. const focused = document.activeElement; @@ -1564,7 +1568,7 @@ function ChannelWorkspace({ focused.dataset.tabValue === opener.tab ? focused.closest("[data-panel-workspace]") : null - : focused === historyContainer.current + : focused === historyContainer.current || focused === document.body ? historyContainer.current?.closest("[data-solo-focus-pane]") : null; const control = @@ -1895,7 +1899,8 @@ function ChannelWorkspace({ if (!fromThread && routedThread && !historyPanel(next)) select(channelId); open(next, fromThread); - // The opener owns the visit now showing its panel, pushed or current. + // Record the visit now showing the panel. Reopening the panel already + // shown pushes none, so the opener takes over the current visit. soloOpener.current = trigger && header && navigator && historyPanel(next) ? { trigger, tab, visits: [navigator.snapshot().entry.id] } diff --git a/tests/browser/solo-workspace.spec.mjs b/tests/browser/solo-workspace.spec.mjs index e5f81f0398..030622b8e6 100644 --- a/tests/browser/solo-workspace.spec.mjs +++ b/tests/browser/solo-workspace.spec.mjs @@ -303,6 +303,35 @@ test.describe("Share access profiles", () => { await closeProfile(page).click(); await expect(tab).toHaveCount(0); await expect(actions).toBeFocused(); + // Choosing the profile already showing pushes no visit, so the opener + // stays mounted until the close's return remounts its header. + const shareFromActions = async () => { + await actions.click(); + await page + .getByRole("menuitem", { name: "Share access", exact: true }) + .click(); + }; + for (const [openDialog, opener] of [ + [() => share.click(), share], + [shareFromActions, actions], + ]) { + await openDialog(); + await name.press("Enter"); + await expect(tab).toBeFocused(); + const shown = await entry(); + await openDialog(); + await name.press("Enter"); + await expect(dialog).toHaveCount(0); + await expect(tab).toBeFocused(); + expect(await entry()).toBe(shown); + const header = await opener.elementHandle(); + await tab.press("Escape"); + await expect(tab).toHaveCount(0); + await expect + .poll(() => header.evaluate((el) => el.isConnected)) + .toBe(false); + await expect(opener).toBeFocused(); + } // An agent's profile opens its owner's in its place. Closing the owner's // still returns to Share access. const agent = page.getByRole("tab", { name: "Scout", exact: true }); @@ -370,8 +399,6 @@ test.describe("Share access profiles", () => { name: "Fixture Reader", exact: true, }); - const entry = () => - page.evaluate(() => history.state.buzzNavigationV1.entry.id); // Beta: the viewer's profile, opened from a message's author. await sidebar.locator(`[data-solo-channel-id="${other}"]`).click(); await conversation @@ -402,11 +429,14 @@ test.describe("Share access profiles", () => { page.getByRole("region", { name: "Chat Beta", exact: true }), ).toBeVisible(); await expect(profile).toHaveAttribute("aria-selected", "true"); - const restored = await entry(); + const header = await share.elementHandle(); await profile.press("Escape"); await expect(profile).toHaveCount(0); - // The dismissal's return is where a stale opener would take focus. - await expect.poll(entry).not.toBe(restored); + // The dismissal's return remounts the header, in the commit where a stale + // opener would take focus. + await expect + .poll(() => header.evaluate((el) => el.isConnected)) + .toBe(false); await expect( page.getByRole("region", { name: "Chat Beta", exact: true }), ).toBeFocused(); @@ -436,10 +466,12 @@ test.describe("Share access profiles", () => { }); const closeRestored = async () => { await expect(sam).toHaveAttribute("aria-selected", "true"); - const restored = await entry(); + const header = await share.elementHandle(); await sam.press("Escape"); await expect(sam).toHaveCount(0); - await expect.poll(entry).not.toBe(restored); + await expect + .poll(() => header.evaluate((el) => el.isConnected)) + .toBe(false); await expect(chat).toBeFocused(); }; await author.click(); From 3dda0183e668b58d4dae26324b5bd8ab8009878b Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 4 Nov 2026 20:07:36 +1100 Subject: [PATCH 08/12] test(people): pin side-tab Share access reopening the shown profile The side-tab return keeps its detached-opener check because selecting a profile tab unmounts the Solo chat's toolbar. 618d19557 confirmed with a probe that this holds even when Share access picks the profile already open beside the chat, which pushes no visit. No journey covered it. The side-tab journey now opens Sam from Beta's Share access, reselects Beta, and picks Sam again from Share access. It checks that entry() is still Sam's visit, presses Escape on Sam's tab and expects Share access focused. It reuses the existing steps' barriers: Sam's tab is gone and Beta is selected. The close focuses the Beta tab, the toolbar is keyed on its chat, and afterClose doesn't focus the trigger while tabs remain. Only the dismissal's return can therefore focus Share access, and the positive assertion can't pass on a transient. A soft-assertion copy fails the new focus assertion in Chromium and WebKit against three throwaway sources: - The dock keeps a Solo chat's toolbar mounted while another tab is selected (PanelWorkspace): it fails with every existing side-tab focus assertion and the toolbar-unmounted check. - The side-tab return is dropped (ChannelsPage): it fails with every existing side-tab focus assertion. - A side-tab opener is recorded only when its opening pushed a visit: it is the only assertion that fails. The step passes on 618d19557. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- tests/browser/solo-workspace.spec.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/browser/solo-workspace.spec.mjs b/tests/browser/solo-workspace.spec.mjs index 030622b8e6..d74977bb56 100644 --- a/tests/browser/solo-workspace.spec.mjs +++ b/tests/browser/solo-workspace.spec.mjs @@ -600,6 +600,25 @@ test.describe("Share access profiles", () => { await tab.press("Escape"); await expect(tab).toHaveCount(0); await expect(share).toBeFocused(); + // Choosing the profile already open beside the chat pushes no visit, and + // selecting its tab still unmounts the toolbar. The close focuses the Beta + // tab, and only the dismissal's return moves focus to Share access. + const entry = () => + page.evaluate(() => history.state.buzzNavigationV1.entry.id); + await share.click(); + await name.press("Enter"); + await expect(tab).toBeFocused(); + const shown = await entry(); + await beta.click(); + await share.click(); + await name.press("Enter"); + await expect(dialog).toHaveCount(0); + await expect(tab).toBeFocused(); + expect(await entry()).toBe(shown); + await tab.press("Escape"); + await expect(tab).toHaveCount(0); + await expect(beta).toHaveAttribute("aria-selected", "true"); + await expect(share).toBeFocused(); }); }); // Real hover, menu hit targets and local-panel handoff cannot be proved in jsdom. From 623b03e0c9c9bdc04ab9dd5dde014ec2f24a951b Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 5 Nov 2026 10:02:47 +1100 Subject: [PATCH 09/12] refactor(people): fold the Solo opener return into the dismissal record A review comment on PR 2415 (r4469565101) found that soloOpenerReturn duplicated keptTabReturn's from/target matching in the same layout effect, then cleared focusKept to override it. The opener lookups also depended on DOM markers owned by the toolbar and dock. One dismissalReturn record now holds the dismissal's from and target, with an optional kept tab and an optional Solo opener control. dismissContent sets it for either, under its existing check that the dismissal retires the current visit. The visit layout effect consumes it in one place. On the matching return the kept tab is reselected as before. The opener's control takes focus while focus still waits where the close left it; otherwise the kept tab does. focusKept is no longer set and then cleared. SoloToolbar owns its Share access and Chat actions refs and registers them with the page through registerOpeners, by pane: the main SoloHeading and the dock's toolbar, which shows only its selected tab. ChannelMembersButton takes the toolbar's ref for its button. openLink finds the trigger among the registered refs instead of reading data-solo-opener and walking to [data-solo-focus-pane] or [data-panel-workspace]. The return focuses the same control in the pane's current registration. Registration runs in a layout effect, so a header remounted by the return registers before the page's layout effect consumes the record. The data-solo-opener attributes go. The side-tab gate still reads the focused tab's data-tab-value, the shared Tabs marker that the page's other tab focus lookups use. No behaviour change. Tests: the ChannelsPage solo, tabs and page RTL files pass, as do ChannelMembersDialog, MemberAdministration, SoloRegions, App.profile, navigation and useConversationTabs. MemberAdministration failed one toBeVisible under load and passed alone. solo-workspace.spec.mjs passes 22/22 in Chromium and WebKit. right-pane-dismissal, navigation, navigation-boundaries and profiles pass in both; one WebKit page.goto timeout under load passed on rerun. With registration made a no-op, all three Share access journeys fail in Chromium. With it moved to a passive effect, the two main-pane journeys fail and the side-tab one passes, since the dock's toolbar mounts at the close. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- src/bundled/channels/ChannelMembersDialog.tsx | 8 +- src/bundled/channels/ChannelsPage.tsx | 205 ++++++++++-------- src/bundled/channels/SoloHeading.tsx | 31 ++- 3 files changed, 151 insertions(+), 93 deletions(-) diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx index 9817e47f4e..aa6e2ecef7 100644 --- a/src/bundled/channels/ChannelMembersDialog.tsx +++ b/src/bundled/channels/ChannelMembersDialog.tsx @@ -433,11 +433,14 @@ export function ChannelMembersButton({ owner, label = "Access", conversationName, + trigger: hostTrigger, }: { session: RelaySession; channelId: string; label?: string; conversationName?: string; + /** The button's ref, for a host that returns focus to it. */ + trigger?: RefObject | undefined; /** Someone else's Solo chat names its owner on the Access control. */ owner?: { name: string; picture?: string | undefined } | undefined; agents?: Agents | undefined; @@ -451,14 +454,14 @@ export function ChannelMembersButton({ const [localOpen, setLocalOpen] = useState(false); const open = presentation ? presentation.open : localOpen; const setOpen = presentation ? presentation.onOpenChange : setLocalOpen; - const trigger = useRef(null); + const ownTrigger = useRef(null); + const trigger = hostTrigger ?? ownTrigger; const access = useHeaderChannel(session.channels, channelId, memberAccess); return ( <> {conversationName !== undefined ? ( ; } +/** The Solo header control that opened a panel, with the side tab whose + * toolbar held it, or none for the main header. */ +type SoloOpenerControl = { + control: keyof SoloOpeners; + tab: string | undefined; +}; + +/** Solo headers' registered openers, by pane. */ +type SoloHeaders = { + main?: SoloOpeners | undefined; + side?: SoloOpeners | undefined; +}; + +/** The registered Solo header control that is `trigger`, if any. */ +function soloControl(headers: SoloHeaders, trigger: HTMLElement) { + for (const pane of ["main", "side"] as const) + for (const control of ["share", "actions"] as const) + if (headers[pane]?.[control].current === trigger) + return { pane, control }; +} + function ChannelWorkspace({ extensions, chatMenus, @@ -1152,49 +1178,61 @@ function ChannelWorkspace({ : {}), }; }; - const keptTabReturn = useRef<{ + const dismissalReturn = useRef<{ from: string; target: string; - id: string; - pane: "main" | "side"; - /** The side pane's visibility to restore: a kept tab shows, and a - * launcher's side panel keeps the visibility it had before the close. */ - paneOpen: boolean; + kept: + | { + id: string; + pane: "main" | "side"; + /** The side pane's visibility to restore: a kept tab shows, and a + * launcher's side panel keeps the visibility it had before the + * close. */ + paneOpen: boolean; + } + | undefined; + opener: SoloOpenerControl | undefined; }>(undefined); const dismissContent = ( visits: readonly string[], fallback: OpenTarget, pane: "main" | "side", entries = tabState.entries, + opener?: SoloOpenerControl, ) => { - keptTabReturn.current = undefined; + dismissalReturn.current = undefined; if (!navigator) return; const keep = pane === "main" ? tabState.getMainSelected() : tabState.getSelected(); const keptTab = tabState.tabs.find((tab) => tab.id === keep); const keptEntry = entries.find((entry) => panelTabId(entry) === keep); const current = navigator.snapshot().entry; - if ( + const kept = keep !== undefined && (keptTab || keptEntry || keep === "settings" || (pane === "side" && tabState.sidePanel)) && - visits.includes(current.id) && keptTab?.kind !== "thread" && !(keptEntry && historyPanel(keptEntry)) - ) { - // A local survivor has no routed address. Preserve it only for this - // dismissal's matching return, never for unrelated later navigation. - keptTabReturn.current = { + ? { + id: keep, + pane, + paneOpen: + !!(keptTab || keptEntry || keep === "settings") || + tabState.paneOpen, + } + : undefined; + // A local survivor has no routed address, and a Solo opener's header + // remounts. Restore them only on this dismissal's matching return, never + // for unrelated later navigation. + if ((kept || opener) && visits.includes(current.id)) + dismissalReturn.current = { from: current.id, target: targetKey(fallback), - id: keep, - pane, - paneOpen: - !!(keptTab || keptEntry || keep === "settings") || tabState.paneOpen, + kept, + opener, }; - } navigator.dismiss(visits, fallback); // A later close can retire more visits before the return lands, moving // where it lands. @@ -1217,17 +1255,22 @@ function ChannelWorkspace({ // chat. Either way the opener is gone by the dismissal's return, so the same // control in that chat's header takes focus back then. Only the record's // visits do: Back can restore the same panel from another opener. - const soloOpener = useRef<{ - trigger: HTMLElement; - tab: string | undefined; - visits: string[]; - }>(undefined); - const soloOpenerReturn = useRef<{ - from: string; - target: string; - control: string; - tab: string | undefined; - }>(undefined); + const soloOpener = useRef< + SoloOpenerControl & { trigger: HTMLElement; visits: string[] } + >(undefined); + // Solo headers register their openers by pane. The dock shows only its + // selected tab's toolbar. + const soloHeaders = useRef({}); + const registerSoloOpeners = useMemo(() => { + const register = (pane: "main" | "side") => (openers: SoloOpeners) => { + soloHeaders.current[pane] = openers; + return () => { + if (soloHeaders.current[pane] === openers) + soloHeaders.current[pane] = undefined; + }; + }; + return { main: register("main"), side: register("side") }; + }, []); // Member lists open profiles and DMs through this page. Side-tab Solo // toolbars render inside useConversationTabs, before canOpenLink and // openLink are declared, so these read them only when called. @@ -1262,6 +1305,7 @@ function ChannelWorkspace({ navigator={navigator} signal={navigation?.signal} {...memberLinks} + registerOpeners={registerSoloOpeners.side} onOpen={open} onLifecycle={(action, trigger) => handoff?.openLifecycle(channel, action, trigger, navigation?.signal) @@ -1319,28 +1363,23 @@ function ChannelWorkspace({ const fallback = closeTarget(pane, remaining); if (!fallback || !navigator) return; const visits = tabState.detailVisits(entry.channelId, entry.target); - const from = navigator.snapshot().entry.id; // The recorded opener takes focus back only from one of its visits, and // only while it is still that panel's trigger. A main header opener // that reopened the panel already showing is still mounted, but the // return remounts its header. The dismissal retires this panel's // visits; once none of its own remain, it is forgotten. const opener = soloOpener.current; - const control = - opener?.visits.includes(from) && - visits.includes(from) && + const returning = + opener?.visits.includes(navigator.snapshot().entry.id) && opener.trigger === panelTrigger.current && (opener.tab === undefined || !opener.trigger.isConnected) - ? opener.trigger.dataset.soloOpener + ? { control: opener.control, tab: opener.tab } : undefined; if (opener) { opener.visits = opener.visits.filter((id) => !visits.includes(id)); if (!opener.visits.length) soloOpener.current = undefined; } - soloOpenerReturn.current = control - ? { from, target: targetKey(fallback), control, tab: opener?.tab } - : undefined; - dismissContent(visits, fallback, pane, remaining); + dismissContent(visits, fallback, pane, remaining, returning); }, onCloseThread: (tab, active, last) => { // X is explicit dismissal; Back/Forward never comes through this path. @@ -1500,7 +1539,8 @@ function ChannelWorkspace({ navigation.target.kind === "conversation" ? navigation.target.detail : undefined; - const kept = keptTabReturn.current; + const pending = dismissalReturn.current; + const kept = pending?.kept; const retained = kept && (kept.pane === "main" @@ -1542,45 +1582,35 @@ function ChannelWorkspace({ } // Reselect it over the restored selection and give it its own visit. if (reopen) open(reopen); - if (kept && navigation.entryId !== kept.from) { - keptTabReturn.current = undefined; - if (retained && targetKey(navigation.target) === kept.target) { - if (kept.pane === "main") tabState.selectMain(kept.id); - else { - tabState.select(kept.id); - tabState.setPaneOpen(kept.paneOpen); + if (pending && navigation.entryId !== pending.from) { + dismissalReturn.current = undefined; + if (targetKey(navigation.target) === pending.target) { + if (kept && retained) { + if (kept.pane === "main") tabState.selectMain(kept.id); + else { + tabState.select(kept.id); + tabState.setPaneOpen(kept.paneOpen); + } } - focusKept.current = kept.id; - } - } - const opener = soloOpenerReturn.current; - if (opener && navigation.entryId !== opener.from) { - soloOpenerReturn.current = undefined; - // Only while focus waits where the close left it: on the chat once the - // pane emptied, or on the chat's tab that its pane selected instead. - // A main header opener that was still mounted took focus itself, and - // this commit's header remount removed it. - // Side-tab toolbars live in the PanelDock, outside the main pane, and - // the dock shows only its selected tab's toolbar. - const focused = document.activeElement; - const pane = opener.tab - ? focused instanceof HTMLElement && - focused.dataset.tabValue === opener.tab - ? focused.closest("[data-panel-workspace]") - : null - : focused === historyContainer.current || focused === document.body - ? historyContainer.current?.closest("[data-solo-focus-pane]") - : null; - const control = - targetKey(navigation.target) === opener.target - ? pane?.querySelector( - `[data-solo-opener="${opener.control}"]`, - ) - : undefined; - if (control) { - control.focus({ preventScroll: true }); - // The opener, rather than its chat's tab, takes focus back. - focusKept.current = undefined; + // The opener's control in its remounted header takes focus, but + // only while focus waits where the close left it: on the chat once + // the pane emptied, or on the chat's tab that its pane selected + // instead. A main header opener that was still mounted took focus + // itself, and this commit's header remount removed it. + const { opener } = pending; + const focused = document.activeElement; + const waiting = opener?.tab + ? focused instanceof HTMLElement && + focused.dataset.tabValue === opener.tab + : focused === historyContainer.current || focused === document.body; + const control = + opener && + waiting && + soloHeaders.current[opener.tab ? "side" : "main"]?.[opener.control] + .current; + if (control) control.focus({ preventScroll: true }); + // Otherwise the kept tab, where the close was, takes focus back. + else if (kept && retained) focusKept.current = kept.id; } } if (navigation.focus === "preserve") setPreservedVisit(navigation); @@ -1888,13 +1918,10 @@ function ChannelWorkspace({ ? document.activeElement : null); panelTrigger.current = trigger; - // Find a Solo header's opener while it is connected to its header. - const header = trigger?.dataset.soloOpener - ? trigger.closest("[data-solo-focus-pane], [data-panel-workspace]") - : null; - const tab = header?.hasAttribute("data-panel-workspace") - ? tabState.getSelected() - : undefined; + // Find a Solo header's opener while its header has it registered. + const header = trigger && soloControl(soloHeaders.current, trigger); + const tab = + header?.pane === "side" ? tabState.getSelected() : undefined; // Thread-origin details cover the live thread instead of retiring it. if (!fromThread && routedThread && !historyPanel(next)) select(channelId); @@ -1903,7 +1930,12 @@ function ChannelWorkspace({ // shown pushes none, so the opener takes over the current visit. soloOpener.current = trigger && header && navigator && historyPanel(next) - ? { trigger, tab, visits: [navigator.snapshot().entry.id] } + ? { + trigger, + control: header.control, + tab, + visits: [navigator.snapshot().entry.id], + } : undefined; return true; } @@ -2366,6 +2398,7 @@ function ChannelWorkspace({ navigator={navigator} signal={navigation?.signal} {...memberLinks} + registerOpeners={registerSoloOpeners.main} onConvert={(trigger) => setConversion({ channelId: current.id, diff --git a/src/bundled/channels/SoloHeading.tsx b/src/bundled/channels/SoloHeading.tsx index b44b07013b..06571a2316 100644 --- a/src/bundled/channels/SoloHeading.tsx +++ b/src/bundled/channels/SoloHeading.tsx @@ -5,6 +5,7 @@ import { soloNavigationFor } from "../../features/channel-navigation/solo-naviga import { useCallback, useEffect, + useLayoutEffect, useRef, useState, useSyncExternalStore, @@ -44,6 +45,12 @@ import { useSoloOwner } from "./ChatSharedMark"; const noSubscription = () => () => {}; +/** A Solo header's dialog openers, which its page returns focus to. */ +export type SoloOpeners = { + share: RefObject; + actions: RefObject; +}; + type SoloToolbarProps = { session: RelaySession; chatMenus?: Conversation["chatMenus"] | undefined; @@ -62,6 +69,8 @@ type SoloToolbarProps = { onLifecycle(action: ChannelLifecycleAction, trigger?: HTMLElement): void; /** Reports a move started here; see `useSoloMoveRecovery`. */ onMove(move: Promise): void; + /** Registers this header's openers while it is mounted. */ + registerOpeners?: ((openers: SoloOpeners) => () => void) | undefined; } & Pick< ComponentProps, "canOpenLink" | "onOpenLink" | "onOpenConversation" @@ -70,6 +79,7 @@ type SoloToolbarProps = { /** The header of a conversation shown with the Solo treatment. */ export function SoloHeading(props: SoloToolbarProps) { const owner = useSoloOwner(props.session, props.channel); + const shareTrigger = useRef(null); return props.channel.archived ? ( @@ -85,12 +95,13 @@ export function SoloHeading(props: SoloToolbarProps) { owner={owner} navigator={props.navigator} signal={props.signal} + trigger={shareTrigger} canOpenLink={props.canOpenLink} onOpenLink={props.onOpenLink} onOpenConversation={props.onOpenConversation} /> } - actions={} + actions={} />
); @@ -113,8 +124,12 @@ export function SoloToolbar({ canOpenLink, onOpenLink, onOpenConversation, - membershipInHeader = false, -}: SoloToolbarProps & { membershipInHeader?: boolean }) { + registerOpeners, + membershipInHeader, +}: SoloToolbarProps & { + /** The header's own Share access control, which replaces the toolbar's. */ + membershipInHeader?: RefObject; +}) { const menus = useSyncExternalStore( chatMenus?.subscribe ?? noSubscription, () => chatMenus?.snapshot(), @@ -123,7 +138,15 @@ export function SoloToolbar({ const region = regions.regions.find((region) => region.channels.some((item) => item.id === channel.id), ); + const ownShareTrigger = useRef(null); + const shareTrigger = membershipInHeader ?? ownShareTrigger; const actionsTrigger = useRef(null); + // A layout effect, so a remounted header registers before its page's + // layout effects run in the same commit. + useLayoutEffect( + () => registerOpeners?.({ share: shareTrigger, actions: actionsTrigger }), + [registerOpeners, shareTrigger], + ); // Each dialog returns focus to the control that opened it. const [settingsOpen, setSettingsOpen] = useState<{ visit: AbortSignal | undefined; @@ -160,6 +183,7 @@ export function SoloToolbar({ owner={owner} navigator={navigator} signal={signal} + trigger={shareTrigger} canOpenLink={canOpenLink} onOpenLink={onOpenLink} onOpenConversation={onOpenConversation} @@ -176,7 +200,6 @@ export function SoloToolbar({ Date: Fri, 6 Nov 2026 13:50:48 +1100 Subject: [PATCH 10/12] test(people): find Solo Share access in the unified header Main's unified conversation headers (8b908c832) name a Solo chat's main-pane Share access control "Share access to ". The header Share access tests now look up that name. On the rebased base, the side-tab profile test's navigation stub, which returned a new snapshot object per call, loops renders with "The result of getSnapshot should be cached". It now returns one cached snapshot. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- src/bundled/channels/ChannelsPage.solo.test.tsx | 5 +++-- src/bundled/channels/ChannelsPage.tabs.test.tsx | 3 ++- tests/browser/solo-workspace.spec.mjs | 6 ++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/bundled/channels/ChannelsPage.solo.test.tsx b/src/bundled/channels/ChannelsPage.solo.test.tsx index 32f325e936..c32dd9555d 100644 --- a/src/bundled/channels/ChannelsPage.solo.test.tsx +++ b/src/bundled/channels/ChannelsPage.solo.test.tsx @@ -1395,8 +1395,9 @@ it("opens a profile from the header's Share access", async () => { Object.assign(f.session.profiles, { ensure: async () => {} }); f.mount(); const user = userEvent.setup(); - const trigger = await screen.findByRole("button", { name: "Share access" }); - await user.click(trigger); + await user.click( + await screen.findByRole("button", { name: "Share access to Planning" }), + ); const dialog = await screen.findByRole("dialog", { name: /Share access/ }); await user.click( await within(dialog).findByRole("button", { diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx index 4c182b5726..b042059745 100644 --- a/src/bundled/channels/ChannelsPage.tabs.test.tsx +++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx @@ -952,9 +952,10 @@ it("gives a Solo chat's side tab its own header actions, opening it in the main it.each(["Share access", "Chat actions"])( "opens a profile from a Solo side tab's %s", async (opener) => { + const snapshot = { entry: { id: "visit" }, attempt: {} }; const h = await page("stream", { open: vi.fn(async () => ({ status: "opened" as const })), - snapshot: () => ({ entry: { id: "visit" }, attempt: {} }), + snapshot: () => snapshot, } as unknown as Navigation); const tabs = renderHook(() => useChannelTabState(h.session, "alpha")); act(() => diff --git a/tests/browser/solo-workspace.spec.mjs b/tests/browser/solo-workspace.spec.mjs index d74977bb56..53f3737c34 100644 --- a/tests/browser/solo-workspace.spec.mjs +++ b/tests/browser/solo-workspace.spec.mjs @@ -265,8 +265,7 @@ test.describe("Share access profiles", () => { .click(); const conversation = page.getByRole("article", { name: "Conversation" }); const share = conversation.getByRole("button", { - name: "Share access", - exact: true, + name: /^Share access to /, }); const actions = conversation.getByRole("button", { name: "Chat actions", @@ -391,8 +390,7 @@ test.describe("Share access profiles", () => { }); const conversation = page.getByRole("article", { name: "Conversation" }); const share = conversation.getByRole("button", { - name: "Share access", - exact: true, + name: /^Share access to /, }); const sam = page.getByRole("tab", { name: "Sam Rivera", exact: true }); const profile = page.getByRole("tab", { From 9119d5fbc03afe7dac533147b6ddca3d1444bebb Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 6 Nov 2026 15:35:01 +1100 Subject: [PATCH 11/12] fix(people): bind a queued Solo reopen's opener to its fresh visit Review thread r4488965070 on PR 2415 found a lost focus return. Close a profile opened from Solo Share access while the close's native return is still pending, then reopen it from Share access or Chat actions. Closing the reopened profile focused the chat's history section instead of the opener. While the return is pending, visitPanel queues the reopen without pushing a visit (7d1366352), so openLink records the retiring visit for the opener. When the return lands, the route effect replays the reopen and pushes a fresh visit from the landing entry. visitPanel adopts only visits pushed from one of the record's own, so the fresh visit was never added. Its close then failed the record's visit check. The route effect now gives the fresh visit to a record that holds the retiring one, replacing it, right after the replay pushes it. The existing checks are unchanged: trigger identity, the dismissal's waiting focus, and the retained-selection gate. The reopened profile keeps its selection and focus when the return lands, and the earlier close's opener does not take focus. The effect now lists navigator as a dependency; it is the plugin's navigation and does not change while the page is mounted. Tests: a solo-workspace.spec.mjs journey holds history.go, closes Sam and reopens him from Share access, then from Chat actions. It expects the navigator still on the closed visit, exactly one held return and, after release, a fresh visit for Sam. Sam's tab stays selected and focused. Escape then waits for the old header to detach and expects the opener focused. On 2976be37b the final focus assertion fails in Chromium and WebKit, and it passes with this change. solo-workspace.spec.mjs and right-pane-dismissal.spec.mjs pass 50/50 in both engines, as do profiles.spec.mjs's two pending-reopen cases. The five ChannelsPage RTL files pass 95/95. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- src/bundled/channels/ChannelsPage.tsx | 18 +++++- tests/browser/solo-workspace.spec.mjs | 88 +++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 9642b5e32b..ad7c0a092a 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1581,7 +1581,18 @@ function ChannelWorkspace({ else navigation.complete({ status: "failed", reason: "unavailable" }); } // Reselect it over the restored selection and give it its own visit. - if (reopen) open(reopen); + // A Solo opener that queued it recorded the retiring visit, so it takes + // the fresh one instead. + if (reopen && returned) { + open(reopen); + const opener = soloOpener.current; + if (opener?.visits.includes(returned.from)) { + const fresh = navigator.snapshot().entry.id; + opener.visits = opener.visits.map((visit) => + visit === returned.from ? fresh : visit, + ); + } + } if (pending && navigation.entryId !== pending.from) { dismissalReturn.current = undefined; if (targetKey(navigation.target) === pending.target) { @@ -1673,6 +1684,7 @@ function ChannelWorkspace({ currentId, panels, panelTrigger, + navigator, ]); const mounted = useRef(false); const channel = useRef(current?.id); @@ -1927,7 +1939,9 @@ function ChannelWorkspace({ select(channelId); open(next, fromThread); // Record the visit now showing the panel. Reopening the panel already - // shown pushes none, so the opener takes over the current visit. + // shown pushes none, so the opener takes over the current visit. A + // reopen queued behind a close's return holds the retiring visit until + // the return gives the panel its own. soloOpener.current = trigger && header && navigator && historyPanel(next) ? { diff --git a/tests/browser/solo-workspace.spec.mjs b/tests/browser/solo-workspace.spec.mjs index 53f3737c34..d5a56ed593 100644 --- a/tests/browser/solo-workspace.spec.mjs +++ b/tests/browser/solo-workspace.spec.mjs @@ -377,6 +377,94 @@ test.describe("Share access profiles", () => { await expect(agent).toHaveCount(0); await expect(share).toBeFocused(); }); + test("a profile reopened from Share access before its close returns keeps its opener", async ({ + page, + app, + }) => { + app.serveProfile(teammate, { name: "Sam Rivera" }); + await page.goto(app.origin); + await newChat(page); + await page + .getByRole("navigation", { name: "Solo conversations" }) + .locator(`[data-solo-channel-id="${id}"]`) + .click(); + const conversation = page.getByRole("article", { name: "Conversation" }); + const share = conversation.getByRole("button", { + name: /^Share access to /, + }); + const actions = conversation.getByRole("button", { + name: "Chat actions", + exact: true, + }); + const dialog = page.getByRole("dialog", { + name: "Share access", + exact: true, + }); + const name = dialog.getByRole("button", { + name: /^Open profile for Sam Rivera/, + }); + const tab = page.getByRole("tab", { name: "Sam Rivera", exact: true }); + const entry = () => + page.evaluate(() => history.state.buzzNavigationV1.entry); + const shareFromActions = async () => { + await actions.click(); + await page + .getByRole("menuitem", { name: "Share access", exact: true }) + .click(); + }; + for (const [openDialog, opener] of [ + [() => share.click(), share], + [shareFromActions, actions], + ]) { + await openDialog(); + await name.press("Enter"); + await expect(tab).toBeFocused(); + const closed = await entry(); + // The close's native return lands a task or more later. Hold it so the + // reopen is queued while the navigator still shows the closed visit. + await page.evaluate(() => { + const go = history.go.bind(history); + const held = []; + history.go = (delta) => held.push(delta); + window.releaseReturn = () => { + history.go = go; + for (const delta of held) go(delta); + return held.length; + }; + }); + let held; + try { + await tab.press("Escape"); + await expect(tab).toHaveCount(0); + await openDialog(); + await name.press("Enter"); + await expect(dialog).toHaveCount(0); + await expect(tab).toBeFocused(); + expect((await entry()).id).toBe(closed.id); + } finally { + held = await page.evaluate(() => window.releaseReturn()); + } + expect(held).toBe(1); + // The reopened profile takes a fresh visit and keeps its selection and + // focus, rather than the closed one's opener taking focus. + await expect + .poll(async () => { + const shown = await entry(); + return shown.id !== closed.id && shown.target.detail?.target; + }) + .toBe(closed.target.detail.target); + await expect(tab).toHaveAttribute("aria-selected", "true"); + await expect(tab).toBeFocused(); + // Closing it returns focus to the opener that reopened it. + const header = await opener.elementHandle(); + await tab.press("Escape"); + await expect(tab).toHaveCount(0); + await expect + .poll(() => header.evaluate((el) => el.isConnected)) + .toBe(false); + await expect(opener).toBeFocused(); + } + }); test("a profile restored by Back or Forward returns focus only to its own opener", async ({ page, app, From 05e215db8838fe14e00bce45fe33098125e05261 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 6 Nov 2026 23:10:26 +1100 Subject: [PATCH 12/12] fix(people): keep the Solo opener when a queued profile closes first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review thread r4489182646 on PR 2415 found a lost focus return. Close person A's profile from Solo Share access while that close's native return is still pending. Then open person B's profile from Chat actions → Share access and close it before the return lands. Chat actions has focus right after the close. The return then remounts the header and leaves focus on . B's open is queued behind the pending return, so B has no visit and its close retires none. dismissContent recorded a dismissal return only when the close retired the current visit. Every close cleared the record first, so B's close dropped A's record and recorded none of its own. The fresh-visit replacement from d82458994 never runs, because the closed reopen isn't replayed. A close made while an earlier close's return is pending now joins that return, as pendingReturn already treats it, and records its kept tab and opener. A dismiss with no visits leaves the native return unchanged, so it still lands where the first close sent it. The latest close's opener and kept tab then apply. The trigger and visit checks in onClosePanel are unchanged, as are the waiting-focus and retained-selection gates on the return. A queued profile opened from somewhere other than a Solo header records no opener. Its close still clears the record, so A's opener doesn't take focus. Tests: a solo-workspace.spec.mjs journey opens Sam from one Solo header control and holds history.go. It closes Sam, opens Scout from the other control and closes Scout. It expects the navigator still on Sam's closed visit and exactly one held return. After release it expects the chat's entry, the old header detached, neither profile back, and Scout's opener focused. It runs Share access then Chat actions, then the reverse. On d82458994 the final focus assertion fails in Chromium and WebKit, and it passes with this change. solo-workspace.spec.mjs and right-pane-dismissal.spec.mjs pass 52/52 in both engines. So do profiles.spec.mjs's pending-reopen and transient-profile cases, 8/8. The five ChannelsPage RTL files pass 95/95. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- src/bundled/channels/ChannelsPage.tsx | 21 +++--- tests/browser/solo-workspace.spec.mjs | 92 +++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index ad7c0a092a..f89facf30c 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1207,6 +1207,14 @@ function ChannelWorkspace({ const keptTab = tabState.tabs.find((tab) => tab.id === keep); const keptEntry = entries.find((entry) => panelTabId(entry) === keep); const current = navigator.snapshot().entry; + // A later close can retire more visits before the return lands, moving + // where it lands. It takes that return even when it retires no visit of + // its own, as for a profile queued behind it. + const pending = + pendingReturn.current?.from === current.id + ? pendingReturn.current + : undefined; + const returns = !!pending || visits.includes(current.id); const kept = keep !== undefined && (keptTab || @@ -1226,7 +1234,7 @@ function ChannelWorkspace({ // A local survivor has no routed address, and a Solo opener's header // remounts. Restore them only on this dismissal's matching return, never // for unrelated later navigation. - if ((kept || opener) && visits.includes(current.id)) + if ((kept || opener) && returns) dismissalReturn.current = { from: current.id, target: targetKey(fallback), @@ -1234,16 +1242,7 @@ function ChannelWorkspace({ opener, }; navigator.dismiss(visits, fallback); - // A later close can retire more visits before the return lands, moving - // where it lands. - const pending = - pendingReturn.current?.from === current.id - ? pendingReturn.current - : undefined; - if ( - (pending || visits.includes(current.id)) && - navigator.snapshot().entry.id === current.id - ) + if (returns && navigator.snapshot().entry.id === current.id) pendingReturn.current = { ...pending, from: current.id, diff --git a/tests/browser/solo-workspace.spec.mjs b/tests/browser/solo-workspace.spec.mjs index d5a56ed593..a13a767349 100644 --- a/tests/browser/solo-workspace.spec.mjs +++ b/tests/browser/solo-workspace.spec.mjs @@ -465,6 +465,98 @@ test.describe("Share access profiles", () => { await expect(opener).toBeFocused(); } }); + test("another profile opened from Share access and closed before an earlier close returns keeps its opener", async ({ + page, + app, + }) => { + app.serveProfile(teammate, { name: "Sam Rivera" }); + app.serveProfile(scout, { name: "Scout", is_agent: true }, teammate); + await page.goto(app.origin); + await newChat(page); + await page + .getByRole("navigation", { name: "Solo conversations" }) + .locator(`[data-solo-channel-id="${id}"]`) + .click(); + const conversation = page.getByRole("article", { name: "Conversation" }); + const share = conversation.getByRole("button", { + name: /^Share access to /, + }); + const actions = conversation.getByRole("button", { + name: "Chat actions", + exact: true, + }); + const dialog = page.getByRole("dialog", { + name: "Share access", + exact: true, + }); + const sam = page.getByRole("tab", { name: "Sam Rivera", exact: true }); + const agent = page.getByRole("tab", { name: "Scout", exact: true }); + const entry = () => + page.evaluate(() => history.state.buzzNavigationV1.entry); + const shareFromActions = async () => { + await actions.click(); + await page + .getByRole("menuitem", { name: "Share access", exact: true }) + .click(); + }; + // The second profile's opener differs from the first's, so only its own + // control can take focus back. + for (const [openFirst, openSecond, opener] of [ + [() => share.click(), shareFromActions, actions], + [shareFromActions, () => share.click(), share], + ]) { + await openFirst(); + await dialog + .getByRole("button", { name: /^Open profile for Sam Rivera/ }) + .press("Enter"); + await expect(sam).toBeFocused(); + const closed = await entry(); + const header = await opener.elementHandle(); + // Hold the close's native return so Scout's profile is queued, with no + // visit of its own, and closes while the navigator still shows Sam's. + await page.evaluate(() => { + const go = history.go.bind(history); + const held = []; + history.go = (delta) => held.push(delta); + window.releaseReturn = () => { + history.go = go; + for (const delta of held) go(delta); + return held.length; + }; + }); + let held; + try { + await sam.press("Escape"); + await expect(sam).toHaveCount(0); + await openSecond(); + await dialog + .getByRole("button", { name: /^Open profile for Scout\b/ }) + .press("Enter"); + await expect(dialog).toHaveCount(0); + await expect(agent).toBeFocused(); + await agent.press("Escape"); + await expect(agent).toHaveCount(0); + expect((await entry()).id).toBe(closed.id); + } finally { + held = await page.evaluate(() => window.releaseReturn()); + } + expect(held).toBe(1); + // The return lands on the chat and remounts its header. Neither profile + // comes back, and the second one's opener takes focus. + await expect + .poll(async () => { + const shown = await entry(); + return shown.id !== closed.id && !shown.target.detail; + }) + .toBe(true); + await expect + .poll(() => header.evaluate((el) => el.isConnected)) + .toBe(false); + await expect(sam).toHaveCount(0); + await expect(agent).toHaveCount(0); + await expect(opener).toBeFocused(); + } + }); test("a profile restored by Back or Forward returns focus only to its own opener", async ({ page, app,