diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 18a2aa6da6..4ac94adb1b 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -52,6 +52,8 @@ import type { ActionabilityJudgmentArtefact, AvailableSuggestedReviewer, AvailableSuggestedReviewersResponse, + ChannelFeedMessage, + ChannelFeedMessageEvent, CodeReferenceArtefact, CommitArtefact, CommitDiffResponse, @@ -2467,6 +2469,56 @@ export class PostHogAPIClient { return (await response.json()) as TaskChannel; } + // A channel's system-announcement feed (context created, CONTEXT.md being + // built), chronological. Durable + team-visible, rendered alongside task cards. + async getChannelFeed(channelId: string): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/task_channels/${channelId}/feed/`; + const response = await this.api.fetcher.fetch({ + method: "get", + url: new URL(`${this.api.baseUrl}${urlPath}`), + path: urlPath, + }); + if (!response.ok) { + throw new Error(`Failed to fetch channel feed: ${response.statusText}`); + } + return (await response.json()) as ChannelFeedMessage[]; + } + + // Post a system announcement into a channel's feed. The row is authored by the + // system; the server records the requester as `author` for "Adam …" rendering. + async postChannelFeedMessage( + channelId: string, + input: { + event: ChannelFeedMessageEvent; + payload?: Record; + // Optional explicit timestamp (ISO) so a burst of announcements orders + // deterministically instead of racing on server insert time. + createdAt?: string; + }, + ): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/task_channels/${channelId}/feed/`; + const response = await this.api.fetcher.fetch({ + method: "post", + url: new URL(`${this.api.baseUrl}${urlPath}`), + path: urlPath, + overrides: { + body: JSON.stringify({ + event: input.event, + payload: input.payload ?? {}, + ...(input.createdAt ? { created_at: input.createdAt } : {}), + }), + }, + }); + if (!response.ok) { + throw new Error( + `Failed to post channel feed message: ${response.statusText}`, + ); + } + return (await response.json()) as ChannelFeedMessage; + } + // Mentions of the current user across task threads, newest first. async getTaskMentions(options?: { since?: string }): Promise { const teamId = await this.getTeamId(); diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 358fa23864..5e46d7f947 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -79,6 +79,27 @@ export interface TaskChannel { created_by?: UserBasic | null; } +/** Lifecycle events a client may post into a channel's feed. */ +export type ChannelFeedMessageEvent = "context_md_building"; + +/** + * A durable, team-visible "PostHog agent" announcement in a channel's feed — + * rendered alongside task cards (e.g. "Adam created this context"). `author` is + * the user whose action produced the row; `author_kind` says who authored it. + * `payload` carries structured event data (e.g. `{ context_name }`) so rendering + * survives renames. + */ +export interface ChannelFeedMessage { + id: string; + channel: string; + author?: UserBasic | null; + author_kind: "human" | "system" | "agent"; + event: ChannelFeedMessageEvent | string; + payload: Record; + content: string; + created_at: string; +} + /** * One human message in a task's thread. Thread messages never reach the agent * unless the task author forwards one, which stamps the forwarded_* fields. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 87b891dfe1..d26f87faf7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -239,6 +239,7 @@ export type { export { formatRelativeTimeLong, formatRelativeTimeShort, + getLocalDayDiff, getRelativeDateGroup, } from "./time"; export { diff --git a/packages/shared/src/time.test.ts b/packages/shared/src/time.test.ts index 4772f871c4..827afc5c77 100644 --- a/packages/shared/src/time.test.ts +++ b/packages/shared/src/time.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { formatRelativeTimeLong, formatRelativeTimeShort, + getLocalDayDiff, getRelativeDateGroup, } from "./time"; @@ -67,6 +68,29 @@ describe("formatRelativeTimeLong", () => { }); }); +describe("getLocalDayDiff", () => { + it("returns 0 for any moment on the same local day", () => { + expect(getLocalDayDiff(NOW - 2 * HOUR)).toBe(0); + }); + + it("counts calendar days, not 24h windows", () => { + // 1h ago but across the local midnight boundary is still "yesterday". + const justAfterMidnight = new Date(NOW); + justAfterMidnight.setHours(0, 30, 0, 0); + vi.setSystemTime(justAfterMidnight); + expect(getLocalDayDiff(justAfterMidnight.getTime() - HOUR)).toBe(1); + }); + + it("accepts an ISO string and an explicit now", () => { + const now = new Date(NOW); + expect(getLocalDayDiff(new Date(NOW - 3 * DAY).toISOString(), now)).toBe(3); + }); + + it("returns negative for future days", () => { + expect(getLocalDayDiff(NOW + 2 * DAY)).toBe(-2); + }); +}); + describe("getRelativeDateGroup", () => { it("returns null for today", () => { expect(getRelativeDateGroup(NOW - 2 * HOUR)).toBeNull(); diff --git a/packages/shared/src/time.ts b/packages/shared/src/time.ts index afc77416fc..dcbb4090da 100644 --- a/packages/shared/src/time.ts +++ b/packages/shared/src/time.ts @@ -51,17 +51,25 @@ export function formatRelativeTimeLong(timestamp: number | string): string { }); } +/** + * Whole local calendar days between `timestamp` and `now` (0 = today, + * 1 = yesterday, negative = future). Uses local-midnight boundaries so the + * split lands on the viewer's midnight, not a UTC one. + */ +export function getLocalDayDiff( + timestamp: number | string | Date, + now: Date = new Date(), +): number { + const date = new Date(timestamp); + const startOfDay = (d: Date) => + new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + return Math.round((startOfDay(now) - startOfDay(date)) / 86_400_000); +} + export function getRelativeDateGroup( timestamp: number | string, ): string | null { - const date = new Date(timestamp); - const startOfToday = new Date(); - startOfToday.setHours(0, 0, 0, 0); - const startOfDate = new Date(date); - startOfDate.setHours(0, 0, 0, 0); - const days = Math.round( - (startOfToday.getTime() - startOfDate.getTime()) / 86_400_000, - ); + const days = getLocalDayDiff(timestamp); if (days <= 0) return null; if (days === 1) return "Yesterday"; if (days < 7) return "This week"; diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index fd5a38f871..3ca78d0b50 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -34,12 +34,13 @@ import { ThreadItemRepliesMeta, ThreadItemTimestamp, } from "@posthog/quill"; -import { formatRelativeTimeShort } from "@posthog/shared"; +import { formatRelativeTimeShort, getLocalDayDiff } from "@posthog/shared"; import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; import { isTerminalStatus } from "@posthog/shared/domain-types"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; import { TaskTabIcon } from "@posthog/ui/features/browser-tabs/TaskTabIcon"; import { mentionChipClass } from "@posthog/ui/features/canvas/components/MentionText"; +import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages"; import { useChannelTaskData } from "@posthog/ui/features/canvas/hooks/useChannelTaskData"; import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; @@ -116,9 +117,7 @@ function ordinal(n: number): string { // year when it differs) further back so older separators stay unambiguous. function dayLabel(iso: string, now: Date): string { const date = new Date(iso); - const startOfDay = (d: Date) => - new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); - const days = Math.round((startOfDay(now) - startOfDay(date)) / 86_400_000); + const days = getLocalDayDiff(date, now); if (days <= 0) return "Today"; if (days === 1) return "Yesterday"; const weekday = date.toLocaleDateString(undefined, { weekday: "long" }); @@ -265,9 +264,9 @@ function TaskCard({ task, onOpen }: { task: Task; onOpen: () => void }) { nav never disagree (generating spinner, needs-permission, cloud status colors, PR state). */} - + {task.title || "Untitled task"} - + @@ -379,7 +378,7 @@ const FeedItem = memo(function FeedItem({ onOpenThread: (task: Task) => void; }) { return ( - + @@ -472,23 +471,108 @@ function FeedRow({ ); } +// A card-less feed row for a synthetic announcement. Rows with an `author` +// render as that user (initials avatar + name — e.g. "Adam L · joined mobile"); +// the rest render as "PostHog / Agent" (context lifecycle updates). Same chrome +// as a task row, minus the task card and reply footer. +function SystemFeedRow({ message }: { message: ChannelFeedSystemMessage }) { + return ( + + + + + + {message.author ? ( + getUserInitials(message.author) + ) : ( + + )} + + + + + + + {message.author ? userDisplayName(message.author) : "PostHog"} + + {!message.author && Agent} + + {formatRelativeTimeShort(message.createdAt)} + + + + {message.text} + + + + + ); +} + +// A single feed entry, either a real task card or a synthetic system row, tagged +// with the timestamp used to interleave the two. +type FeedEntry = + | { kind: "task"; id: string; createdAt: string; task: Task } + | { + kind: "system"; + id: string; + createdAt: string; + message: ChannelFeedSystemMessage; + }; + // The Slack-style channel feed: every task kicked off in the channel, oldest // first, rendered as a kickoff message + task card. Multiplayer — the list is -// team-visible and polls for teammates' cards and status flips. +// team-visible and polls for teammates' cards and status flips. Synthetic +// "PostHog agent" system rows (context lifecycle) are interleaved by timestamp. export function ChannelFeedView({ tasks, + systemMessages, isLoading, emptyState, + intro, onOpenTask, onOpenThread, }: { tasks: Task[]; + systemMessages?: ChannelFeedSystemMessage[]; isLoading: boolean; emptyState?: React.ReactNode; + /** Rendered pinned above the first entry — the Slack-style channel intro + * (name, creation line, onboarding card). When set, the feed renders even + * with no entries instead of falling back to `emptyState`. */ + intro?: ReactNode; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; }) { - if (isLoading && tasks.length === 0) { + // Merge tasks + system rows into one chronological list. ISO timestamps sort + // lexically, so a plain string compare is chronological. Announcements are + // posted 1ms before the task they describe; if the backend truncates that + // sub-second offset the timestamps tie, so break ties system-row-first to + // keep the announcement above its card. + const entries = useMemo(() => { + const merged: FeedEntry[] = [ + ...tasks.map((task) => ({ + kind: "task" as const, + id: task.id, + createdAt: task.created_at, + task, + })), + ...(systemMessages ?? []).map((message) => ({ + kind: "system" as const, + id: message.id, + createdAt: message.createdAt, + message, + })), + ]; + merged.sort( + (a, b) => + a.createdAt.localeCompare(b.createdAt) || + (a.kind === b.kind ? 0 : a.kind === "system" ? -1 : 1), + ); + return merged; + }, [tasks, systemMessages]); + + if (isLoading && entries.length === 0) { return (
@@ -496,7 +580,7 @@ export function ChannelFeedView({ ); } - if (tasks.length === 0) { + if (entries.length === 0 && !intro) { return
{emptyState}
; } @@ -510,25 +594,30 @@ export function ChannelFeedView({ the row's top-right corner (absolute, past the row edge). Without a gutter they hug the scroll container and get clipped. */} - {tasks.map((task, index) => { - const previous = tasks[index - 1]; + {intro} + {entries.map((entry, index) => { + const previous = entries[index - 1]; const showDayMarker = !previous || - dayKey(previous.created_at) !== dayKey(task.created_at); + dayKey(previous.createdAt) !== dayKey(entry.createdAt); return ( - + {showDayMarker && ( - {dayLabel(task.created_at, now)} + {dayLabel(entry.createdAt, now)} )} - + {entry.kind === "task" ? ( + + ) : ( + + )} ); })} diff --git a/packages/ui/src/features/canvas/components/ChannelIntro.tsx b/packages/ui/src/features/canvas/components/ChannelIntro.tsx new file mode 100644 index 0000000000..33919338f7 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelIntro.tsx @@ -0,0 +1,137 @@ +import { + Item, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, + Spinner, +} from "@posthog/quill"; +import { getLocalDayDiff } from "@posthog/shared"; +import type { TaskChannel } from "@posthog/shared/domain-types"; +import { mentionChipClass } from "@posthog/ui/features/canvas/components/MentionText"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { Heading, Text } from "@radix-ui/themes"; +import { FileCheckCorner, FilePlusCorner, Info } from "lucide-react"; + +// "today" / "yesterday" / "on July 10" for the intro's creation line. +function creationDatePhrase(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + const days = getLocalDayDiff(date); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + return `on ${date.toLocaleDateString(undefined, { month: "long", day: "numeric" })}`; +} + +/** The intro card's lifecycle: unknown while the instructions query loads, a + * create call to action, an in-flight plan session, or a published file. */ +export type ContextMdState = "loading" | "none" | "building" | "created"; + +// The Slack-style intro pinned at the very start of a channel's feed: the +// channel name, who created it and when, and a context.md card that walks the +// create → building → created lifecycle. Derived entirely from the channel row, +// so it renders for every public channel, not just freshly created ones. +export function ChannelIntro({ + channel, + channelName, + contextMdState, + onCreateContextMd, +}: { + /** The backend channel row (creator + creation time). */ + channel: TaskChannel | undefined; + channelName: string; + contextMdState: ContextMdState; + onCreateContextMd: () => void; +}) { + const creator = channel?.created_by; + + return ( +
+
+ {channelName} + {channel && ( + + {/* Mention-styled but inert for now; later it opens the person. */} + + @{userDisplayName(creator ?? null)} + {" "} + created this context {creationDatePhrase(channel.created_at)}. This + is the very beginning of the{" "} + {channelName} context. + + )} +
+
+ {contextMdState === "created" && ( + + + + + + Created context.md + + Used in all sessions within this context + + + + )} + {contextMdState === "building" && ( + + + + + + Creating context.md + + A planning session is building it now. Open its task card below + to shape the result. + + + + )} + {(contextMdState === "none" || contextMdState === "loading") && ( + + } + > + + + + + Create a context.md + + Gives your agentic sessions everything they need to start new, + or continue existing, tasks. + + + + )} + {}} />} + > + + + + + Learn more about contexts + + Context is a group of tasks that are related to a specific topic. + + + +
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx index 5852bf723e..d2c61fc89b 100644 --- a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx +++ b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx @@ -1,55 +1,106 @@ -import { XIcon } from "@phosphor-icons/react"; import { validateChannelName } from "@posthog/core/canvas/channelName"; -import { Button } from "@posthog/quill"; +import { + Button, + Dialog, + DialogBody, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Field, + FieldError, + FieldLabel, + Input, + Switch, + Textarea, +} from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useChannelMutations } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useGenerateContext } from "@posthog/ui/features/canvas/hooks/useGenerateContext"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; -import { Dialog, Flex, IconButton, Text, TextField } from "@radix-ui/themes"; import { useNavigate } from "@tanstack/react-router"; -import { SquircleDashed } from "lucide-react"; import { useState } from "react"; // Matches Slack's "Create a channel" naming constraint. -const MAX_CHANNEL_NAME_LENGTH = 80; +const MAX_CONTEXT_NAME_LENGTH = 80; + +const DESCRIPTION_PLACEHOLDER = + "Grab all files relating to X feature, get all relevant pull requests, in this X repo(s)"; interface CreateChannelModalProps { open: boolean; onOpenChange: (open: boolean) => void; + // When set, the dialog is the "Create your CONTEXT.md" flow for an existing + // context: no name field, just a description that seeds the planning session. + existingContext?: { channelId: string; channelName: string }; } +// Two dialogs in one, split on `existingContext`: +// - Create mode: names the context, creates it, and lands the user in its feed +// (the intro card there carries onboarding). An off-by-default toggle reveals +// the description textarea to also launch the context.md plan session at +// creation time. +// - Describe mode: the "Create your context.md" dialog (opened from the intro +// card or the CONTEXT.md empty state). A single textarea whose text seeds +// a plan-mode session that builds the context's CONTEXT.md with the user. export function CreateChannelModal({ open, onOpenChange, + existingContext, }: CreateChannelModalProps) { + const isDescribeMode = !!existingContext; const { createChannel, isCreating } = useChannelMutations(); + const { generate, isStarting } = useGenerateContext(); const navigate = useNavigate(); const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + // Create mode's opt-in "also plan the context.md now" toggle. + const [withContextMd, setWithContextMd] = useState(false); - // Reset the field each time the modal opens so a previous draft never lingers. - // Adjusted inline during render (prev-prop comparison) rather than in an - // effect, which would flash a stale value for one commit. + // Reset the fields each time the modal opens so a previous draft never + // lingers. Adjusted inline during render (prev-prop comparison) rather than in + // an effect, which would flash a stale value for one commit. const [wasOpen, setWasOpen] = useState(open); if (open !== wasOpen) { setWasOpen(open); - if (open) setName(""); + if (open) { + setName(""); + setDescription(""); + setWithContextMd(false); + } } - const trimmed = name.trim(); - const remaining = MAX_CHANNEL_NAME_LENGTH - name.length; - const validationError = validateChannelName(trimmed); + const trimmedName = name.trim(); + const trimmedDescription = description.trim(); + const remaining = MAX_CONTEXT_NAME_LENGTH - name.length; + const nameError = isDescribeMode ? null : validateChannelName(trimmedName); - const submit = async () => { - if (!trimmed || validationError || isCreating) return; - let channel: Awaited>; + // The description textarea is live in describe mode and in create mode once + // the toggle is on; either way it must be filled to submit. + const needsDescription = isDescribeMode || withContextMd; + const busy = isCreating || isStarting; + const canSubmit = + !busy && + (isDescribeMode ? true : !!trimmedName && !nameError) && + (!needsDescription || !!trimmedDescription); + + // Create mode: create the context, then land in the channel — its feed opens + // with the intro (name, creation line, context.md card) and the "joined" row, + // both derived from the channel row. With the toggle on, also launch the + // plan session that builds context.md, seeded by the description. + const submitCreate = async () => { + let contextId: string; try { - channel = await createChannel(trimmed); + const channel = await createChannel(trimmedName); track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "create", surface: "sidebar", channel_id: channel.id, success: true, }); + contextId = channel.id; } catch (error) { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "create", @@ -61,93 +112,168 @@ export function CreateChannelModal({ }); return; } + + if (withContextMd && trimmedDescription) { + track(ANALYTICS_EVENTS.CONTEXT_ACTION, { + action_type: "generate_started", + channel_id: contextId, + }); + // Failure is fine to swallow here (generate() already toasted): the + // context exists, so land the user on it — the intro card offers the + // retry. + await generate({ + channelId: contextId, + channelName: trimmedName, + description: trimmedDescription, + }); + } + onOpenChange(false); - // Open the new channel's static homepage. void navigate({ to: "/website/$channelId", - params: { channelId: channel.id }, + params: { channelId: contextId }, }); }; + // Describe mode: launch the plan-mode session that builds CONTEXT.md. On + // failure (generate() already toasted) the dialog stays open, state intact, + // for a clean retry. + const submitDescribe = async () => { + if (!existingContext) return; + track(ANALYTICS_EVENTS.CONTEXT_ACTION, { + action_type: "generate_started", + channel_id: existingContext.channelId, + }); + const task = await generate({ + channelId: existingContext.channelId, + channelName: existingContext.channelName, + description: trimmedDescription, + }); + if (!task) return; + + // Land on the context index (its feed), where the announcement and the plan + // task card show. The user clicks the card to open the session. + onOpenChange(false); + void navigate({ + to: "/website/$channelId", + params: { channelId: existingContext.channelId }, + }); + }; + + const submit = async () => { + if (!canSubmit) return; + if (isDescribeMode) { + await submitDescribe(); + } else { + await submitCreate(); + } + }; + return ( - { - if (!isCreating) onOpenChange(next); + if (!busy) onOpenChange(next); }} > - - - - Create a context - - - - - - - - - - - Name - - setName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - void submit(); - } - }} - > - - - - - - {remaining} - - - - {validationError && ( - - {validationError} - + + {isDescribeMode ? ( + // No visible header in describe mode — the textarea's label carries + // the dialog; the title stays for screen readers. + Create your context.md + ) : ( + + Create a context + + )} + + + {!isDescribeMode && ( + <> + + Name + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void submit(); + } + }} + /> + {nameError ? ( + {nameError} + ) : ( + + {remaining} left + + )} + + + + )} + + {needsDescription && ( + + + What's this context about? + +