Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TaskChannel> {
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<ChannelFeedMessage[]> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
Expand All @@ -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",
Expand Down
64 changes: 63 additions & 1 deletion packages/ui/src/features/canvas/hooks/useChannelStars.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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();
});
});
34 changes: 34 additions & 0 deletions packages/ui/src/features/canvas/hooks/useChannelStars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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
Expand Down
13 changes: 12 additions & 1 deletion packages/ui/src/features/canvas/hooks/useChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Schemas.FileSystem[]>(
CHANNELS_QUERY_KEY,
(old) => old?.map((fs) => (fs.id === updated.id ? updated : fs)),
);
invalidate();
},
});

return {
Expand Down
133 changes: 133 additions & 0 deletions packages/ui/src/features/canvas/hooks/useTaskChannels.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

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<TaskChannel[]>(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<TaskChannel[]>(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<TaskChannel[]>(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<TaskChannel[]>(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<TaskChannel[]>(TASK_CHANNELS_QUERY_KEY)?.[0]
.name,
).toBe("harley");
});
});
Loading
Loading