Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/bundled/channels/ChannelMembersDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement | null> | undefined;
/** Someone else's Solo chat names its owner on the Access control. */
owner?: { name: string; picture?: string | undefined } | undefined;
agents?: Agents | undefined;
Expand All @@ -451,7 +454,8 @@ export function ChannelMembersButton({
const [localOpen, setLocalOpen] = useState(false);
const open = presentation ? presentation.open : localOpen;
const setOpen = presentation ? presentation.onOpenChange : setLocalOpen;
const trigger = useRef<HTMLButtonElement>(null);
const ownTrigger = useRef<HTMLButtonElement>(null);
const trigger = hostTrigger ?? ownTrigger;
const access = useHeaderChannel(session.channels, channelId, memberAccess);
return (
<>
Expand Down
104 changes: 102 additions & 2 deletions src/bundled/channels/ChannelsPage.solo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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: () => <p>Profile content</p>,
} as RegisteredPanel;
// A stable snapshot, as useSyncExternalStore requires.
const available = [profilePanel];
const page = (sidebar = false) => (
<ChannelNavigationProvider relay={relay}>
{sidebar && (
Expand All @@ -410,9 +422,11 @@ function fixture({
<ChannelsPage
relay={relay}
panels={{
...contribution,
snapshot: () => available,
subscribe: () => () => {},
register() {},
resolve: () => undefined,
resolve: (target) =>
profilePanel.matches(target) ? profilePanel : undefined,
requestOpen: () => false,
handleOpenRequests: () => () => {},
}}
Expand Down Expand Up @@ -1371,6 +1385,92 @@ it.each([
expect(screen.queryByText(/’s chat/)).toBeNull();
});

// 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.
Object.assign(f.session.profiles, { ensure: async () => {} });
f.mount();
const user = userEvent.setup();
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", {
name: /^Open profile for Teammate/,
}),
);
expect(screen.queryByRole("dialog")).toBeNull();
expect(await screen.findByText("Profile content")).toBeVisible();
});

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<typeof within>, 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();
});

it("marks an archived chat once and keeps only its lifecycle actions", async () => {
fixture({ archived: true }).mount();
await screen.findByRole("button", { name: "Chat actions" });
Expand Down
44 changes: 43 additions & 1 deletion src/bundled/channels/ChannelsPage.tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,16 @@ async function page(
matches: (target) => target === "https://app.example/",
component: appComponent ?? (() => <p>App content</p>),
};
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: () => <p>Profile content</p>,
};
const available = [panel, app, profile];
let handleOpen: ((request: PanelOpenRequest) => boolean) | undefined;
const panels: Panels = {
snapshot: () => available,
Expand Down Expand Up @@ -937,6 +946,39 @@ 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();
});

// 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 snapshot = { entry: { id: "visit" }, attempt: {} };
const h = await page("stream", {
open: vi.fn(async () => ({ status: "opened" as const })),
snapshot: () => snapshot,
} 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 = {
status: "ready",
Expand Down
Loading
Loading