Skip to content

Commit b6f77aa

Browse files
authored
feat(canvas): hide contexts into a collapsed group
Add a "Hide context" action to each context's three-dot / right-click menu in the Contexts explorer, mirroring "Star context". Hidden contexts drop out of the starred and main lists and collapse into a "Hidden" group at the bottom of the sidebar (collapsed by default). Hidden state persists per-user via desktop file-system shortcuts (a distinct "hidden-folder" shortcut type), reusing the shared shortcuts query so stars and hides don't double-fetch. Generated-By: PostHog Code Task-Id: 4c16844c-acc9-4124-909d-f07335710a91
1 parent 6306e29 commit b6f77aa

5 files changed

Lines changed: 367 additions & 16 deletions

File tree

packages/shared/src/analytics-events.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,8 @@ export type ChannelActionType =
863863
| "delete"
864864
| "star"
865865
| "unstar"
866+
| "hide"
867+
| "unhide"
866868
| "edit_context_open"
867869
| "new_task_open"
868870
| "new_task_suggestion"
@@ -966,6 +968,7 @@ export interface ChannelsSpaceViewedProperties {
966968
/** Total channels visible when the space mounts. */
967969
channel_count: number;
968970
starred_count: number;
971+
hidden_count: number;
969972
}
970973

971974
// Subscription / billing events

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

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import {
2+
CaretDownIcon,
3+
CaretRightIcon,
24
ChartBarIcon,
35
DotsThreeIcon,
6+
EyeIcon,
7+
EyeSlashIcon,
48
FileTextIcon,
59
LinkIcon,
610
LockSimpleIcon,
@@ -40,6 +44,10 @@ import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
4044
import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal";
4145
import { RenameChannelModal } from "@posthog/ui/features/canvas/components/RenameChannelModal";
4246
import { trackAndCreateCanvas } from "@posthog/ui/features/canvas/createCanvasAnalytics";
47+
import {
48+
useChannelHides,
49+
useChannelHideToggle,
50+
} from "@posthog/ui/features/canvas/hooks/useChannelHides";
4351
import {
4452
useChannelStars,
4553
useChannelStarToggle,
@@ -76,9 +84,10 @@ type ChannelActionItem = {
7684
separatorBefore?: boolean;
7785
};
7886

79-
// The channel actions (star, copy link, rename, delete) plus the rename-modal
80-
// state they drive. Single source of truth so the dropdown and context menus
81-
// stay in lockstep — add an action here and both surfaces pick it up.
87+
// The channel actions (star, hide, copy link, rename, delete) plus the
88+
// rename-modal state they drive. Single source of truth so the dropdown and
89+
// context menus stay in lockstep — add an action here and both surfaces pick
90+
// it up.
8291
function useChannelActions(channel: Channel): {
8392
actions: ChannelActionItem[];
8493
renameOpen: boolean;
@@ -96,6 +105,8 @@ function useChannelActions(channel: Channel): {
96105
const pathname = useRouterState({ select: (s) => s.location.pathname });
97106
const { deleteChannel, isDeleting } = useChannelMutations();
98107
const { isStarred, toggleStar, removeStar } = useChannelStarToggle(channel);
108+
const { isHidden, toggleHidden, removeHidden } =
109+
useChannelHideToggle(channel);
99110

100111
// Runs the actual delete once confirmed. Returns whether it succeeded so the
101112
// dialog can stay open (and show the toast) on failure.
@@ -120,6 +131,7 @@ function useChannelActions(channel: Channel): {
120131

121132
await deleteChannel(channel.id);
122133
removeStar();
134+
removeHidden();
123135
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
124136
action_type: "delete",
125137
surface: "sidebar",
@@ -159,6 +171,19 @@ function useChannelActions(channel: Channel): {
159171
toggleStar();
160172
},
161173
},
174+
{
175+
key: "hide",
176+
label: isHidden ? "Unhide context" : "Hide context",
177+
icon: isHidden ? <EyeIcon size={14} /> : <EyeSlashIcon size={14} />,
178+
onSelect: () => {
179+
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
180+
action_type: isHidden ? "unhide" : "hide",
181+
surface: "sidebar",
182+
channel_id: channel.id,
183+
});
184+
toggleHidden();
185+
},
186+
},
162187
{
163188
key: "copy-link",
164189
label: "Copy link",
@@ -532,17 +557,26 @@ function PersonalChannelRow() {
532557

533558
// The channel list — the Channels space sidebar body. The private "#me"
534559
// channel is pinned at the top; starred channels surface in their own section
535-
// so the ones you use most stay in reach; the rest sit under a "Channels"
536-
// label with the "New" channel button.
560+
// so the ones you use most stay in reach; the rest sit under a "Contexts"
561+
// label with the "New" channel button; channels the user has hidden collapse
562+
// into a "Hidden" group at the bottom.
537563
export function ChannelsList() {
538564
const { channels: allChannels, isLoading } = useChannels();
539565
const { starredRefToShortcutId } = useChannelStars();
566+
const { hiddenRefToShortcutId } = useChannelHides();
540567
const [modalOpen, setModalOpen] = useState(false);
568+
// The "Hidden" group is collapsed by default — hidden channels are ones the
569+
// user chose to get out of the way.
570+
const [hiddenExpanded, setHiddenExpanded] = useState(false);
541571

542572
// The "me" folder renders as the pinned personal row, not a shared channel.
543573
const channels = allChannels.filter((c) => c.name !== PERSONAL_CHANNEL_NAME);
544-
const starred = channels.filter((c) => starredRefToShortcutId.has(c.path));
545-
const others = channels.filter((c) => !starredRefToShortcutId.has(c.path));
574+
// Hiding wins over starring: a hidden channel leaves both the starred and
575+
// main lists and only surfaces under the collapsed "Hidden" group.
576+
const hidden = channels.filter((c) => hiddenRefToShortcutId.has(c.path));
577+
const visible = channels.filter((c) => !hiddenRefToShortcutId.has(c.path));
578+
const starred = visible.filter((c) => starredRefToShortcutId.has(c.path));
579+
const others = visible.filter((c) => !starredRefToShortcutId.has(c.path));
546580

547581
// Fire CHANNELS_SPACE_VIEWED once per space mount, after channels first load
548582
// (so the counts are accurate). The sidebar stays mounted while navigating
@@ -554,8 +588,9 @@ export function ChannelsList() {
554588
track(ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, {
555589
channel_count: channels.length,
556590
starred_count: starred.length,
591+
hidden_count: hidden.length,
557592
});
558-
}, [isLoading, channels.length, starred.length]);
593+
}, [isLoading, channels.length, starred.length, hidden.length]);
559594

560595
return (
561596
// One shared provider groups every row tooltip so that once one shows,
@@ -608,6 +643,36 @@ export function ChannelsList() {
608643
<ChannelSection key={channel.id} channel={channel} />
609644
))}
610645
</div>
646+
647+
{hidden.length > 0 && (
648+
<Box className="mt-3">
649+
<MenuLabel className="uppercase">
650+
<button
651+
type="button"
652+
aria-expanded={hiddenExpanded}
653+
onClick={() => setHiddenExpanded((open) => !open)}
654+
className="flex w-full items-center gap-2"
655+
>
656+
{hiddenExpanded ? (
657+
<CaretDownIcon size={14} className="text-gray-9" />
658+
) : (
659+
<CaretRightIcon size={14} className="text-gray-9" />
660+
)}
661+
<EyeSlashIcon size={14} className="text-gray-9" />
662+
Hidden
663+
<span className="ml-0.5 text-gray-9">{hidden.length}</span>
664+
</button>
665+
</MenuLabel>
666+
</Box>
667+
)}
668+
669+
{hidden.length > 0 && hiddenExpanded && (
670+
<div className="pl-2">
671+
{hidden.map((channel) => (
672+
<ChannelSection key={channel.id} channel={channel} />
673+
))}
674+
</div>
675+
)}
611676
</Flex>
612677

613678
<CreateChannelModal open={modalOpen} onOpenChange={setModalOpen} />
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import type { Schemas } from "@posthog/api-client";
2+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3+
import { act, renderHook, waitFor } from "@testing-library/react";
4+
import type { ReactNode } from "react";
5+
import { beforeEach, describe, expect, it, vi } from "vitest";
6+
7+
const mockClient = vi.hoisted(() => ({
8+
getDesktopFileSystemShortcuts: vi.fn(),
9+
createDesktopFileSystemShortcut: vi.fn(),
10+
deleteDesktopFileSystemShortcut: vi.fn(),
11+
}));
12+
vi.mock("@posthog/ui/features/auth/authClient", () => ({
13+
useOptionalAuthenticatedClient: () => mockClient,
14+
}));
15+
vi.mock("@posthog/ui/primitives/toast", () => ({
16+
toast: { error: vi.fn(), success: vi.fn() },
17+
}));
18+
19+
import { useChannelHides, useChannelHideToggle } from "./useChannelHides";
20+
import type { Channel } from "./useChannels";
21+
22+
function shortcut(
23+
id: string,
24+
type: string,
25+
ref: string | null,
26+
): Schemas.FileSystemShortcut {
27+
return {
28+
id,
29+
path: ref?.replace(/^\/+/, "") ?? "x",
30+
type,
31+
ref,
32+
created_at: "2026-01-01T00:00:00Z",
33+
};
34+
}
35+
36+
function channel(id: string, name: string, path: string): Channel {
37+
return { id, name, path };
38+
}
39+
40+
let queryClient: QueryClient;
41+
function wrapper({ children }: { children: ReactNode }) {
42+
return (
43+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
44+
);
45+
}
46+
47+
describe("useChannelHides", () => {
48+
beforeEach(() => {
49+
vi.clearAllMocks();
50+
queryClient = new QueryClient({
51+
defaultOptions: { queries: { retry: false } },
52+
});
53+
});
54+
55+
it("maps hidden-folder shortcuts by ref, ignoring stars and ref-less rows", async () => {
56+
mockClient.getDesktopFileSystemShortcuts.mockResolvedValue([
57+
shortcut("s1", "hidden-folder", "/alpha"),
58+
shortcut("s2", "folder", "/beta"), // a star, not a hide
59+
shortcut("s3", "hidden-folder", null), // no ref to link
60+
]);
61+
62+
const { result } = renderHook(() => useChannelHides(), { wrapper });
63+
await waitFor(() => expect(result.current.isLoading).toBe(false));
64+
65+
expect([...result.current.hiddenRefToShortcutId.entries()]).toEqual([
66+
["/alpha", "s1"],
67+
]);
68+
});
69+
70+
it("hides an unhidden channel via its raw path, updating the cache immediately", async () => {
71+
mockClient.getDesktopFileSystemShortcuts.mockResolvedValue([]);
72+
73+
const hides = renderHook(() => useChannelHides(), { wrapper });
74+
await waitFor(() => expect(hides.result.current.isLoading).toBe(false));
75+
76+
const created = shortcut("s1", "hidden-folder", "/alpha");
77+
mockClient.createDesktopFileSystemShortcut.mockResolvedValue(created);
78+
// Hang the refetch so only the optimistic cache write is exercised.
79+
mockClient.getDesktopFileSystemShortcuts.mockReturnValue(
80+
new Promise(() => {}),
81+
);
82+
83+
const toggle = renderHook(
84+
() => useChannelHideToggle(channel("1", "alpha", "/alpha")),
85+
{ wrapper },
86+
);
87+
expect(toggle.result.current.isHidden).toBe(false);
88+
89+
await act(async () => {
90+
toggle.result.current.toggleHidden();
91+
});
92+
93+
expect(mockClient.createDesktopFileSystemShortcut).toHaveBeenCalledWith({
94+
path: "alpha",
95+
type: "hidden-folder",
96+
ref: "/alpha",
97+
});
98+
await waitFor(() =>
99+
expect(hides.result.current.hiddenRefToShortcutId.get("/alpha")).toBe(
100+
"s1",
101+
),
102+
);
103+
});
104+
105+
it("unhides a hidden channel by deleting its shortcut id", async () => {
106+
mockClient.getDesktopFileSystemShortcuts.mockResolvedValue([
107+
shortcut("s1", "hidden-folder", "/alpha"),
108+
]);
109+
110+
const hides = renderHook(() => useChannelHides(), { wrapper });
111+
await waitFor(() => expect(hides.result.current.isLoading).toBe(false));
112+
113+
mockClient.deleteDesktopFileSystemShortcut.mockResolvedValue(undefined);
114+
mockClient.getDesktopFileSystemShortcuts.mockReturnValue(
115+
new Promise(() => {}),
116+
);
117+
118+
const toggle = renderHook(
119+
() => useChannelHideToggle(channel("1", "alpha", "/alpha")),
120+
{ wrapper },
121+
);
122+
expect(toggle.result.current.isHidden).toBe(true);
123+
124+
await act(async () => {
125+
toggle.result.current.toggleHidden();
126+
});
127+
128+
expect(mockClient.deleteDesktopFileSystemShortcut).toHaveBeenCalledWith(
129+
"s1",
130+
);
131+
await waitFor(() =>
132+
expect(hides.result.current.hiddenRefToShortcutId.has("/alpha")).toBe(
133+
false,
134+
),
135+
);
136+
});
137+
});

0 commit comments

Comments
 (0)