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..759ab51434 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,20 @@ 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. 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 Promise.all([ + renameBackendChannel(previousName, trimmed), + 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