From e8fd0e163334439efa12ee7e9f900062aef67e9a Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:29:44 +0200 Subject: [PATCH 01/25] feat(channels): add task board view Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../canvas/components/ChannelBoardView.tsx | 208 ++++++++++++++++++ .../canvas/components/ChannelHeader.tsx | 10 +- .../canvas/components/WebsiteChannelHome.tsx | 66 +++++- .../canvas/stores/channelHomeUiStore.ts | 23 ++ .../canvas/utils/channelBoardStatus.test.ts | 31 +++ .../canvas/utils/channelBoardStatus.ts | 20 ++ 6 files changed, 345 insertions(+), 13 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/ChannelBoardView.tsx create mode 100644 packages/ui/src/features/canvas/stores/channelHomeUiStore.ts create mode 100644 packages/ui/src/features/canvas/utils/channelBoardStatus.test.ts create mode 100644 packages/ui/src/features/canvas/utils/channelBoardStatus.ts diff --git a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx new file mode 100644 index 0000000000..7ca5abe9c7 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx @@ -0,0 +1,208 @@ +import { + ChatCircleIcon, + CheckCircleIcon, + CircleIcon, + SpinnerGapIcon, + XCircleIcon, +} from "@phosphor-icons/react"; +import { Card, CardContent, cn } from "@posthog/quill"; +import type { Task } from "@posthog/shared/domain-types"; +import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; +import { + TaskStatusBadge, + useTaskStatusDisplay, +} from "@posthog/ui/features/canvas/components/ChannelFeedView"; +import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; +import type { ChannelBoardStatus } from "@posthog/ui/features/canvas/utils/channelBoardStatus"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { ScrollArea } from "@radix-ui/themes"; + +const BOARD_REPLIES_POLL_INTERVAL_MS = 15_000; + +const COLUMNS: Array<{ + id: ChannelBoardStatus; + label: string; + Icon: typeof CircleIcon; +}> = [ + { id: "todo", label: "To do", Icon: CircleIcon }, + { id: "in_progress", label: "In progress", Icon: SpinnerGapIcon }, + { id: "ready", label: "Ready", Icon: CheckCircleIcon }, + { id: "closed", label: "Closed", Icon: XCircleIcon }, +]; + +export function ChannelBoardView({ + tasks, + isLoading, + onOpenTask, + onOpenThread, +}: { + tasks: Task[]; + isLoading: boolean; + onOpenTask: (task: Task) => void; + onOpenThread: (task: Task) => void; +}) { + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {COLUMNS.map((column) => ( + + ))} +
+ ); +} + +function BoardColumn({ + column, + tasks, + onOpenTask, + onOpenThread, +}: { + column: (typeof COLUMNS)[number]; + tasks: Task[]; + onOpenTask: (task: Task) => void; + onOpenThread: (task: Task) => void; +}) { + return ( +
+
+ + + {column.label} + +
+
+ +
+ {tasks.map((task) => ( + + ))} +
+
+
+
+ ); +} + +function ChannelBoardCardFilter({ + task, + status, + onOpenTask, + onOpenThread, +}: { + task: Task; + status: ChannelBoardStatus; + onOpenTask: (task: Task) => void; + onOpenThread: (task: Task) => void; +}) { + const display = useTaskStatusDisplay(task); + if (display.boardStatus !== status) return null; + return ( + + ); +} + +function ChannelBoardCard({ + task, + display, + onOpenTask, + onOpenThread, +}: { + task: Task; + display: ReturnType; + onOpenTask: (task: Task) => void; + onOpenThread: (task: Task) => void; +}) { + const { messages } = useTaskThread(task.id, { + pollIntervalMs: BOARD_REPLIES_POLL_INTERVAL_MS, + }); + const creator = task.created_by; + const creatorName = userDisplayName(creator); + const replyLabel = `${messages.length} ${messages.length === 1 ? "reply" : "replies"}`; + + return ( + onOpenTask(task)} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpenTask(task); + } + }} + className={cn( + "cursor-pointer py-0 hover:border-border-primary hover:bg-fill-hover", + display.isMerged && "border-(--purple-8) bg-(--purple-a2)", + )} + > + +
+ + {task.title || "Untitled task"} + + +
+ {task.repository ? ( + + {task.repository} + + ) : null} +
+
+ + + {creatorName} + +
+ +
+
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index bc08328a46..11fda27af2 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -5,6 +5,7 @@ import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen"; import { Text } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; +import type { ReactNode } from "react"; // The shared channel header: a clickable "# channel" that doubles as the Home // item — it routes to the channel home (`/website/$channelId`, like the sidebar @@ -12,7 +13,13 @@ import { useNavigate, useRouterState } from "@tanstack/react-router"; // pathname-driven active state the rest of the channel tab strip uses. Followed // by that strip (Artifacts / Recents / CONTEXT.md), rendered into the // header bar by every channel view so the tabs stay in view. -export function ChannelHeader({ channelId }: { channelId: string }) { +export function ChannelHeader({ + channelId, + trailing, +}: { + channelId: string; + trailing?: ReactNode; +}) { const navigate = useNavigate(); const { channels } = useChannels(); const channelName = channels.find((c) => c.id === channelId)?.name; @@ -39,6 +46,7 @@ export function ChannelHeader({ channelId }: { channelId: string }) { + {trailing} ); } diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index d6c1f6bd27..396a401305 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -1,8 +1,11 @@ +import { Kanban, ListBullets } from "@phosphor-icons/react"; import { insertTaskDedup } from "@posthog/core/tasks/taskDelete"; +import { Button } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; import { isTerminalStatus } from "@posthog/shared/domain-types"; import { CHANNEL_TASK_SUGGESTIONS } from "@posthog/ui/features/canvas/channelTaskSuggestions"; +import { ChannelBoardView } from "@posthog/ui/features/canvas/components/ChannelBoardView"; import { ChannelFeedView, type PendingKickoff, @@ -34,6 +37,7 @@ import { PERSONAL_CHANNEL_NAME, useBackendChannel, } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useChannelHomeUiStore } from "@posthog/ui/features/canvas/stores/channelHomeUiStore"; import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore"; import { SuggestedPromptCard } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; @@ -55,6 +59,8 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { const { channels, isLoading: isLoadingChannels } = useChannels(); const channelName = channels.find((c) => c.id === channelId)?.name; const { fileTask } = useChannelTaskMutations(); + const viewMode = useChannelHomeUiStore((state) => state.viewMode); + const setViewMode = useChannelHomeUiStore((state) => state.setViewMode); // Poll while empty so the intro's context.md card flips to "created" when // the agent publishes mid plan-session, without a manual reload. @@ -93,8 +99,35 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { return creation ? [creation, ...feedMessages] : feedMessages; }, [backendChannel, feedMessages]); + const viewToggle = useMemo( + () => ( +
+ + +
+ ), + [setViewMode, viewMode], + ); + useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId, viewToggle], + ), ); const composerRef = useRef(null); @@ -276,17 +309,26 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { return (
- + {viewMode === "board" ? ( + + ) : ( + + )}
void; +} + +export const useChannelHomeUiStore = create()( + persist( + (set) => ({ + viewMode: "feed", + setViewMode: (viewMode) => set({ viewMode }), + }), + { + name: "channel-home-ui-store", + storage: electronStorage, + }, + ), +); diff --git a/packages/ui/src/features/canvas/utils/channelBoardStatus.test.ts b/packages/ui/src/features/canvas/utils/channelBoardStatus.test.ts new file mode 100644 index 0000000000..af55a18c6d --- /dev/null +++ b/packages/ui/src/features/canvas/utils/channelBoardStatus.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { channelBoardStatus } from "./channelBoardStatus"; + +describe("channelBoardStatus", () => { + it.each([ + [{}, "todo"], + [{ status: "not_started" }, "todo"], + [{ status: "queued" }, "todo"], + [{ status: "in_progress" }, "in_progress"], + [{ status: "completed" }, "ready"], + [{ status: "failed" }, "closed"], + [{ status: "cancelled" }, "closed"], + [{ prState: "open" }, "ready"], + [{ prState: "draft" }, "ready"], + [{ prState: "merged" }, "closed"], + [{ prState: "closed" }, "closed"], + [{ needsPermission: true, status: "completed" }, "in_progress"], + [{ isGenerating: true, prState: "open" }, "in_progress"], + [{ status: "failed", prState: "open" }, "closed"], + ] as const)("maps %o to %s", (overrides, expected) => { + expect( + channelBoardStatus({ + status: undefined, + prState: null, + needsPermission: false, + isGenerating: false, + ...overrides, + }), + ).toBe(expected); + }); +}); diff --git a/packages/ui/src/features/canvas/utils/channelBoardStatus.ts b/packages/ui/src/features/canvas/utils/channelBoardStatus.ts new file mode 100644 index 0000000000..1a754e33dd --- /dev/null +++ b/packages/ui/src/features/canvas/utils/channelBoardStatus.ts @@ -0,0 +1,20 @@ +import type { TaskRunStatus } from "@posthog/shared/domain-types"; +import type { SidebarPrState } from "@posthog/ui/features/sidebar/useTaskPrStatus"; + +export type ChannelBoardStatus = "todo" | "in_progress" | "ready" | "closed"; + +export function channelBoardStatus(input: { + status?: TaskRunStatus; + prState: SidebarPrState; + needsPermission: boolean; + isGenerating: boolean; +}): ChannelBoardStatus { + if (input.needsPermission || input.isGenerating) return "in_progress"; + if (input.status === "failed" || input.status === "cancelled") + return "closed"; + if (input.prState === "merged" || input.prState === "closed") return "closed"; + if (input.prState === "open" || input.prState === "draft") return "ready"; + if (input.status === "completed") return "ready"; + if (input.status === "in_progress") return "in_progress"; + return "todo"; +} From 4764f2e897ad74fb31136a82dc4172c2bb18ad37 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:29:45 +0200 Subject: [PATCH 02/25] feat(channels): refine board workflow Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../canvas/components/ChannelBoardView.tsx | 48 +++++++++++++++++-- .../components/ChannelTaskPreviewDialog.tsx | 42 ++++++++++++++++ .../canvas/components/WebsiteChannelHome.tsx | 17 +++++-- .../canvas/hooks/useChannelFeedbackRequest.ts | 33 +++++++++++++ .../canvas/utils/channelBoardStatus.test.ts | 10 ++-- .../canvas/utils/channelBoardStatus.ts | 18 ++++++- 6 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/ChannelTaskPreviewDialog.tsx create mode 100644 packages/ui/src/features/canvas/hooks/useChannelFeedbackRequest.ts diff --git a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx index 7ca5abe9c7..4a69df8523 100644 --- a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx @@ -1,20 +1,24 @@ import { ChatCircleIcon, + ChatTeardropTextIcon, CheckCircleIcon, - CircleIcon, SpinnerGapIcon, XCircleIcon, } from "@phosphor-icons/react"; -import { Card, CardContent, cn } from "@posthog/quill"; +import { Button, Card, CardContent, cn } from "@posthog/quill"; import type { Task } from "@posthog/shared/domain-types"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; +import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { TaskStatusBadge, useTaskStatusDisplay, } from "@posthog/ui/features/canvas/components/ChannelFeedView"; +import { useChannelFeedbackRequest } from "@posthog/ui/features/canvas/hooks/useChannelFeedbackRequest"; import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; import type { ChannelBoardStatus } from "@posthog/ui/features/canvas/utils/channelBoardStatus"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { toast } from "@posthog/ui/primitives/toast"; import { ScrollArea } from "@radix-ui/themes"; const BOARD_REPLIES_POLL_INTERVAL_MS = 15_000; @@ -22,10 +26,14 @@ const BOARD_REPLIES_POLL_INTERVAL_MS = 15_000; const COLUMNS: Array<{ id: ChannelBoardStatus; label: string; - Icon: typeof CircleIcon; + Icon: typeof SpinnerGapIcon; }> = [ - { id: "todo", label: "To do", Icon: CircleIcon }, { id: "in_progress", label: "In progress", Icon: SpinnerGapIcon }, + { + id: "needs_feedback", + label: "Needs feedback", + Icon: ChatTeardropTextIcon, + }, { id: "ready", label: "Ready", Icon: CheckCircleIcon }, { id: "closed", label: "Closed", Icon: XCircleIcon }, ]; @@ -145,8 +153,26 @@ function ChannelBoardCard({ }); const creator = task.created_by; const creatorName = userDisplayName(creator); + const client = useOptionalAuthenticatedClient(); + const { data: currentUser } = useCurrentUser({ client }); + const { setNeedsFeedback, isPending } = useChannelFeedbackRequest(); + const isCreator = + !!currentUser?.uuid && currentUser.uuid === task.created_by?.uuid; + const needsFeedback = display.boardStatus === "needs_feedback"; + const canRequestFeedback = + isCreator && + !!task.latest_run && + (display.boardStatus === "ready" || needsFeedback); const replyLabel = `${messages.length} ${messages.length === 1 ? "reply" : "replies"}`; + const toggleFeedbackRequest = (value: boolean) => { + void setNeedsFeedback(task, value).catch((error: unknown) => { + toast.error("Couldn't update feedback request", { + description: error instanceof Error ? error.message : String(error), + }); + }); + }; + return ( ) : null} + {canRequestFeedback ? ( + + ) : null}
void; + onOpenFull: (task: Task) => void; +}) { + return ( + !open && onClose()}> + + + {task?.title || "Task preview"} + + + Preview the task conversation without leaving the channel board. + + {task ? ( + onOpenFull(task)} + /> + ) : null} + + + ); +} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 396a401305..7a5ddcc7d2 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -19,6 +19,7 @@ import { ChannelIntro, type ContextMdState, } from "@posthog/ui/features/canvas/components/ChannelIntro"; +import { ChannelTaskPreviewDialog } from "@posthog/ui/features/canvas/components/ChannelTaskPreviewDialog"; import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; import { ThreadSidebar } from "@posthog/ui/features/canvas/components/ThreadSidebar"; import { CONTEXT_MD_TASK_TITLE_PREFIX } from "@posthog/ui/features/canvas/contextPrompt"; @@ -156,6 +157,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { // The "Create your context.md" dialog, opened from the welcome message's // onboarding checklist. Describe-mode: seeds a plan session for this context. const [contextMdDialogOpen, setContextMdDialogOpen] = useState(false); + const [previewTask, setPreviewTask] = useState(null); const threadTaskId = useThreadPanelStore( (s) => s.openByChannel[channelId] ?? null, @@ -226,10 +228,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { }, [channelId, navigate], ); - const handleOpenTask = useCallback( - (task: Task) => handleOpenFull(task.id), - [handleOpenFull], - ); + const handleOpenTask = useCallback((task: Task) => setPreviewTask(task), []); const handleOpenThread = useCallback( (task: Task) => openThread(channelId, task.id), @@ -360,6 +359,16 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { existingContext={{ channelId, channelName }} /> )} + + setPreviewTask(null)} + onOpenFull={(task) => { + setPreviewTask(null); + handleOpenFull(task.id); + }} + />
); } diff --git a/packages/ui/src/features/canvas/hooks/useChannelFeedbackRequest.ts b/packages/ui/src/features/canvas/hooks/useChannelFeedbackRequest.ts new file mode 100644 index 0000000000..e00058b8cc --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useChannelFeedbackRequest.ts @@ -0,0 +1,33 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { CHANNEL_NEEDS_FEEDBACK_STATE_KEY } from "@posthog/ui/features/canvas/utils/channelBoardStatus"; +import { useAuthenticatedMutation } from "@posthog/ui/hooks/useAuthenticatedMutation"; +import { useQueryClient } from "@tanstack/react-query"; + +export function useChannelFeedbackRequest(): { + setNeedsFeedback: (task: Task, value: boolean) => Promise; + isPending: boolean; +} { + const queryClient = useQueryClient(); + const mutation = useAuthenticatedMutation( + (client, { task, value }: { task: Task; value: boolean }) => { + if (!task.latest_run) throw new Error("Task has no run to update"); + return client.updateTaskRun(task.id, task.latest_run.id, { + state: { + ...task.latest_run.state, + [CHANNEL_NEEDS_FEEDBACK_STATE_KEY]: value, + }, + }); + }, + { + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["channel-feed"] }), + }, + ); + + return { + setNeedsFeedback: async (task, value) => { + await mutation.mutateAsync({ task, value }); + }, + isPending: mutation.isPending, + }; +} diff --git a/packages/ui/src/features/canvas/utils/channelBoardStatus.test.ts b/packages/ui/src/features/canvas/utils/channelBoardStatus.test.ts index af55a18c6d..60df104b54 100644 --- a/packages/ui/src/features/canvas/utils/channelBoardStatus.test.ts +++ b/packages/ui/src/features/canvas/utils/channelBoardStatus.test.ts @@ -3,9 +3,9 @@ import { channelBoardStatus } from "./channelBoardStatus"; describe("channelBoardStatus", () => { it.each([ - [{}, "todo"], - [{ status: "not_started" }, "todo"], - [{ status: "queued" }, "todo"], + [{}, "in_progress"], + [{ status: "not_started" }, "in_progress"], + [{ status: "queued" }, "in_progress"], [{ status: "in_progress" }, "in_progress"], [{ status: "completed" }, "ready"], [{ status: "failed" }, "closed"], @@ -17,6 +17,9 @@ describe("channelBoardStatus", () => { [{ needsPermission: true, status: "completed" }, "in_progress"], [{ isGenerating: true, prState: "open" }, "in_progress"], [{ status: "failed", prState: "open" }, "closed"], + [{ needsFeedback: true, status: "completed" }, "needs_feedback"], + [{ needsFeedback: true, status: "failed" }, "closed"], + [{ needsFeedback: true, prState: "merged" }, "closed"], ] as const)("maps %o to %s", (overrides, expected) => { expect( channelBoardStatus({ @@ -24,6 +27,7 @@ describe("channelBoardStatus", () => { prState: null, needsPermission: false, isGenerating: false, + needsFeedback: false, ...overrides, }), ).toBe(expected); diff --git a/packages/ui/src/features/canvas/utils/channelBoardStatus.ts b/packages/ui/src/features/canvas/utils/channelBoardStatus.ts index 1a754e33dd..e27eaab648 100644 --- a/packages/ui/src/features/canvas/utils/channelBoardStatus.ts +++ b/packages/ui/src/features/canvas/utils/channelBoardStatus.ts @@ -1,20 +1,34 @@ import type { TaskRunStatus } from "@posthog/shared/domain-types"; import type { SidebarPrState } from "@posthog/ui/features/sidebar/useTaskPrStatus"; -export type ChannelBoardStatus = "todo" | "in_progress" | "ready" | "closed"; +export const CHANNEL_NEEDS_FEEDBACK_STATE_KEY = "posthog_code_needs_feedback"; + +export type ChannelBoardStatus = + | "in_progress" + | "needs_feedback" + | "ready" + | "closed"; + +export function taskNeedsFeedback( + state: Record | undefined, +): boolean { + return state?.[CHANNEL_NEEDS_FEEDBACK_STATE_KEY] === true; +} export function channelBoardStatus(input: { status?: TaskRunStatus; prState: SidebarPrState; needsPermission: boolean; isGenerating: boolean; + needsFeedback: boolean; }): ChannelBoardStatus { if (input.needsPermission || input.isGenerating) return "in_progress"; if (input.status === "failed" || input.status === "cancelled") return "closed"; if (input.prState === "merged" || input.prState === "closed") return "closed"; + if (input.needsFeedback) return "needs_feedback"; if (input.prState === "open" || input.prState === "draft") return "ready"; if (input.status === "completed") return "ready"; if (input.status === "in_progress") return "in_progress"; - return "todo"; + return "in_progress"; } From 45de14563dc9ba8dda268af966b579d32bc77833 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:29:47 +0200 Subject: [PATCH 03/25] refactor(channels): reuse home board workflow Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../canvas/components/ChannelBoardView.tsx | 280 ++++++------------ .../canvas/components/WebsiteChannelHome.tsx | 104 +++++-- .../canvas/hooks/useChannelFeedbackRequest.ts | 33 --- .../canvas/stores/channelHomeUiStore.ts | 5 + .../canvas/utils/channelBoardStatus.test.ts | 32 +- .../canvas/utils/channelBoardStatus.ts | 35 +-- .../features/home/components/WorkBoard.tsx | 80 +++++ 7 files changed, 279 insertions(+), 290 deletions(-) delete mode 100644 packages/ui/src/features/canvas/hooks/useChannelFeedbackRequest.ts create mode 100644 packages/ui/src/features/home/components/WorkBoard.tsx diff --git a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx index 4a69df8523..7dacc2d396 100644 --- a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx @@ -1,183 +1,115 @@ -import { - ChatCircleIcon, - ChatTeardropTextIcon, - CheckCircleIcon, - SpinnerGapIcon, - XCircleIcon, -} from "@phosphor-icons/react"; -import { Button, Card, CardContent, cn } from "@posthog/quill"; +import { ChatCircleIcon } from "@phosphor-icons/react"; +import type { SituationId } from "@posthog/core/workflow/schemas"; import type { Task } from "@posthog/shared/domain-types"; -import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; -import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { TaskStatusBadge, useTaskStatusDisplay, } from "@posthog/ui/features/canvas/components/ChannelFeedView"; -import { useChannelFeedbackRequest } from "@posthog/ui/features/canvas/hooks/useChannelFeedbackRequest"; import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; -import type { ChannelBoardStatus } from "@posthog/ui/features/canvas/utils/channelBoardStatus"; +import { channelBoardStatus } from "@posthog/ui/features/canvas/utils/channelBoardStatus"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; -import { toast } from "@posthog/ui/primitives/toast"; -import { ScrollArea } from "@radix-ui/themes"; +import { + WorkBoard, + type WorkBoardColumn, +} from "@posthog/ui/features/home/components/WorkBoard"; +import { SITUATION_VISUAL } from "@posthog/ui/features/home/utils/situationDisplay"; +import { Box } from "@radix-ui/themes"; +import { useMemo } from "react"; const BOARD_REPLIES_POLL_INTERVAL_MS = 15_000; - -const COLUMNS: Array<{ - id: ChannelBoardStatus; - label: string; - Icon: typeof SpinnerGapIcon; -}> = [ - { id: "in_progress", label: "In progress", Icon: SpinnerGapIcon }, - { - id: "needs_feedback", - label: "Needs feedback", - Icon: ChatTeardropTextIcon, - }, - { id: "ready", label: "Ready", Icon: CheckCircleIcon }, - { id: "closed", label: "Closed", Icon: XCircleIcon }, +const CHANNEL_COLUMN_IDS: SituationId[] = [ + "working", + "in_review", + "ci_failing", + "changes_requested", + "comments_waiting", + "ready_to_merge", + "done", ]; export function ChannelBoardView({ tasks, isLoading, + situationByTaskId, onOpenTask, onOpenThread, }: { tasks: Task[]; isLoading: boolean; + situationByTaskId: ReadonlyMap; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; }) { - if (isLoading) { - return ( -
- -
+ const columns = useMemo[]>(() => { + const grouped = new Map( + CHANNEL_COLUMN_IDS.map((id) => [id, []]), ); + for (const task of tasks) { + const prUrl = task.latest_run?.output?.pr_url; + const situation = channelBoardStatus({ + status: task.latest_run?.status, + prState: typeof prUrl === "string" ? "open" : null, + needsPermission: false, + isGenerating: task.latest_run?.status === "in_progress", + homeSituation: situationByTaskId.get(task.id), + }); + grouped.get(situation)?.push(task); + } + return CHANNEL_COLUMN_IDS.map((id) => ({ + id, + label: id === "in_review" ? "Needs feedback" : SITUATION_VISUAL[id].label, + description: SITUATION_VISUAL[id].description, + color: SITUATION_VISUAL[id].color, + Icon: SITUATION_VISUAL[id].Icon, + items: grouped.get(id) ?? [], + })); + }, [situationByTaskId, tasks]); + + if (isLoading) { + return
; } return ( -
- {COLUMNS.map((column) => ( - task.id} + renderCard={(task) => ( + - ))} -
- ); -} - -function BoardColumn({ - column, - tasks, - onOpenTask, - onOpenThread, -}: { - column: (typeof COLUMNS)[number]; - tasks: Task[]; - onOpenTask: (task: Task) => void; - onOpenThread: (task: Task) => void; -}) { - return ( -
-
- - - {column.label} - -
-
- -
- {tasks.map((task) => ( - - ))} -
-
-
-
- ); -} - -function ChannelBoardCardFilter({ - task, - status, - onOpenTask, - onOpenThread, -}: { - task: Task; - status: ChannelBoardStatus; - onOpenTask: (task: Task) => void; - onOpenThread: (task: Task) => void; -}) { - const display = useTaskStatusDisplay(task); - if (display.boardStatus !== status) return null; - return ( - ); } function ChannelBoardCard({ task, - display, + homeSituation, onOpenTask, onOpenThread, }: { task: Task; - display: ReturnType; + homeSituation?: SituationId; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; }) { + const display = useTaskStatusDisplay(task, homeSituation); const { messages } = useTaskThread(task.id, { pollIntervalMs: BOARD_REPLIES_POLL_INTERVAL_MS, }); - const creator = task.created_by; - const creatorName = userDisplayName(creator); - const client = useOptionalAuthenticatedClient(); - const { data: currentUser } = useCurrentUser({ client }); - const { setNeedsFeedback, isPending } = useChannelFeedbackRequest(); - const isCreator = - !!currentUser?.uuid && currentUser.uuid === task.created_by?.uuid; - const needsFeedback = display.boardStatus === "needs_feedback"; - const canRequestFeedback = - isCreator && - !!task.latest_run && - (display.boardStatus === "ready" || needsFeedback); + const creatorName = userDisplayName(task.created_by); + const visual = SITUATION_VISUAL[display.boardStatus]; const replyLabel = `${messages.length} ${messages.length === 1 ? "reply" : "replies"}`; - const toggleFeedbackRequest = (value: boolean) => { - void setNeedsFeedback(task, value).catch((error: unknown) => { - toast.error("Couldn't update feedback request", { - description: error instanceof Error ? error.message : String(error), - }); - }); - }; - return ( - onOpenTask(task)} onKeyDown={(event) => { if (event.target !== event.currentTarget) return; @@ -186,63 +118,43 @@ function ChannelBoardCard({ onOpenTask(task); } }} - className={cn( - "cursor-pointer py-0 hover:border-border-primary hover:bg-fill-hover", - display.isMerged && "border-(--purple-8) bg-(--purple-a2)", - )} + className="group hover:-translate-y-px relative flex cursor-pointer flex-col gap-2 overflow-hidden rounded-lg border border-(--gray-4) bg-(--color-panel-solid) px-3 pt-3 pb-2.5 transition-all hover:border-(--gray-7) hover:shadow-md" > - -
- - {task.title || "Untitled task"} - - -
- {task.repository ? ( - - {task.repository} + +
+ + {task.title || "Untitled task"} + + +
+ {task.repository ? ( + + {task.repository} + + ) : null} +
+
+ + + {creatorName} - ) : null} - {canRequestFeedback ? ( - - ) : null} -
-
- - - {creatorName} - -
-
- - + +
+ ); } diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 7a5ddcc7d2..602c034c44 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -1,9 +1,12 @@ -import { Kanban, ListBullets } from "@phosphor-icons/react"; +import { Kanban, ListBullets, User } from "@phosphor-icons/react"; import { insertTaskDedup } from "@posthog/core/tasks/taskDelete"; +import type { SituationId } from "@posthog/core/workflow/schemas"; import { Button } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; import { isTerminalStatus } from "@posthog/shared/domain-types"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { CHANNEL_TASK_SUGGESTIONS } from "@posthog/ui/features/canvas/channelTaskSuggestions"; import { ChannelBoardView } from "@posthog/ui/features/canvas/components/ChannelBoardView"; import { @@ -40,6 +43,7 @@ import { } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useChannelHomeUiStore } from "@posthog/ui/features/canvas/stores/channelHomeUiStore"; import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore"; +import { useHomeSnapshot } from "@posthog/ui/features/home/hooks/useHomeSnapshot"; import { SuggestedPromptCard } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; @@ -62,6 +66,11 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { const { fileTask } = useChannelTaskMutations(); const viewMode = useChannelHomeUiStore((state) => state.viewMode); const setViewMode = useChannelHomeUiStore((state) => state.setViewMode); + const taskScope = useChannelHomeUiStore((state) => state.taskScope); + const setTaskScope = useChannelHomeUiStore((state) => state.setTaskScope); + const client = useOptionalAuthenticatedClient(); + const { data: currentUser } = useCurrentUser({ client }); + const { snapshot: homeSnapshot } = useHomeSnapshot(); // Poll while empty so the intro's context.md card flips to "created" when // the agent publishes mid plan-session, without a manual reload. @@ -100,36 +109,33 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { return creation ? [creation, ...feedMessages] : feedMessages; }, [backendChannel, feedMessages]); - const viewToggle = useMemo( - () => ( -
- - -
- ), - [setViewMode, viewMode], + useSetHeaderContent( + useMemo(() => , [channelId]), ); - useSetHeaderContent( - useMemo( - () => , - [channelId, viewToggle], - ), + const visibleTasks = useMemo( + () => + taskScope === "me" + ? tasks.filter( + (task) => + !!currentUser?.uuid && task.created_by?.uuid === currentUser.uuid, + ) + : tasks, + [currentUser?.uuid, taskScope, tasks], ); + const situationByTaskId = useMemo(() => { + const result = new Map(); + for (const workstream of [ + ...homeSnapshot.needsAttention, + ...homeSnapshot.inProgress, + ]) { + if (!workstream.primarySituation) continue; + for (const task of workstream.tasks) { + result.set(task.id, workstream.primarySituation); + } + } + return result; + }, [homeSnapshot.inProgress, homeSnapshot.needsAttention]); const composerRef = useRef(null); @@ -308,17 +314,55 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { return (
+
+
+ + +
+
+ + +
+
{viewMode === "board" ? ( ) : ( Promise; - isPending: boolean; -} { - const queryClient = useQueryClient(); - const mutation = useAuthenticatedMutation( - (client, { task, value }: { task: Task; value: boolean }) => { - if (!task.latest_run) throw new Error("Task has no run to update"); - return client.updateTaskRun(task.id, task.latest_run.id, { - state: { - ...task.latest_run.state, - [CHANNEL_NEEDS_FEEDBACK_STATE_KEY]: value, - }, - }); - }, - { - onSuccess: () => - queryClient.invalidateQueries({ queryKey: ["channel-feed"] }), - }, - ); - - return { - setNeedsFeedback: async (task, value) => { - await mutation.mutateAsync({ task, value }); - }, - isPending: mutation.isPending, - }; -} diff --git a/packages/ui/src/features/canvas/stores/channelHomeUiStore.ts b/packages/ui/src/features/canvas/stores/channelHomeUiStore.ts index 873f85369d..00095a5be6 100644 --- a/packages/ui/src/features/canvas/stores/channelHomeUiStore.ts +++ b/packages/ui/src/features/canvas/stores/channelHomeUiStore.ts @@ -3,10 +3,13 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; export type ChannelHomeViewMode = "feed" | "board"; +export type ChannelTaskScope = "all" | "me"; interface ChannelHomeUiStore { viewMode: ChannelHomeViewMode; setViewMode: (mode: ChannelHomeViewMode) => void; + taskScope: ChannelTaskScope; + setTaskScope: (scope: ChannelTaskScope) => void; } export const useChannelHomeUiStore = create()( @@ -14,6 +17,8 @@ export const useChannelHomeUiStore = create()( (set) => ({ viewMode: "feed", setViewMode: (viewMode) => set({ viewMode }), + taskScope: "all", + setTaskScope: (taskScope) => set({ taskScope }), }), { name: "channel-home-ui-store", diff --git a/packages/ui/src/features/canvas/utils/channelBoardStatus.test.ts b/packages/ui/src/features/canvas/utils/channelBoardStatus.test.ts index 60df104b54..62fb0f1eef 100644 --- a/packages/ui/src/features/canvas/utils/channelBoardStatus.test.ts +++ b/packages/ui/src/features/canvas/utils/channelBoardStatus.test.ts @@ -3,23 +3,20 @@ import { channelBoardStatus } from "./channelBoardStatus"; describe("channelBoardStatus", () => { it.each([ - [{}, "in_progress"], - [{ status: "not_started" }, "in_progress"], - [{ status: "queued" }, "in_progress"], - [{ status: "in_progress" }, "in_progress"], - [{ status: "completed" }, "ready"], - [{ status: "failed" }, "closed"], - [{ status: "cancelled" }, "closed"], - [{ prState: "open" }, "ready"], - [{ prState: "draft" }, "ready"], - [{ prState: "merged" }, "closed"], - [{ prState: "closed" }, "closed"], - [{ needsPermission: true, status: "completed" }, "in_progress"], - [{ isGenerating: true, prState: "open" }, "in_progress"], - [{ status: "failed", prState: "open" }, "closed"], - [{ needsFeedback: true, status: "completed" }, "needs_feedback"], - [{ needsFeedback: true, status: "failed" }, "closed"], - [{ needsFeedback: true, prState: "merged" }, "closed"], + [{}, "working"], + [{ status: "not_started" }, "working"], + [{ status: "queued" }, "working"], + [{ status: "in_progress" }, "working"], + [{ status: "completed" }, "in_review"], + [{ status: "failed" }, "done"], + [{ status: "cancelled" }, "done"], + [{ prState: "open" }, "in_review"], + [{ prState: "draft" }, "in_review"], + [{ prState: "merged" }, "done"], + [{ prState: "closed" }, "done"], + [{ needsPermission: true, status: "completed" }, "working"], + [{ isGenerating: true, prState: "open" }, "working"], + [{ homeSituation: "ci_failing" }, "ci_failing"], ] as const)("maps %o to %s", (overrides, expected) => { expect( channelBoardStatus({ @@ -27,7 +24,6 @@ describe("channelBoardStatus", () => { prState: null, needsPermission: false, isGenerating: false, - needsFeedback: false, ...overrides, }), ).toBe(expected); diff --git a/packages/ui/src/features/canvas/utils/channelBoardStatus.ts b/packages/ui/src/features/canvas/utils/channelBoardStatus.ts index e27eaab648..00c16cc799 100644 --- a/packages/ui/src/features/canvas/utils/channelBoardStatus.ts +++ b/packages/ui/src/features/canvas/utils/channelBoardStatus.ts @@ -1,34 +1,19 @@ +import type { SituationId } from "@posthog/core/workflow/schemas"; import type { TaskRunStatus } from "@posthog/shared/domain-types"; import type { SidebarPrState } from "@posthog/ui/features/sidebar/useTaskPrStatus"; -export const CHANNEL_NEEDS_FEEDBACK_STATE_KEY = "posthog_code_needs_feedback"; - -export type ChannelBoardStatus = - | "in_progress" - | "needs_feedback" - | "ready" - | "closed"; - -export function taskNeedsFeedback( - state: Record | undefined, -): boolean { - return state?.[CHANNEL_NEEDS_FEEDBACK_STATE_KEY] === true; -} - export function channelBoardStatus(input: { status?: TaskRunStatus; prState: SidebarPrState; needsPermission: boolean; isGenerating: boolean; - needsFeedback: boolean; -}): ChannelBoardStatus { - if (input.needsPermission || input.isGenerating) return "in_progress"; - if (input.status === "failed" || input.status === "cancelled") - return "closed"; - if (input.prState === "merged" || input.prState === "closed") return "closed"; - if (input.needsFeedback) return "needs_feedback"; - if (input.prState === "open" || input.prState === "draft") return "ready"; - if (input.status === "completed") return "ready"; - if (input.status === "in_progress") return "in_progress"; - return "in_progress"; + homeSituation?: SituationId | null; +}): SituationId { + if (input.homeSituation) return input.homeSituation; + if (input.needsPermission || input.isGenerating) return "working"; + if (input.status === "failed" || input.status === "cancelled") return "done"; + if (input.prState === "merged" || input.prState === "closed") return "done"; + if (input.prState === "open" || input.prState === "draft") return "in_review"; + if (input.status === "completed") return "in_review"; + return "working"; } diff --git a/packages/ui/src/features/home/components/WorkBoard.tsx b/packages/ui/src/features/home/components/WorkBoard.tsx new file mode 100644 index 0000000000..dc4d7c09fb --- /dev/null +++ b/packages/ui/src/features/home/components/WorkBoard.tsx @@ -0,0 +1,80 @@ +import type { Icon } from "@phosphor-icons/react"; +import { + type SituationColor, + situationCss, +} from "@posthog/ui/features/home/utils/situationDisplay"; +import { ScrollArea } from "@radix-ui/themes"; +import type { ReactNode } from "react"; + +export interface WorkBoardColumn { + id: string; + label: string; + description: string; + color: SituationColor; + Icon: Icon; + items: T[]; +} + +export function WorkBoard({ + columns, + getKey, + renderCard, +}: { + columns: WorkBoardColumn[]; + getKey: (item: T) => string; + renderCard: (item: T) => ReactNode; +}) { + return ( +
+ {columns.map((column) => { + const c = situationCss(column.color); + const count = column.items.length; + return ( +
+
+ + + + + {column.label} + + + {count} + +
+
+ +
+ {count === 0 ? ( +
+ + + Nothing here + +
+ ) : ( + column.items.map((item) => ( +
{renderCard(item)}
+ )) + )} +
+
+
+
+ ); + })} +
+ ); +} From c0f011f61e4ab85a5a6e9751277153631c68a180 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:29:49 +0200 Subject: [PATCH 04/25] fix(channels): align board status and modal Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../canvas/components/ChannelBoardView.tsx | 33 +++++-------------- .../components/ChannelTaskPreviewDialog.tsx | 5 ++- .../canvas/components/ThreadPanel.tsx | 4 +-- .../canvas/components/WebsiteChannelHome.tsx | 6 ++-- .../canvas/utils/channelBoardStatus.test.ts | 31 ----------------- .../canvas/utils/channelBoardStatus.ts | 19 ----------- 6 files changed, 18 insertions(+), 80 deletions(-) delete mode 100644 packages/ui/src/features/canvas/utils/channelBoardStatus.test.ts delete mode 100644 packages/ui/src/features/canvas/utils/channelBoardStatus.ts diff --git a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx index 7dacc2d396..10e77e2450 100644 --- a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx @@ -7,27 +7,17 @@ import { useTaskStatusDisplay, } from "@posthog/ui/features/canvas/components/ChannelFeedView"; import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; -import { channelBoardStatus } from "@posthog/ui/features/canvas/utils/channelBoardStatus"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { WorkBoard, type WorkBoardColumn, } from "@posthog/ui/features/home/components/WorkBoard"; +import { HOME_BOARD_COLUMN_IDS } from "@posthog/ui/features/home/utils/boardColumns"; import { SITUATION_VISUAL } from "@posthog/ui/features/home/utils/situationDisplay"; import { Box } from "@radix-ui/themes"; import { useMemo } from "react"; const BOARD_REPLIES_POLL_INTERVAL_MS = 15_000; -const CHANNEL_COLUMN_IDS: SituationId[] = [ - "working", - "in_review", - "ci_failing", - "changes_requested", - "comments_waiting", - "ready_to_merge", - "done", -]; - export function ChannelBoardView({ tasks, isLoading, @@ -43,22 +33,15 @@ export function ChannelBoardView({ }) { const columns = useMemo[]>(() => { const grouped = new Map( - CHANNEL_COLUMN_IDS.map((id) => [id, []]), + HOME_BOARD_COLUMN_IDS.map((id) => [id, []]), ); for (const task of tasks) { - const prUrl = task.latest_run?.output?.pr_url; - const situation = channelBoardStatus({ - status: task.latest_run?.status, - prState: typeof prUrl === "string" ? "open" : null, - needsPermission: false, - isGenerating: task.latest_run?.status === "in_progress", - homeSituation: situationByTaskId.get(task.id), - }); - grouped.get(situation)?.push(task); + const situation = situationByTaskId.get(task.id); + if (situation) grouped.get(situation)?.push(task); } - return CHANNEL_COLUMN_IDS.map((id) => ({ + return HOME_BOARD_COLUMN_IDS.map((id) => ({ id, - label: id === "in_review" ? "Needs feedback" : SITUATION_VISUAL[id].label, + label: SITUATION_VISUAL[id].label, description: SITUATION_VISUAL[id].description, color: SITUATION_VISUAL[id].color, Icon: SITUATION_VISUAL[id].Icon, @@ -97,12 +80,12 @@ function ChannelBoardCard({ onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; }) { - const display = useTaskStatusDisplay(task, homeSituation); + const display = useTaskStatusDisplay(task); const { messages } = useTaskThread(task.id, { pollIntervalMs: BOARD_REPLIES_POLL_INTERVAL_MS, }); const creatorName = userDisplayName(task.created_by); - const visual = SITUATION_VISUAL[display.boardStatus]; + const visual = SITUATION_VISUAL[homeSituation ?? "working"]; const replyLabel = `${messages.length} ${messages.length === 1 ? "reply" : "replies"}`; return ( diff --git a/packages/ui/src/features/canvas/components/ChannelTaskPreviewDialog.tsx b/packages/ui/src/features/canvas/components/ChannelTaskPreviewDialog.tsx index 7719c8f0a1..6079c71be1 100644 --- a/packages/ui/src/features/canvas/components/ChannelTaskPreviewDialog.tsx +++ b/packages/ui/src/features/canvas/components/ChannelTaskPreviewDialog.tsx @@ -20,7 +20,10 @@ export function ChannelTaskPreviewDialog({ }) { return ( !open && onClose()}> - + {task?.title || "Task preview"} diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 8b242509ee..cf45cd9c97 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -652,7 +652,7 @@ function ThreadConversation({ const isReady = !isInitializing && !isLoading; return ( -
+
)} -
+
{ - it.each([ - [{}, "working"], - [{ status: "not_started" }, "working"], - [{ status: "queued" }, "working"], - [{ status: "in_progress" }, "working"], - [{ status: "completed" }, "in_review"], - [{ status: "failed" }, "done"], - [{ status: "cancelled" }, "done"], - [{ prState: "open" }, "in_review"], - [{ prState: "draft" }, "in_review"], - [{ prState: "merged" }, "done"], - [{ prState: "closed" }, "done"], - [{ needsPermission: true, status: "completed" }, "working"], - [{ isGenerating: true, prState: "open" }, "working"], - [{ homeSituation: "ci_failing" }, "ci_failing"], - ] as const)("maps %o to %s", (overrides, expected) => { - expect( - channelBoardStatus({ - status: undefined, - prState: null, - needsPermission: false, - isGenerating: false, - ...overrides, - }), - ).toBe(expected); - }); -}); diff --git a/packages/ui/src/features/canvas/utils/channelBoardStatus.ts b/packages/ui/src/features/canvas/utils/channelBoardStatus.ts deleted file mode 100644 index 00c16cc799..0000000000 --- a/packages/ui/src/features/canvas/utils/channelBoardStatus.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { SituationId } from "@posthog/core/workflow/schemas"; -import type { TaskRunStatus } from "@posthog/shared/domain-types"; -import type { SidebarPrState } from "@posthog/ui/features/sidebar/useTaskPrStatus"; - -export function channelBoardStatus(input: { - status?: TaskRunStatus; - prState: SidebarPrState; - needsPermission: boolean; - isGenerating: boolean; - homeSituation?: SituationId | null; -}): SituationId { - if (input.homeSituation) return input.homeSituation; - if (input.needsPermission || input.isGenerating) return "working"; - if (input.status === "failed" || input.status === "cancelled") return "done"; - if (input.prState === "merged" || input.prState === "closed") return "done"; - if (input.prState === "open" || input.prState === "draft") return "in_review"; - if (input.status === "completed") return "in_review"; - return "working"; -} From a33cf7abfd047e8d6696cfb39bafe4852e06a3e4 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:29:50 +0200 Subject: [PATCH 05/25] feat(channels): add PR links to board cards Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../canvas/components/ChannelBoardView.tsx | 28 ++++++++++++++++++- .../canvas/components/WebsiteChannelHome.tsx | 14 ++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx index 10e77e2450..fbc8e5e8a2 100644 --- a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx @@ -1,5 +1,6 @@ -import { ChatCircleIcon } from "@phosphor-icons/react"; +import { ChatCircleIcon, GitPullRequest } from "@phosphor-icons/react"; import type { SituationId } from "@posthog/core/workflow/schemas"; +import { Button } from "@posthog/quill"; import type { Task } from "@posthog/shared/domain-types"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { @@ -14,6 +15,7 @@ import { } from "@posthog/ui/features/home/components/WorkBoard"; import { HOME_BOARD_COLUMN_IDS } from "@posthog/ui/features/home/utils/boardColumns"; import { SITUATION_VISUAL } from "@posthog/ui/features/home/utils/situationDisplay"; +import { openUrlInBrowser } from "@posthog/ui/utils/browser"; import { Box } from "@radix-ui/themes"; import { useMemo } from "react"; @@ -22,12 +24,14 @@ export function ChannelBoardView({ tasks, isLoading, situationByTaskId, + prUrlByTaskId, onOpenTask, onOpenThread, }: { tasks: Task[]; isLoading: boolean; situationByTaskId: ReadonlyMap; + prUrlByTaskId: ReadonlyMap; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; }) { @@ -61,6 +65,12 @@ export function ChannelBoardView({ @@ -72,11 +82,13 @@ export function ChannelBoardView({ function ChannelBoardCard({ task, homeSituation, + prUrl, onOpenTask, onOpenThread, }: { task: Task; homeSituation?: SituationId; + prUrl?: string; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; }) { @@ -119,6 +131,20 @@ function ChannelBoardCard({ {task.repository} ) : null} + {prUrl ? ( + + ) : null}
diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 0bd78cfa12..93d5f5ca10 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -138,6 +138,19 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { } return result; }, [homeSnapshot.inProgress, homeSnapshot.needsAttention]); + const prUrlByTaskId = useMemo(() => { + const result = new Map(); + for (const workstream of [ + ...homeSnapshot.needsAttention, + ...homeSnapshot.inProgress, + ]) { + if (!workstream.prUrl) continue; + for (const task of workstream.tasks) { + result.set(task.id, workstream.prUrl); + } + } + return result; + }, [homeSnapshot.inProgress, homeSnapshot.needsAttention]); const composerRef = useRef(null); @@ -358,6 +371,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { tasks={visibleTasks} isLoading={isLoading} situationByTaskId={situationByTaskId} + prUrlByTaskId={prUrlByTaskId} onOpenTask={handleOpenTask} onOpenThread={handleOpenThread} /> From dac8c973377c96eeb0cd87963eb112bc099b4ae1 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:29:52 +0200 Subject: [PATCH 06/25] fix(channels): show teammate tasks in board Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../canvas/components/ChannelBoardView.tsx | 29 +++++++----- .../canvas/utils/channelTaskSituation.test.ts | 45 +++++++++++++++++++ .../canvas/utils/channelTaskSituation.ts | 19 ++++++++ 3 files changed, 81 insertions(+), 12 deletions(-) create mode 100644 packages/ui/src/features/canvas/utils/channelTaskSituation.test.ts create mode 100644 packages/ui/src/features/canvas/utils/channelTaskSituation.ts diff --git a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx index fbc8e5e8a2..20e6310078 100644 --- a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx @@ -8,6 +8,7 @@ import { useTaskStatusDisplay, } from "@posthog/ui/features/canvas/components/ChannelFeedView"; import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; +import { fallbackChannelTaskSituation } from "@posthog/ui/features/canvas/utils/channelTaskSituation"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { WorkBoard, @@ -35,13 +36,17 @@ export function ChannelBoardView({ onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; }) { - const columns = useMemo[]>(() => { - const grouped = new Map( - HOME_BOARD_COLUMN_IDS.map((id) => [id, []]), - ); + const columns = useMemo< + WorkBoardColumn<{ task: Task; situation: SituationId }>[] + >(() => { + const grouped = new Map< + SituationId, + Array<{ task: Task; situation: SituationId }> + >(HOME_BOARD_COLUMN_IDS.map((id) => [id, []])); for (const task of tasks) { - const situation = situationByTaskId.get(task.id); - if (situation) grouped.get(situation)?.push(task); + const situation = + situationByTaskId.get(task.id) ?? fallbackChannelTaskSituation(task); + if (situation) grouped.get(situation)?.push({ task, situation }); } return HOME_BOARD_COLUMN_IDS.map((id) => ({ id, @@ -60,11 +65,11 @@ export function ChannelBoardView({ return ( task.id} - renderCard={(task) => ( + getKey={(item) => item.task.id} + renderCard={({ task, situation }) => ( void; onOpenThread: (task: Task) => void; @@ -97,7 +102,7 @@ function ChannelBoardCard({ pollIntervalMs: BOARD_REPLIES_POLL_INTERVAL_MS, }); const creatorName = userDisplayName(task.created_by); - const visual = SITUATION_VISUAL[homeSituation ?? "working"]; + const visual = SITUATION_VISUAL[situation]; const replyLabel = `${messages.length} ${messages.length === 1 ? "reply" : "replies"}`; return ( diff --git a/packages/ui/src/features/canvas/utils/channelTaskSituation.test.ts b/packages/ui/src/features/canvas/utils/channelTaskSituation.test.ts new file mode 100644 index 0000000000..8f0fe0b9a5 --- /dev/null +++ b/packages/ui/src/features/canvas/utils/channelTaskSituation.test.ts @@ -0,0 +1,45 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { fallbackChannelTaskSituation } from "./channelTaskSituation"; + +function task(latestRun?: Task["latest_run"]): Task { + return { + id: "task-1", + task_number: 1, + slug: "task-1", + title: "Task", + description: "", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + origin_product: "user_created", + latest_run: latestRun, + }; +} + +describe("fallbackChannelTaskSituation", () => { + it.each([ + [undefined, "working"], + ["not_started", "working"], + ["queued", "working"], + ["in_progress", "working"], + ["completed", "in_review"], + ["failed", null], + ["cancelled", null], + ] as const)("maps %s to %s", (status, expected) => { + const latestRun = status + ? ({ status, output: null } as unknown as Task["latest_run"]) + : undefined; + expect(fallbackChannelTaskSituation(task(latestRun))).toBe(expected); + }); + + it("places a task with an attached PR in review", () => { + expect( + fallbackChannelTaskSituation( + task({ + status: "in_progress", + output: { pr_url: "https://pr" }, + } as unknown as Task["latest_run"]), + ), + ).toBe("in_review"); + }); +}); diff --git a/packages/ui/src/features/canvas/utils/channelTaskSituation.ts b/packages/ui/src/features/canvas/utils/channelTaskSituation.ts new file mode 100644 index 0000000000..21f549021a --- /dev/null +++ b/packages/ui/src/features/canvas/utils/channelTaskSituation.ts @@ -0,0 +1,19 @@ +import type { SituationId } from "@posthog/core/workflow/schemas"; +import type { Task } from "@posthog/shared/domain-types"; + +/** + * Fallback for channel tasks outside the current user's Home snapshot. Home's + * computed workstream situation always wins; this only mirrors the two states + * that can be determined from the task payload alone. + */ +export function fallbackChannelTaskSituation(task: Task): SituationId | null { + const status = task.latest_run?.status; + if (status === "failed" || status === "cancelled") return null; + if ( + status === "completed" || + typeof task.latest_run?.output?.pr_url === "string" + ) { + return "in_review"; + } + return "working"; +} From 74357e11140af9beb9942495e27abcf21fb93172 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:29:53 +0200 Subject: [PATCH 07/25] refactor(channels): centralize task board lifecycle Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../core/src/home/taskBoardStatus.test.ts | 21 ++++ packages/core/src/home/taskBoardStatus.ts | 30 ++++++ .../canvas/components/ChannelBoardView.tsx | 102 +++++++++++++----- .../canvas/components/WebsiteChannelHome.tsx | 14 ++- .../canvas/hooks/useChannelTaskPrStates.ts | 33 ++++++ .../canvas/utils/channelTaskSituation.test.ts | 45 -------- .../canvas/utils/channelTaskSituation.ts | 19 ---- 7 files changed, 165 insertions(+), 99 deletions(-) create mode 100644 packages/core/src/home/taskBoardStatus.test.ts create mode 100644 packages/core/src/home/taskBoardStatus.ts create mode 100644 packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts delete mode 100644 packages/ui/src/features/canvas/utils/channelTaskSituation.test.ts delete mode 100644 packages/ui/src/features/canvas/utils/channelTaskSituation.ts diff --git a/packages/core/src/home/taskBoardStatus.test.ts b/packages/core/src/home/taskBoardStatus.test.ts new file mode 100644 index 0000000000..7fbdd70d08 --- /dev/null +++ b/packages/core/src/home/taskBoardStatus.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { taskBoardStatus } from "./taskBoardStatus"; + +describe("taskBoardStatus", () => { + it.each([ + [{}, "working"], + [{ runStatus: "in_progress" }, "working"], + [{ runStatus: "completed" }, "working"], + [{ prState: "draft", ciStatus: "passing" }, "working"], + [{ prState: "open", ciStatus: "failing" }, "working"], + [{ prState: "open", ciStatus: "pending" }, "working"], + [{ prState: "open", ciStatus: null }, "working"], + [{ prState: "open", ciStatus: "passing" }, "in_review"], + [{ prState: "merged", runStatus: "failed" }, "done"], + [{ prState: "closed" }, "cancelled"], + [{ runStatus: "failed" }, "cancelled"], + [{ runStatus: "cancelled" }, "cancelled"], + ] as const)("maps %o to %s", (input, expected) => { + expect(taskBoardStatus(input)).toBe(expected); + }); +}); diff --git a/packages/core/src/home/taskBoardStatus.ts b/packages/core/src/home/taskBoardStatus.ts new file mode 100644 index 0000000000..4fe9644676 --- /dev/null +++ b/packages/core/src/home/taskBoardStatus.ts @@ -0,0 +1,30 @@ +import type { TaskRunStatus } from "@posthog/shared/domain-types"; +import type { PrCiStatus, PrSnapshotState } from "./prSnapshot"; + +export const TASK_BOARD_STATUSES = [ + "working", + "in_review", + "done", + "cancelled", +] as const; +export type TaskBoardStatus = (typeof TASK_BOARD_STATUSES)[number]; + +/** + * General task-board lifecycle. PR truth takes precedence over the run because + * run status commonly becomes stale after a PR is opened or merged. + */ +export function taskBoardStatus(input: { + runStatus?: TaskRunStatus | null; + prState?: PrSnapshotState | null; + ciStatus?: PrCiStatus | null; +}): TaskBoardStatus { + if (input.prState === "merged") return "done"; + if (input.prState === "closed") return "cancelled"; + if (input.runStatus === "failed" || input.runStatus === "cancelled") { + return "cancelled"; + } + if (input.prState === "open" && input.ciStatus === "passing") { + return "in_review"; + } + return "working"; +} diff --git a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx index 20e6310078..c9620d6d4d 100644 --- a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx @@ -1,5 +1,17 @@ -import { ChatCircleIcon, GitPullRequest } from "@phosphor-icons/react"; -import type { SituationId } from "@posthog/core/workflow/schemas"; +import { + ChatCircleIcon, + CheckCircle, + Eye, + GitCommit, + GitPullRequest, + XCircle, +} from "@phosphor-icons/react"; +import type { PrSnapshot } from "@posthog/core/home/prSnapshot"; +import { + TASK_BOARD_STATUSES, + type TaskBoardStatus, + taskBoardStatus, +} from "@posthog/core/home/taskBoardStatus"; import { Button } from "@posthog/quill"; import type { Task } from "@posthog/shared/domain-types"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; @@ -7,56 +19,92 @@ import { TaskStatusBadge, useTaskStatusDisplay, } from "@posthog/ui/features/canvas/components/ChannelFeedView"; +import { useChannelTaskPrStates } from "@posthog/ui/features/canvas/hooks/useChannelTaskPrStates"; import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; -import { fallbackChannelTaskSituation } from "@posthog/ui/features/canvas/utils/channelTaskSituation"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { WorkBoard, type WorkBoardColumn, } from "@posthog/ui/features/home/components/WorkBoard"; -import { HOME_BOARD_COLUMN_IDS } from "@posthog/ui/features/home/utils/boardColumns"; -import { SITUATION_VISUAL } from "@posthog/ui/features/home/utils/situationDisplay"; +import type { SituationColor } from "@posthog/ui/features/home/utils/situationDisplay"; import { openUrlInBrowser } from "@posthog/ui/utils/browser"; import { Box } from "@radix-ui/themes"; import { useMemo } from "react"; const BOARD_REPLIES_POLL_INTERVAL_MS = 15_000; +const STATUS_VISUAL: Record< + TaskBoardStatus, + { + label: string; + description: string; + color: SituationColor; + Icon: typeof GitCommit; + } +> = { + working: { + label: "Working", + description: "No PR, draft PR, or CI is not passing", + color: "purple", + Icon: GitCommit, + }, + in_review: { + label: "In review", + description: "Open PR with passing CI", + color: "blue", + Icon: Eye, + }, + done: { + label: "Done", + description: "PR merged", + color: "gray", + Icon: CheckCircle, + }, + cancelled: { + label: "Cancelled", + description: "PR closed or task failed/cancelled", + color: "red", + Icon: XCircle, + }, +}; + export function ChannelBoardView({ tasks, isLoading, - situationByTaskId, + prSnapshotByTaskId, prUrlByTaskId, onOpenTask, onOpenThread, }: { tasks: Task[]; isLoading: boolean; - situationByTaskId: ReadonlyMap; + prSnapshotByTaskId: ReadonlyMap; prUrlByTaskId: ReadonlyMap; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; }) { + const prStates = useChannelTaskPrStates(tasks); const columns = useMemo< - WorkBoardColumn<{ task: Task; situation: SituationId }>[] + WorkBoardColumn<{ task: Task; status: TaskBoardStatus }>[] >(() => { const grouped = new Map< - SituationId, - Array<{ task: Task; situation: SituationId }> - >(HOME_BOARD_COLUMN_IDS.map((id) => [id, []])); + TaskBoardStatus, + Array<{ task: Task; status: TaskBoardStatus }> + >(TASK_BOARD_STATUSES.map((status) => [status, []])); for (const task of tasks) { - const situation = - situationByTaskId.get(task.id) ?? fallbackChannelTaskSituation(task); - if (situation) grouped.get(situation)?.push({ task, situation }); + const snapshot = prSnapshotByTaskId.get(task.id); + const status = taskBoardStatus({ + runStatus: task.latest_run?.status, + prState: snapshot?.state ?? prStates.get(task.id), + ciStatus: snapshot?.ciStatus, + }); + grouped.get(status)?.push({ task, status }); } - return HOME_BOARD_COLUMN_IDS.map((id) => ({ - id, - label: SITUATION_VISUAL[id].label, - description: SITUATION_VISUAL[id].description, - color: SITUATION_VISUAL[id].color, - Icon: SITUATION_VISUAL[id].Icon, - items: grouped.get(id) ?? [], + return TASK_BOARD_STATUSES.map((status) => ({ + id: status, + ...STATUS_VISUAL[status], + items: grouped.get(status) ?? [], })); - }, [situationByTaskId, tasks]); + }, [prSnapshotByTaskId, prStates, tasks]); if (isLoading) { return
; @@ -66,10 +114,10 @@ export function ChannelBoardView({ item.task.id} - renderCard={({ task, situation }) => ( + renderCard={({ task, status }) => ( void; onOpenThread: (task: Task) => void; @@ -102,7 +150,7 @@ function ChannelBoardCard({ pollIntervalMs: BOARD_REPLIES_POLL_INTERVAL_MS, }); const creatorName = userDisplayName(task.created_by); - const visual = SITUATION_VISUAL[situation]; + const visual = STATUS_VISUAL[status]; const replyLabel = `${messages.length} ${messages.length === 1 ? "reply" : "replies"}`; return ( diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 93d5f5ca10..08e5442e15 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -1,6 +1,6 @@ import { Kanban, ListBullets, User } from "@phosphor-icons/react"; +import type { PrSnapshot } from "@posthog/core/home/prSnapshot"; import { insertTaskDedup } from "@posthog/core/tasks/taskDelete"; -import type { SituationId } from "@posthog/core/workflow/schemas"; import { Button } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; @@ -44,7 +44,6 @@ import { import { useChannelHomeUiStore } from "@posthog/ui/features/canvas/stores/channelHomeUiStore"; import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore"; import { useHomeSnapshot } from "@posthog/ui/features/home/hooks/useHomeSnapshot"; -import { columnForWorkstream } from "@posthog/ui/features/home/utils/boardColumns"; import { SuggestedPromptCard } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; @@ -124,16 +123,15 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { : tasks, [currentUser?.uuid, taskScope, tasks], ); - const situationByTaskId = useMemo(() => { - const result = new Map(); + const prSnapshotByTaskId = useMemo(() => { + const result = new Map(); for (const workstream of [ ...homeSnapshot.needsAttention, ...homeSnapshot.inProgress, ]) { - const column = columnForWorkstream(workstream); - if (!column) continue; + if (!workstream.pr) continue; for (const task of workstream.tasks) { - result.set(task.id, column); + result.set(task.id, workstream.pr); } } return result; @@ -370,7 +368,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { { + const trpc = useHostTRPC(); + const results = useQueries({ + queries: tasks.map((task) => { + const prUrl = + typeof task.latest_run?.output?.pr_url === "string" + ? task.latest_run.output.pr_url + : null; + return trpc.workspace.getTaskPrStatus.queryOptions( + { taskId: task.id, cloudPrUrl: prUrl }, + { staleTime: 60_000 }, + ); + }), + }); + return useMemo( + () => + new Map( + tasks.map((task, index) => [ + task.id, + results[index]?.data?.prState ?? null, + ]), + ), + [results, tasks], + ); +} diff --git a/packages/ui/src/features/canvas/utils/channelTaskSituation.test.ts b/packages/ui/src/features/canvas/utils/channelTaskSituation.test.ts deleted file mode 100644 index 8f0fe0b9a5..0000000000 --- a/packages/ui/src/features/canvas/utils/channelTaskSituation.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Task } from "@posthog/shared/domain-types"; -import { describe, expect, it } from "vitest"; -import { fallbackChannelTaskSituation } from "./channelTaskSituation"; - -function task(latestRun?: Task["latest_run"]): Task { - return { - id: "task-1", - task_number: 1, - slug: "task-1", - title: "Task", - description: "", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", - origin_product: "user_created", - latest_run: latestRun, - }; -} - -describe("fallbackChannelTaskSituation", () => { - it.each([ - [undefined, "working"], - ["not_started", "working"], - ["queued", "working"], - ["in_progress", "working"], - ["completed", "in_review"], - ["failed", null], - ["cancelled", null], - ] as const)("maps %s to %s", (status, expected) => { - const latestRun = status - ? ({ status, output: null } as unknown as Task["latest_run"]) - : undefined; - expect(fallbackChannelTaskSituation(task(latestRun))).toBe(expected); - }); - - it("places a task with an attached PR in review", () => { - expect( - fallbackChannelTaskSituation( - task({ - status: "in_progress", - output: { pr_url: "https://pr" }, - } as unknown as Task["latest_run"]), - ), - ).toBe("in_review"); - }); -}); diff --git a/packages/ui/src/features/canvas/utils/channelTaskSituation.ts b/packages/ui/src/features/canvas/utils/channelTaskSituation.ts deleted file mode 100644 index 21f549021a..0000000000 --- a/packages/ui/src/features/canvas/utils/channelTaskSituation.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { SituationId } from "@posthog/core/workflow/schemas"; -import type { Task } from "@posthog/shared/domain-types"; - -/** - * Fallback for channel tasks outside the current user's Home snapshot. Home's - * computed workstream situation always wins; this only mirrors the two states - * that can be determined from the task payload alone. - */ -export function fallbackChannelTaskSituation(task: Task): SituationId | null { - const status = task.latest_run?.status; - if (status === "failed" || status === "cancelled") return null; - if ( - status === "completed" || - typeof task.latest_run?.output?.pr_url === "string" - ) { - return "in_review"; - } - return "working"; -} From 2f33d788763571f3b1c01318a0fb7d9a5af0796f Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:29:55 +0200 Subject: [PATCH 08/25] fix(channels): prefer resolved merged PR state Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- packages/core/src/home/taskBoardStatus.test.ts | 14 +++++++++++++- packages/core/src/home/taskBoardStatus.ts | 15 ++++++++++++++- .../canvas/components/ChannelBoardView.tsx | 11 ++++++----- .../canvas/hooks/useChannelTaskPrStates.ts | 8 +++++--- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/packages/core/src/home/taskBoardStatus.test.ts b/packages/core/src/home/taskBoardStatus.test.ts index 7fbdd70d08..ad68b3a430 100644 --- a/packages/core/src/home/taskBoardStatus.test.ts +++ b/packages/core/src/home/taskBoardStatus.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { taskBoardStatus } from "./taskBoardStatus"; +import { taskBoardStatus, taskBoardStatusFromSources } from "./taskBoardStatus"; describe("taskBoardStatus", () => { it.each([ @@ -19,3 +19,15 @@ describe("taskBoardStatus", () => { expect(taskBoardStatus(input)).toBe(expected); }); }); + +it("prefers a directly resolved merged PR over a stale open Home snapshot", () => { + expect( + taskBoardStatusFromSources({ + resolvedPrState: "merged", + prSnapshot: { + state: "open", + ciStatus: "passing", + } as never, + }), + ).toBe("done"); +}); diff --git a/packages/core/src/home/taskBoardStatus.ts b/packages/core/src/home/taskBoardStatus.ts index 4fe9644676..656164035b 100644 --- a/packages/core/src/home/taskBoardStatus.ts +++ b/packages/core/src/home/taskBoardStatus.ts @@ -1,5 +1,5 @@ import type { TaskRunStatus } from "@posthog/shared/domain-types"; -import type { PrCiStatus, PrSnapshotState } from "./prSnapshot"; +import type { PrCiStatus, PrSnapshot, PrSnapshotState } from "./prSnapshot"; export const TASK_BOARD_STATUSES = [ "working", @@ -28,3 +28,16 @@ export function taskBoardStatus(input: { } return "working"; } + +export function taskBoardStatusFromSources(input: { + runStatus?: TaskRunStatus | null; + resolvedPrState?: PrSnapshotState | null; + prSnapshot?: PrSnapshot | null; +}): TaskBoardStatus { + return taskBoardStatus({ + runStatus: input.runStatus, + // Direct PR resolution is fresher than the periodically rebuilt Home row. + prState: input.resolvedPrState ?? input.prSnapshot?.state, + ciStatus: input.prSnapshot?.ciStatus, + }); +} diff --git a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx index c9620d6d4d..2d869a8c94 100644 --- a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx @@ -10,7 +10,7 @@ import type { PrSnapshot } from "@posthog/core/home/prSnapshot"; import { TASK_BOARD_STATUSES, type TaskBoardStatus, - taskBoardStatus, + taskBoardStatusFromSources, } from "@posthog/core/home/taskBoardStatus"; import { Button } from "@posthog/quill"; import type { Task } from "@posthog/shared/domain-types"; @@ -82,7 +82,7 @@ export function ChannelBoardView({ onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; }) { - const prStates = useChannelTaskPrStates(tasks); + const prStates = useChannelTaskPrStates(tasks, prUrlByTaskId); const columns = useMemo< WorkBoardColumn<{ task: Task; status: TaskBoardStatus }>[] >(() => { @@ -92,10 +92,11 @@ export function ChannelBoardView({ >(TASK_BOARD_STATUSES.map((status) => [status, []])); for (const task of tasks) { const snapshot = prSnapshotByTaskId.get(task.id); - const status = taskBoardStatus({ + const resolvedPrState = prStates.get(task.id); + const status = taskBoardStatusFromSources({ runStatus: task.latest_run?.status, - prState: snapshot?.state ?? prStates.get(task.id), - ciStatus: snapshot?.ciStatus, + resolvedPrState, + prSnapshot: snapshot, }); grouped.get(status)?.push({ task, status }); } diff --git a/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts index 26dc8350fb..77cfe92378 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts @@ -6,17 +6,19 @@ import { useMemo } from "react"; export function useChannelTaskPrStates( tasks: Task[], + prUrlByTaskId: ReadonlyMap, ): Map { const trpc = useHostTRPC(); const results = useQueries({ queries: tasks.map((task) => { const prUrl = - typeof task.latest_run?.output?.pr_url === "string" + prUrlByTaskId.get(task.id) ?? + (typeof task.latest_run?.output?.pr_url === "string" ? task.latest_run.output.pr_url - : null; + : null); return trpc.workspace.getTaskPrStatus.queryOptions( { taskId: task.id, cloudPrUrl: prUrl }, - { staleTime: 60_000 }, + { staleTime: 15_000, refetchInterval: 15_000 }, ); }), }); From 86f2a24ef7bc0b0041fe5b7f96cd4c840fda74e7 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:29:57 +0200 Subject: [PATCH 09/25] fix(channels): resolve PR state directly by URL Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../hooks/useChannelTaskPrStates.test.ts | 14 +++++ .../canvas/hooks/useChannelTaskPrStates.ts | 61 ++++++++++++------- 2 files changed, 54 insertions(+), 21 deletions(-) create mode 100644 packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.test.ts diff --git a/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.test.ts b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.test.ts new file mode 100644 index 0000000000..84092010c5 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { prDetailsToState } from "./useChannelTaskPrStates"; + +describe("prDetailsToState", () => { + it.each([ + [undefined, null], + [{ state: "open", merged: false, draft: false }, "open"], + [{ state: "open", merged: false, draft: true }, "draft"], + [{ state: "closed", merged: false, draft: false }, "closed"], + [{ state: "closed", merged: true, draft: false }, "merged"], + ] as const)("maps %o to %s", (details, expected) => { + expect(prDetailsToState(details)).toBe(expected); + }); +}); diff --git a/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts index 77cfe92378..975c39426e 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts @@ -1,35 +1,54 @@ -import { useHostTRPC } from "@posthog/host-router/react"; import type { Task } from "@posthog/shared/domain-types"; +import { + type PrStateDetails, + usePrDetailsMap, +} from "@posthog/ui/features/git-interaction/usePrDetails"; import type { SidebarPrState } from "@posthog/ui/features/sidebar/useTaskPrStatus"; -import { useQueries } from "@tanstack/react-query"; import { useMemo } from "react"; +export function prDetailsToState( + details: PrStateDetails | undefined, +): SidebarPrState { + if (!details) return null; + if (details.merged) return "merged"; + if (details.draft) return "draft"; + if (details.state === "open") return "open"; + if (details.state === "closed") return "closed"; + return null; +} + export function useChannelTaskPrStates( tasks: Task[], prUrlByTaskId: ReadonlyMap, ): Map { - const trpc = useHostTRPC(); - const results = useQueries({ - queries: tasks.map((task) => { - const prUrl = - prUrlByTaskId.get(task.id) ?? - (typeof task.latest_run?.output?.pr_url === "string" - ? task.latest_run.output.pr_url - : null); - return trpc.workspace.getTaskPrStatus.queryOptions( - { taskId: task.id, cloudPrUrl: prUrl }, - { staleTime: 15_000, refetchInterval: 15_000 }, - ); - }), - }); + const prUrls = useMemo( + () => + tasks.flatMap((task) => { + const prUrl = + prUrlByTaskId.get(task.id) ?? + (typeof task.latest_run?.output?.pr_url === "string" + ? task.latest_run.output.pr_url + : null); + return prUrl ? [prUrl] : []; + }), + [prUrlByTaskId, tasks], + ); + const detailsByUrl = usePrDetailsMap(prUrls); return useMemo( () => new Map( - tasks.map((task, index) => [ - task.id, - results[index]?.data?.prState ?? null, - ]), + tasks.map((task) => { + const prUrl = + prUrlByTaskId.get(task.id) ?? + (typeof task.latest_run?.output?.pr_url === "string" + ? task.latest_run.output.pr_url + : null); + return [ + task.id, + prDetailsToState(prUrl ? detailsByUrl[prUrl] : undefined), + ]; + }), ), - [results, tasks], + [detailsByUrl, prUrlByTaskId, tasks], ); } From 6f7d14f8e3522e6a7b60d83e762ac10d3c9c0a1b Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:29:58 +0200 Subject: [PATCH 10/25] refactor(channels): treat ready PRs as in review Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- packages/core/src/home/taskBoardStatus.test.ts | 9 +++------ packages/core/src/home/taskBoardStatus.ts | 8 ++------ .../src/features/canvas/components/ChannelBoardView.tsx | 2 +- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/core/src/home/taskBoardStatus.test.ts b/packages/core/src/home/taskBoardStatus.test.ts index ad68b3a430..d746225da6 100644 --- a/packages/core/src/home/taskBoardStatus.test.ts +++ b/packages/core/src/home/taskBoardStatus.test.ts @@ -6,11 +6,8 @@ describe("taskBoardStatus", () => { [{}, "working"], [{ runStatus: "in_progress" }, "working"], [{ runStatus: "completed" }, "working"], - [{ prState: "draft", ciStatus: "passing" }, "working"], - [{ prState: "open", ciStatus: "failing" }, "working"], - [{ prState: "open", ciStatus: "pending" }, "working"], - [{ prState: "open", ciStatus: null }, "working"], - [{ prState: "open", ciStatus: "passing" }, "in_review"], + [{ prState: "draft" }, "working"], + [{ prState: "open" }, "in_review"], [{ prState: "merged", runStatus: "failed" }, "done"], [{ prState: "closed" }, "cancelled"], [{ runStatus: "failed" }, "cancelled"], @@ -26,7 +23,7 @@ it("prefers a directly resolved merged PR over a stale open Home snapshot", () = resolvedPrState: "merged", prSnapshot: { state: "open", - ciStatus: "passing", + ciStatus: "failing", } as never, }), ).toBe("done"); diff --git a/packages/core/src/home/taskBoardStatus.ts b/packages/core/src/home/taskBoardStatus.ts index 656164035b..9442912a5a 100644 --- a/packages/core/src/home/taskBoardStatus.ts +++ b/packages/core/src/home/taskBoardStatus.ts @@ -1,5 +1,5 @@ import type { TaskRunStatus } from "@posthog/shared/domain-types"; -import type { PrCiStatus, PrSnapshot, PrSnapshotState } from "./prSnapshot"; +import type { PrSnapshot, PrSnapshotState } from "./prSnapshot"; export const TASK_BOARD_STATUSES = [ "working", @@ -16,16 +16,13 @@ export type TaskBoardStatus = (typeof TASK_BOARD_STATUSES)[number]; export function taskBoardStatus(input: { runStatus?: TaskRunStatus | null; prState?: PrSnapshotState | null; - ciStatus?: PrCiStatus | null; }): TaskBoardStatus { if (input.prState === "merged") return "done"; if (input.prState === "closed") return "cancelled"; if (input.runStatus === "failed" || input.runStatus === "cancelled") { return "cancelled"; } - if (input.prState === "open" && input.ciStatus === "passing") { - return "in_review"; - } + if (input.prState === "open") return "in_review"; return "working"; } @@ -38,6 +35,5 @@ export function taskBoardStatusFromSources(input: { runStatus: input.runStatus, // Direct PR resolution is fresher than the periodically rebuilt Home row. prState: input.resolvedPrState ?? input.prSnapshot?.state, - ciStatus: input.prSnapshot?.ciStatus, }); } diff --git a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx index 2d869a8c94..317a9650af 100644 --- a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx @@ -49,7 +49,7 @@ const STATUS_VISUAL: Record< }, in_review: { label: "In review", - description: "Open PR with passing CI", + description: "Open, non-draft PR ready for review", color: "blue", Icon: Eye, }, From a15480626737c9de5d09ee8ca569f6e262a6fd29 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:30:00 +0200 Subject: [PATCH 11/25] fix(channels): stabilize board status loading Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../canvas/components/ChannelBoardView.tsx | 60 ++++++---- .../canvas/components/WebsiteChannelHome.tsx | 5 + .../canvas/hooks/useChannelTaskPrStates.ts | 105 ++++++++++++------ 3 files changed, 116 insertions(+), 54 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx index 317a9650af..b213681879 100644 --- a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx @@ -19,7 +19,7 @@ import { TaskStatusBadge, useTaskStatusDisplay, } from "@posthog/ui/features/canvas/components/ChannelFeedView"; -import { useChannelTaskPrStates } from "@posthog/ui/features/canvas/hooks/useChannelTaskPrStates"; +import type { ChannelTaskPrStates } from "@posthog/ui/features/canvas/hooks/useChannelTaskPrStates"; import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { @@ -28,7 +28,7 @@ import { } from "@posthog/ui/features/home/components/WorkBoard"; import type { SituationColor } from "@posthog/ui/features/home/utils/situationDisplay"; import { openUrlInBrowser } from "@posthog/ui/utils/browser"; -import { Box } from "@radix-ui/themes"; +import { Box, Spinner } from "@radix-ui/themes"; import { useMemo } from "react"; const BOARD_REPLIES_POLL_INTERVAL_MS = 15_000; @@ -72,6 +72,7 @@ export function ChannelBoardView({ isLoading, prSnapshotByTaskId, prUrlByTaskId, + taskPrStates, onOpenTask, onOpenThread, }: { @@ -79,10 +80,10 @@ export function ChannelBoardView({ isLoading: boolean; prSnapshotByTaskId: ReadonlyMap; prUrlByTaskId: ReadonlyMap; + taskPrStates: ChannelTaskPrStates; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; }) { - const prStates = useChannelTaskPrStates(tasks, prUrlByTaskId); const columns = useMemo< WorkBoardColumn<{ task: Task; status: TaskBoardStatus }>[] >(() => { @@ -91,8 +92,11 @@ export function ChannelBoardView({ Array<{ task: Task; status: TaskBoardStatus }> >(TASK_BOARD_STATUSES.map((status) => [status, []])); for (const task of tasks) { + // A task with a PR cannot be classified until its first PR response. + // Omitting it temporarily avoids showing it as Working and then moving it. + if (taskPrStates.pendingTaskIds.has(task.id)) continue; const snapshot = prSnapshotByTaskId.get(task.id); - const resolvedPrState = prStates.get(task.id); + const resolvedPrState = taskPrStates.states.get(task.id); const status = taskBoardStatusFromSources({ runStatus: task.latest_run?.status, resolvedPrState, @@ -105,31 +109,41 @@ export function ChannelBoardView({ ...STATUS_VISUAL[status], items: grouped.get(status) ?? [], })); - }, [prSnapshotByTaskId, prStates, tasks]); + }, [prSnapshotByTaskId, taskPrStates, tasks]); if (isLoading) { return
; } return ( - item.task.id} - renderCard={({ task, status }) => ( - - )} - /> +
+ {taskPrStates.isResolving || taskPrStates.isRefreshing ? ( +
+ + {taskPrStates.isResolving + ? `Updating ${taskPrStates.pendingTaskIds.size} task ${taskPrStates.pendingTaskIds.size === 1 ? "status" : "statuses"}…` + : "Refreshing statuses…"} +
+ ) : null} + item.task.id} + renderCard={({ task, status }) => ( + + )} + /> +
); } diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 08e5442e15..4ce98700f9 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -35,6 +35,7 @@ import { useChannelFeedMessages, } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannelTaskPrStates } from "@posthog/ui/features/canvas/hooks/useChannelTaskPrStates"; import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; import { @@ -149,6 +150,9 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { } return result; }, [homeSnapshot.inProgress, homeSnapshot.needsAttention]); + // Resolve PR state even while the feed is visible so switching to the board + // can use the shared React Query cache instead of briefly misplacing cards. + const taskPrStates = useChannelTaskPrStates(visibleTasks, prUrlByTaskId); const composerRef = useRef(null); @@ -370,6 +374,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { isLoading={isLoading} prSnapshotByTaskId={prSnapshotByTaskId} prUrlByTaskId={prUrlByTaskId} + taskPrStates={taskPrStates} onOpenTask={handleOpenTask} onOpenThread={handleOpenThread} /> diff --git a/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts index 975c39426e..9c54f3504a 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts @@ -1,11 +1,13 @@ +import { useHostTRPC } from "@posthog/host-router/react"; import type { Task } from "@posthog/shared/domain-types"; -import { - type PrStateDetails, - usePrDetailsMap, -} from "@posthog/ui/features/git-interaction/usePrDetails"; +import type { PrStateDetails } from "@posthog/ui/features/git-interaction/usePrDetails"; import type { SidebarPrState } from "@posthog/ui/features/sidebar/useTaskPrStatus"; +import { keepPreviousData, useQueries } from "@tanstack/react-query"; import { useMemo } from "react"; +const PR_STALE_TIME_MS = 60_000; +const PR_CACHE_TIME_MS = 30 * 60_000; + export function prDetailsToState( details: PrStateDetails | undefined, ): SidebarPrState { @@ -17,38 +19,79 @@ export function prDetailsToState( return null; } +export function taskPrUrl( + task: Task, + prUrlByTaskId: ReadonlyMap, +): string | null { + return ( + prUrlByTaskId.get(task.id) ?? + (typeof task.latest_run?.output?.pr_url === "string" + ? task.latest_run.output.pr_url + : null) + ); +} + +export interface ChannelTaskPrStates { + states: Map; + pendingTaskIds: Set; + isResolving: boolean; + isRefreshing: boolean; +} + export function useChannelTaskPrStates( tasks: Task[], prUrlByTaskId: ReadonlyMap, -): Map { +): ChannelTaskPrStates { + const trpc = useHostTRPC(); const prUrls = useMemo( - () => - tasks.flatMap((task) => { - const prUrl = - prUrlByTaskId.get(task.id) ?? - (typeof task.latest_run?.output?.pr_url === "string" - ? task.latest_run.output.pr_url - : null); - return prUrl ? [prUrl] : []; - }), - [prUrlByTaskId, tasks], - ); - const detailsByUrl = usePrDetailsMap(prUrls); - return useMemo( - () => - new Map( - tasks.map((task) => { - const prUrl = - prUrlByTaskId.get(task.id) ?? - (typeof task.latest_run?.output?.pr_url === "string" - ? task.latest_run.output.pr_url - : null); - return [ - task.id, - prDetailsToState(prUrl ? detailsByUrl[prUrl] : undefined), - ]; + () => [ + ...new Set( + tasks.flatMap((task) => { + const prUrl = taskPrUrl(task, prUrlByTaskId); + return prUrl ? [prUrl] : []; }), ), - [detailsByUrl, prUrlByTaskId, tasks], + ], + [prUrlByTaskId, tasks], ); + const results = useQueries({ + queries: prUrls.map((prUrl) => ({ + ...trpc.git.getPrDetailsByUrl.queryOptions({ prUrl }), + staleTime: PR_STALE_TIME_MS, + gcTime: PR_CACHE_TIME_MS, + placeholderData: keepPreviousData, + retry: 1, + })), + }); + + return useMemo(() => { + const resultByUrl = new Map( + prUrls.map((url, index) => [url, results[index]]), + ); + const states = new Map(); + const pendingTaskIds = new Set(); + let isRefreshing = false; + + for (const task of tasks) { + const prUrl = taskPrUrl(task, prUrlByTaskId); + const result = prUrl ? resultByUrl.get(prUrl) : undefined; + if (prUrl && result && !result.data && result.isPending) { + pendingTaskIds.add(task.id); + } + if (result?.data && result.isFetching) isRefreshing = true; + states.set( + task.id, + prDetailsToState( + result?.data?.state === "unknown" ? undefined : result?.data, + ), + ); + } + + return { + states, + pendingTaskIds, + isResolving: pendingTaskIds.size > 0, + isRefreshing, + }; + }, [prUrlByTaskId, prUrls, results, tasks]); } From a43857fd5fcc0f5f932dee2aca18c707c70bc5d2 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:30:01 +0200 Subject: [PATCH 12/25] fix(channels): polish board loading and preview Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../canvas/components/ChannelBoardView.tsx | 49 ++++++++----------- .../components/ChannelTaskPreviewDialog.tsx | 1 + .../canvas/components/ThreadPanel.tsx | 9 +++- .../features/home/components/WorkBoard.tsx | 25 ++++++++-- 4 files changed, 51 insertions(+), 33 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx index b213681879..dc4e033324 100644 --- a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx @@ -28,7 +28,7 @@ import { } from "@posthog/ui/features/home/components/WorkBoard"; import type { SituationColor } from "@posthog/ui/features/home/utils/situationDisplay"; import { openUrlInBrowser } from "@posthog/ui/utils/browser"; -import { Box, Spinner } from "@radix-ui/themes"; +import { Box } from "@radix-ui/themes"; import { useMemo } from "react"; const BOARD_REPLIES_POLL_INTERVAL_MS = 15_000; @@ -116,34 +116,25 @@ export function ChannelBoardView({ } return ( -
- {taskPrStates.isResolving || taskPrStates.isRefreshing ? ( -
- - {taskPrStates.isResolving - ? `Updating ${taskPrStates.pendingTaskIds.size} task ${taskPrStates.pendingTaskIds.size === 1 ? "status" : "statuses"}…` - : "Refreshing statuses…"} -
- ) : null} - item.task.id} - renderCard={({ task, status }) => ( - - )} - /> -
+ item.task.id} + renderCard={({ task, status }) => ( + + )} + /> ); } diff --git a/packages/ui/src/features/canvas/components/ChannelTaskPreviewDialog.tsx b/packages/ui/src/features/canvas/components/ChannelTaskPreviewDialog.tsx index 6079c71be1..221d23550e 100644 --- a/packages/ui/src/features/canvas/components/ChannelTaskPreviewDialog.tsx +++ b/packages/ui/src/features/canvas/components/ChannelTaskPreviewDialog.tsx @@ -37,6 +37,7 @@ export function ChannelTaskPreviewDialog({ task={task} onClose={onClose} onOpenFull={() => onOpenFull(task)} + showAgentStatus={false} /> ) : null} diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index cf45cd9c97..e8b23c9b52 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -501,6 +501,7 @@ function ThreadConversation({ onToggleCollapsed, onOpenFull, showTaskSummary, + showAgentStatus, }: { task: Task; channelId: string; @@ -508,6 +509,7 @@ function ThreadConversation({ onToggleCollapsed?: () => void; onOpenFull?: () => void; showTaskSummary: boolean; + showAgentStatus: boolean; }) { const taskId = task.id; const client = useOptionalAuthenticatedClient(); @@ -677,7 +679,9 @@ function ThreadConversation({ />
- {agentStatus && } + {showAgentStatus && agentStatus && ( + + )} void; onOpenFull?: () => void; showTaskSummary?: boolean; + showAgentStatus?: boolean; }) { const { data: fetchedTask } = useQuery({ ...taskDetailQuery(taskId), @@ -744,6 +750,7 @@ export function ThreadPanel({ onToggleCollapsed={onToggleCollapsed} onOpenFull={onOpenFull} showTaskSummary={showTaskSummary} + showAgentStatus={showAgentStatus} /> ); } diff --git a/packages/ui/src/features/home/components/WorkBoard.tsx b/packages/ui/src/features/home/components/WorkBoard.tsx index dc4d7c09fb..86383d3836 100644 --- a/packages/ui/src/features/home/components/WorkBoard.tsx +++ b/packages/ui/src/features/home/components/WorkBoard.tsx @@ -19,10 +19,12 @@ export function WorkBoard({ columns, getKey, renderCard, + isLoading = false, }: { columns: WorkBoardColumn[]; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; + isLoading?: boolean; }) { return (
@@ -57,18 +59,19 @@ export function WorkBoard({ >
- {count === 0 ? ( + {count === 0 && !isLoading ? (
Nothing here
- ) : ( + ) : count > 0 ? ( column.items.map((item) => (
{renderCard(item)}
)) - )} + ) : null} + {isLoading ? : null}
@@ -78,3 +81,19 @@ export function WorkBoard({
); } + +function WorkBoardCardSkeleton() { + return ( + +
+
+
+
+
+
+ + ); +} From 280566bd86638667028f3689709feff0eaa2e1ed Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:30:03 +0200 Subject: [PATCH 13/25] feat(channels): create tasks from board modal Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../components/ChannelCreateTaskDialog.tsx | 47 +++++++++++ .../canvas/components/WebsiteChannelHome.tsx | 84 ++++++++++++------- 2 files changed, 101 insertions(+), 30 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/ChannelCreateTaskDialog.tsx diff --git a/packages/ui/src/features/canvas/components/ChannelCreateTaskDialog.tsx b/packages/ui/src/features/canvas/components/ChannelCreateTaskDialog.tsx new file mode 100644 index 0000000000..e061458d57 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelCreateTaskDialog.tsx @@ -0,0 +1,47 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@posthog/quill"; +import type { Task } from "@posthog/shared/domain-types"; +import { CHANNEL_TASK_SUGGESTIONS } from "@posthog/ui/features/canvas/channelTaskSuggestions"; +import { TaskInput } from "@posthog/ui/features/task-detail/components/TaskInput"; + +export function ChannelCreateTaskDialog({ + open, + channelId, + channelName, + channelContext, + onOpenChange, + onTaskCreated, +}: { + open: boolean; + channelId: string; + channelName?: string; + channelContext?: string; + onOpenChange: (open: boolean) => void; + onTaskCreated: (task: Task) => void; +}) { + return ( + + + Create task + + Create a task in {channelName ?? "this channel"}. + + { + onTaskCreated(task); + onOpenChange(false); + }} + channelContext={channelContext} + channelName={channelName} + channelContextId={channelId} + allowNoRepo + suggestions={CHANNEL_TASK_SUGGESTIONS} + /> + + + ); +} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 4ce98700f9..0e90c74d42 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -1,4 +1,4 @@ -import { Kanban, ListBullets, User } from "@phosphor-icons/react"; +import { Kanban, ListBullets, Plus, User } from "@phosphor-icons/react"; import type { PrSnapshot } from "@posthog/core/home/prSnapshot"; import { insertTaskDedup } from "@posthog/core/tasks/taskDelete"; import { Button } from "@posthog/quill"; @@ -9,6 +9,7 @@ import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authCl import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { CHANNEL_TASK_SUGGESTIONS } from "@posthog/ui/features/canvas/channelTaskSuggestions"; import { ChannelBoardView } from "@posthog/ui/features/canvas/components/ChannelBoardView"; +import { ChannelCreateTaskDialog } from "@posthog/ui/features/canvas/components/ChannelCreateTaskDialog"; import { ChannelFeedView, type PendingKickoff, @@ -180,6 +181,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { // The "Create your context.md" dialog, opened from the welcome message's // onboarding checklist. Describe-mode: seeds a plan session for this context. const [contextMdDialogOpen, setContextMdDialogOpen] = useState(false); + const [createTaskDialogOpen, setCreateTaskDialogOpen] = useState(false); const [previewTask, setPreviewTask] = useState(null); const threadTaskId = useThreadPanelStore( @@ -349,23 +351,35 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { Me
-
- - +
+ {viewMode === "board" ? ( + + ) : null} +
+ + +
{viewMode === "board" ? ( @@ -391,18 +405,20 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { onOpenThread={handleOpenThread} /> )} -
- -
+ {viewMode === "feed" ? ( +
+ +
+ ) : null}
{threadTaskId && ( @@ -432,6 +448,14 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { handleOpenFull(task.id); }} /> +
); } From 9ff16819869436db295b54bc71532be5ad443e72 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:30:05 +0200 Subject: [PATCH 14/25] fix(channels): refine board task dialog Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../components/ChannelCreateTaskDialog.tsx | 5 ++--- .../canvas/components/WebsiteChannelHome.tsx | 20 +++++++++---------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelCreateTaskDialog.tsx b/packages/ui/src/features/canvas/components/ChannelCreateTaskDialog.tsx index e061458d57..748d5912a6 100644 --- a/packages/ui/src/features/canvas/components/ChannelCreateTaskDialog.tsx +++ b/packages/ui/src/features/canvas/components/ChannelCreateTaskDialog.tsx @@ -5,7 +5,6 @@ import { DialogTitle, } from "@posthog/quill"; import type { Task } from "@posthog/shared/domain-types"; -import { CHANNEL_TASK_SUGGESTIONS } from "@posthog/ui/features/canvas/channelTaskSuggestions"; import { TaskInput } from "@posthog/ui/features/task-detail/components/TaskInput"; export function ChannelCreateTaskDialog({ @@ -25,7 +24,7 @@ export function ChannelCreateTaskDialog({ }) { return ( - + Create task Create a task in {channelName ?? "this channel"}. @@ -39,7 +38,7 @@ export function ChannelCreateTaskDialog({ channelName={channelName} channelContextId={channelId} allowNoRepo - suggestions={CHANNEL_TASK_SUGGESTIONS} + suggestions={[]} /> diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 0e90c74d42..9b697f5baa 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -351,17 +351,17 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { Me
+ {viewMode === "board" ? ( + + ) : null}
- {viewMode === "board" ? ( - - ) : null}
+ ); +} + function ThreadTimeline({ timeline, isReady, @@ -502,6 +549,7 @@ function ThreadConversation({ onOpenFull, showTaskSummary, showAgentStatus, + taskSummaryInHeader, }: { task: Task; channelId: string; @@ -510,6 +558,7 @@ function ThreadConversation({ onOpenFull?: () => void; showTaskSummary: boolean; showAgentStatus: boolean; + taskSummaryInHeader: boolean; }) { const taskId = task.id; const client = useOptionalAuthenticatedClient(); @@ -656,12 +705,13 @@ function ThreadConversation({ return (
- {showTaskSummary && ( + {showTaskSummary && !taskSummaryInHeader && (
@@ -706,6 +756,7 @@ export function ThreadPanel({ onOpenFull, showTaskSummary = true, showAgentStatus = true, + taskSummaryInHeader = false, }: { taskId: string; channelId: string; @@ -716,6 +767,7 @@ export function ThreadPanel({ onOpenFull?: () => void; showTaskSummary?: boolean; showAgentStatus?: boolean; + taskSummaryInHeader?: boolean; }) { const { data: fetchedTask } = useQuery({ ...taskDetailQuery(taskId), @@ -751,6 +803,7 @@ export function ThreadPanel({ onOpenFull={onOpenFull} showTaskSummary={showTaskSummary} showAgentStatus={showAgentStatus} + taskSummaryInHeader={taskSummaryInHeader} /> ); } From 23c35a881121dea7d2629bcbd7e89f9f7180bc4b Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:30:09 +0200 Subject: [PATCH 16/25] fix(channels): simplify preview task header Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../canvas/components/ThreadPanel.tsx | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index fa1ea960bb..75580b7afe 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -1,6 +1,7 @@ import { ArrowSquareOutIcon, CaretRightIcon, + ChatCircleIcon, DotsThreeIcon, GitBranchIcon, PaperPlaneRightIcon, @@ -58,7 +59,6 @@ import { isTerminalStatus } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; -import { TaskTabIcon } from "@posthog/ui/features/browser-tabs/TaskTabIcon"; import { TaskCard, TaskStatusBadge, @@ -399,29 +399,23 @@ function ThreadHeaderTaskSummary({ task }: { task: Task }) { : undefined; return ( -
-
- - - {task.title || "Untitled task"} +
+ + + {task.title || "Untitled task"} + + + {prUrl ? ( + + + PR + + ) : null} + {task.repository ? ( + + + {task.repository} - -
- {task.repository || prUrl ? ( -
- {task.repository ? ( - - - {task.repository} - - ) : null} - {prUrl ? ( - - - PR - - ) : null} -
) : null}
); From e9b0e6d3680b43ecaa79183d343a044a642f46eb Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:30:10 +0200 Subject: [PATCH 17/25] feat(channels): gate task board behind feature flag Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- packages/shared/src/flags.ts | 2 + .../canvas/components/WebsiteChannelHome.tsx | 117 ++++++++++-------- 2 files changed, 68 insertions(+), 51 deletions(-) diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index cab52efaec..e8afeafd88 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -14,6 +14,8 @@ export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run"; // Gates the entire canvas feature: the app rail's Channels space, the /website // routes, channels and dashboards. export const PROJECT_BLUEBIRD_FLAG = "project-bluebird"; +// Gates the per-channel task board, including its view/scope controls. +export const CHANNEL_TASK_BOARD_FLAG = "posthog-code-channel-task-board"; // Gates the Loops feature: the sidebar Loops space and the per-channel Loops tab. export const LOOPS_FLAG = "loops"; export const TASKS_PREWARM_SANDBOX_FLAG = "tasks-prewarm-sandbox"; diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 9b697f5baa..53909c8b3d 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -2,6 +2,7 @@ import { Kanban, ListBullets, Plus, User } from "@phosphor-icons/react"; import type { PrSnapshot } from "@posthog/core/home/prSnapshot"; import { insertTaskDedup } from "@posthog/core/tasks/taskDelete"; import { Button } from "@posthog/quill"; +import { CHANNEL_TASK_BOARD_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; import { isTerminalStatus } from "@posthog/shared/domain-types"; @@ -45,6 +46,7 @@ import { } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useChannelHomeUiStore } from "@posthog/ui/features/canvas/stores/channelHomeUiStore"; import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { useHomeSnapshot } from "@posthog/ui/features/home/hooks/useHomeSnapshot"; import { SuggestedPromptCard } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; @@ -70,6 +72,8 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { const setViewMode = useChannelHomeUiStore((state) => state.setViewMode); const taskScope = useChannelHomeUiStore((state) => state.taskScope); const setTaskScope = useChannelHomeUiStore((state) => state.setTaskScope); + const boardEnabled = useFeatureFlag(CHANNEL_TASK_BOARD_FLAG); + const effectiveViewMode = boardEnabled ? viewMode : "feed"; const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); const { snapshot: homeSnapshot } = useHomeSnapshot(); @@ -117,13 +121,13 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { const visibleTasks = useMemo( () => - taskScope === "me" + boardEnabled && taskScope === "me" ? tasks.filter( (task) => !!currentUser?.uuid && task.created_by?.uuid === currentUser.uuid, ) : tasks, - [currentUser?.uuid, taskScope, tasks], + [boardEnabled, currentUser?.uuid, taskScope, tasks], ); const prSnapshotByTaskId = useMemo(() => { const result = new Map(); @@ -153,7 +157,10 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { }, [homeSnapshot.inProgress, homeSnapshot.needsAttention]); // Resolve PR state even while the feed is visible so switching to the board // can use the shared React Query cache instead of briefly misplacing cards. - const taskPrStates = useChannelTaskPrStates(visibleTasks, prUrlByTaskId); + const taskPrStates = useChannelTaskPrStates( + boardEnabled ? visibleTasks : [], + prUrlByTaskId, + ); const composerRef = useRef(null); @@ -333,56 +340,62 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { return (
-
-
- - -
- {viewMode === "board" ? ( - - ) : null} -
+ {boardEnabled ? ( +
+
+ {effectiveViewMode === "board" ? ( + + ) : null} +
+
+ + +
-
- {viewMode === "board" ? ( + ) : null} + {effectiveViewMode === "board" ? ( )} - {viewMode === "feed" ? ( + {effectiveViewMode === "feed" ? (
- + {boardEnabled ? ( + + ) : null}
); } From 6ce5e5e7b13509c5b9a81fb71db7a0493e25a3cc Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:30:12 +0200 Subject: [PATCH 18/25] fix(channels): classify closed draft PRs as cancelled Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../ui/src/features/canvas/hooks/useChannelTaskPrStates.test.ts | 1 + packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.test.ts b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.test.ts index 84092010c5..457636c5c1 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.test.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.test.ts @@ -7,6 +7,7 @@ describe("prDetailsToState", () => { [{ state: "open", merged: false, draft: false }, "open"], [{ state: "open", merged: false, draft: true }, "draft"], [{ state: "closed", merged: false, draft: false }, "closed"], + [{ state: "closed", merged: false, draft: true }, "closed"], [{ state: "closed", merged: true, draft: false }, "merged"], ] as const)("maps %o to %s", (details, expected) => { expect(prDetailsToState(details)).toBe(expected); diff --git a/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts index 9c54f3504a..26a39ec33e 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts @@ -13,9 +13,9 @@ export function prDetailsToState( ): SidebarPrState { if (!details) return null; if (details.merged) return "merged"; + if (details.state === "closed") return "closed"; if (details.draft) return "draft"; if (details.state === "open") return "open"; - if (details.state === "closed") return "closed"; return null; } From cb450911012dbc50cf26a29435325522f7211a5e Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:30:13 +0200 Subject: [PATCH 19/25] fix(channels): keep board-created tasks in channel Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../ChannelCreateTaskDialog.test.tsx | 47 +++++++++++++++++++ .../components/ChannelCreateTaskDialog.tsx | 3 ++ .../canvas/components/WebsiteChannelHome.tsx | 38 ++++++++------- .../task-detail/components/TaskInput.tsx | 4 ++ 4 files changed, 74 insertions(+), 18 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/ChannelCreateTaskDialog.test.tsx diff --git a/packages/ui/src/features/canvas/components/ChannelCreateTaskDialog.test.tsx b/packages/ui/src/features/canvas/components/ChannelCreateTaskDialog.test.tsx new file mode 100644 index 0000000000..ce5ce3674f --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelCreateTaskDialog.test.tsx @@ -0,0 +1,47 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +const { taskInputProps } = vi.hoisted(() => ({ + taskInputProps: {} as Record, +})); + +vi.mock("@posthog/ui/features/task-detail/components/TaskInput", () => ({ + TaskInput: (props: Record) => { + Object.assign(taskInputProps, props); + return
Task input
; + }, +})); + +import { ChannelCreateTaskDialog } from "./ChannelCreateTaskDialog"; + +describe("ChannelCreateTaskDialog", () => { + it("creates the task in the backend channel and closes on success", () => { + const onOpenChange = vi.fn(); + const onTaskCreated = vi.fn(); + render( + , + ); + + expect(taskInputProps).toMatchObject({ + channelId: "feed-channel-id", + channelContextId: "folder-channel-id", + channelName: "code", + channelContext: "# code", + }); + + const task = { id: "task-1" } as Task; + (taskInputProps.onTaskCreated as (createdTask: Task) => void)(task); + + expect(onTaskCreated).toHaveBeenCalledWith(task); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ChannelCreateTaskDialog.tsx b/packages/ui/src/features/canvas/components/ChannelCreateTaskDialog.tsx index 748d5912a6..d435a85755 100644 --- a/packages/ui/src/features/canvas/components/ChannelCreateTaskDialog.tsx +++ b/packages/ui/src/features/canvas/components/ChannelCreateTaskDialog.tsx @@ -10,6 +10,7 @@ import { TaskInput } from "@posthog/ui/features/task-detail/components/TaskInput export function ChannelCreateTaskDialog({ open, channelId, + backendChannelId, channelName, channelContext, onOpenChange, @@ -17,6 +18,7 @@ export function ChannelCreateTaskDialog({ }: { open: boolean; channelId: string; + backendChannelId: string; channelName?: string; channelContext?: string; onOpenChange: (open: boolean) => void; @@ -36,6 +38,7 @@ export function ChannelCreateTaskDialog({ }} channelContext={channelContext} channelName={channelName} + channelId={backendChannelId} channelContextId={channelId} allowNoRepo suggestions={[]} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 53909c8b3d..f3e44a308d 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -204,11 +204,15 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { [], ); - const invalidateFeed = useCallback(() => { - void queryClient.invalidateQueries({ - queryKey: channelFeedQueryKey(backendChannel?.id), - }); - }, [queryClient, backendChannel?.id]); + const handleOpenFull = useCallback( + (taskId: string) => { + void navigate({ + to: "/website/$channelId/tasks/$taskId", + params: { channelId, taskId }, + }); + }, + [channelId, navigate], + ); // Slack behavior: submitting keeps you in the channel; the new card appears // in the feed and updates live. Filing into the folder keeps the Artifacts / @@ -224,7 +228,13 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { channelFeedQueryKey(backendChannel?.id), (old) => (old ? insertTaskDedup(old, task) : [task]), ); - invalidateFeed(); + toast.success("Task started", { + description: task.title || undefined, + action: { + label: "View task", + onClick: () => handleOpenFull(task.id), + }, + }); void fileTask(channelId, task.id, task.title) .then(() => track(ANALYTICS_EVENTS.CHANNEL_ACTION, { @@ -248,17 +258,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { }); }); }, - [backendChannel?.id, channelId, fileTask, invalidateFeed, queryClient], - ); - - const handleOpenFull = useCallback( - (taskId: string) => { - void navigate({ - to: "/website/$channelId/tasks/$taskId", - params: { channelId, taskId }, - }); - }, - [channelId, navigate], + [backendChannel?.id, channelId, fileTask, handleOpenFull, queryClient], ); const handleOpenTask = useCallback((task: Task) => setPreviewTask(task), []); @@ -363,6 +363,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { + ); +} From 9d49cd172bc2575fb9178e7c086d633166f51667 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:30:18 +0200 Subject: [PATCH 22/25] style(channels): align preview PR control Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../canvas/components/ChannelBoardView.tsx | 65 +------------------ .../components/ChannelPrButton.test.tsx | 43 ++++++++++++ .../canvas/components/ChannelPrButton.tsx | 51 +++++++++++++++ .../components/ChannelTaskPreviewDialog.tsx | 7 ++ .../canvas/components/ThreadPanel.tsx | 52 +++++++++++---- .../canvas/components/WebsiteChannelHome.tsx | 15 +++++ 6 files changed, 157 insertions(+), 76 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/ChannelPrButton.test.tsx create mode 100644 packages/ui/src/features/canvas/components/ChannelPrButton.tsx diff --git a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx index a428569356..fb4fde08b0 100644 --- a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx @@ -3,48 +3,34 @@ import { CheckCircle, Eye, GitCommit, - GitPullRequest, XCircle, } from "@phosphor-icons/react"; -import { - getPrVisualConfig, - type PrVisualConfig, - parsePrNumber, -} from "@posthog/core/git-interaction/prStatus"; import type { PrSnapshot } from "@posthog/core/home/prSnapshot"; import { TASK_BOARD_STATUSES, type TaskBoardStatus, taskBoardStatusFromSources, } from "@posthog/core/home/taskBoardStatus"; -import { Button } from "@posthog/quill"; import type { Task } from "@posthog/shared/domain-types"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { TaskStatusBadge, useTaskStatusDisplay, } from "@posthog/ui/features/canvas/components/ChannelFeedView"; +import { ChannelPrButton } from "@posthog/ui/features/canvas/components/ChannelPrButton"; import type { ChannelTaskPrStates } from "@posthog/ui/features/canvas/hooks/useChannelTaskPrStates"; import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; -import { getPrVisualIcon } from "@posthog/ui/features/git-interaction/prIcon"; import { WorkBoard, type WorkBoardColumn, } from "@posthog/ui/features/home/components/WorkBoard"; import type { SituationColor } from "@posthog/ui/features/home/utils/situationDisplay"; import type { SidebarPrState } from "@posthog/ui/features/sidebar/useTaskPrStatus"; -import { openUrlInBrowser } from "@posthog/ui/utils/browser"; import { Box } from "@radix-ui/themes"; import { useMemo } from "react"; const BOARD_REPLIES_POLL_INTERVAL_MS = 15_000; -const PR_BUTTON_COLOR_CLASSES: Record = { - gray: "border-(--gray-6) text-(--gray-11) hover:bg-(--gray-3)", - green: "border-(--green-6) text-(--green-11) hover:bg-(--green-3)", - red: "border-(--red-6) text-(--red-11) hover:bg-(--red-3)", - purple: "border-(--purple-6) text-(--purple-11) hover:bg-(--purple-3)", -}; const STATUS_VISUAL: Record< TaskBoardStatus, { @@ -226,22 +212,7 @@ function ChannelBoardCard({ {task.repository} ) : null} - {prUrl && prState ? ( - - ) : prUrl ? ( - - ) : null} + {prUrl ? : null}
@@ -264,35 +235,3 @@ function ChannelBoardCard({ ); } - -function BoardPrButton({ - prUrl, - prState, -}: { - prUrl: string; - prState: Exclude; -}) { - const config = getPrVisualConfig( - prState === "merged" ? "closed" : prState, - prState === "merged", - prState === "draft", - ); - const PrIcon = getPrVisualIcon(config.icon); - const prNumber = parsePrNumber(prUrl); - - return ( - - ); -} diff --git a/packages/ui/src/features/canvas/components/ChannelPrButton.test.tsx b/packages/ui/src/features/canvas/components/ChannelPrButton.test.tsx new file mode 100644 index 0000000000..77451379b1 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelPrButton.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +const { openUrlInBrowser } = vi.hoisted(() => ({ + openUrlInBrowser: vi.fn(), +})); + +vi.mock("@posthog/ui/utils/browser", () => ({ openUrlInBrowser })); + +import { ChannelPrButton } from "./ChannelPrButton"; + +describe("ChannelPrButton", () => { + it.each([ + ["open", "Ready #123"], + ["merged", "Merged #123"], + ] as const)("renders the %s lifecycle caption", (prState, label) => { + render( + , + ); + + expect(screen.getByRole("button", { name: label })).toBeInTheDocument(); + }); + + it("opens the PR without activating the parent card", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Ready #123" })); + + expect(openUrlInBrowser).toHaveBeenCalledWith( + "https://github.com/PostHog/code/pull/123", + ); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ChannelPrButton.tsx b/packages/ui/src/features/canvas/components/ChannelPrButton.tsx new file mode 100644 index 0000000000..ea72332eea --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelPrButton.tsx @@ -0,0 +1,51 @@ +import { GitPullRequest } from "@phosphor-icons/react"; +import { + getPrVisualConfig, + type PrVisualConfig, + parsePrNumber, +} from "@posthog/core/git-interaction/prStatus"; +import { Button } from "@posthog/quill"; +import { getPrVisualIcon } from "@posthog/ui/features/git-interaction/prIcon"; +import type { SidebarPrState } from "@posthog/ui/features/sidebar/useTaskPrStatus"; +import { openUrlInBrowser } from "@posthog/ui/utils/browser"; + +const COLOR_CLASSES: Record = { + gray: "border-(--gray-6) text-(--gray-11) hover:bg-(--gray-3)", + green: "border-(--green-6) text-(--green-11) hover:bg-(--green-3)", + red: "border-(--red-6) text-(--red-11) hover:bg-(--red-3)", + purple: "border-(--purple-6) text-(--purple-11) hover:bg-(--purple-3)", +}; + +export function ChannelPrButton({ + prUrl, + prState, +}: { + prUrl: string; + prState: SidebarPrState; +}) { + const config = prState + ? getPrVisualConfig( + prState === "merged" ? "closed" : prState, + prState === "merged", + prState === "draft", + ) + : null; + const PrIcon = config ? getPrVisualIcon(config.icon) : GitPullRequest; + const prNumber = parsePrNumber(prUrl); + + return ( + + ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelTaskPreviewDialog.tsx b/packages/ui/src/features/canvas/components/ChannelTaskPreviewDialog.tsx index cd2313035b..20c2c8de31 100644 --- a/packages/ui/src/features/canvas/components/ChannelTaskPreviewDialog.tsx +++ b/packages/ui/src/features/canvas/components/ChannelTaskPreviewDialog.tsx @@ -6,15 +6,20 @@ import { } from "@posthog/quill"; import type { Task } from "@posthog/shared/domain-types"; import { ThreadPanel } from "@posthog/ui/features/canvas/components/ThreadPanel"; +import type { SidebarPrState } from "@posthog/ui/features/sidebar/useTaskPrStatus"; export function ChannelTaskPreviewDialog({ task, channelId, + prUrl, + prState, onClose, onOpenFull, }: { task: Task | null; channelId: string; + prUrl?: string; + prState: SidebarPrState; onClose: () => void; onOpenFull: (task: Task) => void; }) { @@ -39,6 +44,8 @@ export function ChannelTaskPreviewDialog({ onOpenFull={() => onOpenFull(task)} showAgentStatus={false} taskSummaryInHeader + taskSummaryPrUrl={prUrl} + taskSummaryPrState={prState} /> ) : null} diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 75580b7afe..a73e86d2c2 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -65,6 +65,7 @@ import { useTaskStatusDisplay, } from "@posthog/ui/features/canvas/components/ChannelFeedView"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { ChannelPrButton } from "@posthog/ui/features/canvas/components/ChannelPrButton"; import { MentionComposer } from "@posthog/ui/features/canvas/components/MentionComposer"; import { MentionText } from "@posthog/ui/features/canvas/components/MentionText"; import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp"; @@ -82,6 +83,7 @@ import { usePrDetails } from "@posthog/ui/features/git-interaction/usePrDetails" import { useSessionConnection } from "@posthog/ui/features/sessions/hooks/useSessionConnection"; import { useSessionViewState } from "@posthog/ui/features/sessions/hooks/useSessionViewState"; import { usePendingPermissionsForTask } from "@posthog/ui/features/sessions/sessionStore"; +import type { SidebarPrState } from "@posthog/ui/features/sidebar/useTaskPrStatus"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; @@ -340,11 +342,15 @@ function ThreadLoadingState() { function ThreadHeader({ task, + taskPrUrl, + taskPrState, onClose, onToggleCollapsed, onOpenFull, }: { task?: Task; + taskPrUrl?: string; + taskPrState: SidebarPrState; onClose?: () => void; onToggleCollapsed?: () => void; onOpenFull?: () => void; @@ -352,7 +358,13 @@ function ThreadHeader({ return (
- {task ? : null} + {task ? ( + + ) : null} {!task ? ( Thread ) : null} @@ -391,12 +403,19 @@ function ThreadHeader({ ); } -function ThreadHeaderTaskSummary({ task }: { task: Task }) { - const display = useTaskStatusDisplay(task); - const prUrl = - typeof task.latest_run?.output?.pr_url === "string" - ? task.latest_run.output.pr_url - : undefined; +function ThreadHeaderTaskSummary({ + task, + prUrl, + prState, +}: { + task: Task; + prUrl?: string; + prState: SidebarPrState; +}) { + const taskDisplay = useTaskStatusDisplay(task); + const display = prState + ? { base: null, prState, isMerged: prState === "merged" } + : taskDisplay; return (
@@ -405,12 +424,7 @@ function ThreadHeaderTaskSummary({ task }: { task: Task }) { {task.title || "Untitled task"} - {prUrl ? ( - - - PR - - ) : null} + {prUrl ? : null} {task.repository ? ( @@ -544,6 +558,8 @@ function ThreadConversation({ showTaskSummary, showAgentStatus, taskSummaryInHeader, + taskSummaryPrUrl, + taskSummaryPrState, }: { task: Task; channelId: string; @@ -553,6 +569,8 @@ function ThreadConversation({ showTaskSummary: boolean; showAgentStatus: boolean; taskSummaryInHeader: boolean; + taskSummaryPrUrl?: string; + taskSummaryPrState: SidebarPrState; }) { const taskId = task.id; const client = useOptionalAuthenticatedClient(); @@ -700,6 +718,8 @@ function ThreadConversation({
); } diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index f3e44a308d..fd3c1b1a87 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -456,6 +456,21 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { setPreviewTask(null)} onOpenFull={(task) => { setPreviewTask(null); From 38ee05bdb59bd9feffe5bcc66e994520623fa086 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:30:19 +0200 Subject: [PATCH 23/25] fix(channels): address task board review feedback Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- apps/web/src/web-host-router.ts | 20 +++++ .../core/src/git-interaction/prStatus.test.ts | 5 +- packages/core/src/git-interaction/prStatus.ts | 2 +- .../core/src/home/taskBoardStatus.test.ts | 3 +- packages/core/src/home/taskBoardStatus.ts | 2 + .../canvas/components/ChannelBoardView.tsx | 56 ++++++++---- .../ChannelCreateTaskDialog.test.tsx | 4 +- .../components/ChannelCreateTaskDialog.tsx | 8 +- .../canvas/components/ChannelHeader.tsx | 10 +-- .../components/ChannelPrButton.test.tsx | 4 +- .../canvas/components/WebsiteChannelHome.tsx | 88 +++++++++---------- .../hooks/useChannelTaskPrStates.test.ts | 27 +++++- .../canvas/hooks/useChannelTaskPrStates.ts | 36 +++----- .../features/git-interaction/usePrDetails.ts | 36 +++++--- 14 files changed, 178 insertions(+), 123 deletions(-) diff --git a/apps/web/src/web-host-router.ts b/apps/web/src/web-host-router.ts index d562ba4cd1..1c1a30e9f5 100644 --- a/apps/web/src/web-host-router.ts +++ b/apps/web/src/web-host-router.ts @@ -1,3 +1,7 @@ +import { + getPrDetailsByUrlInput, + getPrDetailsByUrlOutput, +} from "@posthog/core/git/router-schemas"; import { TEAM_SKILLS_SERVICE } from "@posthog/core/skills/identifiers"; import type { TeamSkillsService } from "@posthog/core/skills/teamSkillsService"; import { resolveService } from "@posthog/di/container"; @@ -224,6 +228,21 @@ const fsStubRouter = router({ .query(({ input }) => getWebAttachmentBase64(input.filePath)), }); +// The browser has no local `gh` process. Return the host-router's canonical +// unknown shape so shared PR consumers can fall back without request errors. +const gitStubRouter = router({ + getPrDetailsByUrl: publicProcedure + .input(getPrDetailsByUrlInput) + .output(getPrDetailsByUrlOutput) + .query(() => ({ + state: "unknown", + merged: false, + draft: false, + headRefName: null, + title: null, + })), +}); + const skillsStubRouter = router({ // Backs the composer's "/" skill menu and typed /skill-command resolution. // No local skills dir on web, so surface the team's cloud skills instead @@ -480,6 +499,7 @@ export const webHostRouter = router({ deepLink: deepLinkStubRouter, folders: foldersStubRouter, fs: fsStubRouter, + git: gitStubRouter, githubIntegration: githubIntegrationRouter, logs: logsStubRouter, os: osStubRouter, diff --git a/packages/core/src/git-interaction/prStatus.test.ts b/packages/core/src/git-interaction/prStatus.test.ts index 7ccaf6354e..fa23b07e75 100644 --- a/packages/core/src/git-interaction/prStatus.test.ts +++ b/packages/core/src/git-interaction/prStatus.test.ts @@ -2,10 +2,7 @@ import { describe, expect, it } from "vitest"; import { getPrVisualConfig } from "./prStatus"; describe("getPrVisualConfig", () => { - it.each([ - ["open", false, false, "Ready", "green", "pull-request"], - ["closed", true, false, "Merged", "purple", "merged"], - ] as const)( + it.each([["closed", true, false, "Merged", "purple", "merged"]] as const)( "maps %s PRs to the expected lifecycle visual", (state, merged, draft, label, color, icon) => { expect(getPrVisualConfig(state, merged, draft)).toMatchObject({ diff --git a/packages/core/src/git-interaction/prStatus.ts b/packages/core/src/git-interaction/prStatus.ts index e28f316480..9d9e990902 100644 --- a/packages/core/src/git-interaction/prStatus.ts +++ b/packages/core/src/git-interaction/prStatus.ts @@ -49,7 +49,7 @@ export function getPrVisualConfig( return { color: "green", icon: "pull-request", - label: "Ready", + label: "Open", actions: [ { id: "draft", label: "Convert to draft" }, { id: "close", label: "Close PR" }, diff --git a/packages/core/src/home/taskBoardStatus.test.ts b/packages/core/src/home/taskBoardStatus.test.ts index d746225da6..a9a7c2e07c 100644 --- a/packages/core/src/home/taskBoardStatus.test.ts +++ b/packages/core/src/home/taskBoardStatus.test.ts @@ -5,7 +5,8 @@ describe("taskBoardStatus", () => { it.each([ [{}, "working"], [{ runStatus: "in_progress" }, "working"], - [{ runStatus: "completed" }, "working"], + [{ runStatus: "completed" }, "done"], + [{ runStatus: "completed", prState: "draft" }, "working"], [{ prState: "draft" }, "working"], [{ prState: "open" }, "in_review"], [{ prState: "merged", runStatus: "failed" }, "done"], diff --git a/packages/core/src/home/taskBoardStatus.ts b/packages/core/src/home/taskBoardStatus.ts index 9442912a5a..5cef8c4da2 100644 --- a/packages/core/src/home/taskBoardStatus.ts +++ b/packages/core/src/home/taskBoardStatus.ts @@ -23,6 +23,8 @@ export function taskBoardStatus(input: { return "cancelled"; } if (input.prState === "open") return "in_review"; + if (input.prState === "draft") return "working"; + if (input.runStatus === "completed") return "done"; return "working"; } diff --git a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx index fb4fde08b0..788fea2214 100644 --- a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx @@ -18,7 +18,10 @@ import { useTaskStatusDisplay, } from "@posthog/ui/features/canvas/components/ChannelFeedView"; import { ChannelPrButton } from "@posthog/ui/features/canvas/components/ChannelPrButton"; -import type { ChannelTaskPrStates } from "@posthog/ui/features/canvas/hooks/useChannelTaskPrStates"; +import { + type ChannelTaskPrStates, + taskPrUrl, +} from "@posthog/ui/features/canvas/hooks/useChannelTaskPrStates"; import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { @@ -27,10 +30,20 @@ import { } from "@posthog/ui/features/home/components/WorkBoard"; import type { SituationColor } from "@posthog/ui/features/home/utils/situationDisplay"; import type { SidebarPrState } from "@posthog/ui/features/sidebar/useTaskPrStatus"; +import { useInView } from "@posthog/ui/primitives/hooks/useInView"; import { Box } from "@radix-ui/themes"; import { useMemo } from "react"; const BOARD_REPLIES_POLL_INTERVAL_MS = 15_000; +const PR_BADGE_STATE_BY_STATUS: Record< + TaskBoardStatus, + Exclude +> = { + working: "draft", + in_review: "open", + done: "merged", + cancelled: "closed", +}; const STATUS_VISUAL: Record< TaskBoardStatus, { @@ -70,7 +83,6 @@ export function ChannelBoardView({ tasks, isLoading, prSnapshotByTaskId, - prUrlByTaskId, taskPrStates, onOpenTask, onOpenThread, @@ -78,7 +90,6 @@ export function ChannelBoardView({ tasks: Task[]; isLoading: boolean; prSnapshotByTaskId: ReadonlyMap; - prUrlByTaskId: ReadonlyMap; taskPrStates: ChannelTaskPrStates; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; @@ -88,6 +99,7 @@ export function ChannelBoardView({ task: Task; status: TaskBoardStatus; prState: SidebarPrState; + badgePrState: SidebarPrState; }>[] >(() => { const grouped = new Map< @@ -96,6 +108,7 @@ export function ChannelBoardView({ task: Task; status: TaskBoardStatus; prState: SidebarPrState; + badgePrState: SidebarPrState; }> >(TASK_BOARD_STATUSES.map((status) => [status, []])); for (const task of tasks) { @@ -109,10 +122,13 @@ export function ChannelBoardView({ resolvedPrState, prSnapshot: snapshot, }); + const prState = resolvedPrState ?? snapshot?.state ?? null; grouped.get(status)?.push({ task, status, - prState: resolvedPrState ?? snapshot?.state ?? null, + prState, + badgePrState: + prState === PR_BADGE_STATE_BY_STATUS[status] ? prState : null, }); } return TASK_BOARD_STATUSES.map((status) => ({ @@ -131,17 +147,13 @@ export function ChannelBoardView({ columns={columns} isLoading={taskPrStates.isResolving} getKey={(item) => item.task.id} - renderCard={({ task, status, prState }) => ( + renderCard={({ task, status, prState, badgePrState }) => ( @@ -154,6 +166,7 @@ function ChannelBoardCard({ task, status, prState, + badgePrState, prUrl, onOpenTask, onOpenThread, @@ -161,21 +174,25 @@ function ChannelBoardCard({ task: Task; status: TaskBoardStatus; prState: SidebarPrState; + badgePrState: SidebarPrState; prUrl?: string; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; }) { const taskDisplay = useTaskStatusDisplay(task); - const display = - prState && - ((status === "done" && prState === "merged") || - (status === "cancelled" && prState === "closed") || - (status === "in_review" && prState === "open") || - (status === "working" && prState === "draft")) - ? { base: null, prState, isMerged: prState === "merged" } - : taskDisplay; + const display = badgePrState + ? { + base: null, + prState: badgePrState, + isMerged: badgePrState === "merged", + } + : taskDisplay; + const [cardRef, inView] = useInView({ + rootMargin: "1200px 0px", + }); const { messages } = useTaskThread(task.id, { pollIntervalMs: BOARD_REPLIES_POLL_INTERVAL_MS, + enabled: inView, }); const creatorName = userDisplayName(task.created_by); const visual = STATUS_VISUAL[status]; @@ -183,6 +200,7 @@ function ChannelBoardCard({ return ( { render( void; @@ -38,8 +38,8 @@ export function ChannelCreateTaskDialog({ }} channelContext={channelContext} channelName={channelName} - channelId={backendChannelId} - channelContextId={channelId} + channelId={channelId} + channelContextId={channelContextId} allowNoRepo suggestions={[]} /> diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index 11fda27af2..bc08328a46 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -5,7 +5,6 @@ import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen"; import { Text } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -import type { ReactNode } from "react"; // The shared channel header: a clickable "# channel" that doubles as the Home // item — it routes to the channel home (`/website/$channelId`, like the sidebar @@ -13,13 +12,7 @@ import type { ReactNode } from "react"; // pathname-driven active state the rest of the channel tab strip uses. Followed // by that strip (Artifacts / Recents / CONTEXT.md), rendered into the // header bar by every channel view so the tabs stay in view. -export function ChannelHeader({ - channelId, - trailing, -}: { - channelId: string; - trailing?: ReactNode; -}) { +export function ChannelHeader({ channelId }: { channelId: string }) { const navigate = useNavigate(); const { channels } = useChannels(); const channelName = channels.find((c) => c.id === channelId)?.name; @@ -46,7 +39,6 @@ export function ChannelHeader({ - {trailing}
); } diff --git a/packages/ui/src/features/canvas/components/ChannelPrButton.test.tsx b/packages/ui/src/features/canvas/components/ChannelPrButton.test.tsx index 77451379b1..2e2ab0a4ec 100644 --- a/packages/ui/src/features/canvas/components/ChannelPrButton.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelPrButton.test.tsx @@ -12,7 +12,7 @@ import { ChannelPrButton } from "./ChannelPrButton"; describe("ChannelPrButton", () => { it.each([ - ["open", "Ready #123"], + ["open", "Open #123"], ["merged", "Merged #123"], ] as const)("renders the %s lifecycle caption", (prState, label) => { render( @@ -34,7 +34,7 @@ describe("ChannelPrButton", () => { />, ); - await user.click(screen.getByRole("button", { name: "Ready #123" })); + await user.click(screen.getByRole("button", { name: "Open #123" })); expect(openUrlInBrowser).toHaveBeenCalledWith( "https://github.com/PostHog/code/pull/123", diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index fd3c1b1a87..7402db3fb2 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -37,7 +37,10 @@ import { useChannelFeedMessages, } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; -import { useChannelTaskPrStates } from "@posthog/ui/features/canvas/hooks/useChannelTaskPrStates"; +import { + taskPrUrl, + useChannelTaskPrStates, +} from "@posthog/ui/features/canvas/hooks/useChannelTaskPrStates"; import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; import { @@ -142,24 +145,9 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { } return result; }, [homeSnapshot.inProgress, homeSnapshot.needsAttention]); - const prUrlByTaskId = useMemo(() => { - const result = new Map(); - for (const workstream of [ - ...homeSnapshot.needsAttention, - ...homeSnapshot.inProgress, - ]) { - if (!workstream.prUrl) continue; - for (const task of workstream.tasks) { - result.set(task.id, workstream.prUrl); - } - } - return result; - }, [homeSnapshot.inProgress, homeSnapshot.needsAttention]); - // Resolve PR state even while the feed is visible so switching to the board - // can use the shared React Query cache instead of briefly misplacing cards. const taskPrStates = useChannelTaskPrStates( - boardEnabled ? visibleTasks : [], - prUrlByTaskId, + boardEnabled && effectiveViewMode === "board" ? visibleTasks : [], + prSnapshotByTaskId, ); const composerRef = useRef(null); @@ -261,6 +249,16 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { [backendChannel?.id, channelId, fileTask, handleOpenFull, queryClient], ); const handleOpenTask = useCallback((task: Task) => setPreviewTask(task), []); + const handleOpenFeedTask = useCallback( + (task: Task) => { + if (boardEnabled) { + setPreviewTask(task); + } else { + handleOpenFull(task.id); + } + }, + [boardEnabled, handleOpenFull], + ); const handleOpenThread = useCallback( (task: Task) => openThread(channelId, task.id), @@ -401,7 +399,6 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { tasks={visibleTasks} isLoading={isLoading} prSnapshotByTaskId={prSnapshotByTaskId} - prUrlByTaskId={prUrlByTaskId} taskPrStates={taskPrStates} onOpenTask={handleOpenTask} onOpenThread={handleOpenThread} @@ -415,7 +412,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { isLoading={isLoading} emptyState={emptyState} intro={intro} - onOpenTask={handleOpenTask} + onOpenTask={handleOpenFeedTask} onOpenThread={handleOpenThread} /> )} @@ -453,35 +450,34 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { /> )} - setPreviewTask(null)} - onOpenFull={(task) => { - setPreviewTask(null); - handleOpenFull(task.id); - }} - /> + {boardEnabled ? ( + setPreviewTask(null)} + onOpenFull={(task) => { + setPreviewTask(null); + handleOpenFull(task.id); + }} + /> + ) : null} {boardEnabled && backendChannel ? ( { it.each([ @@ -13,3 +14,27 @@ describe("prDetailsToState", () => { expect(prDetailsToState(details)).toBe(expected); }); }); + +describe("taskPrUrl", () => { + const task = { + id: "task-1", + latest_run: { output: { pr_url: "https://github.com/o/r/pull/1" } }, + } as unknown as Task; + + it("prefers the Home snapshot URL", () => { + expect( + taskPrUrl( + task, + new Map([ + ["task-1", { url: "https://github.com/o/r/pull/2" }], + ]), + ), + ).toBe("https://github.com/o/r/pull/2"); + }); + + it("falls back to the latest run URL", () => { + expect(taskPrUrl(task, new Map())).toBe( + "https://github.com/o/r/pull/1", + ); + }); +}); diff --git a/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts index 26a39ec33e..cea0b93eeb 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts @@ -1,13 +1,12 @@ -import { useHostTRPC } from "@posthog/host-router/react"; +import type { PrSnapshot } from "@posthog/core/home/prSnapshot"; import type { Task } from "@posthog/shared/domain-types"; -import type { PrStateDetails } from "@posthog/ui/features/git-interaction/usePrDetails"; +import { + type PrStateDetails, + usePrDetailsQueries, +} from "@posthog/ui/features/git-interaction/usePrDetails"; import type { SidebarPrState } from "@posthog/ui/features/sidebar/useTaskPrStatus"; -import { keepPreviousData, useQueries } from "@tanstack/react-query"; import { useMemo } from "react"; -const PR_STALE_TIME_MS = 60_000; -const PR_CACHE_TIME_MS = 30 * 60_000; - export function prDetailsToState( details: PrStateDetails | undefined, ): SidebarPrState { @@ -21,10 +20,10 @@ export function prDetailsToState( export function taskPrUrl( task: Task, - prUrlByTaskId: ReadonlyMap, + prSnapshotByTaskId: ReadonlyMap>, ): string | null { return ( - prUrlByTaskId.get(task.id) ?? + prSnapshotByTaskId.get(task.id)?.url ?? (typeof task.latest_run?.output?.pr_url === "string" ? task.latest_run.output.pr_url : null) @@ -40,29 +39,20 @@ export interface ChannelTaskPrStates { export function useChannelTaskPrStates( tasks: Task[], - prUrlByTaskId: ReadonlyMap, + prSnapshotByTaskId: ReadonlyMap, ): ChannelTaskPrStates { - const trpc = useHostTRPC(); const prUrls = useMemo( () => [ ...new Set( tasks.flatMap((task) => { - const prUrl = taskPrUrl(task, prUrlByTaskId); + const prUrl = taskPrUrl(task, prSnapshotByTaskId); return prUrl ? [prUrl] : []; }), ), ], - [prUrlByTaskId, tasks], + [prSnapshotByTaskId, tasks], ); - const results = useQueries({ - queries: prUrls.map((prUrl) => ({ - ...trpc.git.getPrDetailsByUrl.queryOptions({ prUrl }), - staleTime: PR_STALE_TIME_MS, - gcTime: PR_CACHE_TIME_MS, - placeholderData: keepPreviousData, - retry: 1, - })), - }); + const results = usePrDetailsQueries(prUrls); return useMemo(() => { const resultByUrl = new Map( @@ -73,7 +63,7 @@ export function useChannelTaskPrStates( let isRefreshing = false; for (const task of tasks) { - const prUrl = taskPrUrl(task, prUrlByTaskId); + const prUrl = taskPrUrl(task, prSnapshotByTaskId); const result = prUrl ? resultByUrl.get(prUrl) : undefined; if (prUrl && result && !result.data && result.isPending) { pendingTaskIds.add(task.id); @@ -93,5 +83,5 @@ export function useChannelTaskPrStates( isResolving: pendingTaskIds.size > 0, isRefreshing, }; - }, [prUrlByTaskId, prUrls, results, tasks]); + }, [prSnapshotByTaskId, prUrls, results, tasks]); } diff --git a/packages/ui/src/features/git-interaction/usePrDetails.ts b/packages/ui/src/features/git-interaction/usePrDetails.ts index 03cc9512e9..823f0c24e9 100644 --- a/packages/ui/src/features/git-interaction/usePrDetails.ts +++ b/packages/ui/src/features/git-interaction/usePrDetails.ts @@ -1,6 +1,6 @@ import { useHostTRPC } from "@posthog/host-router/react"; import type { PrReviewThread } from "@posthog/shared"; -import { useQueries, useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQueries, useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import type { PrCommentThread } from "../code-review/prCommentAnnotations"; @@ -20,6 +20,23 @@ export interface PrStateDetails { draft: boolean; } +const PR_DETAILS_STALE_TIME_MS = 60_000; +const PR_DETAILS_CACHE_TIME_MS = 30 * 60_000; + +/** Shared per-URL PR queries used by task actions and channel boards. */ +export function usePrDetailsQueries(prUrls: string[]) { + const trpc = useHostTRPC(); + return useQueries({ + queries: prUrls.map((prUrl) => ({ + ...trpc.git.getPrDetailsByUrl.queryOptions({ prUrl }), + staleTime: PR_DETAILS_STALE_TIME_MS, + gcTime: PR_DETAILS_CACHE_TIME_MS, + placeholderData: keepPreviousData, + retry: 1, + })), + }); +} + /** * Fetch lifecycle state for a set of PRs at once (the "Other PRs" submenu). * Also serves as a prefetch: it warms the same `getPrDetailsByUrl` cache @@ -29,14 +46,9 @@ export interface PrStateDetails { export function usePrDetailsMap( prUrls: string[], ): Record { - const trpc = useHostTRPC(); - return useQueries({ - queries: prUrls.map((prUrl) => ({ - ...trpc.git.getPrDetailsByUrl.queryOptions({ prUrl }), - staleTime: 60_000, - retry: 1, - })), - combine: (results) => + const results = usePrDetailsQueries(prUrls); + return useMemo( + () => Object.fromEntries( results.flatMap((result, i) => result.data && result.data.state !== "unknown" @@ -44,7 +56,8 @@ export function usePrDetailsMap( : [], ), ), - }); + [prUrls, results], + ); } export function usePrDetails( @@ -57,7 +70,8 @@ export function usePrDetails( const metaQuery = useQuery({ ...trpc.git.getPrDetailsByUrl.queryOptions({ prUrl: prUrl as string }), enabled: !!prUrl, - staleTime: 60_000, + staleTime: PR_DETAILS_STALE_TIME_MS, + gcTime: PR_DETAILS_CACHE_TIME_MS, placeholderData: (prev) => prev, retry: 1, }); From 5c729585d2cf5126e49cdcd8c78d66c606c5f225 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:30:21 +0200 Subject: [PATCH 24/25] refactor(tasks): move shared board modules out of home Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../{home => tasks}/taskBoardStatus.test.ts | 0 .../src/{home => tasks}/taskBoardStatus.ts | 2 +- .../canvas/components/ChannelBoardView.tsx | 12 +++---- .../components => primitives}/WorkBoard.tsx | 36 +++++++++++++------ 4 files changed, 33 insertions(+), 17 deletions(-) rename packages/core/src/{home => tasks}/taskBoardStatus.test.ts (100%) rename packages/core/src/{home => tasks}/taskBoardStatus.ts (94%) rename packages/ui/src/{features/home/components => primitives}/WorkBoard.tsx (82%) diff --git a/packages/core/src/home/taskBoardStatus.test.ts b/packages/core/src/tasks/taskBoardStatus.test.ts similarity index 100% rename from packages/core/src/home/taskBoardStatus.test.ts rename to packages/core/src/tasks/taskBoardStatus.test.ts diff --git a/packages/core/src/home/taskBoardStatus.ts b/packages/core/src/tasks/taskBoardStatus.ts similarity index 94% rename from packages/core/src/home/taskBoardStatus.ts rename to packages/core/src/tasks/taskBoardStatus.ts index 5cef8c4da2..af76a1a15b 100644 --- a/packages/core/src/home/taskBoardStatus.ts +++ b/packages/core/src/tasks/taskBoardStatus.ts @@ -1,5 +1,5 @@ import type { TaskRunStatus } from "@posthog/shared/domain-types"; -import type { PrSnapshot, PrSnapshotState } from "./prSnapshot"; +import type { PrSnapshot, PrSnapshotState } from "../home/prSnapshot"; export const TASK_BOARD_STATUSES = [ "working", diff --git a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx index 788fea2214..13ff49518d 100644 --- a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx @@ -10,7 +10,7 @@ import { TASK_BOARD_STATUSES, type TaskBoardStatus, taskBoardStatusFromSources, -} from "@posthog/core/home/taskBoardStatus"; +} from "@posthog/core/tasks/taskBoardStatus"; import type { Task } from "@posthog/shared/domain-types"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { @@ -24,13 +24,13 @@ import { } from "@posthog/ui/features/canvas/hooks/useChannelTaskPrStates"; import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import type { SidebarPrState } from "@posthog/ui/features/sidebar/useTaskPrStatus"; +import { useInView } from "@posthog/ui/primitives/hooks/useInView"; import { WorkBoard, + type WorkBoardColor, type WorkBoardColumn, -} from "@posthog/ui/features/home/components/WorkBoard"; -import type { SituationColor } from "@posthog/ui/features/home/utils/situationDisplay"; -import type { SidebarPrState } from "@posthog/ui/features/sidebar/useTaskPrStatus"; -import { useInView } from "@posthog/ui/primitives/hooks/useInView"; +} from "@posthog/ui/primitives/WorkBoard"; import { Box } from "@radix-ui/themes"; import { useMemo } from "react"; @@ -49,7 +49,7 @@ const STATUS_VISUAL: Record< { label: string; description: string; - color: SituationColor; + color: WorkBoardColor; Icon: typeof GitCommit; } > = { diff --git a/packages/ui/src/features/home/components/WorkBoard.tsx b/packages/ui/src/primitives/WorkBoard.tsx similarity index 82% rename from packages/ui/src/features/home/components/WorkBoard.tsx rename to packages/ui/src/primitives/WorkBoard.tsx index 86383d3836..4e92e4f9d8 100644 --- a/packages/ui/src/features/home/components/WorkBoard.tsx +++ b/packages/ui/src/primitives/WorkBoard.tsx @@ -1,20 +1,33 @@ import type { Icon } from "@phosphor-icons/react"; -import { - type SituationColor, - situationCss, -} from "@posthog/ui/features/home/utils/situationDisplay"; import { ScrollArea } from "@radix-ui/themes"; import type { ReactNode } from "react"; +export type WorkBoardColor = + | "red" + | "orange" + | "amber" + | "green" + | "blue" + | "purple" + | "gray"; + export interface WorkBoardColumn { id: string; label: string; description: string; - color: SituationColor; + color: WorkBoardColor; Icon: Icon; items: T[]; } +function columnColors(color: WorkBoardColor) { + return { + foreground: `var(--${color}-11)`, + tint: `var(--${color}-a3)`, + wash: `var(--${color}-a2)`, + }; +} + export function WorkBoard({ columns, getKey, @@ -29,7 +42,7 @@ export function WorkBoard({ return (
{columns.map((column) => { - const c = situationCss(column.color); + const colors = columnColors(column.color); const count = column.items.length; return (
({ className="mb-2 flex items-center gap-2 px-1" title={column.description} > - + @@ -48,14 +61,17 @@ export function WorkBoard({ {count}
@@ -85,7 +101,7 @@ export function WorkBoard({ function WorkBoardCardSkeleton() { return (
From 3573683acd9999685f0c59503b81edbb1cd200ba Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 24 Jul 2026 09:30:22 +0200 Subject: [PATCH 25/25] fix(channels): decouple task board from removed home Generated-By: PostHog Code Task-Id: 1fbd8776-9d5b-4e74-9629-a6bdb8d3b78f --- .../core/src/tasks/taskBoardStatus.test.ts | 14 +------- packages/core/src/tasks/taskBoardStatus.ts | 17 ++------- .../canvas/components/ChannelBoardView.tsx | 17 ++++----- .../canvas/components/ChannelFeedView.tsx | 6 ++-- .../canvas/components/WebsiteChannelHome.tsx | 36 +++---------------- .../hooks/useChannelTaskPrStates.test.ts | 19 ++-------- .../canvas/hooks/useChannelTaskPrStates.ts | 28 +++++---------- 7 files changed, 29 insertions(+), 108 deletions(-) diff --git a/packages/core/src/tasks/taskBoardStatus.test.ts b/packages/core/src/tasks/taskBoardStatus.test.ts index a9a7c2e07c..3c2cff6b94 100644 --- a/packages/core/src/tasks/taskBoardStatus.test.ts +++ b/packages/core/src/tasks/taskBoardStatus.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { taskBoardStatus, taskBoardStatusFromSources } from "./taskBoardStatus"; +import { taskBoardStatus } from "./taskBoardStatus"; describe("taskBoardStatus", () => { it.each([ @@ -17,15 +17,3 @@ describe("taskBoardStatus", () => { expect(taskBoardStatus(input)).toBe(expected); }); }); - -it("prefers a directly resolved merged PR over a stale open Home snapshot", () => { - expect( - taskBoardStatusFromSources({ - resolvedPrState: "merged", - prSnapshot: { - state: "open", - ciStatus: "failing", - } as never, - }), - ).toBe("done"); -}); diff --git a/packages/core/src/tasks/taskBoardStatus.ts b/packages/core/src/tasks/taskBoardStatus.ts index af76a1a15b..d5c2978bed 100644 --- a/packages/core/src/tasks/taskBoardStatus.ts +++ b/packages/core/src/tasks/taskBoardStatus.ts @@ -1,5 +1,6 @@ import type { TaskRunStatus } from "@posthog/shared/domain-types"; -import type { PrSnapshot, PrSnapshotState } from "../home/prSnapshot"; + +export type TaskBoardPrState = "open" | "draft" | "merged" | "closed"; export const TASK_BOARD_STATUSES = [ "working", @@ -15,7 +16,7 @@ export type TaskBoardStatus = (typeof TASK_BOARD_STATUSES)[number]; */ export function taskBoardStatus(input: { runStatus?: TaskRunStatus | null; - prState?: PrSnapshotState | null; + prState?: TaskBoardPrState | null; }): TaskBoardStatus { if (input.prState === "merged") return "done"; if (input.prState === "closed") return "cancelled"; @@ -27,15 +28,3 @@ export function taskBoardStatus(input: { if (input.runStatus === "completed") return "done"; return "working"; } - -export function taskBoardStatusFromSources(input: { - runStatus?: TaskRunStatus | null; - resolvedPrState?: PrSnapshotState | null; - prSnapshot?: PrSnapshot | null; -}): TaskBoardStatus { - return taskBoardStatus({ - runStatus: input.runStatus, - // Direct PR resolution is fresher than the periodically rebuilt Home row. - prState: input.resolvedPrState ?? input.prSnapshot?.state, - }); -} diff --git a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx index 13ff49518d..9ac75a55b5 100644 --- a/packages/ui/src/features/canvas/components/ChannelBoardView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx @@ -5,11 +5,10 @@ import { GitCommit, XCircle, } from "@phosphor-icons/react"; -import type { PrSnapshot } from "@posthog/core/home/prSnapshot"; import { TASK_BOARD_STATUSES, type TaskBoardStatus, - taskBoardStatusFromSources, + taskBoardStatus, } from "@posthog/core/tasks/taskBoardStatus"; import type { Task } from "@posthog/shared/domain-types"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; @@ -82,14 +81,12 @@ const STATUS_VISUAL: Record< export function ChannelBoardView({ tasks, isLoading, - prSnapshotByTaskId, taskPrStates, onOpenTask, onOpenThread, }: { tasks: Task[]; isLoading: boolean; - prSnapshotByTaskId: ReadonlyMap; taskPrStates: ChannelTaskPrStates; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; @@ -115,14 +112,12 @@ export function ChannelBoardView({ // A task with a PR cannot be classified until its first PR response. // Omitting it temporarily avoids showing it as Working and then moving it. if (taskPrStates.pendingTaskIds.has(task.id)) continue; - const snapshot = prSnapshotByTaskId.get(task.id); const resolvedPrState = taskPrStates.states.get(task.id); - const status = taskBoardStatusFromSources({ + const status = taskBoardStatus({ runStatus: task.latest_run?.status, - resolvedPrState, - prSnapshot: snapshot, + prState: resolvedPrState, }); - const prState = resolvedPrState ?? snapshot?.state ?? null; + const prState = resolvedPrState ?? null; grouped.get(status)?.push({ task, status, @@ -136,7 +131,7 @@ export function ChannelBoardView({ ...STATUS_VISUAL[status], items: grouped.get(status) ?? [], })); - }, [prSnapshotByTaskId, taskPrStates, tasks]); + }, [taskPrStates, tasks]); if (isLoading) { return
; @@ -153,7 +148,7 @@ export function ChannelBoardView({ status={status} prState={prState} badgePrState={badgePrState} - prUrl={taskPrUrl(task, prSnapshotByTaskId) ?? undefined} + prUrl={taskPrUrl(task) ?? undefined} onOpenTask={onOpenTask} onOpenThread={onOpenThread} /> diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 2b1878b44f..f6e4ac0d23 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -148,7 +148,7 @@ function dayLabel(iso: string, now: Date): string { return `${weekday}, ${month} ${day}${year}`; } -interface TaskStatusDisplay { +export interface TaskStatusDisplay { // The run/environment badge ("Local", "Completed", "In progress", …). base: ReactNode; // The PR's GitHub state, shown alongside the run badge when a PR exists. @@ -168,7 +168,7 @@ interface TaskStatusDisplay { // shipped task never reads "Ready + Merged" or a stale "In progress + PR // ready". A failed/cancelled run suppresses the PR badge instead — that is a // deliberate end state we should not soften with a PR. -function useTaskStatusDisplay(task: Task): TaskStatusDisplay { +export function useTaskStatusDisplay(task: Task): TaskStatusDisplay { const data = useChannelTaskData(task); const { prState } = useTaskPrStatus({ id: task.id, @@ -247,7 +247,7 @@ function PrStateBadge({ prState }: { prState: Exclude }) { return {label}; } -function TaskStatusBadge({ display }: { display: TaskStatusDisplay }) { +export function TaskStatusBadge({ display }: { display: TaskStatusDisplay }) { return (
{display.base} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 7402db3fb2..d7a86faec2 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -1,5 +1,4 @@ import { Kanban, ListBullets, Plus, User } from "@phosphor-icons/react"; -import type { PrSnapshot } from "@posthog/core/home/prSnapshot"; import { insertTaskDedup } from "@posthog/core/tasks/taskDelete"; import { Button } from "@posthog/quill"; import { CHANNEL_TASK_BOARD_FLAG } from "@posthog/shared"; @@ -50,7 +49,6 @@ import { import { useChannelHomeUiStore } from "@posthog/ui/features/canvas/stores/channelHomeUiStore"; import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; -import { useHomeSnapshot } from "@posthog/ui/features/home/hooks/useHomeSnapshot"; import { SuggestedPromptCard } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; @@ -79,7 +77,6 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { const effectiveViewMode = boardEnabled ? viewMode : "feed"; const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); - const { snapshot: homeSnapshot } = useHomeSnapshot(); // Poll while empty so the intro's context.md card flips to "created" when // the agent publishes mid plan-session, without a manual reload. @@ -132,22 +129,8 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { : tasks, [boardEnabled, currentUser?.uuid, taskScope, tasks], ); - const prSnapshotByTaskId = useMemo(() => { - const result = new Map(); - for (const workstream of [ - ...homeSnapshot.needsAttention, - ...homeSnapshot.inProgress, - ]) { - if (!workstream.pr) continue; - for (const task of workstream.tasks) { - result.set(task.id, workstream.pr); - } - } - return result; - }, [homeSnapshot.inProgress, homeSnapshot.needsAttention]); const taskPrStates = useChannelTaskPrStates( boardEnabled && effectiveViewMode === "board" ? visibleTasks : [], - prSnapshotByTaskId, ); const composerRef = useRef(null); @@ -250,14 +233,8 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { ); const handleOpenTask = useCallback((task: Task) => setPreviewTask(task), []); const handleOpenFeedTask = useCallback( - (task: Task) => { - if (boardEnabled) { - setPreviewTask(task); - } else { - handleOpenFull(task.id); - } - }, - [boardEnabled, handleOpenFull], + (task: Task) => handleOpenFull(task.id), + [handleOpenFull], ); const handleOpenThread = useCallback( @@ -398,7 +375,6 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { setPreviewTask(null)} diff --git a/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.test.ts b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.test.ts index 622c954e8f..e4e09e88c2 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.test.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; import type { Task } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; import { prDetailsToState, taskPrUrl } from "./useChannelTaskPrStates"; describe("prDetailsToState", () => { @@ -21,20 +21,7 @@ describe("taskPrUrl", () => { latest_run: { output: { pr_url: "https://github.com/o/r/pull/1" } }, } as unknown as Task; - it("prefers the Home snapshot URL", () => { - expect( - taskPrUrl( - task, - new Map([ - ["task-1", { url: "https://github.com/o/r/pull/2" }], - ]), - ), - ).toBe("https://github.com/o/r/pull/2"); - }); - - it("falls back to the latest run URL", () => { - expect(taskPrUrl(task, new Map())).toBe( - "https://github.com/o/r/pull/1", - ); + it("reads the latest run URL", () => { + expect(taskPrUrl(task)).toBe("https://github.com/o/r/pull/1"); }); }); diff --git a/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts index cea0b93eeb..beccc4aef1 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts @@ -1,4 +1,3 @@ -import type { PrSnapshot } from "@posthog/core/home/prSnapshot"; import type { Task } from "@posthog/shared/domain-types"; import { type PrStateDetails, @@ -18,16 +17,10 @@ export function prDetailsToState( return null; } -export function taskPrUrl( - task: Task, - prSnapshotByTaskId: ReadonlyMap>, -): string | null { - return ( - prSnapshotByTaskId.get(task.id)?.url ?? - (typeof task.latest_run?.output?.pr_url === "string" - ? task.latest_run.output.pr_url - : null) - ); +export function taskPrUrl(task: Task): string | null { + return typeof task.latest_run?.output?.pr_url === "string" + ? task.latest_run.output.pr_url + : null; } export interface ChannelTaskPrStates { @@ -37,20 +30,17 @@ export interface ChannelTaskPrStates { isRefreshing: boolean; } -export function useChannelTaskPrStates( - tasks: Task[], - prSnapshotByTaskId: ReadonlyMap, -): ChannelTaskPrStates { +export function useChannelTaskPrStates(tasks: Task[]): ChannelTaskPrStates { const prUrls = useMemo( () => [ ...new Set( tasks.flatMap((task) => { - const prUrl = taskPrUrl(task, prSnapshotByTaskId); + const prUrl = taskPrUrl(task); return prUrl ? [prUrl] : []; }), ), ], - [prSnapshotByTaskId, tasks], + [tasks], ); const results = usePrDetailsQueries(prUrls); @@ -63,7 +53,7 @@ export function useChannelTaskPrStates( let isRefreshing = false; for (const task of tasks) { - const prUrl = taskPrUrl(task, prSnapshotByTaskId); + const prUrl = taskPrUrl(task); const result = prUrl ? resultByUrl.get(prUrl) : undefined; if (prUrl && result && !result.data && result.isPending) { pendingTaskIds.add(task.id); @@ -83,5 +73,5 @@ export function useChannelTaskPrStates( isResolving: pendingTaskIds.size > 0, isRefreshing, }; - }, [prSnapshotByTaskId, prUrls, results, tasks]); + }, [prUrls, results, tasks]); }