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 new file mode 100644 index 0000000000..fa23b07e75 --- /dev/null +++ b/packages/core/src/git-interaction/prStatus.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { getPrVisualConfig } from "./prStatus"; + +describe("getPrVisualConfig", () => { + 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({ + label, + color, + icon, + }); + }, + ); +}); diff --git a/packages/core/src/tasks/taskBoardStatus.test.ts b/packages/core/src/tasks/taskBoardStatus.test.ts new file mode 100644 index 0000000000..3c2cff6b94 --- /dev/null +++ b/packages/core/src/tasks/taskBoardStatus.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { taskBoardStatus } from "./taskBoardStatus"; + +describe("taskBoardStatus", () => { + it.each([ + [{}, "working"], + [{ runStatus: "in_progress" }, "working"], + [{ runStatus: "completed" }, "done"], + [{ runStatus: "completed", prState: "draft" }, "working"], + [{ prState: "draft" }, "working"], + [{ prState: "open" }, "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/tasks/taskBoardStatus.ts b/packages/core/src/tasks/taskBoardStatus.ts new file mode 100644 index 0000000000..d5c2978bed --- /dev/null +++ b/packages/core/src/tasks/taskBoardStatus.ts @@ -0,0 +1,30 @@ +import type { TaskRunStatus } from "@posthog/shared/domain-types"; + +export type TaskBoardPrState = "open" | "draft" | "merged" | "closed"; + +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?: TaskBoardPrState | 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") return "in_review"; + if (input.prState === "draft") return "working"; + if (input.runStatus === "completed") return "done"; + return "working"; +} 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/ChannelBoardView.tsx b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx new file mode 100644 index 0000000000..9ac75a55b5 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelBoardView.tsx @@ -0,0 +1,250 @@ +import { + ChatCircleIcon, + CheckCircle, + Eye, + GitCommit, + XCircle, +} from "@phosphor-icons/react"; +import { + TASK_BOARD_STATUSES, + type TaskBoardStatus, + taskBoardStatus, +} from "@posthog/core/tasks/taskBoardStatus"; +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, + 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 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/primitives/WorkBoard"; +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, + { + label: string; + description: string; + color: WorkBoardColor; + 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, non-draft PR ready for review", + 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, + taskPrStates, + onOpenTask, + onOpenThread, +}: { + tasks: Task[]; + isLoading: boolean; + taskPrStates: ChannelTaskPrStates; + onOpenTask: (task: Task) => void; + onOpenThread: (task: Task) => void; +}) { + const columns = useMemo< + WorkBoardColumn<{ + task: Task; + status: TaskBoardStatus; + prState: SidebarPrState; + badgePrState: SidebarPrState; + }>[] + >(() => { + const grouped = new Map< + TaskBoardStatus, + Array<{ + task: Task; + status: TaskBoardStatus; + prState: SidebarPrState; + badgePrState: SidebarPrState; + }> + >(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 resolvedPrState = taskPrStates.states.get(task.id); + const status = taskBoardStatus({ + runStatus: task.latest_run?.status, + prState: resolvedPrState, + }); + const prState = resolvedPrState ?? null; + grouped.get(status)?.push({ + task, + status, + prState, + badgePrState: + prState === PR_BADGE_STATE_BY_STATUS[status] ? prState : null, + }); + } + return TASK_BOARD_STATUSES.map((status) => ({ + id: status, + ...STATUS_VISUAL[status], + items: grouped.get(status) ?? [], + })); + }, [taskPrStates, tasks]); + + if (isLoading) { + return
; + } + + return ( + item.task.id} + renderCard={({ task, status, prState, badgePrState }) => ( + + )} + /> + ); +} + +function ChannelBoardCard({ + task, + status, + prState, + badgePrState, + prUrl, + onOpenTask, + onOpenThread, +}: { + task: Task; + status: TaskBoardStatus; + prState: SidebarPrState; + badgePrState: SidebarPrState; + prUrl?: string; + onOpenTask: (task: Task) => void; + onOpenThread: (task: Task) => void; +}) { + const taskDisplay = useTaskStatusDisplay(task); + 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]; + 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="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} + + ) : null} + {prUrl ? : null} +
+
+ + + {creatorName} + +
+ +
+
+ ); +} 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..dea241d07e --- /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 new file mode 100644 index 0000000000..df349f2b73 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelCreateTaskDialog.tsx @@ -0,0 +1,49 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@posthog/quill"; +import type { Task } from "@posthog/shared/domain-types"; +import { TaskInput } from "@posthog/ui/features/task-detail/components/TaskInput"; + +export function ChannelCreateTaskDialog({ + open, + channelId, + channelContextId, + channelName, + channelContext, + onOpenChange, + onTaskCreated, +}: { + open: boolean; + channelId: string; + channelContextId: 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} + channelId={channelId} + channelContextId={channelContextId} + allowNoRepo + suggestions={[]} + /> + + + ); +} 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/ChannelPrButton.test.tsx b/packages/ui/src/features/canvas/components/ChannelPrButton.test.tsx new file mode 100644 index 0000000000..2e2ab0a4ec --- /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", "Open #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: "Open #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 new file mode 100644 index 0000000000..20c2c8de31 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelTaskPreviewDialog.tsx @@ -0,0 +1,54 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} 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; +}) { + return ( + !open && onClose()}> + + + {task?.title || "Task preview"} + + + Preview the task conversation without leaving the channel board. + + {task ? ( + 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 8b242509ee..a73e86d2c2 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -1,7 +1,9 @@ import { ArrowSquareOutIcon, CaretRightIcon, + ChatCircleIcon, DotsThreeIcon, + GitBranchIcon, PaperPlaneRightIcon, RobotIcon, TrashIcon, @@ -57,8 +59,13 @@ 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 { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView"; +import { + TaskCard, + TaskStatusBadge, + 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"; @@ -76,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"; @@ -333,10 +341,16 @@ function ThreadLoadingState() { } function ThreadHeader({ + task, + taskPrUrl, + taskPrState, onClose, onToggleCollapsed, onOpenFull, }: { + task?: Task; + taskPrUrl?: string; + taskPrState: SidebarPrState; onClose?: () => void; onToggleCollapsed?: () => void; onOpenFull?: () => void; @@ -344,7 +358,16 @@ function ThreadHeader({ return (
- Thread + {task ? ( + + ) : null} + {!task ? ( + Thread + ) : null}
{onOpenFull && (
+ ); +} + function ThreadTimeline({ timeline, isReady, @@ -501,6 +556,10 @@ function ThreadConversation({ onToggleCollapsed, onOpenFull, showTaskSummary, + showAgentStatus, + taskSummaryInHeader, + taskSummaryPrUrl, + taskSummaryPrState, }: { task: Task; channelId: string; @@ -508,6 +567,10 @@ function ThreadConversation({ onToggleCollapsed?: () => void; onOpenFull?: () => void; showTaskSummary: boolean; + showAgentStatus: boolean; + taskSummaryInHeader: boolean; + taskSummaryPrUrl?: string; + taskSummaryPrState: SidebarPrState; }) { const taskId = task.id; const client = useOptionalAuthenticatedClient(); @@ -652,19 +715,22 @@ function ThreadConversation({ const isReady = !isInitializing && !isLoading; return ( -
+
- {showTaskSummary && ( + {showTaskSummary && !taskSummaryInHeader && (
)} -
+
- {agentStatus && } + {showAgentStatus && agentStatus && ( + + )} void; onOpenFull?: () => void; showTaskSummary?: boolean; + showAgentStatus?: boolean; + taskSummaryInHeader?: boolean; + taskSummaryPrUrl?: string; + taskSummaryPrState?: SidebarPrState; }) { const { data: fetchedTask } = useQuery({ ...taskDetailQuery(taskId), @@ -744,6 +820,10 @@ export function ThreadPanel({ onToggleCollapsed={onToggleCollapsed} onOpenFull={onOpenFull} showTaskSummary={showTaskSummary} + showAgentStatus={showAgentStatus} + taskSummaryInHeader={taskSummaryInHeader} + taskSummaryPrUrl={taskSummaryPrUrl} + taskSummaryPrState={taskSummaryPrState} /> ); } diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index d6c1f6bd27..d7a86faec2 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -1,8 +1,15 @@ +import { Kanban, ListBullets, Plus, User } from "@phosphor-icons/react"; 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"; +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 { ChannelCreateTaskDialog } from "@posthog/ui/features/canvas/components/ChannelCreateTaskDialog"; import { ChannelFeedView, type PendingKickoff, @@ -16,6 +23,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"; @@ -28,13 +36,19 @@ import { useChannelFeedMessages, } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +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 { 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 { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; 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"; @@ -55,6 +69,14 @@ 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); + 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 }); // Poll while empty so the intro's context.md card flips to "created" when // the agent publishes mid plan-session, without a manual reload. @@ -97,6 +119,20 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { useMemo(() => , [channelId]), ); + const visibleTasks = useMemo( + () => + boardEnabled && taskScope === "me" + ? tasks.filter( + (task) => + !!currentUser?.uuid && task.created_by?.uuid === currentUser.uuid, + ) + : tasks, + [boardEnabled, currentUser?.uuid, taskScope, tasks], + ); + const taskPrStates = useChannelTaskPrStates( + boardEnabled && effectiveViewMode === "board" ? visibleTasks : [], + ); + const composerRef = useRef(null); // Optimistic kickoffs: the message a user just submitted, shown in the feed @@ -123,6 +159,8 @@ 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( (s) => s.openByChannel[channelId] ?? null, @@ -137,11 +175,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 / @@ -157,7 +199,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, { @@ -181,19 +229,10 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { }); }); }, - [backendChannel?.id, channelId, fileTask, invalidateFeed, queryClient], + [backendChannel?.id, channelId, fileTask, handleOpenFull, queryClient], ); - - const handleOpenFull = useCallback( - (taskId: string) => { - void navigate({ - to: "/website/$channelId/tasks/$taskId", - params: { channelId, taskId }, - }); - }, - [channelId, navigate], - ); - const handleOpenTask = useCallback( + const handleOpenTask = useCallback((task: Task) => setPreviewTask(task), []); + const handleOpenFeedTask = useCallback( (task: Task) => handleOpenFull(task.id), [handleOpenFull], ); @@ -276,29 +315,97 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { return (
- -
- +
+ + +
+ {effectiveViewMode === "board" ? ( + + ) : null} +
+
+ + +
+
+
+ ) : null} + {effectiveViewMode === "board" ? ( + + ) : ( + -
+ )} + {effectiveViewMode === "feed" ? ( +
+ +
+ ) : null}
{threadTaskId && ( @@ -318,6 +425,37 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { existingContext={{ channelId, channelName }} /> )} + + {boardEnabled ? ( + setPreviewTask(null)} + onOpenFull={(task) => { + setPreviewTask(null); + handleOpenFull(task.id); + }} + /> + ) : null} + {boardEnabled && backendChannel ? ( + + ) : null}
); } 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..e4e09e88c2 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.test.ts @@ -0,0 +1,27 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { prDetailsToState, taskPrUrl } 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: false, draft: true }, "closed"], + [{ state: "closed", merged: true, draft: false }, "merged"], + ] as const)("maps %o to %s", (details, expected) => { + 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("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 new file mode 100644 index 0000000000..beccc4aef1 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useChannelTaskPrStates.ts @@ -0,0 +1,77 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { + type PrStateDetails, + usePrDetailsQueries, +} from "@posthog/ui/features/git-interaction/usePrDetails"; +import type { SidebarPrState } from "@posthog/ui/features/sidebar/useTaskPrStatus"; +import { useMemo } from "react"; + +export function prDetailsToState( + details: PrStateDetails | undefined, +): 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"; + return 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 { + states: Map; + pendingTaskIds: Set; + isResolving: boolean; + isRefreshing: boolean; +} + +export function useChannelTaskPrStates(tasks: Task[]): ChannelTaskPrStates { + const prUrls = useMemo( + () => [ + ...new Set( + tasks.flatMap((task) => { + const prUrl = taskPrUrl(task); + return prUrl ? [prUrl] : []; + }), + ), + ], + [tasks], + ); + const results = usePrDetailsQueries(prUrls); + + 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); + 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, + }; + }, [prUrls, results, tasks]); +} diff --git a/packages/ui/src/features/canvas/stores/channelHomeUiStore.ts b/packages/ui/src/features/canvas/stores/channelHomeUiStore.ts new file mode 100644 index 0000000000..00095a5be6 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/channelHomeUiStore.ts @@ -0,0 +1,28 @@ +import { electronStorage } from "@posthog/ui/shell/rendererStorage"; +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()( + persist( + (set) => ({ + viewMode: "feed", + setViewMode: (viewMode) => set({ viewMode }), + taskScope: "all", + setTaskScope: (taskScope) => set({ taskScope }), + }), + { + name: "channel-home-ui-store", + storage: electronStorage, + }, + ), +); 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, }); diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 0c0d51fca6..edaa2cb25a 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -110,6 +110,8 @@ interface TaskInputProps { channelContext?: string; /** Display name of the channel the CONTEXT.md came from (for the chip). */ channelName?: string; + /** Backend channel UUID that owns the created task in the channel feed. */ + channelId?: string; /** * Desktop file-system folder id that owns the channel's CONTEXT.md. When set, * the injected context lets the agent publish upkeep corrections addressed to @@ -154,6 +156,7 @@ export function TaskInput({ reportAssociation, channelContext, channelName, + channelId, channelContextId, allowNoRepo, suggestions, @@ -878,6 +881,7 @@ export function TaskInput({ signalReportId: activeReportAssociation?.reportId, channelContext: includeChannelContext ? channelContext : undefined, channelName, + channelId, channelContextId, allowNoRepo, }); diff --git a/packages/ui/src/primitives/WorkBoard.tsx b/packages/ui/src/primitives/WorkBoard.tsx new file mode 100644 index 0000000000..4e92e4f9d8 --- /dev/null +++ b/packages/ui/src/primitives/WorkBoard.tsx @@ -0,0 +1,115 @@ +import type { Icon } from "@phosphor-icons/react"; +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: 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, + renderCard, + isLoading = false, +}: { + columns: WorkBoardColumn[]; + getKey: (item: T) => string; + renderCard: (item: T) => ReactNode; + isLoading?: boolean; +}) { + return ( +
+ {columns.map((column) => { + const colors = columnColors(column.color); + const count = column.items.length; + return ( +
+
+ + + + + {column.label} + + + {count} + +
+
+ +
+ {count === 0 && !isLoading ? ( +
+ + + Nothing here + +
+ ) : count > 0 ? ( + column.items.map((item) => ( +
{renderCard(item)}
+ )) + ) : null} + {isLoading ? : null} +
+
+
+
+ ); + })} +
+ ); +} + +function WorkBoardCardSkeleton() { + return ( + +
+
+
+
+
+
+ + ); +}