Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { PostHogAPIClient } from "@posthog/api-client/posthog-client";
import type { TaskChannel } from "@posthog/shared/domain-types";
import { describe, expect, it, vi } from "vitest";
import { resolveBackendChannelId } from "./useFileTaskToChannelFeed";

function channel(overrides: Partial<TaskChannel>): TaskChannel {
return {
id: "c-1",
name: "eng",
channel_type: "public",
created_at: "2026-01-01T00:00:00Z",
...overrides,
};
}

describe("resolveBackendChannelId", () => {
it("resolves a public channel by its normalized name", async () => {
const resolveTaskChannel = vi
.fn()
.mockResolvedValue(channel({ id: "backend-42", name: "growth-team" }));
const client = {
getTaskChannels: vi.fn(),
resolveTaskChannel,
} as unknown as Pick<
PostHogAPIClient,
"getTaskChannels" | "resolveTaskChannel"
>;

const id = await resolveBackendChannelId(client, "Growth Team");

expect(id).toBe("backend-42");
// Name is normalized to the backend's directory-safe form before resolving.
expect(resolveTaskChannel).toHaveBeenCalledWith("growth-team");
});

it("maps the 'me' folder onto the personal channel instead of creating a public one", async () => {
const getTaskChannels = vi
.fn()
.mockResolvedValue([
channel({ id: "pub", channel_type: "public", name: "eng" }),
channel({ id: "personal-7", channel_type: "personal", name: "me" }),
]);
const resolveTaskChannel = vi.fn();
const client = {
getTaskChannels,
resolveTaskChannel,
} as unknown as Pick<
PostHogAPIClient,
"getTaskChannels" | "resolveTaskChannel"
>;

const id = await resolveBackendChannelId(client, "me");

expect(id).toBe("personal-7");
expect(resolveTaskChannel).not.toHaveBeenCalled();
});

it("falls back to resolve-or-create when no personal channel is present", async () => {
const getTaskChannels = vi.fn().mockResolvedValue([]);
const resolveTaskChannel = vi
.fn()
.mockResolvedValue(channel({ id: "created", name: "me" }));
const client = {
getTaskChannels,
resolveTaskChannel,
} as unknown as Pick<
PostHogAPIClient,
"getTaskChannels" | "resolveTaskChannel"
>;

const id = await resolveBackendChannelId(client, "me");

expect(id).toBe("created");
expect(resolveTaskChannel).toHaveBeenCalledWith("me");
});
});
73 changes: 73 additions & 0 deletions packages/ui/src/features/canvas/hooks/useFileTaskToChannelFeed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { PostHogAPIClient } from "@posthog/api-client/posthog-client";
import type { Task } from "@posthog/shared/domain-types";
import { channelFeedQueryKey } from "@posthog/ui/features/canvas/hooks/useChannelFeed";
import {
normalizeChannelName,
PERSONAL_CHANNEL_NAME,
} from "@posthog/ui/features/canvas/hooks/useTaskChannels";
import { useAuthenticatedMutation } from "@posthog/ui/hooks/useAuthenticatedMutation";
import { useQueryClient } from "@tanstack/react-query";

/**
* Resolve the backend task channel that a folder channel (by display name)
* maps onto, creating the public channel if it doesn't exist yet. Mirrors the
* resolution in `useBackendChannel`, but runs imperatively for a channel
* chosen at click time (e.g. the "File to…" menu).
*/
export async function resolveBackendChannelId(
client: Pick<PostHogAPIClient, "getTaskChannels" | "resolveTaskChannel">,
channelName: string,
): Promise<string> {
const normalized = normalizeChannelName(channelName);
if (normalized === PERSONAL_CHANNEL_NAME) {
// Listing lazily provisions the requester's #me channel server-side.
const channels = await client.getTaskChannels();
const personal = channels.find((c) => c.channel_type === "personal");
if (personal) return personal.id;
}
const channel = await client.resolveTaskChannel(normalized);
return channel.id;
}

/**
* Associate an existing task with a channel's backend feed so it shows up as a
* thread item in the channel — the feed side `useChannelFeed` reads via
* `getTasks({ channel })`. Filing to the desktop file system (see
* `useChannelTaskMutations().fileTask`) only powers the Artifacts / Recents
* tabs, so both are needed for a task to fully belong to a channel.
*/
export function useFileTaskToChannelFeed(): {
fileTaskToChannelFeed: (
channelName: string,
taskId: string,
) => Promise<string>;
} {
const queryClient = useQueryClient();
const mutation = useAuthenticatedMutation(
async (
client,
{ channelName, taskId }: { channelName: string; taskId: string },
) => {
const backendChannelId = await resolveBackendChannelId(
client,
channelName,
);
await client.updateTask(taskId, {
channel: backendChannelId,
} as Partial<Task> as Parameters<typeof client.updateTask>[1]);
return backendChannelId;
},
{
onSuccess: (backendChannelId) => {
void queryClient.invalidateQueries({
queryKey: channelFeedQueryKey(backendChannelId),
});
},
},
);

return {
fileTaskToChannelFeed: (channelName: string, taskId: string) =>
mutation.mutateAsync({ channelName, taskId }),
};
}
16 changes: 15 additions & 1 deletion packages/ui/src/features/tasks/useTaskContextMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Task } from "@posthog/shared/domain-types";
import { useArchiveTask } from "@posthog/ui/features/archive/useArchiveTask";
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks";
import { useFileTaskToChannelFeed } from "@posthog/ui/features/canvas/hooks/useFileTaskToChannelFeed";
import { useExternalAppAction } from "@posthog/ui/features/external-apps/useExternalAppAction";
import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
import { useRestoreTask } from "@posthog/ui/features/suspension/useRestoreTask";
Expand Down Expand Up @@ -35,6 +36,7 @@ export function useTaskContextMenu() {
);
const { channels } = useChannels({ enabled: bluebirdEnabled });
const { fileTask } = useChannelTaskMutations();
const { fileTaskToChannelFeed } = useFileTaskToChannelFeed();

const showContextMenu = useCallback(
async (
Expand Down Expand Up @@ -120,16 +122,27 @@ export function useTaskContextMenu() {
case "add-to-command-center":
onAddToCommandCenter?.();
break;
case "file-to-channel":
case "file-to-channel": {
// Two sides make a task "belong" to a channel: the desktop
// file-system row (Artifacts / Recents tabs) and the backend
// channel feed (the Slack-style thread items). File to both so the
// task actually appears in the channel, not just its file browser.
const channelName = channels.find(
(c) => c.id === intent.channelId,
)?.name;
try {
await fileTask(intent.channelId, task.id, task.title);
if (channelName) {
await fileTaskToChannelFeed(channelName, task.id);
}
} catch (error) {
toast.error("Couldn't file task to channel", {
description:
error instanceof Error ? error.message : String(error),
});
}
break;
}
case "external-app": {
const effectivePath = resolveExternalAppPath(
worktreePath,
Expand All @@ -155,6 +168,7 @@ export function useTaskContextMenu() {
channels,
deleteWithConfirm,
fileTask,
fileTaskToChannelFeed,
restoreTask,
suspendTask,
hostClient,
Expand Down
Loading