diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 9f73742883..978952fdac 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -10,25 +10,30 @@ import { AvatarFallback, AvatarGroup, Badge, - Button, - ButtonGroup, Card, CardContent, - ChatMessage, - ChatMessageAvatar, - ChatMessageContent, - ChatMessageHeader, + ChatMarker, + ChatMarkerContent, ChatMessageScroller, ChatMessageScrollerButton, ChatMessageScrollerContent, ChatMessageScrollerItem, ChatMessageScrollerProvider, ChatMessageScrollerViewport, + cn, Spinner, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, + ThreadItem, + ThreadItemAction, + ThreadItemActions, + ThreadItemAuthor, + ThreadItemBody, + ThreadItemContent, + ThreadItemGutter, + ThreadItemHeader, + ThreadItemReplies, + ThreadItemRepliesLabel, + ThreadItemRepliesMeta, + ThreadItemTimestamp, } from "@posthog/quill"; import { formatRelativeTimeShort } from "@posthog/shared"; import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; @@ -41,8 +46,13 @@ import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { xmlToPlainText } from "@posthog/ui/features/message-editor/content"; import { extractChannelContext } from "@posthog/ui/features/sessions/components/session-update/channelContext"; import { getOriginProductMeta } from "@posthog/ui/features/sidebar/components/items/TaskIcon"; +import { + type SidebarPrState, + useTaskPrStatus, +} from "@posthog/ui/features/sidebar/useTaskPrStatus"; +import { useInView } from "@posthog/ui/primitives/hooks/useInView"; import { Text } from "@radix-ui/themes"; -import { useMemo } from "react"; +import { Fragment, memo, type ReactNode, useMemo } from "react"; // Feed rows poll their reply counts slower than the open thread panel — the // shared query key means an open panel naturally speeds the row up too. @@ -52,11 +62,27 @@ const STATUS_LABELS: Record = { not_started: "Not started", queued: "Queued", in_progress: "In progress", - completed: "Completed", + // "Ready", not "Completed": the agent has finished its work and the task is + // ready to look at, but the change itself isn't necessarily shipped/done. + completed: "Ready", failed: "Failed", cancelled: "Cancelled", }; +// Once a PR exists its GitHub state is the truest top-line status — more +// accurate than the run status, which routinely lingers on "in_progress" +// (or a stale cloud status) after the agent opens the PR. Mirrors the PR +// states the sidebar's TaskIcon already renders. +const PR_STATE_LABELS: Record< + Exclude, + { label: string; variant: "success" | "info" | "default" | "destructive" } +> = { + merged: { label: "Merged", variant: "success" }, + open: { label: "PR ready", variant: "info" }, + draft: { label: "Draft PR", variant: "default" }, + closed: { label: "Closed", variant: "destructive" }, +}; + function statusBadge(status: TaskRunStatus) { const variant = status === "completed" @@ -74,30 +100,145 @@ function statusBadge(status: TaskRunStatus) { ); } +// Local calendar-day identity, so tasks created on the same day share a heading +// regardless of time. Uses local getters (not the UTC ISO) so the split lands +// on the viewer's midnight. +function dayKey(iso: string): string { + const d = new Date(iso); + return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; +} + +function ordinal(n: number): string { + const suffix = ["th", "st", "nd", "rd"]; + const rem = n % 100; + return `${n}${suffix[(rem - 20) % 10] ?? suffix[rem] ?? suffix[0]}`; +} + +// The day-separator label: "Today" / "Yesterday" for the recent days, then a +// weekday + ordinal ("Monday 5th") within the week, adding the month (and the +// 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); + if (days <= 0) return "Today"; + if (days === 1) return "Yesterday"; + const weekday = date.toLocaleDateString(undefined, { weekday: "long" }); + const day = ordinal(date.getDate()); + if (days < 7) return `${weekday} ${day}`; + const month = date.toLocaleDateString(undefined, { month: "long" }); + const year = + date.getFullYear() === now.getFullYear() ? "" : `, ${date.getFullYear()}`; + return `${weekday}, ${month} ${day}${year}`; +} + +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. + prState: Exclude | null; + // Whether the PR has merged — the card lifts this to a purple border + tint. + isMerged: boolean; +} + // Live status for the card, derived the same way the sidebar's TaskIcon does // (via useChannelTaskData: local session + workspace + cloud run). The raw // `latest_run.status` alone is wrong for local runs — the backend row often // stays "queued" while the agent runs on the creator's machine — so it is // only trusted for cloud runs and terminal states (which imply a sync). -function TaskStatusBadge({ task }: { task: Task }) { +// +// Once a PR exists its state ("PR ready", "Merged", …) is the sole top-line +// status — it replaces the run badge rather than sitting next to it, so a +// 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 { const data = useChannelTaskData(task); - if (data?.needsPermission) - return Needs input; - if (data?.isGenerating) { - return ( + const { prState } = useTaskPrStatus({ + id: task.id, + cloudPrUrl: data?.cloudPrUrl ?? null, + taskRunEnvironment: data?.taskRunEnvironment ?? null, + }); + const status = data?.taskRunStatus ?? task.latest_run?.status; + const environment = data?.taskRunEnvironment ?? task.latest_run?.environment; + // `prState` is resolved async from git/`gh` and is routinely null for cloud + // tasks (the details fetch hasn't landed, or there's no cached row). But the + // PR URL itself is a hard signal a PR exists — the card's "PR" link keys off + // exactly this. Fall back to it so the badge and the link never disagree; a + // known URL with no resolved state is shown as the neutral "open" ("PR + // ready"), never something stronger like "merged". + const hasPrUrl = + typeof (data?.cloudPrUrl ?? task.latest_run?.output?.pr_url) === "string"; + const effectivePrState: Exclude | null = + prState ?? (hasPrUrl ? "open" : null); + const showPrState = + !!effectivePrState && status !== "failed" && status !== "cancelled"; + + let base: ReactNode; + if (data?.needsPermission) { + // Live, actionable states still win over the PR badge — the agent is + // waiting on the user right now, which matters more than a PR existing. + base = Needs input; + } else if (data?.isGenerating) { + base = ( In progress ); + } else if (showPrState) { + // Otherwise the PR badge is the whole story once a PR exists; skip the run + // badge so we never show "Ready + Merged" or a stale "In progress". + base = null; + } else if (!status) { + base = Draft; + } else if (environment === "cloud" || isTerminalStatus(status)) { + base = statusBadge(status); + } else { + // Local, non-terminal: the run status is unreliable (the backend row stays + // "queued" while the agent runs on the creator's machine), and the + // environment already shows in the card's meta row ("· Local"), so we + // render no status badge here rather than a redundant "Local" pill. + base = null; } - const status = data?.taskRunStatus ?? task.latest_run?.status; - const environment = data?.taskRunEnvironment ?? task.latest_run?.environment; - if (!status) return Draft; - if (environment === "cloud" || isTerminalStatus(status)) { - return statusBadge(status); + + return { + base, + prState: showPrState ? effectivePrState : null, + isMerged: showPrState && effectivePrState === "merged", + }; +} + +// The merged badge borrows the purple GitHub-merge accent (matching the +// sidebar's TaskIcon merge glyph). Quill has no purple variant, so we tint a +// neutral badge with the Radix purple scale — allowed inline because the +// values are CSS variables, not hardcoded colors. +function PrStateBadge({ prState }: { prState: Exclude }) { + const { label, variant } = PR_STATE_LABELS[prState]; + if (prState === "merged") { + return ( + + {label} + + ); } - return Local; + return {label}; +} + +function TaskStatusBadge({ display }: { display: TaskStatusDisplay }) { + return ( +
+ {display.base} + {display.prState && } +
+ ); } // The prompt as the user typed it: drop the channel CONTEXT.md block the saga @@ -130,28 +271,39 @@ function TaskCardOrigin({ task }: { task: Task }) { // The task the message kicked off, as a card everyone in the channel sees: // origin + status up top, bold title, then run metadata. function TaskCard({ task, onOpen }: { task: Task; onOpen: () => void }) { + const statusDisplay = useTaskStatusDisplay(task); const prUrl = typeof task.latest_run?.output?.pr_url === "string" ? task.latest_run.output.pr_url : undefined; // The repository renders separately with its icon; `meta` is the plain-text // remainder of the row. + const environment = task.latest_run?.environment; const meta = [ task.slug || null, task.latest_run?.stage ?? null, - task.latest_run?.environment === "cloud" ? "Cloud" : null, + environment === "cloud" + ? "Cloud" + : environment === "local" + ? "Local" + : null, ].filter(Boolean) as string[]; return (
- +
{/* Same live status icon as the code side nav, so the card and the @@ -214,34 +366,32 @@ function RepliesRow({ const last = messages[messages.length - 1]; return ( - + + ); } -function FeedItem({ +const FeedItem = memo(function FeedItem({ task, + inView, onOpenTask, onOpenThread, }: { task: Task; + inView: boolean; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; }) { @@ -249,11 +399,8 @@ function FeedItem({ const isAgent = !task.created_by || task.origin_product !== "user_created"; return ( - - {/* Quill's avatar slot bottom-aligns for bubble chats; a Slack feed - anchors it beside the name row. The slot draws its own circle, so - drop its background and let the inner Avatar render. */} - + + {isAgent && !task.created_by ? ( @@ -263,64 +410,91 @@ function FeedItem({ )} - - - - + + + + + {task.created_by ? userDisplayName(task.created_by) : "Agent"} - + {isAgent && Agent} - + {formatRelativeTimeShort(task.created_at)} - - + + - + {prompt} - + onOpenTask(task)} /> - onOpenThread(task)} /> - + {/* Off-screen rows drop the reply teaser so a long feed isn't running a + 15s poll timer per row; the wide inView margin mounts it well before + the row scrolls into view, so nothing pops in. */} + {inView && ( + onOpenThread(task)} + /> + )} + - {/* Hover toolbar, storybook-style: floats top-right of the row. */} -
- - - - onOpenThread(task)} - > - - - } - /> - Reply in thread - - - onOpenTask(task)} - > - - - } - /> - Open task - - - -
-
+ {/* Actions anchor to the row's top-right corner; a top tooltip there + overhangs the panel edge and gets clipped by the scroll container, so + open tooltips toward the content instead. */} + + onOpenThread(task)} + > + + + onOpenTask(task)} + > + + + + + ); +}); + +// One feed row: owns the scroller item (the `content-visibility` boundary, so +// its box is always laid out and safe to observe) and reports whether it is +// near the viewport, letting `FeedItem` shed off-screen polling. +function FeedRow({ + task, + onOpenTask, + onOpenThread, +}: { + task: Task; + onOpenTask: (task: Task) => void; + onOpenThread: (task: Task) => void; +}) { + const [ref, inView] = useInView({ rootMargin: "1200px 0px" }); + return ( + + + ); } @@ -352,20 +526,38 @@ export function ChannelFeedView({ return
{emptyState}
; } + const now = new Date(); + return ( - - {tasks.map((task) => ( - - - - ))} + {/* Horizontal padding is load-bearing: ThreadItem's actions float at + 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]; + const showDayMarker = + !previous || + dayKey(previous.created_at) !== dayKey(task.created_at); + return ( + + {showDayMarker && ( + + + {dayLabel(task.created_at, now)} + + + )} + + + ); + })} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index fed001daea..1f7e217988 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -168,7 +168,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { onOpenTask={handleOpenTask} onOpenThread={handleOpenThread} /> -
+
( =20'} peerDependencies: '@base-ui/react': ^1.4.0 @@ -18847,7 +18847,7 @@ snapshots: react-dom: 19.2.6(react@19.2.6) simple-statistics: 7.8.9 - '@posthog/quill@0.3.0-beta.21(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.2.2)': + '@posthog/quill@0.3.0-beta.22(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.2.2)': dependencies: '@base-ui/react': 1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) class-variance-authority: 0.7.1 @@ -18859,7 +18859,7 @@ snapshots: tailwind-merge: 2.6.1 tailwindcss: 4.2.2 - '@posthog/quill@0.3.0-beta.21(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.1)': + '@posthog/quill@0.3.0-beta.22(@base-ui/react@1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.1)': dependencies: '@base-ui/react': 1.3.0(@types/react@19.2.17)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) class-variance-authority: 0.7.1 @@ -21403,7 +21403,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/ui@4.1.8)(jsdom@26.1.0)(msw@2.12.8(@types/node@22.20.0)(typescript@5.9.3))(vite@7.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/ui@4.1.8)(jsdom@26.1.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rolldown-vite@7.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/utils@3.2.4': dependencies: @@ -29146,6 +29146,36 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/ui@4.1.8)(jsdom@26.1.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rolldown-vite@7.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rolldown-vite@7.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: rolldown-vite@7.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.0 + '@vitest/ui': 4.1.8(vitest@4.1.8) + jsdom: 26.1.0 + transitivePeerDependencies: + - msw + vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.2.0)(@vitest/ui@4.1.8)(jsdom@26.1.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@7.3.5(@types/node@25.2.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 45a646a55a..d0e75b2c93 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,7 +8,7 @@ catalog: '@hono/trpc-server': ^0.3.4 '@parcel/watcher': ^2.5.6 '@phosphor-icons/react': ^2.1.10 - '@posthog/quill': 0.3.0-beta.21 + '@posthog/quill': 0.3.0-beta.22 '@radix-ui/themes': ^3.2.1 '@tanstack/react-query': ^5.100.14 '@tanstack/react-router': ^1.170.10