Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 46a504c

Browse files
authored
fix(channels): flatten the list and make the panes swipeable (#3832)
1 parent 118880b commit 46a504c

6 files changed

Lines changed: 208 additions & 15 deletions

File tree

packages/ui/src/features/canvas/AGENTS.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,12 @@ The root `AGENTS.md` architecture rules still apply.
4444
(`ChannelPanes` in `ChannelsSidebar.tsx`): the searchable channel list, and the
4545
channel you're in (`ChannelSidebar`, headed by `ChannelBackRow`). Both panes
4646
stay mounted — the offscreen one is `inert` — so the slide has something to
47-
slide and returning to the list doesn't rebuild every row.
47+
slide and returning to the list doesn't rebuild every row. A two-finger
48+
horizontal swipe moves between them (`useChannelPaneSwipe`, wheel `deltaX`
49+
accumulated per gesture and locked until the wheel goes quiet).
50+
- In the list, "Starred"/"Channels" are headings, not parents: under the layout
51+
the rows sit at the heading's level (no indent) and the "#"/lock glyph belongs
52+
to the rows. The alpha's indented tree is unchanged.
4853
- One `ChannelsFab` serves both panes: given a `channelId` it creates inside
4954
that channel (task, canvas), and either way it can create a channel. Off the
5055
layout it keeps its original two-item menu. Archived moves out of the sidebar

packages/ui/src/features/canvas/components/ChannelsList.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,26 @@ describe("ChannelsList", () => {
8282
expect(me.parentElement?.textContent).toMatch(/me(|Ctrl)/);
8383
});
8484

85+
// "Starred" and "Channels" are headings over the rows, not parents of them —
86+
// under the layout the rows sit at the heading's level and keep the "#" for
87+
// themselves. The alpha's tree is unchanged.
88+
describe("group headings", () => {
89+
beforeEach(() => {
90+
mocks.starredPaths = [ENG.path];
91+
});
92+
93+
it("does not indent rows under the layout", () => {
94+
renderList();
95+
expect(screen.getByText("engineering").closest(".pl-5")).toBeNull();
96+
});
97+
98+
it("keeps the indented tree off the layout", () => {
99+
mocks.channelsLayout = false;
100+
renderList();
101+
expect(screen.getByText("engineering").closest(".pl-5")).toBeTruthy();
102+
});
103+
});
104+
85105
describe("search", () => {
86106
// The list is the only way to switch channels now, so with a few dozen
87107
// channels it has to be filterable rather than only scrollable.

packages/ui/src/features/canvas/components/ChannelsList.tsx

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ function ChannelSection({
407407
{channel.name}
408408
</OverflowTickerText>
409409
{hotkeySlot != null && (
410-
<Kbd className="ml-auto shrink-0 group-hover/chan:opacity-0">
410+
<Kbd className="ml-auto shrink-0 opacity-50 group-hover/chan:opacity-0">
411411
{formatHotkey(`mod+${hotkeySlot}`)}
412412
</Kbd>
413413
)}
@@ -645,7 +645,7 @@ function PersonalChannelRow({ hotkeySlot }: { hotkeySlot?: number }) {
645645
{PERSONAL_CHANNEL_NAME}
646646
</span>
647647
{hotkeySlot != null && (
648-
<Kbd className="ml-auto shrink-0 group-hover/chan:opacity-0">
648+
<Kbd className="ml-auto shrink-0 opacity-50 group-hover/chan:opacity-0">
649649
{formatHotkey(`mod+${hotkeySlot}`)}
650650
</Kbd>
651651
)}
@@ -708,18 +708,23 @@ const CHANNELS_SECTION_ID = "channels:all";
708708
// the label styling) and animates the panel height (which janked on a list this
709709
// long). Unstyled parts give a plain label row that snaps.
710710
//
711-
// The whole header row is the trigger. It rests as a "#" and swaps to a chevron
712-
// on hover or keyboard focus, so the row only advertises the disclosure when
713-
// you're actually reaching for it.
711+
// The whole header row is the trigger. Under the layout the icon well rests
712+
// empty and fills with a chevron on hover or keyboard focus, so the row only
713+
// advertises the disclosure when you're reaching for it — a "#" there read as a
714+
// channel named "Starred", and the glyph belongs to the rows, not the label
715+
// above them.
714716
function ChannelGroup({
715717
sectionId,
716718
label,
717719
className,
720+
flat,
718721
children,
719722
}: {
720723
sectionId: string;
721724
label: string;
722725
className?: string;
726+
/** Layout-only: rows sit at the label's level instead of indented under it. */
727+
flat?: boolean;
723728
children: ReactNode;
724729
}) {
725730
const collapsedSections = useSidebarStore((s) => s.collapsedSections);
@@ -744,10 +749,12 @@ function ChannelGroup({
744749
render={<MenuLabel render={<button type="button" />} />}
745750
>
746751
<span className="relative flex size-3.5 shrink-0 items-center justify-center">
747-
<HashIcon
748-
size={14}
749-
className="group-hover/group-trigger:hidden group-focus-visible/group-trigger:hidden"
750-
/>
752+
{!flat && (
753+
<HashIcon
754+
size={14}
755+
className="group-hover/group-trigger:hidden group-focus-visible/group-trigger:hidden"
756+
/>
757+
)}
751758
{isOpen ? (
752759
<CaretDownIcon
753760
size={14}
@@ -767,7 +774,7 @@ function ChannelGroup({
767774
makes each expand rebuild the lot (~940ms for 46 channels, vs ~80ms
768775
to collapse). */}
769776
<Collapsible.Panel keepMounted>
770-
<div className="pl-5">{children}</div>
777+
<div className={cn(!flat && "pl-5")}>{children}</div>
771778
</Collapsible.Panel>
772779
</Collapsible.Root>
773780
);
@@ -859,7 +866,11 @@ export function ChannelsList() {
859866
/>
860867

861868
{starred.length > 0 && (
862-
<ChannelGroup sectionId={STARRED_SECTION_ID} label="Starred">
869+
<ChannelGroup
870+
sectionId={STARRED_SECTION_ID}
871+
label="Starred"
872+
flat={channelsLayout}
873+
>
863874
{starred.map((channel) => (
864875
<ChannelSection
865876
key={channel.id}
@@ -871,7 +882,11 @@ export function ChannelsList() {
871882
</ChannelGroup>
872883
)}
873884

874-
<ChannelGroup sectionId={CHANNELS_SECTION_ID} label="Channels">
885+
<ChannelGroup
886+
sectionId={CHANNELS_SECTION_ID}
887+
label="Channels"
888+
flat={channelsLayout}
889+
>
875890
{!isLoading && channels.length === 0 && (
876891
<Empty className="px-2 py-1 text-subtle-foreground text-xs">
877892
<EmptyHeader className="text-left">

packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Theme } from "@radix-ui/themes";
22
import { act, render, screen } from "@testing-library/react";
3-
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44

55
const mocks = vi.hoisted(() => ({
66
featureFlags: new Map<string, boolean>(),
@@ -169,6 +169,79 @@ describe("ChannelsSidebar", () => {
169169
expect(listIsInteractive()).toBe(true);
170170
expect(screen.queryByTestId("channel-sidebar")).toBeNull();
171171
});
172+
173+
// A trackpad swipe reaches the panes as a horizontal wheel. Right (negative
174+
// deltaX, the platform "back" direction) leaves the channel; left returns to
175+
// the one still scoped.
176+
describe("swiping", () => {
177+
// Wheel deltas within one gesture arrive back to back; a pause between
178+
// them is what ends it. Fake timers let a test say which it's sending.
179+
const wheel = (deltaX: number, deltaY = 0) =>
180+
act(() => {
181+
screen.getByTestId("channels-list").dispatchEvent(
182+
new WheelEvent("wheel", {
183+
deltaX,
184+
deltaY,
185+
bubbles: true,
186+
cancelable: true,
187+
}),
188+
);
189+
});
190+
const pause = () => act(() => void vi.advanceTimersByTime(500));
191+
192+
beforeEach(() => {
193+
vi.useFakeTimers();
194+
mocks.routeChannelId = ENG.id;
195+
});
196+
afterEach(() => vi.useRealTimers());
197+
198+
it("goes back to the list and forward into the channel", () => {
199+
renderSidebar();
200+
201+
wheel(-80);
202+
expect(listIsInteractive()).toBe(true);
203+
// The channel is browsed away from, not left.
204+
expect(useCurrentChannelStore.getState().currentChannelId).toBe(ENG.id);
205+
206+
pause();
207+
wheel(80);
208+
expect(listIsInteractive()).toBe(false);
209+
});
210+
211+
// One flick is dozens of small deltas, so the distance has to add up
212+
// across them rather than be read off any one event.
213+
it("adds a gesture's deltas up", () => {
214+
renderSidebar();
215+
wheel(-20);
216+
expect(listIsInteractive()).toBe(false);
217+
wheel(-20);
218+
wheel(-20);
219+
expect(listIsInteractive()).toBe(true);
220+
});
221+
222+
it("ignores a mostly-vertical wheel", () => {
223+
renderSidebar();
224+
wheel(-80, -200);
225+
expect(listIsInteractive()).toBe(false);
226+
});
227+
228+
it("forgets a nudge once the gesture ends", () => {
229+
renderSidebar();
230+
wheel(-30);
231+
pause();
232+
wheel(-30);
233+
expect(listIsInteractive()).toBe(false);
234+
});
235+
236+
// The momentum tail of one flick keeps delivering deltas; read as fresh
237+
// travel they'd swipe straight back to where the flick started.
238+
it("does not let one flick's momentum swipe twice", () => {
239+
renderSidebar();
240+
wheel(-80);
241+
wheel(200);
242+
expect(listIsInteractive()).toBe(true);
243+
});
244+
});
172245
});
173246

174247
describe("the Archived row", () => {

packages/ui/src/features/canvas/components/ChannelsSidebar.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import { ChannelSidebar } from "@posthog/ui/features/canvas/components/ChannelSi
77
import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab";
88
import { ChannelsList } from "@posthog/ui/features/canvas/components/ChannelsList";
99
import { useChannelsSidebarStore } from "@posthog/ui/features/canvas/components/channelsSidebarStore";
10+
import { useChannelPaneSwipe } from "@posthog/ui/features/canvas/hooks/useChannelPaneSwipe";
1011
import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout";
1112
import { useCurrentChannel } from "@posthog/ui/features/canvas/hooks/useCurrentChannel";
1213
import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels";
1314
import { useTrackChannelsSpaceViewed } from "@posthog/ui/features/canvas/hooks/useTrackChannelsSpaceViewed";
1415
import {
16+
showChannelList,
1517
showChannelPane,
1618
useChannelPaneStore,
1719
} from "@posthog/ui/features/canvas/stores/channelPaneStore";
@@ -48,6 +50,10 @@ import { useDeferredValue, useEffect, useRef } from "react";
4850
* Both panes stay mounted so the slide has something to slide, and so coming
4951
* back to the list doesn't rebuild every row's menus and dialogs. The offscreen
5052
* one is `inert`, keeping it out of the tab order and off screen readers.
53+
*
54+
* A two-finger horizontal swipe moves between them, so the back row isn't the
55+
* only way out of a channel — and swiping the other way returns to the channel
56+
* that stayed scoped the whole time.
5157
*/
5258
function ChannelPanes({
5359
channelId,
@@ -56,8 +62,17 @@ function ChannelPanes({
5662
channelId: string | null;
5763
showList: boolean;
5864
}) {
65+
const panesRef = useRef<HTMLDivElement | null>(null);
66+
useChannelPaneSwipe(panesRef, {
67+
// With no channel to slide to, the list is all there is — leave the gesture
68+
// to the platform rather than eat it for a slide that can't happen.
69+
enabled: channelId != null,
70+
onBack: showChannelList,
71+
onForward: showChannelPane,
72+
});
73+
5974
return (
60-
<Box className="min-h-0 flex-1 overflow-hidden">
75+
<Box ref={panesRef} className="min-h-0 flex-1 overflow-hidden">
6176
<div
6277
className={cn(
6378
"flex h-full w-[200%] transition-transform duration-200 ease-out motion-reduce:transition-none",
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { RefObject } from "react";
2+
import { useEffect } from "react";
3+
4+
/** How far a horizontal gesture has to travel before it counts as a swipe. */
5+
const SWIPE_THRESHOLD_PX = 45;
6+
/** A pause this long ends the gesture, so momentum can't chain two swipes. */
7+
const GESTURE_GAP_MS = 180;
8+
9+
/**
10+
* Two-finger horizontal swipes over the sidebar, mapped to the pane slider.
11+
*
12+
* A trackpad swipe arrives as a `wheel` event carrying `deltaX`; the sidebar has
13+
* nothing to scroll sideways, so we claim those and translate them into the
14+
* same back/forward the back row and a channel click already do. Swipe right
15+
* (the platform "back" direction, a negative `deltaX`) goes out to the list;
16+
* swipe left goes back into the channel you're still scoped to.
17+
*
18+
* Distance is accumulated across the event stream rather than read off a single
19+
* event — one flick is dozens of small deltas. Once a swipe fires, the gesture
20+
* is locked until the wheel goes quiet, so the momentum tail doesn't bounce the
21+
* panes back and forth.
22+
*/
23+
export function useChannelPaneSwipe(
24+
ref: RefObject<HTMLElement | null>,
25+
{
26+
enabled,
27+
onBack,
28+
onForward,
29+
}: { enabled: boolean; onBack: () => void; onForward: () => void },
30+
): void {
31+
useEffect(() => {
32+
const element = ref.current;
33+
if (!element || !enabled) return;
34+
35+
let travelled = 0;
36+
let lastEventAt = Number.NEGATIVE_INFINITY;
37+
let locked = false;
38+
39+
const onWheel = (event: WheelEvent) => {
40+
// A mostly-vertical wheel is someone scrolling the list, not swiping.
41+
if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return;
42+
// Claim it before anything upstream reads it as history navigation.
43+
event.preventDefault();
44+
45+
if (event.timeStamp - lastEventAt > GESTURE_GAP_MS) {
46+
travelled = 0;
47+
locked = false;
48+
}
49+
lastEventAt = event.timeStamp;
50+
if (locked) return;
51+
52+
travelled += event.deltaX;
53+
if (travelled <= -SWIPE_THRESHOLD_PX) {
54+
locked = true;
55+
onBack();
56+
} else if (travelled >= SWIPE_THRESHOLD_PX) {
57+
locked = true;
58+
onForward();
59+
}
60+
};
61+
62+
element.addEventListener("wheel", onWheel, { passive: false });
63+
return () => element.removeEventListener("wheel", onWheel);
64+
}, [ref, enabled, onBack, onForward]);
65+
}

0 commit comments

Comments
 (0)