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]);