From 116289c790ce6cd6ba68b0063c434a6ddfa7ef73 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 7 Jul 2026 23:12:27 +0100 Subject: [PATCH] =?UTF-8?q?fix(tasks):=20File=20to=E2=80=A6=20also=20adds?= =?UTF-8?q?=20the=20task=20to=20the=20channel=20feed=20as=20a=20thread=20i?= =?UTF-8?q?tem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The task-list "File to…" menu only wrote a desktop_file_system row, which powers a channel's Artifacts / Recents tabs but not its Slack-style feed. The feed reads getTasks({ channel }), so a filed task never appeared as a thread item in the channel. File to both sides: keep the existing folder filing, and additionally associate the task with the backend channel (resolve-or-create by name, mapping the "me" folder onto the personal channel) so it shows up in the feed, then invalidate the feed query. Generated-By: PostHog Code Task-Id: d2eb875e-f010-4bf8-b6fe-9c40d550706f --- .../hooks/useFileTaskToChannelFeed.test.ts | 76 +++++++++++++++++++ .../canvas/hooks/useFileTaskToChannelFeed.ts | 73 ++++++++++++++++++ .../src/features/tasks/useTaskContextMenu.ts | 16 +++- 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/features/canvas/hooks/useFileTaskToChannelFeed.test.ts create mode 100644 packages/ui/src/features/canvas/hooks/useFileTaskToChannelFeed.ts diff --git a/packages/ui/src/features/canvas/hooks/useFileTaskToChannelFeed.test.ts b/packages/ui/src/features/canvas/hooks/useFileTaskToChannelFeed.test.ts new file mode 100644 index 0000000000..b95b1f173a --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useFileTaskToChannelFeed.test.ts @@ -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 { + 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"); + }); +}); diff --git a/packages/ui/src/features/canvas/hooks/useFileTaskToChannelFeed.ts b/packages/ui/src/features/canvas/hooks/useFileTaskToChannelFeed.ts new file mode 100644 index 0000000000..1585d9e08d --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useFileTaskToChannelFeed.ts @@ -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, + channelName: string, +): Promise { + 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; +} { + 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 as Parameters[1]); + return backendChannelId; + }, + { + onSuccess: (backendChannelId) => { + void queryClient.invalidateQueries({ + queryKey: channelFeedQueryKey(backendChannelId), + }); + }, + }, + ); + + return { + fileTaskToChannelFeed: (channelName: string, taskId: string) => + mutation.mutateAsync({ channelName, taskId }), + }; +} diff --git a/packages/ui/src/features/tasks/useTaskContextMenu.ts b/packages/ui/src/features/tasks/useTaskContextMenu.ts index bafeb8270c..b91cf3bf7e 100644 --- a/packages/ui/src/features/tasks/useTaskContextMenu.ts +++ b/packages/ui/src/features/tasks/useTaskContextMenu.ts @@ -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"; @@ -35,6 +36,7 @@ export function useTaskContextMenu() { ); const { channels } = useChannels({ enabled: bluebirdEnabled }); const { fileTask } = useChannelTaskMutations(); + const { fileTaskToChannelFeed } = useFileTaskToChannelFeed(); const showContextMenu = useCallback( async ( @@ -120,9 +122,19 @@ 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: @@ -130,6 +142,7 @@ export function useTaskContextMenu() { }); } break; + } case "external-app": { const effectivePath = resolveExternalAppPath( worktreePath, @@ -155,6 +168,7 @@ export function useTaskContextMenu() { channels, deleteWithConfirm, fileTask, + fileTaskToChannelFeed, restoreTask, suspendTask, hostClient,