@@ -24,6 +24,7 @@ import {
2424 ChatMessageScrollerItem ,
2525 ChatMessageScrollerProvider ,
2626 ChatMessageScrollerViewport ,
27+ cn ,
2728 Spinner ,
2829 Tooltip ,
2930 TooltipContent ,
@@ -41,8 +42,12 @@ import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
4142import { xmlToPlainText } from "@posthog/ui/features/message-editor/content" ;
4243import { extractChannelContext } from "@posthog/ui/features/sessions/components/session-update/channelContext" ;
4344import { getOriginProductMeta } from "@posthog/ui/features/sidebar/components/items/TaskIcon" ;
45+ import {
46+ type SidebarPrState ,
47+ useTaskPrStatus ,
48+ } from "@posthog/ui/features/sidebar/useTaskPrStatus" ;
4449import { Text } from "@radix-ui/themes" ;
45- import { useMemo } from "react" ;
50+ import { type ReactNode , useMemo } from "react" ;
4651
4752// Feed rows poll their reply counts slower than the open thread panel — the
4853// shared query key means an open panel naturally speeds the row up too.
@@ -52,11 +57,27 @@ const STATUS_LABELS: Record<TaskRunStatus, string> = {
5257 not_started : "Not started" ,
5358 queued : "Queued" ,
5459 in_progress : "In progress" ,
55- completed : "Completed" ,
60+ // "Ready", not "Completed": the agent has finished its work and the task is
61+ // ready to look at, but the change itself isn't necessarily shipped/done.
62+ completed : "Ready" ,
5663 failed : "Failed" ,
5764 cancelled : "Cancelled" ,
5865} ;
5966
67+ // Once a PR exists its GitHub state is the truest top-line status — more
68+ // accurate than the run status, which routinely lingers on "in_progress"
69+ // (or a stale cloud status) after the agent opens the PR. Mirrors the PR
70+ // states the sidebar's TaskIcon already renders.
71+ const PR_STATE_LABELS : Record <
72+ Exclude < SidebarPrState , null > ,
73+ { label : string ; variant : "success" | "info" | "default" | "destructive" }
74+ > = {
75+ merged : { label : "Merged" , variant : "success" } ,
76+ open : { label : "PR ready" , variant : "info" } ,
77+ draft : { label : "Draft PR" , variant : "default" } ,
78+ closed : { label : "Closed" , variant : "destructive" } ,
79+ } ;
80+
6081function statusBadge ( status : TaskRunStatus ) {
6182 const variant =
6283 status === "completed"
@@ -74,30 +95,112 @@ function statusBadge(status: TaskRunStatus) {
7495 ) ;
7596}
7697
98+ interface TaskStatusDisplay {
99+ // The run/environment badge ("Local", "Completed", "In progress", …).
100+ base : ReactNode ;
101+ // The PR's GitHub state, shown alongside the run badge when a PR exists.
102+ prState : Exclude < SidebarPrState , null > | null ;
103+ // Whether the PR has merged — the card lifts this to a purple border + tint.
104+ isMerged : boolean ;
105+ }
106+
77107// Live status for the card, derived the same way the sidebar's TaskIcon does
78108// (via useChannelTaskData: local session + workspace + cloud run). The raw
79109// `latest_run.status` alone is wrong for local runs — the backend row often
80110// stays "queued" while the agent runs on the creator's machine — so it is
81111// only trusted for cloud runs and terminal states (which imply a sync).
82- function TaskStatusBadge ( { task } : { task : Task } ) {
112+ //
113+ // Once a PR exists its state ("PR ready", "Merged", …) is the sole top-line
114+ // status — it replaces the run badge rather than sitting next to it, so a
115+ // shipped task never reads "Ready + Merged" or a stale "In progress + PR
116+ // ready". A failed/cancelled run suppresses the PR badge instead — that is a
117+ // deliberate end state we should not soften with a PR.
118+ function useTaskStatusDisplay ( task : Task ) : TaskStatusDisplay {
83119 const data = useChannelTaskData ( task ) ;
84- if ( data ?. needsPermission )
85- return < Badge variant = "warning" > Needs input</ Badge > ;
86- if ( data ?. isGenerating ) {
87- return (
120+ const { prState } = useTaskPrStatus ( {
121+ id : task . id ,
122+ cloudPrUrl : data ?. cloudPrUrl ?? null ,
123+ taskRunEnvironment : data ?. taskRunEnvironment ?? null ,
124+ } ) ;
125+ const status = data ?. taskRunStatus ?? task . latest_run ?. status ;
126+ const environment = data ?. taskRunEnvironment ?? task . latest_run ?. environment ;
127+ // `prState` is resolved async from git/`gh` and is routinely null for cloud
128+ // tasks (the details fetch hasn't landed, or there's no cached row). But the
129+ // PR URL itself is a hard signal a PR exists — the card's "PR" link keys off
130+ // exactly this. Fall back to it so the badge and the link never disagree; a
131+ // known URL with no resolved state is shown as the neutral "open" ("PR
132+ // ready"), never something stronger like "merged".
133+ const hasPrUrl =
134+ typeof ( data ?. cloudPrUrl ?? task . latest_run ?. output ?. pr_url ) === "string" ;
135+ const effectivePrState : Exclude < SidebarPrState , null > | null =
136+ prState ?? ( hasPrUrl ? "open" : null ) ;
137+ const showPrState =
138+ ! ! effectivePrState && status !== "failed" && status !== "cancelled" ;
139+
140+ let base : ReactNode ;
141+ if ( data ?. needsPermission ) {
142+ // Live, actionable states still win over the PR badge — the agent is
143+ // waiting on the user right now, which matters more than a PR existing.
144+ base = < Badge variant = "warning" > Needs input</ Badge > ;
145+ } else if ( data ?. isGenerating ) {
146+ base = (
88147 < Badge variant = "info" >
89148 < Spinner className = "size-2.5" />
90149 In progress
91150 </ Badge >
92151 ) ;
152+ } else if ( showPrState ) {
153+ // Otherwise the PR badge is the whole story once a PR exists; skip the run
154+ // badge so we never show "Ready + Merged" or a stale "In progress".
155+ base = null ;
156+ } else if ( ! status ) {
157+ base = < Badge > Draft</ Badge > ;
158+ } else if ( environment === "cloud" || isTerminalStatus ( status ) ) {
159+ base = statusBadge ( status ) ;
160+ } else {
161+ // Local, non-terminal: the run status is unreliable (the backend row stays
162+ // "queued" while the agent runs on the creator's machine), and the
163+ // environment already shows in the card's meta row ("· Local"), so we
164+ // render no status badge here rather than a redundant "Local" pill.
165+ base = null ;
93166 }
94- const status = data ?. taskRunStatus ?? task . latest_run ?. status ;
95- const environment = data ?. taskRunEnvironment ?? task . latest_run ?. environment ;
96- if ( ! status ) return < Badge > Draft</ Badge > ;
97- if ( environment === "cloud" || isTerminalStatus ( status ) ) {
98- return statusBadge ( status ) ;
167+
168+ return {
169+ base,
170+ prState : showPrState ? effectivePrState : null ,
171+ isMerged : showPrState && effectivePrState === "merged" ,
172+ } ;
173+ }
174+
175+ // The merged badge borrows the purple GitHub-merge accent (matching the
176+ // sidebar's TaskIcon merge glyph). Quill has no purple variant, so we tint a
177+ // neutral badge with the Radix purple scale — allowed inline because the
178+ // values are CSS variables, not hardcoded colors.
179+ function PrStateBadge ( { prState } : { prState : Exclude < SidebarPrState , null > } ) {
180+ const { label, variant } = PR_STATE_LABELS [ prState ] ;
181+ if ( prState === "merged" ) {
182+ return (
183+ < Badge
184+ variant = "default"
185+ style = { {
186+ backgroundColor : "var(--purple-a3)" ,
187+ color : "var(--purple-11)" ,
188+ } }
189+ >
190+ { label }
191+ </ Badge >
192+ ) ;
99193 }
100- return < Badge > Local</ Badge > ;
194+ return < Badge variant = { variant } > { label } </ Badge > ;
195+ }
196+
197+ function TaskStatusBadge ( { display } : { display : TaskStatusDisplay } ) {
198+ return (
199+ < div className = "flex shrink-0 items-center gap-1" >
200+ { display . base }
201+ { display . prState && < PrStateBadge prState = { display . prState } /> }
202+ </ div >
203+ ) ;
101204}
102205
103206// The prompt as the user typed it: drop the channel CONTEXT.md block the saga
@@ -130,28 +233,39 @@ function TaskCardOrigin({ task }: { task: Task }) {
130233// The task the message kicked off, as a card everyone in the channel sees:
131234// origin + status up top, bold title, then run metadata.
132235function TaskCard ( { task, onOpen } : { task : Task ; onOpen : ( ) => void } ) {
236+ const statusDisplay = useTaskStatusDisplay ( task ) ;
133237 const prUrl =
134238 typeof task . latest_run ?. output ?. pr_url === "string"
135239 ? task . latest_run . output . pr_url
136240 : undefined ;
137241 // The repository renders separately with its icon; `meta` is the plain-text
138242 // remainder of the row.
243+ const environment = task . latest_run ?. environment ;
139244 const meta = [
140245 task . slug || null ,
141246 task . latest_run ?. stage ?? null ,
142- task . latest_run ?. environment === "cloud" ? "Cloud" : null ,
247+ environment === "cloud"
248+ ? "Cloud"
249+ : environment === "local"
250+ ? "Local"
251+ : null ,
143252 ] . filter ( Boolean ) as string [ ] ;
144253
145254 return (
146255 < Card
147256 size = "sm"
148- className = "mt-1.5 w-full cursor-pointer transition-colors hover:border-border-primary"
257+ className = { cn (
258+ "mt-1.5 w-full cursor-pointer py-0 transition-colors hover:bg-fill-hover" ,
259+ statusDisplay . isMerged
260+ ? "border-transparent bg-(--purple-a1) shadow-[0_0_0_1px_var(--purple-8)]"
261+ : "hover:border-border-primary" ,
262+ ) }
149263 onClick = { onOpen }
150264 >
151265 < CardContent className = "flex flex-col gap-1 py-2.5" >
152266 < div className = "flex items-center justify-between gap-2" >
153267 < TaskCardOrigin task = { task } />
154- < TaskStatusBadge task = { task } />
268+ < TaskStatusBadge display = { statusDisplay } />
155269 </ div >
156270 < div className = "flex min-w-0 items-center gap-1.5" >
157271 { /* Same live status icon as the code side nav, so the card and the
0 commit comments