From b6f77aa3be590e956763135eb4d2f807215e624f Mon Sep 17 00:00:00 2001
From: Harley Alexander <43975092+mayteio@users.noreply.github.com>
Date: Wed, 15 Jul 2026 15:23:07 +0100
Subject: [PATCH 1/3] 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
---
packages/shared/src/analytics-events.ts | 3 +
.../canvas/components/ChannelsList.tsx | 81 +++++++++-
.../canvas/hooks/useChannelHides.test.tsx | 137 +++++++++++++++++
.../features/canvas/hooks/useChannelHides.ts | 142 ++++++++++++++++++
.../features/canvas/hooks/useChannelStars.ts | 20 ++-
5 files changed, 367 insertions(+), 16 deletions(-)
create mode 100644 packages/ui/src/features/canvas/hooks/useChannelHides.test.tsx
create mode 100644 packages/ui/src/features/canvas/hooks/useChannelHides.ts
diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts
index c1dffa2051..ead05506f3 100644
--- a/packages/shared/src/analytics-events.ts
+++ b/packages/shared/src/analytics-events.ts
@@ -863,6 +863,8 @@ export type ChannelActionType =
| "delete"
| "star"
| "unstar"
+ | "hide"
+ | "unhide"
| "edit_context_open"
| "new_task_open"
| "new_task_suggestion"
@@ -966,6 +968,7 @@ export interface ChannelsSpaceViewedProperties {
/** Total channels visible when the space mounts. */
channel_count: number;
starred_count: number;
+ hidden_count: number;
}
// Subscription / billing events
diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx
index a228e5dca1..00eb00368a 100644
--- a/packages/ui/src/features/canvas/components/ChannelsList.tsx
+++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx
@@ -1,6 +1,10 @@
import {
+ CaretDownIcon,
+ CaretRightIcon,
ChartBarIcon,
DotsThreeIcon,
+ EyeIcon,
+ EyeSlashIcon,
FileTextIcon,
LinkIcon,
LockSimpleIcon,
@@ -40,6 +44,10 @@ import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal";
import { RenameChannelModal } from "@posthog/ui/features/canvas/components/RenameChannelModal";
import { trackAndCreateCanvas } from "@posthog/ui/features/canvas/createCanvasAnalytics";
+import {
+ useChannelHides,
+ useChannelHideToggle,
+} from "@posthog/ui/features/canvas/hooks/useChannelHides";
import {
useChannelStars,
useChannelStarToggle,
@@ -76,9 +84,10 @@ type ChannelActionItem = {
separatorBefore?: boolean;
};
-// The channel actions (star, copy link, rename, delete) plus the rename-modal
-// state they drive. Single source of truth so the dropdown and context menus
-// stay in lockstep — add an action here and both surfaces pick it up.
+// The channel actions (star, hide, copy link, rename, delete) plus the
+// rename-modal state they drive. Single source of truth so the dropdown and
+// context menus stay in lockstep — add an action here and both surfaces pick
+// it up.
function useChannelActions(channel: Channel): {
actions: ChannelActionItem[];
renameOpen: boolean;
@@ -96,6 +105,8 @@ function useChannelActions(channel: Channel): {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const { deleteChannel, isDeleting } = useChannelMutations();
const { isStarred, toggleStar, removeStar } = useChannelStarToggle(channel);
+ const { isHidden, toggleHidden, removeHidden } =
+ useChannelHideToggle(channel);
// Runs the actual delete once confirmed. Returns whether it succeeded so the
// dialog can stay open (and show the toast) on failure.
@@ -120,6 +131,7 @@ function useChannelActions(channel: Channel): {
await deleteChannel(channel.id);
removeStar();
+ removeHidden();
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
action_type: "delete",
surface: "sidebar",
@@ -159,6 +171,19 @@ function useChannelActions(channel: Channel): {
toggleStar();
},
},
+ {
+ key: "hide",
+ label: isHidden ? "Unhide context" : "Hide context",
+ icon: isHidden ? : ,
+ onSelect: () => {
+ track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
+ action_type: isHidden ? "unhide" : "hide",
+ surface: "sidebar",
+ channel_id: channel.id,
+ });
+ toggleHidden();
+ },
+ },
{
key: "copy-link",
label: "Copy link",
@@ -532,17 +557,26 @@ function PersonalChannelRow() {
// The channel list — the Channels space sidebar body. The private "#me"
// channel is pinned at the top; starred channels surface in their own section
-// so the ones you use most stay in reach; the rest sit under a "Channels"
-// label with the "New" channel button.
+// so the ones you use most stay in reach; the rest sit under a "Contexts"
+// label with the "New" channel button; channels the user has hidden collapse
+// into a "Hidden" group at the bottom.
export function ChannelsList() {
const { channels: allChannels, isLoading } = useChannels();
const { starredRefToShortcutId } = useChannelStars();
+ const { hiddenRefToShortcutId } = useChannelHides();
const [modalOpen, setModalOpen] = useState(false);
+ // The "Hidden" group is collapsed by default — hidden channels are ones the
+ // user chose to get out of the way.
+ const [hiddenExpanded, setHiddenExpanded] = useState(false);
// The "me" folder renders as the pinned personal row, not a shared channel.
const channels = allChannels.filter((c) => c.name !== PERSONAL_CHANNEL_NAME);
- const starred = channels.filter((c) => starredRefToShortcutId.has(c.path));
- const others = channels.filter((c) => !starredRefToShortcutId.has(c.path));
+ // Hiding wins over starring: a hidden channel leaves both the starred and
+ // main lists and only surfaces under the collapsed "Hidden" group.
+ const hidden = channels.filter((c) => hiddenRefToShortcutId.has(c.path));
+ const visible = channels.filter((c) => !hiddenRefToShortcutId.has(c.path));
+ const starred = visible.filter((c) => starredRefToShortcutId.has(c.path));
+ const others = visible.filter((c) => !starredRefToShortcutId.has(c.path));
// Fire CHANNELS_SPACE_VIEWED once per space mount, after channels first load
// (so the counts are accurate). The sidebar stays mounted while navigating
@@ -554,8 +588,9 @@ export function ChannelsList() {
track(ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, {
channel_count: channels.length,
starred_count: starred.length,
+ hidden_count: hidden.length,
});
- }, [isLoading, channels.length, starred.length]);
+ }, [isLoading, channels.length, starred.length, hidden.length]);
return (
// One shared provider groups every row tooltip so that once one shows,
@@ -608,6 +643,36 @@ export function ChannelsList() {
))}
+
+ {hidden.length > 0 && (
+
+
+
+
+
+ )}
+
+ {hidden.length > 0 && hiddenExpanded && (
+
+ {hidden.map((channel) => (
+
+ ))}
+
+ )}
diff --git a/packages/ui/src/features/canvas/hooks/useChannelHides.test.tsx b/packages/ui/src/features/canvas/hooks/useChannelHides.test.tsx
new file mode 100644
index 0000000000..3cac03877f
--- /dev/null
+++ b/packages/ui/src/features/canvas/hooks/useChannelHides.test.tsx
@@ -0,0 +1,137 @@
+import type { Schemas } from "@posthog/api-client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mockClient = vi.hoisted(() => ({
+ getDesktopFileSystemShortcuts: vi.fn(),
+ createDesktopFileSystemShortcut: vi.fn(),
+ deleteDesktopFileSystemShortcut: vi.fn(),
+}));
+vi.mock("@posthog/ui/features/auth/authClient", () => ({
+ useOptionalAuthenticatedClient: () => mockClient,
+}));
+vi.mock("@posthog/ui/primitives/toast", () => ({
+ toast: { error: vi.fn(), success: vi.fn() },
+}));
+
+import { useChannelHides, useChannelHideToggle } from "./useChannelHides";
+import type { Channel } from "./useChannels";
+
+function shortcut(
+ id: string,
+ type: string,
+ ref: string | null,
+): Schemas.FileSystemShortcut {
+ return {
+ id,
+ path: ref?.replace(/^\/+/, "") ?? "x",
+ type,
+ ref,
+ created_at: "2026-01-01T00:00:00Z",
+ };
+}
+
+function channel(id: string, name: string, path: string): Channel {
+ return { id, name, path };
+}
+
+let queryClient: QueryClient;
+function wrapper({ children }: { children: ReactNode }) {
+ return (
+ {children}
+ );
+}
+
+describe("useChannelHides", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ });
+
+ it("maps hidden-folder shortcuts by ref, ignoring stars and ref-less rows", async () => {
+ mockClient.getDesktopFileSystemShortcuts.mockResolvedValue([
+ shortcut("s1", "hidden-folder", "/alpha"),
+ shortcut("s2", "folder", "/beta"), // a star, not a hide
+ shortcut("s3", "hidden-folder", null), // no ref to link
+ ]);
+
+ const { result } = renderHook(() => useChannelHides(), { wrapper });
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect([...result.current.hiddenRefToShortcutId.entries()]).toEqual([
+ ["/alpha", "s1"],
+ ]);
+ });
+
+ it("hides an unhidden channel via its raw path, updating the cache immediately", async () => {
+ mockClient.getDesktopFileSystemShortcuts.mockResolvedValue([]);
+
+ const hides = renderHook(() => useChannelHides(), { wrapper });
+ await waitFor(() => expect(hides.result.current.isLoading).toBe(false));
+
+ const created = shortcut("s1", "hidden-folder", "/alpha");
+ mockClient.createDesktopFileSystemShortcut.mockResolvedValue(created);
+ // Hang the refetch so only the optimistic cache write is exercised.
+ mockClient.getDesktopFileSystemShortcuts.mockReturnValue(
+ new Promise(() => {}),
+ );
+
+ const toggle = renderHook(
+ () => useChannelHideToggle(channel("1", "alpha", "/alpha")),
+ { wrapper },
+ );
+ expect(toggle.result.current.isHidden).toBe(false);
+
+ await act(async () => {
+ toggle.result.current.toggleHidden();
+ });
+
+ expect(mockClient.createDesktopFileSystemShortcut).toHaveBeenCalledWith({
+ path: "alpha",
+ type: "hidden-folder",
+ ref: "/alpha",
+ });
+ await waitFor(() =>
+ expect(hides.result.current.hiddenRefToShortcutId.get("/alpha")).toBe(
+ "s1",
+ ),
+ );
+ });
+
+ it("unhides a hidden channel by deleting its shortcut id", async () => {
+ mockClient.getDesktopFileSystemShortcuts.mockResolvedValue([
+ shortcut("s1", "hidden-folder", "/alpha"),
+ ]);
+
+ const hides = renderHook(() => useChannelHides(), { wrapper });
+ await waitFor(() => expect(hides.result.current.isLoading).toBe(false));
+
+ mockClient.deleteDesktopFileSystemShortcut.mockResolvedValue(undefined);
+ mockClient.getDesktopFileSystemShortcuts.mockReturnValue(
+ new Promise(() => {}),
+ );
+
+ const toggle = renderHook(
+ () => useChannelHideToggle(channel("1", "alpha", "/alpha")),
+ { wrapper },
+ );
+ expect(toggle.result.current.isHidden).toBe(true);
+
+ await act(async () => {
+ toggle.result.current.toggleHidden();
+ });
+
+ expect(mockClient.deleteDesktopFileSystemShortcut).toHaveBeenCalledWith(
+ "s1",
+ );
+ await waitFor(() =>
+ expect(hides.result.current.hiddenRefToShortcutId.has("/alpha")).toBe(
+ false,
+ ),
+ );
+ });
+});
diff --git a/packages/ui/src/features/canvas/hooks/useChannelHides.ts b/packages/ui/src/features/canvas/hooks/useChannelHides.ts
new file mode 100644
index 0000000000..6285792406
--- /dev/null
+++ b/packages/ui/src/features/canvas/hooks/useChannelHides.ts
@@ -0,0 +1,142 @@
+import type { Schemas } from "@posthog/api-client";
+import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient";
+import {
+ SHORTCUTS_POLL_INTERVAL_MS,
+ SHORTCUTS_QUERY_KEY,
+} from "@posthog/ui/features/canvas/hooks/useChannelStars";
+import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels";
+import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery";
+import { toast } from "@posthog/ui/primitives/toast";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useCallback } from "react";
+
+// A hidden channel is a shortcut of its own type, distinct from the "folder"
+// type that backs stars, so the two never collide on the shared shortcuts list.
+const HIDDEN_SHORTCUT_TYPE = "hidden-folder";
+
+/**
+ * The current user's hidden channels, persisted in the PostHog backend as
+ * per-user desktop file-system shortcuts (mirroring stars, with a distinct
+ * type). Returns a map from a channel's raw path (the shortcut `ref`) to the
+ * shortcut id, so callers can both check whether a channel is hidden and delete
+ * the right shortcut when unhiding.
+ */
+export function useChannelHides(options?: { enabled?: boolean }): {
+ hiddenRefToShortcutId: Map;
+ isLoading: boolean;
+} {
+ const query = useAuthenticatedQuery(
+ SHORTCUTS_QUERY_KEY,
+ (client) => client.getDesktopFileSystemShortcuts(),
+ {
+ enabled: options?.enabled ?? true,
+ refetchInterval: SHORTCUTS_POLL_INTERVAL_MS,
+ },
+ );
+
+ const hiddenRefToShortcutId = new Map();
+ for (const shortcut of query.data ?? []) {
+ if (shortcut.type === HIDDEN_SHORTCUT_TYPE && shortcut.ref) {
+ hiddenRefToShortcutId.set(shortcut.ref, shortcut.id);
+ }
+ }
+
+ return { hiddenRefToShortcutId, isLoading: query.isLoading };
+}
+
+/**
+ * Hide/unhide a channel by creating or deleting its desktop shortcut. Both
+ * paths update the shared shortcuts cache immediately so the sidebar re-groups
+ * the instant the request resolves, rather than waiting on the poll.
+ */
+export function useChannelHideMutations() {
+ const client = useOptionalAuthenticatedClient();
+ const queryClient = useQueryClient();
+
+ const invalidate = useCallback(() => {
+ void queryClient.invalidateQueries({ queryKey: SHORTCUTS_QUERY_KEY });
+ }, [queryClient]);
+
+ const hideMutation = useMutation({
+ mutationFn: async (channel: Channel) => {
+ if (!client) throw new Error("Not authenticated");
+ return client.createDesktopFileSystemShortcut({
+ path: channel.name,
+ type: HIDDEN_SHORTCUT_TYPE,
+ ref: channel.path,
+ });
+ },
+ onSuccess: (created) => {
+ queryClient.setQueryData(
+ SHORTCUTS_QUERY_KEY,
+ (old) => {
+ if (!old) return [created];
+ if (old.some((s) => s.id === created.id)) return old;
+ return [...old, created];
+ },
+ );
+ invalidate();
+ },
+ });
+
+ const unhideMutation = useMutation({
+ mutationFn: async (shortcutId: string) => {
+ if (!client) throw new Error("Not authenticated");
+ await client.deleteDesktopFileSystemShortcut(shortcutId);
+ return shortcutId;
+ },
+ onSuccess: (shortcutId) => {
+ queryClient.setQueryData(
+ SHORTCUTS_QUERY_KEY,
+ (old) => (old ?? []).filter((s) => s.id !== shortcutId),
+ );
+ invalidate();
+ },
+ });
+
+ return {
+ hide: (channel: Channel) => hideMutation.mutateAsync(channel),
+ unhide: (shortcutId: string) => unhideMutation.mutateAsync(shortcutId),
+ isHiding: hideMutation.isPending,
+ isUnhiding: unhideMutation.isPending,
+ };
+}
+
+/**
+ * Per-channel hidden state plus the actions a channel row needs. Wraps the
+ * shared shortcuts query and mutations so the row components stay declarative.
+ * Multiple rows calling this share one underlying query (React Query dedupes by
+ * key).
+ */
+export function useChannelHideToggle(channel: Channel): {
+ isHidden: boolean;
+ toggleHidden: () => void;
+ /** Remove the hidden marker if present — used when the channel itself is
+ * deleted so a same-named channel created later doesn't inherit it. */
+ removeHidden: () => void;
+} {
+ const { hiddenRefToShortcutId } = useChannelHides();
+ const { hide, unhide } = useChannelHideMutations();
+ const shortcutId = hiddenRefToShortcutId.get(channel.path);
+ const isHidden = shortcutId !== undefined;
+
+ const toggleHidden = useCallback(() => {
+ const run = shortcutId ? unhide(shortcutId) : hide(channel);
+ run.catch((error: unknown) => {
+ toast.error(
+ isHidden ? "Couldn't unhide channel" : "Couldn't hide channel",
+ {
+ description: error instanceof Error ? error.message : String(error),
+ },
+ );
+ });
+ }, [channel, shortcutId, isHidden, hide, unhide]);
+
+ const removeHidden = useCallback(() => {
+ if (shortcutId) {
+ void unhide(shortcutId);
+ }
+ }, [shortcutId, unhide]);
+
+ return { isHidden, toggleHidden, removeHidden };
+}
diff --git a/packages/ui/src/features/canvas/hooks/useChannelStars.ts b/packages/ui/src/features/canvas/hooks/useChannelStars.ts
index ca1d79e3b4..9989655609 100644
--- a/packages/ui/src/features/canvas/hooks/useChannelStars.ts
+++ b/packages/ui/src/features/canvas/hooks/useChannelStars.ts
@@ -6,11 +6,15 @@ import { toast } from "@posthog/ui/primitives/toast";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
-const STARS_POLL_INTERVAL_MS = 60_000;
-const STARS_QUERY_KEY = ["canvas-channel-stars"] as const;
+// Both the star and hide surfaces read the same per-user desktop shortcuts
+// list, so they share one polled query (React Query dedupes by key) rather than
+// hitting the endpoint twice.
+export const SHORTCUTS_POLL_INTERVAL_MS = 60_000;
+export const SHORTCUTS_QUERY_KEY = ["canvas-channel-shortcuts"] as const;
// Channels are folders, so their stars are folder-typed shortcuts. Anything
-// else on the desktop surface (a starred insight, say) is ignored here.
+// else on the desktop surface (a hidden channel, a starred insight, say) is
+// ignored here.
const FOLDER_SHORTCUT_TYPE = "folder";
/**
@@ -24,11 +28,11 @@ export function useChannelStars(options?: { enabled?: boolean }): {
isLoading: boolean;
} {
const query = useAuthenticatedQuery(
- STARS_QUERY_KEY,
+ SHORTCUTS_QUERY_KEY,
(client) => client.getDesktopFileSystemShortcuts(),
{
enabled: options?.enabled ?? true,
- refetchInterval: STARS_POLL_INTERVAL_MS,
+ refetchInterval: SHORTCUTS_POLL_INTERVAL_MS,
},
);
@@ -52,7 +56,7 @@ export function useChannelStarMutations() {
const queryClient = useQueryClient();
const invalidate = useCallback(() => {
- void queryClient.invalidateQueries({ queryKey: STARS_QUERY_KEY });
+ void queryClient.invalidateQueries({ queryKey: SHORTCUTS_QUERY_KEY });
}, [queryClient]);
const starMutation = useMutation({
@@ -66,7 +70,7 @@ export function useChannelStarMutations() {
},
onSuccess: (created) => {
queryClient.setQueryData(
- STARS_QUERY_KEY,
+ SHORTCUTS_QUERY_KEY,
(old) => {
if (!old) return [created];
if (old.some((s) => s.id === created.id)) return old;
@@ -85,7 +89,7 @@ export function useChannelStarMutations() {
},
onSuccess: (shortcutId) => {
queryClient.setQueryData(
- STARS_QUERY_KEY,
+ SHORTCUTS_QUERY_KEY,
(old) => (old ?? []).filter((s) => s.id !== shortcutId),
);
invalidate();
From 615d95ddf468889dd2fc35ab41c731fb2315b4f6 Mon Sep 17 00:00:00 2001
From: Harley Alexander <43975092+mayteio@users.noreply.github.com>
Date: Wed, 15 Jul 2026 15:30:30 +0100
Subject: [PATCH 2/3] fix(canvas): wait for shortcuts before tracking
space-viewed counts
CHANNELS_SPACE_VIEWED is a one-shot event gated only on the channels query. The
stars/hides shortcuts query is independent, so if channels resolved first the
event recorded starred_count and hidden_count as zero and viewedTrackedRef
blocked any correction. Gate the effect on the shortcut queries' loading state
too. Addresses Greptile P1.
Generated-By: PostHog Code
Task-Id: 4c16844c-acc9-4124-909d-f07335710a91
---
.../canvas/components/ChannelsList.tsx | 24 +++++++++++++------
1 file changed, 17 insertions(+), 7 deletions(-)
diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx
index 00eb00368a..b2edf5405a 100644
--- a/packages/ui/src/features/canvas/components/ChannelsList.tsx
+++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx
@@ -562,8 +562,8 @@ function PersonalChannelRow() {
// into a "Hidden" group at the bottom.
export function ChannelsList() {
const { channels: allChannels, isLoading } = useChannels();
- const { starredRefToShortcutId } = useChannelStars();
- const { hiddenRefToShortcutId } = useChannelHides();
+ const { starredRefToShortcutId, isLoading: starsLoading } = useChannelStars();
+ const { hiddenRefToShortcutId, isLoading: hidesLoading } = useChannelHides();
const [modalOpen, setModalOpen] = useState(false);
// The "Hidden" group is collapsed by default — hidden channels are ones the
// user chose to get out of the way.
@@ -578,19 +578,29 @@ export function ChannelsList() {
const starred = visible.filter((c) => starredRefToShortcutId.has(c.path));
const others = visible.filter((c) => !starredRefToShortcutId.has(c.path));
- // Fire CHANNELS_SPACE_VIEWED once per space mount, after channels first load
- // (so the counts are accurate). The sidebar stays mounted while navigating
- // between channels, so this naturally fires once per entry into the space.
+ // Fire CHANNELS_SPACE_VIEWED once per space mount, after channels *and* the
+ // shortcuts (stars/hides) first load — the shortcuts query is independent, so
+ // gating only on channels would let the one-shot event record stale zero
+ // starred/hidden counts. The sidebar stays mounted while navigating between
+ // channels, so this naturally fires once per entry into the space.
const viewedTrackedRef = useRef(false);
useEffect(() => {
- if (isLoading || viewedTrackedRef.current) return;
+ if (isLoading || starsLoading || hidesLoading || viewedTrackedRef.current)
+ return;
viewedTrackedRef.current = true;
track(ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, {
channel_count: channels.length,
starred_count: starred.length,
hidden_count: hidden.length,
});
- }, [isLoading, channels.length, starred.length, hidden.length]);
+ }, [
+ isLoading,
+ starsLoading,
+ hidesLoading,
+ channels.length,
+ starred.length,
+ hidden.length,
+ ]);
return (
// One shared provider groups every row tooltip so that once one shows,
From 5bc73ea11323234797c15ea0a414158f70d7b26b Mon Sep 17 00:00:00 2001
From: Harley Alexander <43975092+mayteio@users.noreply.github.com>
Date: Wed, 15 Jul 2026 15:37:23 +0100
Subject: [PATCH 3/3] fix(canvas): guard star/hide toggles against
double-submit races
Two Greptile findings on the hide hook, both inherited from the star hook, so
fix them symmetrically:
- Ignore toggle re-clicks while a create/delete is in flight. The cache only
updates on success, so a quick double-tap fired two creates for the same path,
leaving a duplicate shortcut that a single later toggle couldn't clear.
- Swallow failures in the delete-on-channel-delete cleanup so a rejected request
doesn't surface as an unhandled promise rejection.
Generated-By: PostHog Code
Task-Id: 4c16844c-acc9-4124-909d-f07335710a91
---
.../ui/src/features/canvas/hooks/useChannelHides.ts | 13 ++++++++++---
.../ui/src/features/canvas/hooks/useChannelStars.ts | 13 ++++++++++---
2 files changed, 20 insertions(+), 6 deletions(-)
diff --git a/packages/ui/src/features/canvas/hooks/useChannelHides.ts b/packages/ui/src/features/canvas/hooks/useChannelHides.ts
index 6285792406..f85dbc6e4d 100644
--- a/packages/ui/src/features/canvas/hooks/useChannelHides.ts
+++ b/packages/ui/src/features/canvas/hooks/useChannelHides.ts
@@ -116,11 +116,16 @@ export function useChannelHideToggle(channel: Channel): {
removeHidden: () => void;
} {
const { hiddenRefToShortcutId } = useChannelHides();
- const { hide, unhide } = useChannelHideMutations();
+ const { hide, unhide, isHiding, isUnhiding } = useChannelHideMutations();
const shortcutId = hiddenRefToShortcutId.get(channel.path);
const isHidden = shortcutId !== undefined;
const toggleHidden = useCallback(() => {
+ // Ignore re-clicks while a hide/unhide is in flight. The cache only updates
+ // once the request resolves, so without this guard a quick double-tap would
+ // fire two creates for the same path — leaving a duplicate marker that a
+ // single later unhide can't fully clear.
+ if (isHiding || isUnhiding) return;
const run = shortcutId ? unhide(shortcutId) : hide(channel);
run.catch((error: unknown) => {
toast.error(
@@ -130,11 +135,13 @@ export function useChannelHideToggle(channel: Channel): {
},
);
});
- }, [channel, shortcutId, isHidden, hide, unhide]);
+ }, [channel, shortcutId, isHidden, hide, unhide, isHiding, isUnhiding]);
const removeHidden = useCallback(() => {
if (shortcutId) {
- void unhide(shortcutId);
+ // Best-effort cleanup when the channel is deleted; swallow failures so a
+ // rejected delete doesn't surface as an unhandled rejection.
+ void unhide(shortcutId).catch(() => {});
}
}, [shortcutId, unhide]);
diff --git a/packages/ui/src/features/canvas/hooks/useChannelStars.ts b/packages/ui/src/features/canvas/hooks/useChannelStars.ts
index 9989655609..7fd6b0815b 100644
--- a/packages/ui/src/features/canvas/hooks/useChannelStars.ts
+++ b/packages/ui/src/features/canvas/hooks/useChannelStars.ts
@@ -117,11 +117,16 @@ export function useChannelStarToggle(channel: Channel): {
removeStar: () => void;
} {
const { starredRefToShortcutId } = useChannelStars();
- const { star, unstar } = useChannelStarMutations();
+ const { star, unstar, isStarring, isUnstarring } = useChannelStarMutations();
const shortcutId = starredRefToShortcutId.get(channel.path);
const isStarred = shortcutId !== undefined;
const toggleStar = useCallback(() => {
+ // Ignore re-clicks while a star/unstar is in flight. The cache only updates
+ // once the request resolves, so without this guard a quick double-tap would
+ // fire two creates for the same path — leaving a duplicate shortcut that a
+ // single later unstar can't fully clear.
+ if (isStarring || isUnstarring) return;
const run = shortcutId ? unstar(shortcutId) : star(channel);
run.catch((error: unknown) => {
toast.error(
@@ -131,11 +136,13 @@ export function useChannelStarToggle(channel: Channel): {
},
);
});
- }, [channel, shortcutId, isStarred, star, unstar]);
+ }, [channel, shortcutId, isStarred, star, unstar, isStarring, isUnstarring]);
const removeStar = useCallback(() => {
if (shortcutId) {
- void unstar(shortcutId);
+ // Best-effort cleanup when the channel is deleted; swallow failures so a
+ // rejected delete doesn't surface as an unhandled rejection.
+ void unstar(shortcutId).catch(() => {});
}
}, [shortcutId, unstar]);