Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { CHAT_CONTENT_MAX_WIDTH } from "@features/sessions/constants";
import type { IconProps } from "@phosphor-icons/react";
import {
BrainIcon,
BugIcon,
ChartLineIcon,
ClipboardTextIcon,
CodeIcon,
DatabaseIcon,
FileTextIcon,
FlagIcon,
FlaskIcon,
GaugeIcon,
GlobeIcon,
PlugIcon,
SparkleIcon,
TableIcon,
VideoIcon,
} from "@phosphor-icons/react";
import type { PostHogProductId } from "@posthog/agent";
import { Badge, Box, Flex, Text } from "@radix-ui/themes";
import type { AcpMessage } from "@shared/types/session-events";
import { openUrlInBrowser } from "@utils/browser";
import { type ComponentType, useMemo } from "react";
import { accumulateSessionResources } from "./accumulateSessionResources";

/**
* Icon per PostHog product. `Record<PostHogProductId, …>` keeps this exhaustive:
* adding a product id in `@posthog/agent` forces an icon here at compile time.
*/
const PRODUCT_ICON: Record<PostHogProductId, ComponentType<IconProps>> = {
product_analytics: ChartLineIcon,
web_analytics: GlobeIcon,
feature_flags: FlagIcon,
experiments: FlaskIcon,
error_tracking: BugIcon,
session_replay: VideoIcon,
surveys: ClipboardTextIcon,
llm_analytics: BrainIcon,
data_warehouse: DatabaseIcon,
cdp: PlugIcon,
logs: FileTextIcon,
apm: GaugeIcon,
sql: TableIcon,
code: CodeIcon,
posthog: SparkleIcon,
};

/**
* Docs page on posthog.com per product, so a chip links to the relevant
* product docs. `Partial` on purpose — products without a dedicated docs page
* (e.g. apm, which PostHog folds into LLM analytics / Logs) render as a plain,
* non-clickable badge rather than linking somewhere misleading.
*/
const PRODUCT_DOC_URL: Partial<Record<PostHogProductId, string>> = {
product_analytics: "https://posthog.com/docs/product-analytics",
web_analytics: "https://posthog.com/docs/web-analytics",
feature_flags: "https://posthog.com/docs/feature-flags",
experiments: "https://posthog.com/docs/experiments",
error_tracking: "https://posthog.com/docs/error-tracking",
session_replay: "https://posthog.com/docs/session-replay",
surveys: "https://posthog.com/docs/surveys",
llm_analytics: "https://posthog.com/docs/ai-observability",
data_warehouse: "https://posthog.com/docs/data-warehouse",
cdp: "https://posthog.com/docs/cdp",
logs: "https://posthog.com/docs/logs",
sql: "https://posthog.com/docs/sql",
code: "https://posthog.com/code",
posthog: "https://posthog.com/docs",
};

interface SessionResourcesBarProps {
events: AcpMessage[];
}

/**
* Persistent bar above the composer listing the PostHog products the agent has
* touched so far this session — via the MCP `exec` tool, or by reading a file
* from the codebase (the "Code" chip). Each product appears once and is added
* the moment it's first used. Hidden until at least one product has been used.
* Mirrors PlanStatusBar's placement and styling.
*/
export function SessionResourcesBar({ events }: SessionResourcesBarProps) {
const products = useMemo(() => accumulateSessionResources(events), [events]);

if (products.length === 0) return null;

return (
<Box className="mb-3">
<Box className="mx-auto" style={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }}>
<Flex align="center" gap="2" wrap="wrap" className="px-3 pt-2">
<Text color="gray" className="whitespace-nowrap text-[12px]">
PostHog resources used
</Text>
{products.map((product) => {
const Icon = PRODUCT_ICON[product.id] ?? SparkleIcon;
const docUrl = PRODUCT_DOC_URL[product.id];
return (
<Badge
key={product.id}
size="1"
color="gray"
variant="soft"
className={
docUrl ? "cursor-pointer hover:bg-gray-4" : undefined
}
onClick={
docUrl ? () => void openUrlInBrowser(docUrl) : undefined
}
title={docUrl ? `Open ${product.label} docs` : undefined}
>
<Icon size={12} />
{product.label}
</Badge>
);
})}
</Flex>
</Box>
</Box>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { PendingChatView } from "./PendingChatView";
import { PlanStatusBar } from "./PlanStatusBar";
import { ReasoningLevelSelector } from "./ReasoningLevelSelector";
import { RawLogsView } from "./raw-logs/RawLogsView";
import { SessionResourcesBar } from "./SessionResourcesBar";

interface SessionViewProps {
events: AcpMessage[];
Expand Down Expand Up @@ -604,6 +605,8 @@ export function SessionView({
compact={compact}
/>

<SessionResourcesBar events={events} />

<PlanStatusBar plan={latestPlan} />

{hasError && !showInlineBanner ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { AcpMessage } from "@shared/types/session-events";
import { describe, expect, it } from "vitest";
import { accumulateSessionResources } from "./accumulateSessionResources";

function resourcesUsedMsg(
ts: number,
products: { id: string; label: string }[],
): AcpMessage {
return {
type: "acp_message",
ts,
message: {
jsonrpc: "2.0",
method: "_posthog/resources_used",
params: { sessionId: "session-1", products },
},
};
}

describe("accumulateSessionResources", () => {
it("collects products across notifications in first-seen order", () => {
const events: AcpMessage[] = [
resourcesUsedMsg(1, [{ id: "feature_flags", label: "Feature flags" }]),
resourcesUsedMsg(2, [
{ id: "product_analytics", label: "Product analytics" },
]),
];

expect(accumulateSessionResources(events)).toEqual([
{ id: "feature_flags", label: "Feature flags" },
{ id: "product_analytics", label: "Product analytics" },
]);
});

it("de-duplicates a product used across multiple turns", () => {
const events: AcpMessage[] = [
resourcesUsedMsg(1, [{ id: "feature_flags", label: "Feature flags" }]),
resourcesUsedMsg(2, [{ id: "experiments", label: "Experiments" }]),
// feature_flags used again on a later turn — must not appear twice.
resourcesUsedMsg(3, [{ id: "feature_flags", label: "Feature flags" }]),
];

const result = accumulateSessionResources(events);
expect(result).toEqual([
{ id: "feature_flags", label: "Feature flags" },
{ id: "experiments", label: "Experiments" },
]);
});

it("ignores unrelated events and empty payloads", () => {
const events: AcpMessage[] = [
{
type: "acp_message",
ts: 1,
message: {
jsonrpc: "2.0",
method: "_posthog/turn_complete",
params: { stopReason: "end_turn" },
},
},
resourcesUsedMsg(2, []),
];

expect(accumulateSessionResources(events)).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
isNotification,
POSTHOG_NOTIFICATIONS,
type PostHogProductId,
} from "@posthog/agent";
import {
type AcpMessage,
isJsonRpcNotification,
} from "@shared/types/session-events";

export interface ResourceProduct {
id: PostHogProductId;
label: string;
}

/**
* Accumulate the de-duplicated, first-seen-ordered list of PostHog products
* used across the whole session, from its `_posthog/resources_used`
* notifications. Works for both live streaming and log replay, since both feed
* the same `events` array. A product used on several turns appears once.
*
* Kept in its own module (no React / tRPC imports) so it stays a cheap,
* dependency-free unit to test.
*/
export function accumulateSessionResources(
events: AcpMessage[],
): ResourceProduct[] {
const byId = new Map<PostHogProductId, ResourceProduct>();
for (const event of events) {
const msg = event.message;
if (!isJsonRpcNotification(msg)) continue;
if (!isNotification(msg.method, POSTHOG_NOTIFICATIONS.RESOURCES_USED)) {
continue;
}
const products = (
msg.params as { products?: ResourceProduct[] } | undefined
)?.products;
if (!products) continue;
for (const product of products) {
if (product && !byId.has(product.id)) byId.set(product.id, product);
}
}
return [...byId.values()];
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,38 @@ function turnCompleteMsg(ts: number, stopReason = "end_turn"): AcpMessage {
};
}

function agentMessageMsg(ts: number, text: string): AcpMessage {
return {
type: "acp_message",
ts,
message: {
jsonrpc: "2.0",
method: "session/update",
params: {
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text },
},
},
},
};
}

function resourcesUsedMsg(
ts: number,
products: { id: string; label: string }[],
): AcpMessage {
return {
type: "acp_message",
ts,
message: {
jsonrpc: "2.0",
method: "_posthog/resources_used",
params: { sessionId: "session-1", products },
},
};
}

describe("buildConversationItems", () => {
it("extracts cloud prompt attachments into user messages", () => {
const uri = makeAttachmentUri("/tmp/hello world.txt");
Expand Down Expand Up @@ -421,6 +453,30 @@ describe("buildConversationItems", () => {
expect(findProgressGroups(result.items)).toHaveLength(0);
});
});

describe("resources_used", () => {
it("does not render an inline item (surfaced in the persistent bar)", () => {
const events: AcpMessage[] = [
userPromptMsg(1, 1, "list my experiments"),
agentMessageMsg(2, "Here are your experiments."),
resourcesUsedMsg(3, [{ id: "experiments", label: "Experiments" }]),
promptResponseMsg(4, 1),
];

const result = buildConversationItems(events, false);

// The notification must not produce any conversation item — it's now
// handled out-of-band by SessionResourcesBar / accumulateSessionResources.
expect(
result.items.some(
(i) =>
i.type === "session_update" &&
// biome-ignore lint/suspicious/noExplicitAny: removed union member
(i.update.sessionUpdate as any) === "resources_used",
),
).toBe(false);
});
});
});

// Local alias kept intentionally narrow to the shape we care about in tests.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,10 @@ function handleNotification(
return;
}

// `_posthog/resources_used` is intentionally NOT rendered inline here — the
// products are surfaced as a persistent, de-duplicated bar above the composer
// (see accumulateSessionResources / SessionResourcesBar).

if (isNotification(msg.method, POSTHOG_NOTIFICATIONS.TURN_COMPLETE)) {
const params = msg.params as { stopReason?: string } | undefined;
if (!b.currentTurn) return;
Expand Down
3 changes: 3 additions & 0 deletions packages/agent/src/acp-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ export const POSTHOG_NOTIFICATIONS = {
/** Token usage update for a session turn */
USAGE_UPDATE: "_posthog/usage_update",

/** PostHog products used during a turn (derived from MCP exec calls) */
RESOURCES_USED: "_posthog/resources_used",

/** Response to a relayed permission request (plan approval, question) */
PERMISSION_RESPONSE: "_posthog/permission_response",

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ function installFakeSession(
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
sessionResources: new Set(),
configOptions: [],
promptRunning: false,
pendingMessages: new Map(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function installFakeSession(
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
sessionResources: new Set(),
configOptions: [],
promptRunning: false,
pendingMessages: new Map(),
Expand Down
Loading
Loading