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

Commit 1ec3e3f

Browse files
authored
fix(channels): settle the slider's chrome
- Drop the back row's border; it's a sidebar row, not a control on top of one. - One FAB on both panes: with a channel it creates a task or canvas inside it, and either way it can create a channel. - Tighten the gap between the global nav and the pane below it. - Move Archived out of the sidebar and into the account menu, beside Settings. - Gate the list's search and ⌘1-9 hints on the layout flag, so the alpha's channel tree is untouched. Generated-By: PostHog Code Task-Id: 1932f1b0-a072-4de2-a21d-6455e6c445ee
1 parent fd0b090 commit 1ec3e3f

11 files changed

Lines changed: 199 additions & 143 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ The root `AGENTS.md` architecture rules still apply.
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
4747
slide and returning to the list doesn't rebuild every row.
48+
- One `ChannelsFab` serves both panes: given a `channelId` it creates inside
49+
that channel (task, canvas), and either way it can create a channel. Off the
50+
layout it keeps its original two-item menu. Archived moves out of the sidebar
51+
and into the account menu (`ProjectSwitcher`), beside Settings.
4852
- **Which pane shows is view state, not a route.** `channelPaneStore` holds it,
4953
separately from the scoped channel (`currentChannelStore`): "back to channels"
5054
browses the list while the route, the main pane and the scoped channel stay

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export function ChannelBackRow({ channelId }: { channelId: string }) {
5050
const showStar = current != null && current.name !== PERSONAL_CHANNEL_NAME;
5151

5252
return (
53-
<div className="relative mx-2 mt-2">
53+
<div className="relative mx-2 mt-1">
5454
<Tooltip content="Back to channels" side="bottom">
5555
<button
5656
type="button"
@@ -65,8 +65,9 @@ export function ChannelBackRow({ channelId }: { channelId: string }) {
6565
}}
6666
// Fixed height with an unconditional star well: sized off its
6767
// contents, a starrable channel ran 4px taller than #me and
68-
// everything below shifted on switch.
69-
className="flex h-8 w-full items-center gap-1.5 rounded-md border border-border px-2 text-left transition-colors hover:bg-fill-hover"
68+
// everything below shifted on switch. No border — it's a row in the
69+
// sidebar like the ones under it, not a control sitting on top.
70+
className="flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left transition-colors hover:bg-fill-hover"
7071
>
7172
<CaretLeftIcon
7273
size={12}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export function ChannelNav() {
9393
const isCommandCenter = view.type === "command-center";
9494

9595
return (
96-
<div className="flex shrink-0 gap-2 px-2 pt-2 pb-2">
96+
<div className="flex shrink-0 gap-2 px-2 pt-2 pb-1">
9797
<NavIcon
9898
icon={<EnvelopeSimple size={16} />}
9999
label="Inbox"

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { LOOPS_FLAG } from "@posthog/shared";
3030
import type { TaskRunStatus } from "@posthog/shared/domain-types";
3131
import { ChannelBackRow } from "@posthog/ui/features/canvas/components/ChannelBackRow";
3232
import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow";
33-
import { NewTaskFab } from "@posthog/ui/features/canvas/components/NewTaskFab";
33+
import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab";
3434
import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems";
3535
import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
3636
import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem";
@@ -375,7 +375,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
375375
</Empty>
376376
)}
377377
</div>
378-
<NewTaskFab channelId={channelId} />
378+
<ChannelsFab channelId={channelId} />
379379
</div>
380380
</div>
381381
);

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

Lines changed: 121 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,153 @@
1-
import { FileTextIcon, HashIcon, PlusIcon } from "@phosphor-icons/react";
1+
import {
2+
ChartBarIcon,
3+
FileTextIcon,
4+
HashIcon,
5+
PlusIcon,
6+
} from "@phosphor-icons/react";
27
import {
38
Button,
49
DropdownMenu,
510
DropdownMenuContent,
611
DropdownMenuItem,
12+
DropdownMenuSeparator,
713
DropdownMenuTrigger,
814
Tooltip,
915
TooltipContent,
1016
TooltipTrigger,
1117
} from "@posthog/quill";
18+
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
1219
import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal";
20+
import { trackAndCreateCanvas } from "@posthog/ui/features/canvas/createCanvasAnalytics";
21+
import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout";
22+
import { useCreateAndOpenDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards";
23+
import {
24+
formatHotkey,
25+
SHORTCUTS,
26+
} from "@posthog/ui/features/command/keyboard-shortcuts";
27+
import { isContentEmpty } from "@posthog/ui/features/message-editor/content";
28+
import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore";
29+
import { Tooltip as ShortcutTooltip } from "@posthog/ui/primitives/Tooltip";
1330
import { openTaskInput } from "@posthog/ui/router/useOpenTask";
31+
import { track } from "@posthog/ui/shell/analytics";
1432
import { useRouterState } from "@tanstack/react-router";
1533
import { useState } from "react";
1634

17-
// The create affordance for the Channels space, floated over the bottom-right
18-
// of the channel list. It owns the create-channel modal (the list itself has no
19-
// other entry point) and opens its menu upward, since it sits at the bottom.
20-
export function ChannelsFab() {
35+
/**
36+
* The create affordance for the Channels space, floated over the bottom-right
37+
* of whichever sidebar pane is showing.
38+
*
39+
* The same button on both panes, so "create" is always the same corner: given a
40+
* channel it creates inside it (task, canvas), and either way it can create a
41+
* channel — the list has no other entry point for that.
42+
*/
43+
export function ChannelsFab({ channelId }: { channelId?: string }) {
44+
const channelsLayout = useChannelsLayout();
2145
const [modalOpen, setModalOpen] = useState(false);
46+
const hasDraft = useDraftStore(
47+
(s) => !isContentEmpty(s.drafts["task-input"]),
48+
);
49+
const createAndOpenCanvas = useCreateAndOpenDashboard(channelId);
2250
// New task has no /website mirror yet, so it jumps back to Code unless we're
2351
// already in the Channels space — same rule as the nav's New task row.
2452
const inChannels = useRouterState({
2553
select: (s) => s.location.pathname.startsWith("/website"),
2654
});
2755

56+
const newTask = () => {
57+
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
58+
action_type: "new_task_open",
59+
surface: "sidebar",
60+
channel_id: channelId,
61+
});
62+
// In a channel the task is filed there; from the list it's whatever the
63+
// space defaults to.
64+
if (channelId) {
65+
openTaskInput({ channelId });
66+
return;
67+
}
68+
openTaskInput(inChannels ? { space: "website" } : undefined);
69+
};
70+
71+
const newChannelItem = (
72+
<DropdownMenuItem onClick={() => setModalOpen(true)}>
73+
<HashIcon size={14} className="text-gray-9" />
74+
New channel
75+
</DropdownMenuItem>
76+
);
77+
78+
const trigger = (
79+
<Button
80+
variant="primary"
81+
size="icon-lg"
82+
aria-label="Create"
83+
className="absolute right-3 bottom-3 z-10 rounded-full shadow-lg"
84+
>
85+
<PlusIcon size={20} weight="bold" />
86+
{channelsLayout && hasDraft && (
87+
<span
88+
aria-hidden
89+
className="absolute top-0.5 right-0.5 size-2 rounded-full bg-current ring-(--primary) ring-2"
90+
/>
91+
)}
92+
</Button>
93+
);
94+
2895
return (
2996
<>
3097
<DropdownMenu>
31-
<Tooltip>
32-
<TooltipTrigger
33-
render={
34-
<DropdownMenuTrigger
35-
render={
36-
<Button
37-
variant="primary"
38-
size="icon-lg"
39-
aria-label="Create"
40-
className="absolute right-3 bottom-3 z-10 rounded-full shadow-lg"
41-
>
42-
<PlusIcon size={20} weight="bold" />
43-
</Button>
44-
}
45-
/>
46-
}
47-
/>
48-
<TooltipContent side="top" align="center">
49-
Create something new
50-
</TooltipContent>
51-
</Tooltip>
52-
<DropdownMenuContent align="center" side="top" sideOffset={6}>
53-
<DropdownMenuItem onClick={() => setModalOpen(true)}>
54-
<HashIcon size={14} className="text-gray-9" />
55-
New channel
56-
</DropdownMenuItem>
57-
<DropdownMenuItem
58-
onClick={() =>
59-
openTaskInput(inChannels ? { space: "website" } : undefined)
60-
}
98+
{channelsLayout ? (
99+
// The draft dot needs saying out loud, and the button is where the
100+
// create shortcut is worth advertising.
101+
<ShortcutTooltip
102+
content={hasDraft ? "Create — you have a draft" : "Create"}
103+
shortcut={formatHotkey(SHORTCUTS.NEW_TASK)}
104+
side="top"
61105
>
106+
<DropdownMenuTrigger render={trigger} />
107+
</ShortcutTooltip>
108+
) : (
109+
<Tooltip>
110+
<TooltipTrigger render={<DropdownMenuTrigger render={trigger} />} />
111+
<TooltipContent side="top" align="center">
112+
Create something new
113+
</TooltipContent>
114+
</Tooltip>
115+
)}
116+
<DropdownMenuContent
117+
align={channelId ? "end" : "center"}
118+
side="top"
119+
sideOffset={6}
120+
>
121+
{/* Off the layout this is the list's only menu, and "New channel"
122+
has led it since it shipped — leave that alone. */}
123+
{!channelsLayout && newChannelItem}
124+
<DropdownMenuItem onClick={newTask}>
62125
<FileTextIcon size={14} className="text-gray-9" />
63126
New task
64127
</DropdownMenuItem>
128+
{channelId && (
129+
<DropdownMenuItem
130+
onClick={() => {
131+
// Create + open a canvas with the default template directly;
132+
// the canvas's own composer drives what gets built.
133+
trackAndCreateCanvas(
134+
channelId,
135+
undefined,
136+
"sidebar",
137+
() => void createAndOpenCanvas(),
138+
);
139+
}}
140+
>
141+
<ChartBarIcon size={14} className="text-gray-9" />
142+
New canvas
143+
</DropdownMenuItem>
144+
)}
145+
{channelsLayout && (
146+
<>
147+
<DropdownMenuSeparator />
148+
{newChannelItem}
149+
</>
150+
)}
65151
</DropdownMenuContent>
66152
</DropdownMenu>
67153

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
66
const mocks = vi.hoisted(() => ({
77
channels: [] as { id: string; name: string; path: string }[],
88
starredPaths: [] as string[],
9+
channelsLayout: true,
910
navigate: vi.fn(),
1011
}));
1112

1213
vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() }));
14+
vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({
15+
useChannelsLayout: () => mocks.channelsLayout,
16+
}));
1317
vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({
1418
useChannels: () => ({ channels: mocks.channels, isLoading: false }),
1519
useChannelMutations: () => ({ createChannel: vi.fn(), isDeleting: false }),
@@ -63,6 +67,7 @@ describe("ChannelsList", () => {
6367
vi.clearAllMocks();
6468
mocks.channels = [ME, ENG, DESIGN];
6569
mocks.starredPaths = [];
70+
mocks.channelsLayout = true;
6671
});
6772

6873
it("pins #me above the channels, with its ⌘1 shortcut", () => {
@@ -105,6 +110,15 @@ describe("ChannelsList", () => {
105110
expect(screen.getByText("engineering")).toBeTruthy();
106111
});
107112

113+
// The alpha renders this list as a plain tree with no slider around it, and
114+
// ChannelHotkeys doesn't bind ⌘1-9 there either — so neither shows.
115+
it("is absent off the channels layout, along with the shortcut hints", () => {
116+
mocks.channelsLayout = false;
117+
renderList();
118+
expect(screen.queryByLabelText("Search channels")).toBeNull();
119+
expect(screen.getByText("me").parentElement?.textContent).toBe("me");
120+
});
121+
108122
it("says so when nothing matches", async () => {
109123
const user = userEvent.setup();
110124
renderList();

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

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
useChannelMutations,
5858
useChannels,
5959
} from "@posthog/ui/features/canvas/hooks/useChannels";
60+
import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout";
6061
import { useCreateAndOpenDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards";
6162
import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots";
6263
import {
@@ -773,13 +774,18 @@ export function ChannelsList() {
773774
const { channels: allChannels, isLoading } = useChannels();
774775
const { starredRefToShortcutId } = useChannelStars();
775776
// ChannelHotkeys owns the keys these slots describe; sharing the derivation
776-
// keeps the advertised key and the key that fires in agreement.
777+
// keeps the advertised key and the key that fires in agreement — including
778+
// the fact that it only binds them under the layout, so off it the list
779+
// advertises nothing.
777780
const { slotFor } = useStarredChannelSlots();
781+
// Search and the shortcut hints belong to the slider, where this list is a
782+
// pane you switch channels from. The alpha still renders it as a plain tree.
783+
const channelsLayout = useChannelsLayout();
778784

779785
const isUnread = useIsChannelUnread();
780786

781787
const [query, setQuery] = useState("");
782-
const normalizedQuery = query.trim().toLowerCase();
788+
const normalizedQuery = channelsLayout ? query.trim().toLowerCase() : "";
783789
const matches = (name: string) =>
784790
!normalizedQuery || name.toLowerCase().includes(normalizedQuery);
785791

@@ -802,15 +808,17 @@ export function ChannelsList() {
802808
// moving to the next row reveals its tooltip instantly (no re-delay).
803809
<TooltipProvider delay={600}>
804810
<Flex direction="column" className="h-full min-h-0">
805-
<Box className="shrink-0 px-2 pt-2">
806-
<Input
807-
value={query}
808-
onChange={(event) => setQuery(event.target.value)}
809-
placeholder="Search channels…"
810-
aria-label="Search channels"
811-
className="h-7 text-[13px]"
812-
/>
813-
</Box>
811+
{channelsLayout && (
812+
<Box className="shrink-0 px-2 pt-1">
813+
<Input
814+
value={query}
815+
onChange={(event) => setQuery(event.target.value)}
816+
placeholder="Search channels…"
817+
aria-label="Search channels"
818+
className="h-7 text-[13px]"
819+
/>
820+
</Box>
821+
)}
814822
{/* Bottom padding clears the floating create button (ChannelsFab), so
815823
the last channel stays reachable at full scroll. */}
816824
<Flex
@@ -838,7 +846,9 @@ export function ChannelsList() {
838846
</>
839847
) : (
840848
<>
841-
<PersonalChannelRow hotkeySlot={me ? slotFor(me) : undefined} />
849+
<PersonalChannelRow
850+
hotkeySlot={channelsLayout && me ? slotFor(me) : undefined}
851+
/>
842852

843853
{starred.length > 0 && (
844854
<ChannelGroup sectionId={STARRED_SECTION_ID} label="Starred">
@@ -847,7 +857,7 @@ export function ChannelsList() {
847857
key={channel.id}
848858
channel={channel}
849859
isUnread={isUnread(channel.name)}
850-
hotkeySlot={slotFor(channel)}
860+
hotkeySlot={channelsLayout ? slotFor(channel) : undefined}
851861
/>
852862
))}
853863
</ChannelGroup>

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,14 +176,14 @@ describe("ChannelsSidebar", () => {
176176
mocks.archivedTaskIds = new Set(["archived-1"]);
177177
});
178178

179-
// The layout puts an Archive action on every item row, so hiding the
180-
// destination leaves archived tasks with nowhere to be seen —
181-
// navigateToArchived has no other caller in the app.
182-
it("is present under the channels layout", () => {
179+
// The layout puts an Archive action on every item row, so the destination
180+
// still has to exist — it moved into the account menu (ProjectSwitcher),
181+
// which is where navigateToArchived is called from now.
182+
it("leaves the sidebar body under the channels layout", () => {
183183
mocks.channelsLayout = true;
184184
mocks.channels = [ME];
185185
renderSidebar();
186-
expect(screen.getByText("Archived")).toBeTruthy();
186+
expect(screen.queryByText("Archived")).toBeNull();
187187
});
188188

189189
it("is present with neither channels world on", () => {

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,9 @@ export function ChannelsSidebar() {
134134
const channelsLayout = useChannelsLayout();
135135
const channelsWorld = channelsLayout || channelsEnabled;
136136
const bodyChannelsWorld = useDeferredValue(channelsWorld);
137-
const showArchivedRow = channelsLayout || !bodyChannelsWorld;
137+
// Under the layout the row moves into the account menu (ProjectSwitcher),
138+
// beside Settings — the bottom of the sidebar belongs to the channel list.
139+
const showArchivedRow = !channelsLayout && !bodyChannelsWorld;
138140
useTrackChannelsSpaceViewed({
139141
enabled: channelsWorld,
140142
layout: channelsLayout ? "channels" : "code",

0 commit comments

Comments
 (0)