Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 5a638ea

Browse files
authored
feat(agent): show PostHog products used below each turn (#2476)
1 parent 8688938 commit 5a638ea

17 files changed

Lines changed: 862 additions & 2 deletions
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { CHAT_CONTENT_MAX_WIDTH } from "@features/sessions/constants";
2+
import type { IconProps } from "@phosphor-icons/react";
3+
import {
4+
BrainIcon,
5+
BugIcon,
6+
ChartLineIcon,
7+
ClipboardTextIcon,
8+
CodeIcon,
9+
DatabaseIcon,
10+
FileTextIcon,
11+
FlagIcon,
12+
FlaskIcon,
13+
GaugeIcon,
14+
GlobeIcon,
15+
PlugIcon,
16+
SparkleIcon,
17+
TableIcon,
18+
VideoIcon,
19+
} from "@phosphor-icons/react";
20+
import type { PostHogProductId } from "@posthog/agent";
21+
import { Badge, Box, Flex, Text } from "@radix-ui/themes";
22+
import type { AcpMessage } from "@shared/types/session-events";
23+
import { openUrlInBrowser } from "@utils/browser";
24+
import { type ComponentType, useMemo } from "react";
25+
import { accumulateSessionResources } from "./accumulateSessionResources";
26+
27+
/**
28+
* Icon per PostHog product. `Record<PostHogProductId, …>` keeps this exhaustive:
29+
* adding a product id in `@posthog/agent` forces an icon here at compile time.
30+
*/
31+
const PRODUCT_ICON: Record<PostHogProductId, ComponentType<IconProps>> = {
32+
product_analytics: ChartLineIcon,
33+
web_analytics: GlobeIcon,
34+
feature_flags: FlagIcon,
35+
experiments: FlaskIcon,
36+
error_tracking: BugIcon,
37+
session_replay: VideoIcon,
38+
surveys: ClipboardTextIcon,
39+
llm_analytics: BrainIcon,
40+
data_warehouse: DatabaseIcon,
41+
cdp: PlugIcon,
42+
logs: FileTextIcon,
43+
apm: GaugeIcon,
44+
sql: TableIcon,
45+
code: CodeIcon,
46+
posthog: SparkleIcon,
47+
};
48+
49+
/**
50+
* Docs page on posthog.com per product, so a chip links to the relevant
51+
* product docs. `Partial` on purpose — products without a dedicated docs page
52+
* (e.g. apm, which PostHog folds into LLM analytics / Logs) render as a plain,
53+
* non-clickable badge rather than linking somewhere misleading.
54+
*/
55+
const PRODUCT_DOC_URL: Partial<Record<PostHogProductId, string>> = {
56+
product_analytics: "https://posthog.com/docs/product-analytics",
57+
web_analytics: "https://posthog.com/docs/web-analytics",
58+
feature_flags: "https://posthog.com/docs/feature-flags",
59+
experiments: "https://posthog.com/docs/experiments",
60+
error_tracking: "https://posthog.com/docs/error-tracking",
61+
session_replay: "https://posthog.com/docs/session-replay",
62+
surveys: "https://posthog.com/docs/surveys",
63+
llm_analytics: "https://posthog.com/docs/ai-observability",
64+
data_warehouse: "https://posthog.com/docs/data-warehouse",
65+
cdp: "https://posthog.com/docs/cdp",
66+
logs: "https://posthog.com/docs/logs",
67+
sql: "https://posthog.com/docs/sql",
68+
code: "https://posthog.com/code",
69+
posthog: "https://posthog.com/docs",
70+
};
71+
72+
interface SessionResourcesBarProps {
73+
events: AcpMessage[];
74+
}
75+
76+
/**
77+
* Persistent bar above the composer listing the PostHog products the agent has
78+
* touched so far this session — via the MCP `exec` tool, or by reading a file
79+
* from the codebase (the "Code" chip). Each product appears once and is added
80+
* the moment it's first used. Hidden until at least one product has been used.
81+
* Mirrors PlanStatusBar's placement and styling.
82+
*/
83+
export function SessionResourcesBar({ events }: SessionResourcesBarProps) {
84+
const products = useMemo(() => accumulateSessionResources(events), [events]);
85+
86+
if (products.length === 0) return null;
87+
88+
return (
89+
<Box className="mb-3">
90+
<Box className="mx-auto" style={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }}>
91+
<Flex align="center" gap="2" wrap="wrap" className="px-3 pt-2">
92+
<Text color="gray" className="whitespace-nowrap text-[12px]">
93+
PostHog resources used
94+
</Text>
95+
{products.map((product) => {
96+
const Icon = PRODUCT_ICON[product.id] ?? SparkleIcon;
97+
const docUrl = PRODUCT_DOC_URL[product.id];
98+
return (
99+
<Badge
100+
key={product.id}
101+
size="1"
102+
color="gray"
103+
variant="soft"
104+
className={
105+
docUrl ? "cursor-pointer hover:bg-gray-4" : undefined
106+
}
107+
onClick={
108+
docUrl ? () => void openUrlInBrowser(docUrl) : undefined
109+
}
110+
title={docUrl ? `Open ${product.label} docs` : undefined}
111+
>
112+
<Icon size={12} />
113+
{product.label}
114+
</Badge>
115+
);
116+
})}
117+
</Flex>
118+
</Box>
119+
</Box>
120+
);
121+
}

apps/code/src/renderer/features/sessions/components/SessionView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { PendingChatView } from "./PendingChatView";
4848
import { PlanStatusBar } from "./PlanStatusBar";
4949
import { ReasoningLevelSelector } from "./ReasoningLevelSelector";
5050
import { RawLogsView } from "./raw-logs/RawLogsView";
51+
import { SessionResourcesBar } from "./SessionResourcesBar";
5152

5253
interface SessionViewProps {
5354
events: AcpMessage[];
@@ -604,6 +605,8 @@ export function SessionView({
604605
compact={compact}
605606
/>
606607

608+
<SessionResourcesBar events={events} />
609+
607610
<PlanStatusBar plan={latestPlan} />
608611

609612
{hasError && !showInlineBanner ? (
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { AcpMessage } from "@shared/types/session-events";
2+
import { describe, expect, it } from "vitest";
3+
import { accumulateSessionResources } from "./accumulateSessionResources";
4+
5+
function resourcesUsedMsg(
6+
ts: number,
7+
products: { id: string; label: string }[],
8+
): AcpMessage {
9+
return {
10+
type: "acp_message",
11+
ts,
12+
message: {
13+
jsonrpc: "2.0",
14+
method: "_posthog/resources_used",
15+
params: { sessionId: "session-1", products },
16+
},
17+
};
18+
}
19+
20+
describe("accumulateSessionResources", () => {
21+
it("collects products across notifications in first-seen order", () => {
22+
const events: AcpMessage[] = [
23+
resourcesUsedMsg(1, [{ id: "feature_flags", label: "Feature flags" }]),
24+
resourcesUsedMsg(2, [
25+
{ id: "product_analytics", label: "Product analytics" },
26+
]),
27+
];
28+
29+
expect(accumulateSessionResources(events)).toEqual([
30+
{ id: "feature_flags", label: "Feature flags" },
31+
{ id: "product_analytics", label: "Product analytics" },
32+
]);
33+
});
34+
35+
it("de-duplicates a product used across multiple turns", () => {
36+
const events: AcpMessage[] = [
37+
resourcesUsedMsg(1, [{ id: "feature_flags", label: "Feature flags" }]),
38+
resourcesUsedMsg(2, [{ id: "experiments", label: "Experiments" }]),
39+
// feature_flags used again on a later turn — must not appear twice.
40+
resourcesUsedMsg(3, [{ id: "feature_flags", label: "Feature flags" }]),
41+
];
42+
43+
const result = accumulateSessionResources(events);
44+
expect(result).toEqual([
45+
{ id: "feature_flags", label: "Feature flags" },
46+
{ id: "experiments", label: "Experiments" },
47+
]);
48+
});
49+
50+
it("ignores unrelated events and empty payloads", () => {
51+
const events: AcpMessage[] = [
52+
{
53+
type: "acp_message",
54+
ts: 1,
55+
message: {
56+
jsonrpc: "2.0",
57+
method: "_posthog/turn_complete",
58+
params: { stopReason: "end_turn" },
59+
},
60+
},
61+
resourcesUsedMsg(2, []),
62+
];
63+
64+
expect(accumulateSessionResources(events)).toEqual([]);
65+
});
66+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {
2+
isNotification,
3+
POSTHOG_NOTIFICATIONS,
4+
type PostHogProductId,
5+
} from "@posthog/agent";
6+
import {
7+
type AcpMessage,
8+
isJsonRpcNotification,
9+
} from "@shared/types/session-events";
10+
11+
export interface ResourceProduct {
12+
id: PostHogProductId;
13+
label: string;
14+
}
15+
16+
/**
17+
* Accumulate the de-duplicated, first-seen-ordered list of PostHog products
18+
* used across the whole session, from its `_posthog/resources_used`
19+
* notifications. Works for both live streaming and log replay, since both feed
20+
* the same `events` array. A product used on several turns appears once.
21+
*
22+
* Kept in its own module (no React / tRPC imports) so it stays a cheap,
23+
* dependency-free unit to test.
24+
*/
25+
export function accumulateSessionResources(
26+
events: AcpMessage[],
27+
): ResourceProduct[] {
28+
const byId = new Map<PostHogProductId, ResourceProduct>();
29+
for (const event of events) {
30+
const msg = event.message;
31+
if (!isJsonRpcNotification(msg)) continue;
32+
if (!isNotification(msg.method, POSTHOG_NOTIFICATIONS.RESOURCES_USED)) {
33+
continue;
34+
}
35+
const products = (
36+
msg.params as { products?: ResourceProduct[] } | undefined
37+
)?.products;
38+
if (!products) continue;
39+
for (const product of products) {
40+
if (product && !byId.has(product.id)) byId.set(product.id, product);
41+
}
42+
}
43+
return [...byId.values()];
44+
}

apps/code/src/renderer/features/sessions/components/buildConversationItems.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,38 @@ function turnCompleteMsg(ts: number, stopReason = "end_turn"): AcpMessage {
7474
};
7575
}
7676

77+
function agentMessageMsg(ts: number, text: string): AcpMessage {
78+
return {
79+
type: "acp_message",
80+
ts,
81+
message: {
82+
jsonrpc: "2.0",
83+
method: "session/update",
84+
params: {
85+
update: {
86+
sessionUpdate: "agent_message_chunk",
87+
content: { type: "text", text },
88+
},
89+
},
90+
},
91+
};
92+
}
93+
94+
function resourcesUsedMsg(
95+
ts: number,
96+
products: { id: string; label: string }[],
97+
): AcpMessage {
98+
return {
99+
type: "acp_message",
100+
ts,
101+
message: {
102+
jsonrpc: "2.0",
103+
method: "_posthog/resources_used",
104+
params: { sessionId: "session-1", products },
105+
},
106+
};
107+
}
108+
77109
describe("buildConversationItems", () => {
78110
it("extracts cloud prompt attachments into user messages", () => {
79111
const uri = makeAttachmentUri("/tmp/hello world.txt");
@@ -421,6 +453,30 @@ describe("buildConversationItems", () => {
421453
expect(findProgressGroups(result.items)).toHaveLength(0);
422454
});
423455
});
456+
457+
describe("resources_used", () => {
458+
it("does not render an inline item (surfaced in the persistent bar)", () => {
459+
const events: AcpMessage[] = [
460+
userPromptMsg(1, 1, "list my experiments"),
461+
agentMessageMsg(2, "Here are your experiments."),
462+
resourcesUsedMsg(3, [{ id: "experiments", label: "Experiments" }]),
463+
promptResponseMsg(4, 1),
464+
];
465+
466+
const result = buildConversationItems(events, false);
467+
468+
// The notification must not produce any conversation item — it's now
469+
// handled out-of-band by SessionResourcesBar / accumulateSessionResources.
470+
expect(
471+
result.items.some(
472+
(i) =>
473+
i.type === "session_update" &&
474+
// biome-ignore lint/suspicious/noExplicitAny: removed union member
475+
(i.update.sessionUpdate as any) === "resources_used",
476+
),
477+
).toBe(false);
478+
});
479+
});
424480
});
425481

426482
// Local alias kept intentionally narrow to the shape we care about in tests.

apps/code/src/renderer/features/sessions/components/buildConversationItems.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,10 @@ function handleNotification(
380380
return;
381381
}
382382

383+
// `_posthog/resources_used` is intentionally NOT rendered inline here — the
384+
// products are surfaced as a persistent, de-duplicated bar above the composer
385+
// (see accumulateSessionResources / SessionResourcesBar).
386+
383387
if (isNotification(msg.method, POSTHOG_NOTIFICATIONS.TURN_COMPLETE)) {
384388
const params = msg.params as { stopReason?: string } | undefined;
385389
if (!b.currentTurn) return;

packages/agent/src/acp-extensions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ export const POSTHOG_NOTIFICATIONS = {
6767
/** Token usage update for a session turn */
6868
USAGE_UPDATE: "_posthog/usage_update",
6969

70+
/** PostHog products used during a turn (derived from MCP exec calls) */
71+
RESOURCES_USED: "_posthog/resources_used",
72+
7073
/** Response to a relayed permission request (plan approval, question) */
7174
PERMISSION_RESPONSE: "_posthog/permission_response",
7275

packages/agent/src/adapters/claude/claude-agent.refresh.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ function installFakeSession(
9797
cachedReadTokens: 0,
9898
cachedWriteTokens: 0,
9999
},
100+
sessionResources: new Set(),
100101
configOptions: [],
101102
promptRunning: false,
102103
pendingMessages: new Map(),

packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ function installFakeSession(
5858
cachedReadTokens: 0,
5959
cachedWriteTokens: 0,
6060
},
61+
sessionResources: new Set(),
6162
configOptions: [],
6263
promptRunning: false,
6364
pendingMessages: new Map(),

0 commit comments

Comments
 (0)