Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e8fd0e1
feat(channels): add task board view
puemos Jul 24, 2026
4764f2e
feat(channels): refine board workflow
puemos Jul 24, 2026
45de145
refactor(channels): reuse home board workflow
puemos Jul 24, 2026
c0f011f
fix(channels): align board status and modal
puemos Jul 24, 2026
a33cf7a
feat(channels): add PR links to board cards
puemos Jul 24, 2026
dac8c97
fix(channels): show teammate tasks in board
puemos Jul 24, 2026
74357e1
refactor(channels): centralize task board lifecycle
puemos Jul 24, 2026
2f33d78
fix(channels): prefer resolved merged PR state
puemos Jul 24, 2026
86f2a24
fix(channels): resolve PR state directly by URL
puemos Jul 24, 2026
6f7d14f
refactor(channels): treat ready PRs as in review
puemos Jul 24, 2026
a154806
fix(channels): stabilize board status loading
puemos Jul 24, 2026
a43857f
fix(channels): polish board loading and preview
puemos Jul 24, 2026
280566b
feat(channels): create tasks from board modal
puemos Jul 24, 2026
9ff1681
fix(channels): refine board task dialog
puemos Jul 24, 2026
bdb32c8
fix(channels): move preview summary into header
puemos Jul 24, 2026
23c35a8
fix(channels): simplify preview task header
puemos Jul 24, 2026
e9b0e6d
feat(channels): gate task board behind feature flag
puemos Jul 24, 2026
6ce5e5e
fix(channels): classify closed draft PRs as cancelled
puemos Jul 24, 2026
cb45091
fix(channels): keep board-created tasks in channel
puemos Jul 24, 2026
8546e5b
fix(channels): synchronize board PR status visuals
puemos Jul 24, 2026
1697757
style(channels): align board PR controls
puemos Jul 24, 2026
9d49cd1
style(channels): align preview PR control
puemos Jul 24, 2026
38ee05b
fix(channels): address task board review feedback
puemos Jul 24, 2026
5c72958
refactor(tasks): move shared board modules out of home
puemos Jul 24, 2026
3573683
fix(channels): decouple task board from removed home
puemos Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/web/src/web-host-router.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -480,6 +499,7 @@ export const webHostRouter = router({
deepLink: deepLinkStubRouter,
folders: foldersStubRouter,
fs: fsStubRouter,
git: gitStubRouter,
githubIntegration: githubIntegrationRouter,
logs: logsStubRouter,
os: osStubRouter,
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/git-interaction/prStatus.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
},
);
});
19 changes: 19 additions & 0 deletions packages/core/src/tasks/taskBoardStatus.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
30 changes: 30 additions & 0 deletions packages/core/src/tasks/taskBoardStatus.ts
Original file line number Diff line number Diff line change
@@ -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";
}
2 changes: 2 additions & 0 deletions packages/shared/src/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
250 changes: 250 additions & 0 deletions packages/ui/src/features/canvas/components/ChannelBoardView.tsx
Original file line number Diff line number Diff line change
@@ -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<SidebarPrState, null>
> = {
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 <div className="min-h-0 flex-1" />;
}

return (
<WorkBoard
columns={columns}
isLoading={taskPrStates.isResolving}
getKey={(item) => item.task.id}
renderCard={({ task, status, prState, badgePrState }) => (
<ChannelBoardCard
task={task}
status={status}
prState={prState}
badgePrState={badgePrState}
prUrl={taskPrUrl(task) ?? undefined}
onOpenTask={onOpenTask}
onOpenThread={onOpenThread}
/>
)}
/>
);
}

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<HTMLDivElement>({
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 (
<Box
ref={cardRef}
role="button"
tabIndex={0}
aria-label={`Open ${task.title || "Untitled task"}`}
onClick={() => 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"
>
<span
aria-hidden
className="absolute inset-x-0 top-0 h-[3px]"
style={{ backgroundColor: `var(--${visual.color}-9)` }}
/>
<div className="flex items-start justify-between gap-2">
<span className="line-clamp-2 font-medium text-[13px] text-gray-12 leading-snug">
{task.title || "Untitled task"}
</span>
<TaskStatusBadge display={display} />
</div>
{task.repository ? (
<span className="truncate text-(--gray-10) text-[11px]">
{task.repository}
</span>
) : null}
{prUrl ? <ChannelPrButton prUrl={prUrl} prState={prState} /> : null}
<div className="mt-1.5 flex items-center justify-between gap-2 border-(--gray-3) border-t pt-2.5">
<div className="flex min-w-0 items-center gap-1.5" title={creatorName}>
<UserAvatar user={task.created_by} size="xs" />
<span className="truncate text-(--gray-10) text-[11px]">
{creatorName}
</span>
</div>
<button
type="button"
className="flex shrink-0 items-center gap-1 text-(--gray-10) text-[11px] hover:text-gray-12"
onClick={(event) => {
event.stopPropagation();
onOpenThread(task);
}}
>
<ChatCircleIcon size={13} />
{replyLabel}
</button>
</div>
</Box>
);
}
Loading
Loading