Skip to content

Commit 991b34d

Browse files
committed
badges, better links in thread item
1 parent 0627e46 commit 991b34d

6 files changed

Lines changed: 186 additions & 6 deletions

File tree

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

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
ArchiveIcon,
23
ArrowSquareOutIcon,
34
ChatCircleIcon,
45
DotsThreeIcon,
@@ -11,6 +12,7 @@ import {
1112
AvatarFallback,
1213
AvatarGroup,
1314
Badge,
15+
Button,
1416
Card,
1517
CardContent,
1618
ChatMarker,
@@ -50,7 +52,10 @@ import {
5052
mentionChipClass,
5153
TaskLinkIcon,
5254
} from "@posthog/ui/features/canvas/components/MentionText";
53-
import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages";
55+
import type {
56+
ChannelFeedSystemMessage,
57+
DemoButtonPreset,
58+
} from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages";
5459
import { useChannelTaskData } from "@posthog/ui/features/canvas/hooks/useChannelTaskData";
5560
import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread";
5661
import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore";
@@ -59,8 +64,14 @@ import {
5964
type SidebarPrState,
6065
useTaskPrStatus,
6166
} from "@posthog/ui/features/sidebar/useTaskPrStatus";
67+
import {
68+
getCachedTask,
69+
taskDetailQuery,
70+
} from "@posthog/ui/features/tasks/queries";
6271
import { useInView } from "@posthog/ui/primitives/hooks/useInView";
72+
import { toast } from "@posthog/ui/primitives/toast";
6373
import { Text } from "@radix-ui/themes";
74+
import { useQuery } from "@tanstack/react-query";
6475
import { useNavigate } from "@tanstack/react-router";
6576
import { Fragment, memo, type ReactNode, useMemo } from "react";
6677

@@ -404,7 +415,7 @@ const FeedItem = memo(function FeedItem({
404415
<ThreadItemContent className="min-w-0">
405416
<ThreadItemHeader>
406417
<ThreadItemAuthor>PostHog</ThreadItemAuthor>
407-
<Badge variant="info">Agent</Badge>
418+
{/* <Badge variant="info">Agent</Badge> */}
408419
<ThreadItemTimestamp
409420
dateTime={new Date(task.created_at).toISOString()}
410421
>
@@ -516,6 +527,47 @@ function SystemFeedRow({ message }: { message: ChannelFeedSystemMessage }) {
516527
);
517528
}
518529

530+
// Buttons derived from a task's live PR / merge state: "Merged" once it lands,
531+
// otherwise a "Review PR" action. A "View PR" button appears when the task has a
532+
// real PR URL. Used by the demo message's `task-pr` button preset, keyed off its
533+
// replied task; the buttons always render (they're a demo affordance) but the
534+
// state reflects the real task when it has one.
535+
function TaskPrButtons({ taskId }: { taskId: string }) {
536+
const { data } = useQuery({ ...taskDetailQuery(taskId), staleTime: 30_000 });
537+
const task = data ?? getCachedTask(taskId);
538+
const prUrl =
539+
typeof task?.latest_run?.output?.pr_url === "string"
540+
? task.latest_run.output.pr_url
541+
: undefined;
542+
const { prState } = useTaskPrStatus({
543+
id: taskId,
544+
cloudPrUrl: prUrl ?? null,
545+
taskRunEnvironment: task?.latest_run?.environment ?? null,
546+
});
547+
const merged = prState === "merged";
548+
const openPr = () =>
549+
prUrl
550+
? window.open(prUrl, "_blank", "noopener,noreferrer")
551+
: toast.success("Review started");
552+
return (
553+
<div className="mt-1.5 flex items-center gap-1.5">
554+
{merged ? (
555+
<Badge variant="default">Merged</Badge>
556+
) : (
557+
<Button variant="outline" size="sm" onClick={openPr}>
558+
Review PR
559+
</Button>
560+
)}
561+
{prUrl && (
562+
<Button variant="default" size="sm" onClick={openPr}>
563+
<ArrowSquareOutIcon size={14} />
564+
View PR
565+
</Button>
566+
)}
567+
</div>
568+
);
569+
}
570+
519571
// Initials for a free-text persona name (the demo composer lets you type any
520572
// "from"), so a fake human message gets a sensible avatar fallback.
521573
function initialsFromName(name: string): string {
@@ -536,6 +588,7 @@ export function DemoMessageItem({
536588
content,
537589
createdAt,
538590
replyTo,
591+
buttons,
539592
onDelete,
540593
}: {
541594
fromName: string;
@@ -544,6 +597,8 @@ export function DemoMessageItem({
544597
createdAt?: string;
545598
/** When set, renders a Slack-style "replied to a thread: …" preview line. */
546599
replyTo?: { label: string; href: string };
600+
/** When set, renders a preset row of action buttons. */
601+
buttons?: DemoButtonPreset;
547602
onDelete?: () => void;
548603
}) {
549604
const navigate = useNavigate();
@@ -578,7 +633,7 @@ export function DemoMessageItem({
578633
<ThreadItemContent className="min-w-0">
579634
<ThreadItemHeader>
580635
<ThreadItemAuthor>{fromName || "Someone"}</ThreadItemAuthor>
581-
{fromKind === "agent" && <Badge variant="info">Agent</Badge>}
636+
{/* {fromKind === "agent" && <Badge variant="info">Agent</Badge>} */}
582637
{createdAt && (
583638
<ThreadItemTimestamp dateTime={createdAt}>
584639
{formatRelativeTimeShort(createdAt)}
@@ -615,6 +670,28 @@ export function DemoMessageItem({
615670
</span>
616671
)}
617672
</ThreadItemBody>
673+
{buttons === "inbox-item" && (
674+
<div className="mt-1.5 flex items-center gap-1.5">
675+
<Button
676+
variant="outline"
677+
size="sm"
678+
onClick={() => toast.success("Review started")}
679+
>
680+
Review
681+
</Button>
682+
<Button
683+
variant="default"
684+
size="sm"
685+
aria-label="Archive"
686+
onClick={() => toast.success("Archived")}
687+
>
688+
<ArchiveIcon size={14} />
689+
</Button>
690+
</div>
691+
)}
692+
{buttons === "task-pr" && replyMatch && (
693+
<TaskPrButtons taskId={replyMatch[2]} />
694+
)}
618695
</ThreadItemContent>
619696
{onDelete && (
620697
<ThreadItemActions aria-label="Message actions" className="inset-bs-2">
@@ -657,6 +734,7 @@ function DemoFeedRow({
657734
content={demo.content}
658735
createdAt={message.createdAt}
659736
replyTo={demo.replyTo}
737+
buttons={demo.buttons}
660738
onDelete={onDelete ? () => onDelete(message.id) : undefined}
661739
/>
662740
</ChatMessageScrollerItem>

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
type ThreadReply,
2222
ThreadReplyPicker,
2323
} from "@posthog/ui/features/canvas/components/ThreadReplyPicker";
24+
import type { DemoButtonPreset } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages";
2425
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
2526
import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers";
2627
import { useDemoFeedStore } from "@posthog/ui/features/canvas/stores/demoFeedStore";
@@ -60,6 +61,7 @@ export function InsertFakeMessageDialog({
6061
const [fromKind, setFromKind] = useState<"human" | "agent">("human");
6162
const [content, setContent] = useState("");
6263
const [replyTo, setReplyTo] = useState<ThreadReply | null>(null);
64+
const [buttons, setButtons] = useState<DemoButtonPreset | null>(null);
6365

6466
// Reset fields on each open so a prior draft never lingers (prev-prop compare,
6567
// not an effect, so there's no stale flash).
@@ -71,6 +73,7 @@ export function InsertFakeMessageDialog({
7173
setFromKind("human");
7274
setContent("");
7375
setReplyTo(null);
76+
setButtons(null);
7477
}
7578
}
7679

@@ -105,6 +108,7 @@ export function InsertFakeMessageDialog({
105108
fromKind,
106109
content: content.trim(),
107110
replyTo: replyTo ?? undefined,
111+
buttons: buttons ?? undefined,
108112
});
109113
toast.success("Added to the channel feed");
110114
onOpenChange(false);
@@ -187,6 +191,26 @@ export function InsertFakeMessageDialog({
187191
/>
188192
</Field>
189193

194+
{/* Optional: a preset of action buttons on the message. */}
195+
<Field>
196+
<FieldLabel>Buttons (optional)</FieldLabel>
197+
<ToggleGroup
198+
value={[buttons ?? "none"]}
199+
onValueChange={(v) => {
200+
const next = v[0];
201+
setButtons(
202+
next === "inbox-item" || next === "task-pr" ? next : null,
203+
);
204+
}}
205+
>
206+
<ToggleGroupItem value="none">None</ToggleGroupItem>
207+
<ToggleGroupItem value="inbox-item">
208+
Inbox item buttons
209+
</ToggleGroupItem>
210+
<ToggleGroupItem value="task-pr">PR buttons</ToggleGroupItem>
211+
</ToggleGroup>
212+
</Field>
213+
190214
{/* Message body: a real thread composer, so @ opens the same member
191215
picker as a thread. The reference picker inserts canvas / channel /
192216
task / CONTEXT.md deep links. */}
@@ -220,6 +244,7 @@ export function InsertFakeMessageDialog({
220244
fromKind={fromKind}
221245
content={content}
222246
replyTo={replyTo ?? undefined}
247+
buttons={buttons ?? undefined}
223248
/>
224249
</div>
225250
</div>

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import "./mention-chip.css";
1616
import {
1717
ExternalLink,
1818
File,
19+
Inbox,
1920
type LucideIcon,
2021
Shapes,
2122
SquircleDashed,
@@ -45,6 +46,7 @@ function internalLinkMeta(
4546
): { Icon: LucideIcon; label: string } {
4647
if (href.includes("/dashboards/")) return { Icon: Shapes, label: text };
4748
if (href.endsWith("/context")) return { Icon: File, label: text };
49+
if (href.includes("/inbox/")) return { Icon: Inbox, label: text };
4850
if (href.startsWith("/website/")) {
4951
return { Icon: SquircleDashed, label: text.replace(/^#/, "") };
5052
}
@@ -100,8 +102,7 @@ export function MentionText({
100102
}, [content, markdownLinks]);
101103
const selfEmail = currentUserEmail?.toLowerCase();
102104
const router = useRouter();
103-
const linkClass =
104-
"text-primary underline underline-offset-2 hover:text-primary/80";
105+
const linkClass = "bg-info/50 px-1 rounded-xs";
105106
return (
106107
<Text size="1" className={className}>
107108
{segments.map(({ segment, key }) => {

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

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ArrowSquareOutIcon, LinkIcon } from "@phosphor-icons/react";
22
import {
3+
Badge,
34
Button,
45
DropdownMenu,
56
DropdownMenuContent,
@@ -18,8 +19,34 @@ import { useChannelFeed } from "@posthog/ui/features/canvas/hooks/useChannelFeed
1819
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
1920
import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards";
2021
import { useBackendChannel } from "@posthog/ui/features/canvas/hooks/useTaskChannels";
22+
import { useInboxReports } from "@posthog/ui/features/inbox/hooks/useInboxReports";
23+
import {
24+
type PrDiffStats,
25+
usePrDiffStatsBatch,
26+
} from "@posthog/ui/features/inbox/hooks/usePrDiffStatsBatch";
2127
import { useTasks } from "@posthog/ui/features/tasks/useTasks";
22-
import { File, Shapes, SquircleDashed } from "lucide-react";
28+
import { File, Inbox, Shapes, SquircleDashed } from "lucide-react";
29+
30+
// The inbox item's PR state as a compact badge + diff stats, so you can tell a
31+
// merged report from an open one when picking.
32+
function PrStatusBadge({ pr }: { pr: PrDiffStats }) {
33+
const { label, variant } = pr.merged
34+
? ({ label: "Merged", variant: "default" } as const)
35+
: pr.draft
36+
? ({ label: "Draft", variant: "default" } as const)
37+
: pr.state === "closed"
38+
? ({ label: "Closed", variant: "destructive" } as const)
39+
: ({ label: "PR", variant: "info" } as const);
40+
return (
41+
<span className="ms-auto flex shrink-0 items-center gap-1">
42+
<Badge variant={variant}>{label}</Badge>
43+
<span className="text-[11px] text-muted-foreground">
44+
<span className="text-green-11">+{pr.additions}</span>{" "}
45+
<span className="text-red-11">-{pr.deletions}</span>
46+
</span>
47+
</span>
48+
);
49+
}
2350

2451
// A menu for inserting an in-app reference (canvas / channel / task / this
2552
// channel's CONTEXT.md) as a deep link. Canvas / Channels / Tasks are submenus;
@@ -37,6 +64,13 @@ export function ReferencePicker({
3764
const { dashboards } = useDashboards(channelId);
3865
const { tasks: channelTasks } = useChannelFeed(backendChannel?.id);
3966
const { data: allTasks = [] } = useTasks();
67+
const { data: inbox } = useInboxReports();
68+
const inboxReports = inbox?.results ?? [];
69+
const { data: prStats } = usePrDiffStatsBatch(
70+
inboxReports
71+
.map((r) => r.implementation_pr_url)
72+
.filter((u): u is string => !!u),
73+
);
4074

4175
// Every task is referenceable: this channel's tasks first, then the rest.
4276
const channelTaskIds = new Set(channelTasks.map((t) => t.id));
@@ -138,6 +172,41 @@ export function ReferencePicker({
138172
</DropdownMenuSubContent>
139173
</DropdownMenuSub>
140174

175+
<DropdownMenuSub>
176+
<DropdownMenuSubTrigger>
177+
<Inbox size={14} className="text-muted-foreground" />
178+
Inbox
179+
</DropdownMenuSubTrigger>
180+
<DropdownMenuSubContent side="right" sideOffset={4} className="w-64">
181+
{inboxReports.length === 0 ? (
182+
<DropdownMenuItem disabled>No inbox items</DropdownMenuItem>
183+
) : (
184+
inboxReports.map((r) => {
185+
const pr = r.implementation_pr_url
186+
? prStats?.[r.implementation_pr_url]
187+
: undefined;
188+
return (
189+
<DropdownMenuItem
190+
key={r.id}
191+
onClick={() =>
192+
onInsert(
193+
r.title || "Untitled report",
194+
`/code/inbox/reports/${r.id}`,
195+
)
196+
}
197+
>
198+
<Inbox size={14} className="text-muted-foreground" />
199+
<span className="truncate">
200+
{r.title || "Untitled report"}
201+
</span>
202+
{pr && <PrStatusBadge pr={pr} />}
203+
</DropdownMenuItem>
204+
);
205+
})
206+
)}
207+
</DropdownMenuSubContent>
208+
</DropdownMenuSub>
209+
141210
<DropdownMenuItem
142211
onClick={() =>
143212
onInsert("CONTEXT.md", `/website/${channelId}/context`)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) {
7878
fromKind: e.fromKind,
7979
content: e.content,
8080
replyTo: e.replyTo,
81+
buttons: e.buttons,
8182
},
8283
}));
8384
const base = [...feedMessages, ...demoMessages];

packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,19 @@ const CHANNEL_FEED_MESSAGES_POLL_INTERVAL_MS = 5_000;
1515
// real author and offers no content field, so the shown persona + body ride the
1616
// payload) and rendered as a full thread item — avatar, name, markdown body —
1717
// rather than the plain announcement text.
18+
// A preset of action buttons shown on the message (a demo affordance).
19+
// `task-pr` derives its buttons from the replied task's live PR / merge state.
20+
export type DemoButtonPreset = "inbox-item" | "task-pr";
21+
1822
export interface DemoFeedMessage {
1923
fromName: string;
2024
fromKind: "human" | "agent";
2125
/** Markdown; may embed links to canvases / other channels. */
2226
content: string;
2327
/** When set, the message renders as a reply to this thread (a task deep link). */
2428
replyTo?: { label: string; href: string };
29+
/** When set, renders a preset row of action buttons on the message. */
30+
buttons?: DemoButtonPreset;
2531
}
2632

2733
// The `payload` key that marks a feed message as a demo composer post — the

0 commit comments

Comments
 (0)