Skip to content

Commit 3336776

Browse files
adamleithpclaude
andcommitted
fix: clearer status on channel task cards
Rework the task card status row in the channel feed: - Once a PR exists, its state (Merged / PR ready / Draft / Closed) is the sole top-line status, replacing the run badge — so a shipped task never reads "Ready + Merged" or a stale "In progress + PR ready". - Merged PRs show a purple badge and lift the card to a purple border + tint, matching the sidebar's merge accent. - Fall back to the neutral "PR ready" when a PR URL exists but its GitHub state hasn't resolved yet, so the badge and the "PR" link never disagree. - "Ready" instead of "Completed" for finished runs — the work is ready to look at, not necessarily shipped. - Drop the redundant "Local" pill; environment shows in the meta row ("· Local" / "· Cloud"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2ecc6b6 commit 3336776

1 file changed

Lines changed: 130 additions & 16 deletions

File tree

packages/ui/src/features/canvas/components/ChannelFeedView.tsx

Lines changed: 130 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
4142
import { xmlToPlainText } from "@posthog/ui/features/message-editor/content";
4243
import { extractChannelContext } from "@posthog/ui/features/sessions/components/session-update/channelContext";
4344
import { getOriginProductMeta } from "@posthog/ui/features/sidebar/components/items/TaskIcon";
45+
import {
46+
type SidebarPrState,
47+
useTaskPrStatus,
48+
} from "@posthog/ui/features/sidebar/useTaskPrStatus";
4449
import { 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+
6081
function 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.
132235
function 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

Comments
 (0)