Skip to content
Open
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
Expand Up @@ -190,11 +190,14 @@ export function AgentBuilderDock() {
}

return (
<Flex direction="column" className="h-full min-h-0 bg-background">
<Flex
direction="column"
className="h-full min-h-0 border-(--amber-5) border-l-2 bg-(--amber-1)/30"
>
<Flex
align="center"
gap="2"
className="shrink-0 border-(--gray-5) border-b px-3 py-2"
className="shrink-0 border-(--amber-4) border-b bg-(--amber-2)/40 px-3 py-2"
>
<SparkleIcon size={15} weight="fill" className="text-(--accent-9)" />
<Text className="font-medium text-[13px] text-gray-12">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,77 +1,116 @@
import { NavigationArrowIcon, SparkleIcon } from "@phosphor-icons/react";
import { agentChatStore } from "@posthog/core/agent-chat/agentChatStore";
import { SidebarSimpleIcon, SparkleIcon } from "@phosphor-icons/react";
import {
Button,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@posthog/quill";
import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
import { Button } from "@posthog/ui/primitives/Button";
import { Badge, Flex, Tooltip } from "@radix-ui/themes";
import { useStore } from "zustand";
import { Flex } from "@radix-ui/themes";
import { AGENT_PLATFORM_FLAG } from "../featureFlag";
import { headerActionForPage } from "./agentBuilderActions";
import {
AGENT_BUILDER_CHAT_ID,
type AgentBuilderPageContext,
useAgentBuilderStore,
} from "./agentBuilderStore";
import { EditWithAIButton } from "./EditWithAIButton";
import { useAgentBuilderStore } from "./agentBuilderStore";

/**
* The agents-header control cluster — identical across every agents view. Driven
* by the view's `context`, it renders:
* - a "Following" indicator while the agent builder is mid-turn and follow mode
* is on (so it's clear the builder is steering navigation),
* - a contextual AI button (New agent / Explain this session / …),
* - a "show" button that opens the dock, ONLY when it's hidden (the inverse of
* the hide button inside the dock header).
* All buttons are small. Renders nothing unless the `agent-platform` flag is on.
* The agents-header control cluster — identical across every agents view.
*
* One split button is the single entry point into the Agent Builder dock:
* - the primary segment is the contextual "edit with AI" action for the view
* you're on (New agent / Edit configuration / Explain this session / …) — it
* opens the dock and seeds the matching prompt,
* - the trailing segment just opens/closes the dock without seeding, so you
* can peek at or dismiss the existing conversation.
* The two were previously near-identical gold buttons; fusing them keeps both
* affordances but with one sparkle (the AI identity) and one neutral toggle.
* Views with no obvious action (Scouts) collapse to the lone open/close toggle.
* Renders nothing unless the `agent-platform` flag is on.
*/
export function AgentBuilderHeaderControls({
context,
}: {
context: AgentBuilderPageContext;
}) {
export function AgentBuilderHeaderControls() {
const enabled = useFeatureFlag(AGENT_PLATFORM_FLAG);
const visible = useAgentBuilderStore((s) => s.visible);
const setVisible = useAgentBuilderStore((s) => s.setVisible);
const followMode = useAgentBuilderStore((s) => s.followMode);
const status = useStore(
agentChatStore,
(s) => s.chats[AGENT_BUILDER_CHAT_ID]?.status,
);
const page = useAgentBuilderStore((s) => s.page);
const toggleVisible = useAgentBuilderStore((s) => s.toggleVisible);
const startAgentBuilder = useAgentBuilderStore((s) => s.startAgentBuilder);

if (!enabled) return null;

const running = status === "streaming" || status === "starting";
const action = headerActionForPage(context);
const action = headerActionForPage(page);
const toggleTip = visible
? "Hide the agent builder (⌘⇧I)"
: "Open the agent builder (⌘⇧I)";

return (
<Flex align="center" gap="2" className="shrink-0">
{running && followMode ? (
<Tooltip content="The agent builder is navigating this view">
<Badge color="purple" variant="soft" size="1">
<NavigationArrowIcon size={11} weight="fill" />
Following
</Badge>
</Tooltip>
) : null}
{action ? (
<EditWithAIButton
prompt={action.prompt}
agentSlug={action.agentSlug}
label={action.label}
size="1"
/>
) : null}
{!visible ? (
<Tooltip content="Open the agent builder (⌘⇧I)">
<Button
variant="outline"
size="1"
onClick={() => setVisible(true)}
aria-label="Open agent builder"
>
<SparkleIcon size={14} weight="fill" />
</Button>
</Tooltip>
) : null}
</Flex>
<TooltipProvider delay={500}>
<Flex align="center" gap="2" className="shrink-0">
{action ? (
<div className="flex items-center">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="sm"
className="rounded-s-[3px] rounded-e-none"
onClick={() =>
startAgentBuilder(action.prompt, action.agentSlug)
}
>
<SparkleIcon
size={14}
weight="fill"
className="text-(--accent-9)"
/>
{action.label}
</Button>
}
/>
<TooltipContent side="top">
Open the agent builder and start here
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
className="rounded-s-none rounded-e-[3px] border-s-0"
aria-label={toggleTip}
onClick={toggleVisible}
>
<SidebarSimpleIcon
size={14}
weight={visible ? "fill" : "regular"}
/>
</Button>
}
/>
<TooltipContent side="top">{toggleTip}</TooltipContent>
</Tooltip>
</div>
) : (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={toggleTip}
onClick={toggleVisible}
>
<SparkleIcon
size={14}
weight="fill"
className="text-(--accent-9)"
/>
</Button>
}
/>
<TooltipContent side="top">{toggleTip}</TooltipContent>
</Tooltip>
)}
</Flex>
</TooltipProvider>
);
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function headerActionForPage(
};
case "agent":
return {
label: "Ask about this agent",
label: "Explain this agent",
prompt: "Explain what this agent does and how it's configured.",
agentSlug: page.slug,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,39 @@ describe("createAgentChatMapper", () => {
expect(mapper.apply(ev("user_message", { text: "" }))).toEqual([]);
});

it("swallows the echo of an optimistically-seeded message", () => {
const mapper = createAgentChatMapper();
mapper.seedUserMessage("hello");
expect(mapper.apply(ev("user_message", { text: "hello" }))).toEqual([]);
});

it("swallows the echo even when the runner adds trailing whitespace", () => {
const mapper = createAgentChatMapper();
mapper.seedUserMessage("hello");
// Runners commonly normalize by adding a trailing newline — that mustn't
// break dedup or the user sees their bubble twice.
expect(mapper.apply(ev("user_message", { text: "hello\n" }))).toEqual([]);
});
Comment on lines +52 to +64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Per the repo's preference for parameterised tests, the first two new dedup tests probe the same behaviour (seed + echo → swallowed) with different string variants. Collapsing them into a single it.each table is more concise and makes adding more whitespace variants (e.g., leading spaces, both ends) trivial.

Suggested change
it("swallows the echo of an optimistically-seeded message", () => {
const mapper = createAgentChatMapper();
mapper.seedUserMessage("hello");
expect(mapper.apply(ev("user_message", { text: "hello" }))).toEqual([]);
});
it("swallows the echo even when the runner adds trailing whitespace", () => {
const mapper = createAgentChatMapper();
mapper.seedUserMessage("hello");
// Runners commonly normalize by adding a trailing newline — that mustn't
// break dedup or the user sees their bubble twice.
expect(mapper.apply(ev("user_message", { text: "hello\n" }))).toEqual([]);
});
it.each([
["exact match", "hello", "hello"],
["trailing newline", "hello", "hello\n"],
["leading spaces", "hello", " hello"],
])(
"swallows the echo of an optimistically-seeded message (%s)",
(_, seeded, echoed) => {
const mapper = createAgentChatMapper();
mapper.seedUserMessage(seeded);
expect(mapper.apply(ev("user_message", { text: echoed }))).toEqual([]);
},
);

Context Used: Do not attempt to comment on incorrect alphabetica... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/ui/src/features/agent-applications/chat/sessionEventToAcp.test.ts
Line: 52-64

Comment:
Per the repo's preference for parameterised tests, the first two new dedup tests probe the same behaviour (seed + echo → swallowed) with different string variants. Collapsing them into a single `it.each` table is more concise and makes adding more whitespace variants (e.g., leading spaces, both ends) trivial.

```suggestion
  it.each([
    ["exact match", "hello", "hello"],
    ["trailing newline", "hello", "hello\n"],
    ["leading spaces", "hello", "  hello"],
  ])(
    "swallows the echo of an optimistically-seeded message (%s)",
    (_, seeded, echoed) => {
      const mapper = createAgentChatMapper();
      mapper.seedUserMessage(seeded);
      expect(mapper.apply(ev("user_message", { text: echoed }))).toEqual([]);
    },
  );
```

**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


it("swallows echoes out of order across rapid sends", () => {
const mapper = createAgentChatMapper();
mapper.seedUserMessage("first");
mapper.seedUserMessage("second");
// Echoes arrive in reverse — both must dedup, neither should render.
expect(mapper.apply(ev("user_message", { text: "second" }))).toEqual([]);
expect(mapper.apply(ev("user_message", { text: "first" }))).toEqual([]);
});

it("drops a duplicate user_message the runner re-emits", () => {
const mapper = createAgentChatMapper();
mapper.seedUserMessage("hello");
expect(mapper.apply(ev("user_message", { text: "hello" }))).toEqual([]);
// The runner re-emits the same user_message later in the stream — there's
// nothing left in `pendingOptimistic`, but we've already rendered this
// text, so it must still be dropped.
expect(mapper.apply(ev("user_message", { text: "hello" }))).toEqual([]);
});

it("maps text and thinking deltas", () => {
const mapper = createAgentChatMapper();
expect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,13 @@ export function createAgentChatMapper(): AgentChatMapper {
let promptId = 0;
const seenToolCalls = new Set<string>();
// Texts shown optimistically and awaiting their echoed `user_message` frame.
// Compared by trimmed form so runner-side whitespace normalization
// (trailing `\n`, padding around envelopes, etc.) doesn't break dedup.
const pendingOptimistic: string[] = [];
// Every user text we've rendered this session, normalized. Catches the runner
// re-emitting the same `user_message` event twice (the second arrival has
// nothing left in `pendingOptimistic` to swallow it).
const seenUserTexts = new Set<string>();

return {
seedUserMessage(text: string, ts?: number): AcpMessage[] {
Expand All @@ -80,6 +86,7 @@ export function createAgentChatMapper(): AgentChatMapper {
}
promptId += 1;
pendingOptimistic.push(text);
seenUserTexts.add(text.trim());
return [promptRequestMessage(promptId, text, ts ?? Date.now())];
},

Expand All @@ -99,11 +106,25 @@ export function createAgentChatMapper(): AgentChatMapper {
// so it never shows in the transcript (and so dedup matches the clean
// optimistic text the composer rendered).
const text = stripConsoleContext(event.data.text);
// Already rendered optimistically on send — swallow the echo.
if (pendingOptimistic[0] === text) {
pendingOptimistic.shift();
const normalized = text.trim();
// Echo of a message we already rendered optimistically. Scan the
// queue (not just `[0]`) so out-of-order echoes from rapid sends
// still match, and compare trimmed forms so trailing/leading
// whitespace from the runner doesn't break the match.
const pendingIdx = pendingOptimistic.findIndex(
(p) => p.trim() === normalized,
);
if (pendingIdx !== -1) {
pendingOptimistic.splice(pendingIdx, 1);
return [];
}
// The runner re-emitted a `user_message` we've already rendered
// (either as optimistic seed or as a non-dedup'd echo). Drop it so
// the bubble doesn't appear twice.
if (seenUserTexts.has(normalized)) {
return [];
}
seenUserTexts.add(normalized);
promptId += 1;
return [promptRequestMessage(promptId, text, ts)];
}
Expand Down
Loading
Loading