From 256501a7afe7ce184baef1477eb58d51669b9391 Mon Sep 17 00:00:00 2001 From: Harley Alexander <43975092+mayteio@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:04:19 +0100 Subject: [PATCH 1/2] Keep a context's star and task history after renaming it Renaming a context (channel) silently dropped it from the Starred section and made its task history look empty. Both stem from name-keyed links that go stale on rename: the star is a desktop shortcut whose `ref` is the channel path, and the task feed resolves its backend channel by name. - Re-point the star's shortcut to the new path after a rename (create new, delete stale) via `useRepointChannelStar`. - Rename the matching backend task channel to the new name so the feed/history follows, using the existing task-channel PATCH endpoint, via `useRenameBackendChannel` (adds `renameTaskChannel` to the API client). - Optimistically update the channels cache on rename so the folder name and the backend channel name flip together, avoiding a transient empty duplicate. Both follow-ups are best-effort and never throw, so a hiccup can't turn a successful rename into an error. Generated-By: PostHog Code Task-Id: 795ba9eb-2d2f-432c-a0a3-f0020bb87ccb --- packages/api-client/src/posthog-client.ts | 22 +++ .../canvas/components/RenameChannelModal.tsx | 15 +- .../canvas/hooks/useChannelStars.test.tsx | 64 ++++++++- .../features/canvas/hooks/useChannelStars.ts | 34 +++++ .../src/features/canvas/hooks/useChannels.ts | 13 +- .../canvas/hooks/useTaskChannels.test.tsx | 133 ++++++++++++++++++ .../features/canvas/hooks/useTaskChannels.ts | 89 +++++++++++- 7 files changed, 366 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/features/canvas/hooks/useTaskChannels.test.tsx diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 88bacc8312..ceebe98306 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -2527,6 +2527,28 @@ export class PostHogAPIClient { return (await response.json()) as TaskChannel; } + // Rename an existing public channel by id (PATCH). Backend channels are the + // feed/history side of a channel and are matched by name, so when the folder + // channel is renamed this must follow — otherwise the feed re-resolves to a + // different (empty) channel and the task history appears to vanish. + async renameTaskChannel( + channelId: string, + name: string, + ): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/task_channels/${encodeURIComponent(channelId)}/`; + const response = await this.api.fetcher.fetch({ + method: "patch", + url: new URL(`${this.api.baseUrl}${urlPath}`), + path: urlPath, + overrides: { body: JSON.stringify({ name }) }, + }); + if (!response.ok) { + throw new Error(`Failed to rename task channel: ${response.statusText}`); + } + return (await response.json()) as TaskChannel; + } + // A channel's system-announcement feed (context created, CONTEXT.md being // built), chronological. Durable + team-visible, rendered alongside task cards. async getChannelFeed(channelId: string): Promise { diff --git a/packages/ui/src/features/canvas/components/RenameChannelModal.tsx b/packages/ui/src/features/canvas/components/RenameChannelModal.tsx index 12ef4df1cd..6e7983c446 100644 --- a/packages/ui/src/features/canvas/components/RenameChannelModal.tsx +++ b/packages/ui/src/features/canvas/components/RenameChannelModal.tsx @@ -2,8 +2,10 @@ import { XIcon } from "@phosphor-icons/react"; import { validateChannelName } from "@posthog/core/canvas/channelName"; import { Button } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useRepointChannelStar } from "@posthog/ui/features/canvas/hooks/useChannelStars"; import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelMutations } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useRenameBackendChannel } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; import { Dialog, Flex, IconButton, Text, TextField } from "@radix-ui/themes"; @@ -25,6 +27,8 @@ export function RenameChannelModal({ onOpenChange, }: RenameChannelModalProps) { const { renameChannel, isRenaming } = useChannelMutations(); + const repointStar = useRepointChannelStar(); + const renameBackendChannel = useRenameBackendChannel(); const [name, setName] = useState(channel.name); // Seed the field with the current name each time the modal opens. @@ -39,8 +43,17 @@ export function RenameChannelModal({ const submit = async () => { if (!trimmed || unchanged || validationError || isRenaming) return; + // Capture the pre-rename identity: name keys the backend feed channel, path + // keys the star. Both go stale the moment the rename lands. + const previousName = channel.name; + const previousPath = channel.path; try { - await renameChannel(channel.id, trimmed); + const renamed = await renameChannel(channel.id, trimmed); + // Carry the name-keyed dependents over to the new name so the context's + // task history and star survive the rename. Both are best-effort and + // never throw, so a hiccup can't turn a successful rename into an error. + await renameBackendChannel(previousName, trimmed); + await repointStar(previousPath, renamed); track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "rename", surface: "sidebar", diff --git a/packages/ui/src/features/canvas/hooks/useChannelStars.test.tsx b/packages/ui/src/features/canvas/hooks/useChannelStars.test.tsx index 8d35f58299..5542f99b87 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelStars.test.tsx +++ b/packages/ui/src/features/canvas/hooks/useChannelStars.test.tsx @@ -16,7 +16,11 @@ vi.mock("@posthog/ui/primitives/toast", () => ({ toast: { error: vi.fn(), success: vi.fn() }, })); -import { useChannelStars, useChannelStarToggle } from "./useChannelStars"; +import { + useChannelStars, + useChannelStarToggle, + useRepointChannelStar, +} from "./useChannelStars"; import type { Channel } from "./useChannels"; function shortcut( @@ -135,3 +139,61 @@ describe("useChannelStars", () => { ); }); }); + +describe("useRepointChannelStar", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + }); + + it("moves a starred channel's shortcut to its new path on rename", async () => { + mockClient.getDesktopFileSystemShortcuts.mockResolvedValue([ + shortcut("s1", "folder", "/alpha"), + ]); + const { result } = renderHook( + () => ({ stars: useChannelStars(), repoint: useRepointChannelStar() }), + { wrapper }, + ); + await waitFor(() => expect(result.current.stars.isLoading).toBe(false)); + + mockClient.createDesktopFileSystemShortcut.mockResolvedValue( + shortcut("s2", "folder", "/beta"), + ); + mockClient.deleteDesktopFileSystemShortcut.mockResolvedValue(undefined); + // Hang the refetch so only the create+delete are exercised. + mockClient.getDesktopFileSystemShortcuts.mockReturnValue( + new Promise(() => {}), + ); + + await act(async () => { + await result.current.repoint("/alpha", channel("1", "beta", "/beta")); + }); + + expect(mockClient.createDesktopFileSystemShortcut).toHaveBeenCalledWith({ + path: "beta", + type: "folder", + ref: "/beta", + }); + expect(mockClient.deleteDesktopFileSystemShortcut).toHaveBeenCalledWith( + "s1", + ); + }); + + it("does nothing when the renamed channel wasn't starred", async () => { + mockClient.getDesktopFileSystemShortcuts.mockResolvedValue([]); + const { result } = renderHook( + () => ({ stars: useChannelStars(), repoint: useRepointChannelStar() }), + { wrapper }, + ); + await waitFor(() => expect(result.current.stars.isLoading).toBe(false)); + + await act(async () => { + await result.current.repoint("/alpha", channel("1", "beta", "/beta")); + }); + + expect(mockClient.createDesktopFileSystemShortcut).not.toHaveBeenCalled(); + expect(mockClient.deleteDesktopFileSystemShortcut).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/features/canvas/hooks/useChannelStars.ts b/packages/ui/src/features/canvas/hooks/useChannelStars.ts index ca1d79e3b4..d4a1633b8a 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelStars.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelStars.ts @@ -100,6 +100,40 @@ export function useChannelStarMutations() { }; } +/** + * Re-point a channel's star after its path changes (a rename). Stars are keyed + * by the channel's path (the shortcut `ref`), so a rename orphans the old + * shortcut and the channel silently leaves the Starred section. Creates a + * shortcut for the renamed channel, then removes the stale one. No-op if the + * channel wasn't starred. Best-effort: creates before deleting so a failure + * never loses the star outright, and never throws — a failed repair must not + * turn a successful rename into an error. + */ +export function useRepointChannelStar(): ( + previousPath: string, + renamed: Channel, +) => Promise { + const { starredRefToShortcutId } = useChannelStars(); + const { star, unstar } = useChannelStarMutations(); + + return useCallback( + async (previousPath: string, renamed: Channel) => { + const staleShortcutId = starredRefToShortcutId.get(previousPath); + // Nothing to do if the channel wasn't starred, or its path didn't change. + if (!staleShortcutId || previousPath === renamed.path) return; + try { + await star(renamed); + await unstar(staleShortcutId); + } catch (error) { + toast.error("Couldn't keep context starred", { + description: error instanceof Error ? error.message : String(error), + }); + } + }, + [starredRefToShortcutId, star, unstar], + ); +} + /** * Per-channel star state plus the actions a channel row needs. Wraps the shared * stars query and mutations so the row components stay declarative. Multiple diff --git a/packages/ui/src/features/canvas/hooks/useChannels.ts b/packages/ui/src/features/canvas/hooks/useChannels.ts index ba2e78945a..fc13d3e155 100644 --- a/packages/ui/src/features/canvas/hooks/useChannels.ts +++ b/packages/ui/src/features/canvas/hooks/useChannels.ts @@ -111,7 +111,18 @@ export function useChannelMutations() { if (!client) throw new Error("Not authenticated"); return client.renameDesktopFileSystemChannel(id, name); }, - onSuccess: invalidate, + onSuccess: (updated) => { + // Flip the renamed channel in the cache immediately so the new name is + // visible the instant the PATCH resolves, rather than after the refetch. + // This also lets callers re-point name-keyed dependents (backend task + // channel, stars) in the same tick, before any consumer re-renders with + // the new name and provisions an empty duplicate. + queryClient.setQueryData( + CHANNELS_QUERY_KEY, + (old) => old?.map((fs) => (fs.id === updated.id ? updated : fs)), + ); + invalidate(); + }, }); return { diff --git a/packages/ui/src/features/canvas/hooks/useTaskChannels.test.tsx b/packages/ui/src/features/canvas/hooks/useTaskChannels.test.tsx new file mode 100644 index 0000000000..c2249ead02 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useTaskChannels.test.tsx @@ -0,0 +1,133 @@ +import type { TaskChannel } from "@posthog/shared/domain-types"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockClient = vi.hoisted(() => ({ + getTaskChannels: vi.fn(), + resolveTaskChannel: vi.fn(), + renameTaskChannel: 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 { + TASK_CHANNELS_QUERY_KEY, + useRenameBackendChannel, +} from "./useTaskChannels"; + +function taskChannel( + id: string, + name: string, + channel_type: "public" | "personal" = "public", +): TaskChannel { + return { id, name, channel_type, created_at: "2026-01-01T00:00:00Z" }; +} + +let queryClient: QueryClient; +function wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); +} + +describe("useRenameBackendChannel", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + }); + + it("renames the matching public channel to the normalized new name", async () => { + // Cache is populated (channel on screen) — the race-free synchronous path. + queryClient.setQueryData(TASK_CHANNELS_QUERY_KEY, [ + taskChannel("c1", "harley"), + ]); + mockClient.renameTaskChannel.mockResolvedValue( + taskChannel("c1", "team-workflows"), + ); + + const { result } = renderHook(() => useRenameBackendChannel(), { wrapper }); + await act(async () => { + await result.current("harley", "Team Workflows"); + }); + + expect(mockClient.renameTaskChannel).toHaveBeenCalledWith( + "c1", + "team-workflows", + ); + expect( + queryClient.getQueryData(TASK_CHANNELS_QUERY_KEY)?.[0] + .name, + ).toBe("team-workflows"); + expect(mockClient.getTaskChannels).not.toHaveBeenCalled(); + }); + + it("falls back to a fetch when the channels cache is empty", async () => { + mockClient.getTaskChannels.mockResolvedValue([taskChannel("c1", "harley")]); + mockClient.renameTaskChannel.mockResolvedValue( + taskChannel("c1", "team-workflows"), + ); + + const { result } = renderHook(() => useRenameBackendChannel(), { wrapper }); + await act(async () => { + await result.current("harley", "team-workflows"); + }); + + expect(mockClient.getTaskChannels).toHaveBeenCalled(); + expect(mockClient.renameTaskChannel).toHaveBeenCalledWith( + "c1", + "team-workflows", + ); + }); + + it("no-ops when there is no matching backend channel", async () => { + mockClient.getTaskChannels.mockResolvedValue([]); + + const { result } = renderHook(() => useRenameBackendChannel(), { wrapper }); + await act(async () => { + await result.current("harley", "team-workflows"); + }); + + expect(mockClient.renameTaskChannel).not.toHaveBeenCalled(); + }); + + it("no-ops for the personal channel and for unchanged names", async () => { + queryClient.setQueryData(TASK_CHANNELS_QUERY_KEY, [ + taskChannel("p1", "me", "personal"), + taskChannel("c1", "harley"), + ]); + + const { result } = renderHook(() => useRenameBackendChannel(), { wrapper }); + await act(async () => { + await result.current("me", "team-workflows"); // personal → never renamed + await result.current("harley", "Harley"); // normalizes to same name + }); + + expect(mockClient.renameTaskChannel).not.toHaveBeenCalled(); + }); + + it("rolls back the optimistic cache rename when the PATCH fails", async () => { + queryClient.setQueryData(TASK_CHANNELS_QUERY_KEY, [ + taskChannel("c1", "harley"), + ]); + mockClient.renameTaskChannel.mockRejectedValue(new Error("boom")); + + const { result } = renderHook(() => useRenameBackendChannel(), { wrapper }); + await act(async () => { + await result.current("harley", "team-workflows"); + }); + + // Never throws, and the cache is restored to the original name. + expect( + queryClient.getQueryData(TASK_CHANNELS_QUERY_KEY)?.[0] + .name, + ).toBe("harley"); + }); +}); diff --git a/packages/ui/src/features/canvas/hooks/useTaskChannels.ts b/packages/ui/src/features/canvas/hooks/useTaskChannels.ts index 709b78707f..d758694431 100644 --- a/packages/ui/src/features/canvas/hooks/useTaskChannels.ts +++ b/packages/ui/src/features/canvas/hooks/useTaskChannels.ts @@ -2,8 +2,9 @@ import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; import type { TaskChannel } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { toast } from "@posthog/ui/primitives/toast"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; const TASK_CHANNELS_POLL_INTERVAL_MS = 30_000; export const TASK_CHANNELS_QUERY_KEY = ["task-channels"] as const; @@ -62,6 +63,92 @@ export function useTaskChannels(options?: { enabled?: boolean }): { return { channels, personalChannel, isLoading: query.isLoading }; } +/** + * Rename a folder channel's backend channel so its task feed/history follows a + * rename. The folder and its backend channel are bridged by name only, so + * without this the feed re-resolves to a different (empty) channel and the + * history appears lost. Renames the existing public channel matching the old + * name to the new name, updating the channels cache first so a mounted + * `useBackendChannel(newName)` matches the existing channel and doesn't + * resolve-or-create an empty duplicate. + * + * Best-effort and never throws: a failed follow must not turn a successful + * folder rename into an error. No-op for the personal channel, an unchanged + * name, or a channel with no backend channel yet (nothing to carry over). + */ +export function useRenameBackendChannel(): ( + oldName: string, + newName: string, +) => Promise { + const client = useOptionalAuthenticatedClient(); + const queryClient = useQueryClient(); + + return useCallback( + async (oldName: string, newName: string) => { + if (!client) return; + const oldNormalized = normalizeChannelName(oldName); + const newNormalized = normalizeChannelName(newName); + if ( + !oldNormalized || + !newNormalized || + oldNormalized === newNormalized || + oldNormalized === PERSONAL_CHANNEL_NAME || + newNormalized === PERSONAL_CHANNEL_NAME + ) { + return; + } + + // Prefer the cache (populated when the channel is on screen, so the + // rename is race-free against the feed's name lookup); fall back to a + // fetch otherwise (nothing is resolving that channel, so no race). + const cached = queryClient.getQueryData( + TASK_CHANNELS_QUERY_KEY, + ); + const findPublic = (list: TaskChannel[]) => + list.find( + (c) => c.channel_type === "public" && c.name === oldNormalized, + ); + let existing = cached ? findPublic(cached) : undefined; + if (!existing && !cached) { + try { + existing = findPublic(await client.getTaskChannels()); + } catch { + return; + } + } + if (!existing) return; // No backend channel/history to carry over. + + const staleId = existing.id; + const previous = existing; + // Optimistically rename in-cache so the feed's name lookup resolves to + // this channel immediately once the folder name flips. + queryClient.setQueryData(TASK_CHANNELS_QUERY_KEY, (prev) => + prev?.map((c) => + c.id === staleId ? { ...c, name: newNormalized } : c, + ), + ); + try { + const updated = await client.renameTaskChannel(staleId, newNormalized); + queryClient.setQueryData( + TASK_CHANNELS_QUERY_KEY, + (prev) => prev?.map((c) => (c.id === updated.id ? updated : c)), + ); + } catch (error) { + // Roll back the optimistic rename so the feed doesn't point at a name + // the backend never accepted. + queryClient.setQueryData( + TASK_CHANNELS_QUERY_KEY, + (prev) => prev?.map((c) => (c.id === staleId ? previous : c)), + ); + toast.error("Couldn't move context history to the new name", { + description: error instanceof Error ? error.message : String(error), + }); + } + }, + [client, queryClient], + ); +} + /** * Map a folder channel (by display name) onto its backend channel. The "me" * folder is the bridge for the personal channel; any other name resolves (or From a3a19c2b0bbe5646d5b0aeca35ac2d86e4723522 Mon Sep 17 00:00:00 2001 From: Harley Alexander <43975092+mayteio@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:21:22 +0100 Subject: [PATCH 2/2] Run rename follow-ups (backend channel + star) in parallel The backend task-channel rename and the star re-point are independent (they touch different caches and resources) and both do their synchronous cache work before their first await, so running them concurrently doesn't affect the rename race. Addresses the React Doctor async-parallel finding. Generated-By: PostHog Code Task-Id: 795ba9eb-2d2f-432c-a0a3-f0020bb87ccb --- .../features/canvas/components/RenameChannelModal.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/canvas/components/RenameChannelModal.tsx b/packages/ui/src/features/canvas/components/RenameChannelModal.tsx index 6e7983c446..759ab51434 100644 --- a/packages/ui/src/features/canvas/components/RenameChannelModal.tsx +++ b/packages/ui/src/features/canvas/components/RenameChannelModal.tsx @@ -50,10 +50,13 @@ export function RenameChannelModal({ try { const renamed = await renameChannel(channel.id, trimmed); // Carry the name-keyed dependents over to the new name so the context's - // task history and star survive the rename. Both are best-effort and + // task history and star survive the rename. They're independent (feed vs + // star, different caches), so run them together. Both are best-effort and // never throw, so a hiccup can't turn a successful rename into an error. - await renameBackendChannel(previousName, trimmed); - await repointStar(previousPath, renamed); + await Promise.all([ + renameBackendChannel(previousName, trimmed), + repointStar(previousPath, renamed), + ]); track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "rename", surface: "sidebar",