Skip to content
Merged
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
16 changes: 16 additions & 0 deletions docs/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ palette; legacy sidebar filters are ignored. The saved-groups
browser regression records every visible return frame and holds the redundant
decode path, so eventual restoration cannot conceal a fallback-group/scroll jump.

Channel row actions share one page-owned `ContextMenuRoot` / `MenuPopup`, labelled
`Actions for <channel>`. **New session** comes first; additional sidebar actions
should extend that popup, with a separator only when another action group follows.
`ChannelSidebarItem` owns the context trigger inside its memo boundary, using stable
`onOpenMenu` props. It wraps the activity select surface rather than merging popup
props onto the activity button; session disclosure and child rows stay outside.
The popup and trigger are enabled only when `rowActions` supplies actual items;
each action owns its eligibility, so Sessions availability never gates sibling
actions. `useChannelRowMenu` owns channel id, the full rendered section key
(`starred`, `channels`, `group:<id>`, etc.), and the keyboard anchor. It clears
that state if the row leaves that section or loses its last action; moving back
or restoring eligibility does not reopen the menu. For future group commands,
derive the saved group id separately from `group:<id>` rather than conflating it
with rendered placement. Right-clicking the separate session disclosure remains
outside the parent menu trigger, as do child-session rows.

## Starting a direct message

The **+** action in the DMs sidebar header opens **New message**, a routed empty
Expand Down
6 changes: 5 additions & 1 deletion docs/sessions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ Sessions are focused work conversations built on ordinary private channels.
through normal channel invitations. Sending waits for both real rosters. Failed
invitations preserve the draft and retry the saved operation.
- New sessions can be created without a parent or beneath a Messages channel.
A channel's hover menu starts a child; its hover chevron collapses the children.
Right-click a channel and choose **New session** to start a child, or focus the
channel and press Shift+F10 / the Menu key. The hover chevron collapses children.
The action is unavailable for DMs, archived channels, sessions, or when Sessions
is disabled. Dismissing the menu restores row focus; starting a session focuses
the draft composer.
Changing a saved session's parent remains future app metadata work.
- Both entry points share the ordinary composer, centered at the bottom, and
channel-style titles. The avatar-and-name picker sits before @ and opens upward.
Expand Down
61 changes: 59 additions & 2 deletions src/bundled/channels/ChannelSidebarItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { afterEach, expect, it, vi } from "vitest";
import type { RelaySession } from "../../features/relay/session";
import type { UnreadSnapshot } from "../../features/relay/unread";
import { ChannelSidebarItem } from "./ChannelSidebarItem";
import { ContextMenuRoot } from "../../shared/design-system/ui/Menu";

afterEach(cleanup);

Expand Down Expand Up @@ -63,7 +64,6 @@ it("keeps live unread updates and uses replacement session callbacks across row
channel: { id: "alpha", name: "Alpha", channelType: "stream" as const },
session: first.session,
working: false,
sessionsEnabled: true,
selected: undefined,
collapsed: false,
onToggle: vi.fn(),
Expand Down Expand Up @@ -139,7 +139,6 @@ it("renders one-to-one DM avatars from supplied profiles only, through the media
},
session,
working: false,
sessionsEnabled: false,
selected: undefined,
collapsed: false,
onToggle: vi.fn(),
Expand Down Expand Up @@ -211,3 +210,61 @@ it("renders one-to-one DM avatars from supplied profiles only, through the media
} else expect(row.querySelector("svg")).toBeInTheDocument();
}
});

it.each(["ContextMenu", "F10"])(
"opens the parent menu with %s without involving disclosure or child sessions",
async (key) => {
const onSelect = vi.fn();
const onToggle = vi.fn();
const onOpenMenu = vi.fn();
const channel = {
id: "alpha",
name: "Alpha",
channelType: "stream" as const,
};
render(
<ContextMenuRoot>
<ChannelSidebarItem
channel={channel}
session={owner().session}
working={false}
selected={undefined}
collapsed={false}
onToggle={onToggle}
draft={false}
draftSelected={false}
sessions={[{ id: "child", name: "Plan", channelType: "session" }]}
onSelect={onSelect}
onNewSession={vi.fn()}
onOpenThread={vi.fn()}
menuEnabled
sectionKey="group:work"
onOpenMenu={onOpenMenu}
/>
</ContextMenuRoot>,
);
const parent = screen.getByRole("button", { name: "Alpha" });
fireEvent.keyDown(parent, { key, shiftKey: key === "F10" });
expect(onOpenMenu).toHaveBeenCalledWith(
channel,
"group:work",
parent.parentElement,
);
expect(onSelect).not.toHaveBeenCalled();
const disclosure = screen.getByRole("button", {
name: "Collapse sessions in Alpha",
});
const child = screen.getByRole("button", {
name: "Plan, session in Alpha",
});
fireEvent.keyDown(disclosure, { key, shiftKey: key === "F10" });
fireEvent.keyDown(child, { key, shiftKey: key === "F10" });
expect(onOpenMenu).toHaveBeenCalledTimes(1);
const user = userEvent.setup();
await user.click(disclosure);
expect(onToggle).toHaveBeenCalledWith("session-children:alpha", false);
expect(onSelect).not.toHaveBeenCalled();
await user.click(child);
expect(onSelect).toHaveBeenCalledWith("child");
},
);
55 changes: 43 additions & 12 deletions src/bundled/channels/ChannelSidebarItem.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { memo } from "react";
import { Avatar } from "../../shared/design-system/ui/Avatar";
import { ContextMenuTrigger } from "../../shared/design-system/ui/Menu";
import type { ChannelSummary, Profile } from "../../features/relay/contracts";
import type { RelaySession } from "../../features/relay/session";
import { ChatCircleIcon } from "../../shared/design-system/icons/index";
Expand All @@ -18,7 +19,6 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({
profile,
session,
working,
sessionsEnabled,
selected,
collapsed,
onToggle,
Expand All @@ -29,12 +29,14 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({
onNewSession,
onOpenThread,
onHideDm,
menuEnabled,
sectionKey,
onOpenMenu,
}: {
channel: ChannelSummary;
profile?: Profile | undefined;
session: RelaySession;
working: boolean;
sessionsEnabled: boolean;
selected: string | undefined;
collapsed: boolean;
onToggle: (key: string, open: boolean) => void;
Expand All @@ -45,6 +47,13 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({
onNewSession: (id: string) => void;
onOpenThread: (channelId: string, rootId: string) => void;
onHideDm?: (id: string) => void;
menuEnabled?: boolean;
sectionKey?: string | undefined;
onOpenMenu?: (
channel: ChannelSummary,
sectionKey: string,
anchor?: HTMLElement,
) => void;
}) {
const Icon =
channel.channelType === "dm" ? ChatCircleIcon : channelIcon(channel);
Expand Down Expand Up @@ -94,17 +103,39 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({
/>
</>
}
wrapSelect={(trigger) => (
<ChannelActivityPopover
session={session}
channelId={channel.id}
channelName={channel.name}
onOpenThread={(item) => onOpenThread(item.channelId, item.rootId)}
trigger={trigger}
/>
)}
wrapSelect={(trigger) => {
const activity = (
<ChannelActivityPopover
session={session}
channelId={channel.id}
channelName={channel.name}
onOpenThread={(item) => onOpenThread(item.channelId, item.rootId)}
trigger={trigger}
/>
);
// Keep popup semantics on separate DOM nodes: activity owns the button,
// the context menu wraps only its select surface, not the child sessions.
return menuEnabled ? (
<ContextMenuTrigger
render={<div />}
onKeyDown={(event) => {
if (
event.key === "ContextMenu" ||
(event.shiftKey && event.key === "F10")
) {
event.preventDefault();
if (sectionKey)
onOpenMenu?.(channel, sectionKey, event.currentTarget);
}
}}
>
{activity}
</ContextMenuTrigger>
) : (
activity
);
}}
selected={selected}
sessionsEnabled={sessionsEnabled}
collapsed={collapsed}
onToggle={(open) => onToggle(`session-children:${channel.id}`, open)}
draft={draft}
Expand Down
18 changes: 0 additions & 18 deletions src/bundled/channels/ChannelSidebarRow.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -66,24 +66,6 @@
color: var(--text-standard);
font-weight: var(--type-weight-medium);
}
.positioner {
z-index: var(--layer-popover);
}
.menu.menu {
position: relative;
width: max-content;
max-width: var(--available-width);
font-size: var(--text-body-sm);
outline: none;
}
.menuItem {
white-space: nowrap;
outline: none;
}
.menuItem[data-highlighted] {
background: var(--completion-highlight);
}

.row .disclosure {
position: absolute;
left: calc(var(--space-control-inset) - (var(--size-row) - 17px) / 2);
Expand Down
60 changes: 1 addition & 59 deletions src/bundled/channels/ChannelSidebarRow.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import type { RelaySession } from "../../features/relay/session";
Expand Down Expand Up @@ -73,7 +73,6 @@ function mount(selected = "child") {
draft={true}
draftSelected={false}
selected={selected}
sessionsEnabled
onSelect={onSelect}
onPrepare={() => {}}
onNewSession={onNewSession}
Expand All @@ -83,61 +82,6 @@ function mount(selected = "child") {
render(<Row />);
return { onSelect, onNewSession };
}
it("opens a compact action menu independently of selecting its channel", async () => {
const user = userEvent.setup();
const callbacks = mount();
const trigger = screen.getByRole("button", {
name: "More options for Engineering",
});
expect(trigger).toHaveAttribute("data-icon-shape", "round");
await user.click(trigger);
await user.click(
await screen.findByRole("menuitem", { name: "New session" }),
);
expect(callbacks.onNewSession).toHaveBeenCalledWith("parent");
expect(callbacks.onSelect).not.toHaveBeenCalled();
// The first close must finish before reopening, and keyboard dismissal must
// start after the popup has taken focus rather than racing its focus effect.
await waitFor(() =>
expect(
screen.queryByRole("menu", { name: "More options for Engineering" }),
).not.toBeInTheDocument(),
);
await user.click(trigger);
await waitFor(() =>
expect(
screen.getByRole("menu", { name: "More options for Engineering" }),
).toHaveFocus(),
);
await user.keyboard("{Escape}");
await waitFor(() => {
expect(
screen.queryByRole("menu", { name: "More options for Engineering" }),
).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
});
it("hides session actions when the Sessions plugin is unavailable", () => {
const onNewSession = vi.fn();
render(
<ChannelSidebarRow
channel={parent}
collapsed={false}
onToggle={() => {}}
icon={<svg />}
sessions={[]}
sessionsEnabled={false}
draft={false}
draftSelected={false}
onSelect={() => {}}
onPrepare={() => {}}
onNewSession={onNewSession}
/>,
);
expect(
screen.queryByRole("button", { name: "More options for Engineering" }),
).not.toBeInTheDocument();
});
it("offers a separate hide action only for DM rows", async () => {
const onHideDm = vi.fn();
const onSelect = vi.fn();
Expand All @@ -148,7 +92,6 @@ it("offers a separate hide action only for DM rows", async () => {
onToggle={() => {}}
icon={<svg />}
sessions={[]}
sessionsEnabled={false}
draft={false}
draftSelected={false}
onSelect={onSelect}
Expand Down Expand Up @@ -182,7 +125,6 @@ it("keeps focus in the sidebar when removing its last visible DM", async () => {
onToggle={() => {}}
icon={<svg />}
sessions={[]}
sessionsEnabled={false}
draft={false}
draftSelected={false}
onSelect={() => {}}
Expand Down
Loading
Loading