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/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index fd5a38f871..a3e4097284 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -1,14 +1,18 @@ import { + ArchiveIcon, ArrowSquareOutIcon, ChatCircleIcon, + DotsThreeIcon, GitBranchIcon, RobotIcon, + TrashIcon, } from "@phosphor-icons/react"; import { Avatar, AvatarFallback, AvatarGroup, Badge, + Button, Card, CardContent, ChatMarker, @@ -20,6 +24,10 @@ import { ChatMessageScrollerProvider, ChatMessageScrollerViewport, cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, Spinner, ThreadItem, ThreadItemAction, @@ -39,16 +47,33 @@ 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 { + MentionText, + mentionChipClass, + TaskLinkIcon, +} from "@posthog/ui/features/canvas/components/MentionText"; +import type { + ChannelFeedSystemMessage, + DemoButtonPreset, +} 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 { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { useSessionSelector } from "@posthog/ui/features/sessions/useSession"; import { type SidebarPrState, useTaskPrStatus, } from "@posthog/ui/features/sidebar/useTaskPrStatus"; +import { + getCachedTask, + taskDetailQuery, +} from "@posthog/ui/features/tasks/queries"; import { useInView } from "@posthog/ui/primitives/hooks/useInView"; +import { toast } from "@posthog/ui/primitives/toast"; import { Text } from "@radix-ui/themes"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; import { Fragment, memo, type ReactNode, useMemo } from "react"; // Feed rows poll their reply counts slower than the open thread panel — the @@ -152,6 +177,20 @@ interface TaskStatusDisplay { // deliberate end state we should not soften with a PR. function useTaskStatusDisplay(task: Task): TaskStatusDisplay { const data = useChannelTaskData(task); + // A cloud run's status lingers on "in_progress" after the agent yields its + // turn — the same staleness the thread's "Ready to ship" sidesteps by reading + // the live session instead. Once the local session has produced output and + // settled (not generating, not blocked on a permission), trust that the agent + // has finished so the card and the thread agree. Gated on real activity so a + // not-yet-started task (empty session) isn't mislabeled "Ready". + const agentSettledAfterRun = useSessionSelector( + task.id, + (s) => + !!s && + (s.events?.length ?? 0) > 0 && + !s.isPromptPending && + (s.pendingPermissions?.size ?? 0) === 0, + ); const { prState } = useTaskPrStatus({ id: task.id, cloudPrUrl: data?.cloudPrUrl ?? null, @@ -191,7 +230,12 @@ function useTaskStatusDisplay(task: Task): TaskStatusDisplay { } else if (!status) { base = Draft; } else if (environment === "cloud" || isTerminalStatus(status)) { - base = statusBadge(status); + // Prefer the settled live session over a stale non-terminal cloud status, so + // a finished run reads "Ready" instead of a lingering "In progress". Never + // softens a terminal status (a failed run stays failed). + const effective = + agentSettledAfterRun && !isTerminalStatus(status) ? "completed" : status; + base = statusBadge(effective); } else { // Local, non-terminal: the run status is unreliable (the backend row stays // "queued" while the agent runs on the creator's machine), so we render no @@ -239,7 +283,7 @@ function TaskStatusBadge({ display }: { display: TaskStatusDisplay }) { // The task the message kicked off, as a card everyone in the channel sees: // bold title + status up top, then run metadata. -function TaskCard({ task, onOpen }: { task: Task; onOpen: () => void }) { +export function TaskCard({ task, onOpen }: { task: Task; onOpen: () => void }) { const statusDisplay = useTaskStatusDisplay(task); const prUrl = typeof task.latest_run?.output?.pr_url === "string" @@ -265,9 +309,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 +423,7 @@ const FeedItem = memo(function FeedItem({ onOpenThread: (task: Task) => void; }) { return ( - + @@ -391,7 +435,7 @@ const FeedItem = memo(function FeedItem({ PostHog - Agent + {/* Agent */} @@ -472,23 +516,306 @@ function FeedRow({ ); } +// A card-less feed row for a synthetic "PostHog agent" announcement (context +// created, CONTEXT.md being built). Same chrome as a task row — Robot avatar, +// "PostHog / Agent" — minus the task card and reply footer. +function SystemFeedRow({ message }: { message: ChannelFeedSystemMessage }) { + return ( + + + + + + + + + + + + PostHog + Agent + + {formatRelativeTimeShort(message.createdAt)} + + + + {message.text} + + + + + ); +} + +// Buttons derived from a task's live PR / merge state: "Merged" once it lands, +// otherwise a "Review PR" action. A "View PR" button appears when the task has a +// real PR URL. Used by the demo message's `task-pr` button preset, keyed off its +// replied task; the buttons always render (they're a demo affordance) but the +// state reflects the real task when it has one. +function TaskPrButtons({ taskId }: { taskId: string }) { + const { data } = useQuery({ ...taskDetailQuery(taskId), staleTime: 30_000 }); + const task = data ?? getCachedTask(taskId); + const prUrl = + typeof task?.latest_run?.output?.pr_url === "string" + ? task.latest_run.output.pr_url + : undefined; + const { prState } = useTaskPrStatus({ + id: taskId, + cloudPrUrl: prUrl ?? null, + taskRunEnvironment: task?.latest_run?.environment ?? null, + }); + const merged = prState === "merged"; + const openPr = () => + prUrl + ? window.open(prUrl, "_blank", "noopener,noreferrer") + : toast.success("Review started"); + return ( +
+ {merged ? ( + Merged + ) : ( + + )} + {prUrl && ( + + )} +
+ ); +} + +// Initials for a free-text persona name (the demo composer lets you type any +// "from"), so a fake human message gets a sensible avatar fallback. +function initialsFromName(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} + +// A dev-only "fake" message rendered as a full thread item: the chosen persona's +// avatar + name, then the body rendered the same way real thread messages are +// (MentionText — @mention chips + linkified URLs). Presentational, so the header +// composer can reuse it as a live preview. `createdAt` omitted (preview) hides +// the timestamp. +export function DemoMessageItem({ + fromName, + fromKind, + content, + createdAt, + replyTo, + buttons, + onDelete, +}: { + fromName: string; + fromKind: "human" | "agent"; + content: string; + createdAt?: string; + /** When set, renders a Slack-style "replied to a thread: …" preview line. */ + replyTo?: { label: string; href: string }; + /** When set, renders a preset row of action buttons. */ + buttons?: DemoButtonPreset; + onDelete?: () => void; +}) { + const navigate = useNavigate(); + const openThread = useThreadPanelStore((s) => s.openThread); + // The reply target is a task deep link; clicking opens that task's *thread* + // dock (not the full task page) in its channel home. + const replyMatch = replyTo?.href.match( + /\/website\/([^/]+)\/tasks\/([^/?#]+)/, + ); + const openReplyThread = () => { + if (!replyMatch) return; + const [, replyChannelId, taskId] = replyMatch; + openThread(replyChannelId, taskId); + void navigate({ + to: "/website/$channelId", + params: { channelId: replyChannelId }, + }); + }; + return ( + + + + + {fromKind === "agent" ? ( + + ) : ( + initialsFromName(fromName) + )} + + + + + + {fromName || "Someone"} + {/* {fromKind === "agent" && Agent} */} + {createdAt && ( + + {formatRelativeTimeShort(createdAt)} + + )} + + {replyTo && ( +
+ replied to a thread: + {/* Opens the task's thread dock (not the task page); the live + task-status icon matches reference links. */} + +
+ )} + + {content ? ( + // Same renderer as real thread messages: @mentions become chips and + // bare URLs linkify, so a fake message reads exactly like a real one. + + ) : ( + + Your message will appear here… + + )} + + {buttons === "inbox-item" && ( +
+ + +
+ )} + {buttons === "task-pr" && replyMatch && ( + + )} +
+ {onDelete && ( + + + + + + } + /> + + + + Delete message + + + + + )} +
+ ); +} + +// The feed wrapper: pins the shared item into the message scroller. +function DemoFeedRow({ + message, + onDelete, +}: { + message: ChannelFeedSystemMessage; + onDelete?: (id: string) => void; +}) { + const demo = message.demo; + if (!demo) return null; + return ( + + onDelete(message.id) : undefined} + /> + + ); +} + +// 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, onOpenTask, onOpenThread, + onDeleteDemoMessage, }: { tasks: Task[]; + systemMessages?: ChannelFeedSystemMessage[]; isLoading: boolean; emptyState?: React.ReactNode; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; + /** Delete a dev-only demo message (its "…" menu is shown only when set). */ + onDeleteDemoMessage?: (id: string) => 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. + 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)); + return merged; + }, [tasks, systemMessages]); + + if (isLoading && entries.length === 0) { return (
@@ -496,7 +823,7 @@ export function ChannelFeedView({ ); } - if (tasks.length === 0) { + if (entries.length === 0) { return
{emptyState}
; } @@ -510,25 +837,34 @@ 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]; + {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" ? ( + + ) : entry.message.demo ? ( + + ) : ( + + )} ); })} diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index d41bd2b299..077e3a157b 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -1,9 +1,12 @@ +import { SparkleIcon } from "@phosphor-icons/react"; import { Button, cn } from "@posthog/quill"; import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs"; +import { InsertFakeMessageDialog } from "@posthog/ui/features/canvas/components/InsertFakeMessageDialog"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { Text } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; import { SquircleDashed } from "lucide-react"; +import { useState } 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 @@ -17,6 +20,8 @@ export function ChannelHeader({ channelId }: { channelId: string }) { const channelName = channels.find((c) => c.id === channelId)?.name; const pathname = useRouterState({ select: (s) => s.location.pathname }); const isHome = pathname === `/website/${channelId}`; + // Dev-only demo tool: post a "fake" message into the channel feed. + const [fakeMessageOpen, setFakeMessageOpen] = useState(false); return (
@@ -38,6 +43,26 @@ export function ChannelHeader({ channelId }: { channelId: string }) { + + {import.meta.env.DEV && ( + <> + + + + )}
); } diff --git a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx index e732ef2b83..0d6bd21201 100644 --- a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx @@ -13,7 +13,6 @@ import { import { useConnectivity } from "../../../hooks/useConnectivity"; import { track } from "../../../shell/analytics"; import { useOptionalAuthenticatedClient } from "../../auth/authClient"; -import { useUserRepositoryIntegration } from "../../integrations/useIntegrations"; import { PromptInput } from "../../message-editor/components/PromptInput"; import { useDraftStore } from "../../message-editor/draftStore"; import type { EditorHandle } from "../../message-editor/types"; @@ -25,14 +24,9 @@ import { type AgentAdapter, useSettingsStore, } from "../../settings/settingsStore"; -import { - type WorkspaceMode, - WorkspaceModeSelect, -} from "../../task-detail/components/WorkspaceModeSelect"; -import { useCloudModeEnabled } from "../../task-detail/hooks/useCloudModeEnabled"; +import type { WorkspaceMode } from "../../task-detail/components/WorkspaceModeSelect"; import { usePreviewConfig } from "../../task-detail/hooks/usePreviewConfig"; import { useTaskCreation } from "../../task-detail/hooks/useTaskCreation"; -import { resolveWorkspaceModePreference } from "../../task-detail/hooks/workspaceModePreference"; import { trackAndCreateCanvas } from "../createCanvasAnalytics"; import { channelFeedQueryKey } from "../hooks/useChannelFeed"; import { @@ -64,8 +58,8 @@ interface ChannelHomeComposerProps { // of TaskInput: it reuses the same task-creation pipeline (model/mode/reasoning // preview config + useTaskCreation) but drops the repo/branch pickers — channel // tasks run repo-less and the agent attaches a repo lazily if it needs one. The -// starter-prompt suggestions render in the parent above the box; this owns the -// local/cloud selector. +// starter-prompt suggestions render in the parent above the box. Channel tasks +// always run in the cloud, so there's no local/cloud selector. export const ChannelHomeComposer = forwardRef< ChannelHomeComposerHandle, ChannelHomeComposerProps @@ -108,9 +102,6 @@ export const ChannelHomeComposer = forwardRef< const { lastUsedAdapter, setLastUsedAdapter, - lastUsedWorkspaceMode, - setLastUsedWorkspaceMode, - setLastUsedLocalWorkspaceMode, allowBypassPermissions, defaultInitialTaskMode, lastUsedInitialTaskMode, @@ -124,30 +115,9 @@ export const ChannelHomeComposer = forwardRef< [setLastUsedAdapter], ); - const cloudModeEnabled = useCloudModeEnabled(); - const { hasGithubIntegration } = useUserRepositoryIntegration(); - - // Repo-less channel tasks only run local or cloud (worktree needs a repo), so - // collapse any lingering worktree preference down to local for the initial pick. - const [workspaceMode, setWorkspaceModeState] = useState(() => - resolveWorkspaceModePreference({ - preferredMode: lastUsedWorkspaceMode === "cloud" ? "cloud" : "local", - cloudModeEnabled, - hasGithubIntegration, - lastUsedLocalWorkspaceMode: "local", - }), - ); - const [selectedCloudEnvId, setSelectedCloudEnvId] = useState( - null, - ); - const setWorkspaceMode = useCallback( - (mode: WorkspaceMode) => { - setWorkspaceModeState(mode); - setLastUsedWorkspaceMode(mode); - if (mode !== "cloud") setLastUsedLocalWorkspaceMode(mode); - }, - [setLastUsedWorkspaceMode, setLastUsedLocalWorkspaceMode], - ); + // Channel tasks always run in the cloud — the local/cloud pick is removed, so + // the mode is fixed here and the default cloud environment is used. + const workspaceMode: WorkspaceMode = "cloud"; const { modeOption, modelOption, thoughtOption, isLoading, setConfigOption } = usePreviewConfig(adapter); @@ -247,10 +217,7 @@ export const ChannelHomeComposer = forwardRef< sessionId, selectedDirectory: "", workspaceMode, - sandboxEnvironmentId: - workspaceMode === "cloud" && selectedCloudEnvId - ? selectedCloudEnvId - : undefined, + sandboxEnvironmentId: undefined, editorIsEmpty, adapter, executionMode: currentExecutionMode, @@ -311,22 +278,6 @@ export const ChannelHomeComposer = forwardRef< return (
- {/* Canvas generation always runs in the cloud, so the local/cloud pick - doesn't apply while canvas mode is armed. */} - {!canvasArmed && ( -
- -
- )} - ({ @@ -13,9 +15,27 @@ const TABS = CHANNEL_SECTIONS.map((s) => ({ // codebase's convention) rather than Link's activeProps. export function ChannelTabs({ channelId }: { channelId: string }) { const pathname = useRouterState({ select: (s) => s.location.pathname }); + // Live report count for the Inbox tab's badge — pipeline reports still needing + // attention (the same status set the real Inbox badges), not every report ever + // (which includes resolved/suppressed history). A cheap `limit: 1` count query + // returns the real total without pulling the list. The tab itself is a + // placeholder (no destination yet) — clicking is a no-op. + const { data: inbox } = useInboxReports({ + status: INBOX_PIPELINE_STATUS_FILTER, + limit: 1, + }); + const inboxCount = inbox?.count ?? 0; return (