From 7a2094ed9fe86c774282bda3a3f470b62b3124d9 Mon Sep 17 00:00:00 2001 From: dmarticus Date: Wed, 17 Jun 2026 14:12:08 -0700 Subject: [PATCH 1/6] refactor(agents): applications landing polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the contextual "New agent / Edit with AI" header CTA on every agents view; the always-on sparkles dock toggle is the single entry point. The dock toggle adds aria + tooltip and a "Following" badge while the builder is mid-turn. Delete EditWithAIButton + agentBuilderActions (only callers were inside this header). - Tint the Agent Builder dock with a subtle amber accent (left border + faint header wash) so it reads as the meta-tool rather than another task chat. Header label and sparkle icon were already amber-accent. - Reorder the Applications landing: agents list moves to the top, sectioned LIVE vs DRAFTS (drafts dimmer with count in the section label, hidden when empty). The activity rollup hides when analytics.empty, and the operational strip drops the live-count chip (the Live now panel below already owns that signal) and renders only the pending-approvals link, amber-tinted when non-zero. - Always render the Live now panel; it owns its own "Nothing running" empty state. - Tighten the per-tab description copy on the Agents shell: shorter Applications line that mirrors the Scouts shape — "[noun] that [what they do] — [what you do here]" — and fits on one line. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../agent-builder/AgentBuilderDock.tsx | 7 +- .../AgentBuilderHeaderControls.tsx | 31 +--- .../agent-builder/EditWithAIButton.tsx | 35 ---- .../agent-builder/agentBuilderActions.ts | 76 -------- .../components/AgentApplicationsListView.tsx | 175 +++++++++++------- .../components/AgentDetailLayout.tsx | 2 +- .../components/AgentSessionTranscriptView.tsx | 2 +- .../agents/components/AgentsTabLayout.tsx | 4 +- 8 files changed, 125 insertions(+), 207 deletions(-) delete mode 100644 packages/ui/src/features/agent-applications/agent-builder/EditWithAIButton.tsx delete mode 100644 packages/ui/src/features/agent-applications/agent-builder/agentBuilderActions.ts diff --git a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx index d7408db794..3157211e83 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx +++ b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx @@ -190,11 +190,14 @@ export function AgentBuilderDock() { } return ( - + diff --git a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderHeaderControls.tsx b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderHeaderControls.tsx index 6112ebc01c..d33dc3a572 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderHeaderControls.tsx +++ b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderHeaderControls.tsx @@ -5,29 +5,19 @@ import { Button } from "@posthog/ui/primitives/Button"; import { Badge, Flex, Tooltip } from "@radix-ui/themes"; import { useStore } from "zustand"; import { AGENT_PLATFORM_FLAG } from "../featureFlag"; -import { headerActionForPage } from "./agentBuilderActions"; import { AGENT_BUILDER_CHAT_ID, - type AgentBuilderPageContext, useAgentBuilderStore, } from "./agentBuilderStore"; -import { EditWithAIButton } from "./EditWithAIButton"; /** - * 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. + * Authoring lives entirely inside the dock (there is no native "new agent" + * form), so the only header affordance is the dock toggle itself, plus a + * "Following" indicator while the builder is mid-turn with follow mode on. + * 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); @@ -40,7 +30,6 @@ export function AgentBuilderHeaderControls({ if (!enabled) return null; const running = status === "streaming" || status === "starting"; - const action = headerActionForPage(context); return ( @@ -52,14 +41,6 @@ export function AgentBuilderHeaderControls({ ) : null} - {action ? ( - - ) : null} {!visible ? ( - ); -} diff --git a/packages/ui/src/features/agent-applications/agent-builder/agentBuilderActions.ts b/packages/ui/src/features/agent-applications/agent-builder/agentBuilderActions.ts deleted file mode 100644 index f9cc0b4d43..0000000000 --- a/packages/ui/src/features/agent-applications/agent-builder/agentBuilderActions.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { AgentBuilderPageContext } from "./agentBuilderStore"; - -export interface AgentBuilderAction { - /** Short button label, e.g. "New agent" / "Explain this session". */ - label: string; - /** Seed prompt sent to the agent builder when clicked. */ - prompt: string; - /** Subject agent for the context envelope (null for fleet-level actions). */ - agentSlug: string | null; -} - -/** - * The contextual agent-builder action for a given view — the AI button's - * content. Drives the abstract header controls so every agents view gets a - * button that fits what you're looking at. Returns null for views with no - * obvious action (just the show/following affordances remain). - */ -export function headerActionForPage( - page: AgentBuilderPageContext, -): AgentBuilderAction | null { - switch (page.kind) { - case "agent-list": - return { - label: "New agent", - prompt: - "Help me create a new agent — walk me through what it should do, then set it up.", - agentSlug: null, - }; - case "agent": - return { - label: "Ask about this agent", - prompt: "Explain what this agent does and how it's configured.", - agentSlug: page.slug, - }; - case "agent-config": - return { - label: "Edit configuration", - prompt: "Help me change this agent's configuration.", - agentSlug: page.slug, - }; - case "agent-sessions": - return { - label: "Review sessions", - prompt: - "Review this agent's recent sessions and surface anything notable.", - agentSlug: page.slug, - }; - case "agent-session": - return { - label: "Explain this session", - prompt: "Explain what happened in this session, step by step.", - agentSlug: page.slug, - }; - case "agent-approvals": - return { - label: "Review approvals", - prompt: "Review the pending approval requests for this agent.", - agentSlug: page.slug, - }; - case "agent-memory": - return { - label: "Ask about memory", - prompt: "Summarize what's stored in this agent's memory.", - agentSlug: page.slug, - }; - case "agent-observability": - return { - label: "Ask about performance", - prompt: - "Summarize this agent's spend, volume, and failure rate, and call out anything notable.", - agentSlug: page.slug, - }; - default: - return null; - } -} diff --git a/packages/ui/src/features/agent-applications/components/AgentApplicationsListView.tsx b/packages/ui/src/features/agent-applications/components/AgentApplicationsListView.tsx index 11f38f5ae0..b781e5c8fd 100644 --- a/packages/ui/src/features/agent-applications/components/AgentApplicationsListView.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentApplicationsListView.tsx @@ -1,6 +1,5 @@ import { ArrowSquareOutIcon, - BroadcastIcon, CaretRightIcon, LockKeyIcon, RobotIcon, @@ -19,7 +18,6 @@ import { useAuthStateValue } from "../../auth/store"; import { useAgentAnalytics } from "../hooks/useAgentAnalytics"; import { useAgentApplications } from "../hooks/useAgentApplications"; import { useAgentFleetApprovals } from "../hooks/useAgentFleetApprovals"; -import { useAgentFleetLiveSessions } from "../hooks/useAgentFleetLiveSessions"; import { formatSpendUsd } from "../utils/format"; import { aiObservabilityTracesUrl } from "../utils/observabilityLinks"; import { AgentAnalyticsKpiStrip } from "./AgentAnalyticsView"; @@ -27,11 +25,10 @@ import { AgentDetailEmptyState } from "./AgentDetailLayout"; import { AgentFleetLiveSessionsPanel } from "./AgentFleetLiveSessionsPanel"; /** - * The Applications tab: the fleet observability KPIs (spend / sessions / - * failure rate / p95 over the team's `$ai_*` events) blended on top of the list - * of deployed agents. The per-agent rollups from the same analytics query are - * merged into each list row as inline stats, so one fetch powers both the KPI - * strip and the rows. Each row links to the per-agent detail view. + * The Applications tab. Renders the deployed-agent fleet as the primary + * surface, with operational / activity / live-now panels appearing below the + * list only when they have something to say. A quiet fleet still feels like a + * launchpad: just the agents, sectioned LIVE vs DRAFTS. */ export function AgentApplicationsListView() { const region = useAuthStateValue((s) => s.cloudRegion); @@ -44,11 +41,10 @@ export function AgentApplicationsListView() { error, } = useAgentApplications(); const { data: analytics, isLoading: analyticsLoading } = useAgentAnalytics(); - const { data: liveSessions } = useAgentFleetLiveSessions(); const { data: queuedApprovals } = useAgentFleetApprovals({ state: "queued" }); const aiObservabilityUrl = aiObservabilityTracesUrl(region, projectId); - const liveCount = liveSessions?.results.length ?? 0; const pendingCount = queuedApprovals?.length ?? 0; + const hasAnalytics = analytics ? !analytics.empty : false; // Index the per-agent rollups by application id so each row can show its own // sessions / spend / failure rate without a second request. @@ -60,39 +56,22 @@ export function AgentApplicationsListView() { return map; }, [analytics]); + // Split LIVE vs DRAFT so the operational view foregrounds what's serving + // traffic; drafts dim and section below. + const { liveApps, draftApps } = useMemo(() => { + const live: AgentApplication[] = []; + const draft: AgentApplication[] = []; + for (const app of applications ?? []) { + if (app.live_revision != null) live.push(app); + else draft.push(app); + } + return { liveApps: live, draftApps: draft }; + }, [applications]); + return ( - - - +
- - - Activity · last 7 days - - {aiObservabilityUrl ? ( - - ) : null} - - -
- - - - - - Agents - {isLoading ? ( ) : isError ? ( @@ -110,20 +89,95 @@ export function AgentApplicationsListView() { description="Deployed agents on the agent platform will show up here." /> ) : ( - applications.map((app) => ( - + - )) + {draftApps.length > 0 ? ( + + ) : null} + )} -
+ + + + + {hasAnalytics ? ( +
+ + + Activity · last 7 days + + {aiObservabilityUrl ? ( + + ) : null} + + +
+ ) : null} + +
); } +/** A labeled group of agent rows; `dimmed` softens drafts so live agents + * dominate the visual hierarchy. */ +function AgentsSection({ + label, + apps, + statsById, + dimmed, +}: { + label: string; + apps: AgentApplication[]; + statsById: Map; + dimmed?: boolean; +}) { + if (apps.length === 0) return null; + return ( + + + + {label} + + + {apps.length} + + +
+ + {apps.map((app) => ( + + ))} + +
+
+ ); +} + function ApplicationRow({ application, stats, @@ -205,33 +259,24 @@ function RowStat({ } /** - * Operational counts strip — restores the "live now / pending approvals" - * signals the M7 analytics KPIs displaced. Live count anchors the live-now - * panel below; pending links to the fleet approvals queue. + * Operational counts strip — only rendered when something is actually + * happening. Pending deep-links to the fleet approvals queue; live anchors the + * live-now panel below. */ -function OperationalStrip({ - liveCount, - pendingCount, -}: { - liveCount: number; - pendingCount: number; -}) { +function OperationalStrip({ pendingCount }: { pendingCount: number }) { + const pendingAttention = pendingCount > 0; return ( -
- - - {liveCount} - - live now -
- + 0 ? "text-(--amber-11)" : "text-gray-12"}`} + className={`font-medium tabular-nums ${pendingAttention ? "text-(--amber-11)" : "text-gray-12"}`} > {pendingCount} diff --git a/packages/ui/src/features/agent-applications/components/AgentDetailLayout.tsx b/packages/ui/src/features/agent-applications/components/AgentDetailLayout.tsx index 9c902442b7..89d1db64bb 100644 --- a/packages/ui/src/features/agent-applications/components/AgentDetailLayout.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentDetailLayout.tsx @@ -153,7 +153,7 @@ export function AgentDetailLayout({ ) : null} - +
{application?.description?.trim() ? ( diff --git a/packages/ui/src/features/agent-applications/components/AgentSessionTranscriptView.tsx b/packages/ui/src/features/agent-applications/components/AgentSessionTranscriptView.tsx index 55137b3324..eb57817cbb 100644 --- a/packages/ui/src/features/agent-applications/components/AgentSessionTranscriptView.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentSessionTranscriptView.tsx @@ -54,7 +54,7 @@ export function AgentSessionTranscriptView({ Session transcript - +
diff --git a/packages/ui/src/features/agents/components/AgentsTabLayout.tsx b/packages/ui/src/features/agents/components/AgentsTabLayout.tsx index bbf98a66a1..5e093097c2 100644 --- a/packages/ui/src/features/agents/components/AgentsTabLayout.tsx +++ b/packages/ui/src/features/agents/components/AgentsTabLayout.tsx @@ -16,7 +16,7 @@ const TAB_DESCRIPTION: Record = { scouts: "Self-driving agents that watch your project and surface work for review — enroll in the canonical fleet or author your own.", applications: - "Your deployed agents — browse their sessions, approvals, configuration and revisions, or build and edit them with the Agent Builder.", + "Deployed agents triggered by chat, Slack, webhooks, or cron — preview, approve, and edit with the Agent Builder.", }; /** @@ -67,7 +67,7 @@ export function AgentsTabLayout({ : "Design, schedule, and deploy the agents that work on your product."} - + Date: Wed, 17 Jun 2026 14:12:38 -0700 Subject: [PATCH 2/6] fix(agents): dedup user_message echoes robustly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The optimistic-seed → SSE-echo dedup in createAgentChatMapper compared strict-equal on `pendingOptimistic[0]`, which broke in three real failure modes — all of which surfaced the same way: the user's just- sent bubble rendered twice. - Whitespace asymmetry. The composer trims before seeding, but the runner sometimes echoes back with a trailing `\n` or padding around the agent-builder context envelope. Strict equality misses by one character. - Out-of-order arrival. Echoes from rapid back-to-back sends can land out of order; the `[0]` check only inspects the head of the queue. - Runner re-emit. Nothing guarded against the runner emitting the same `user_message` event twice — the second arrival had nothing in `pendingOptimistic` to swallow it and fell through to a fresh render. The mapper now (1) compares trimmed forms, (2) findIndex's the pending queue rather than only `[0]`, and (3) tracks every rendered user text in a `seenUserTexts` set so any later duplicate `user_message` is suppressed even after `pendingOptimistic` is drained. Tests: four new cases covering the trailing-whitespace, out-of-order, and runner-re-emit failure modes plus a baseline echo-swallow. 15/15 in sessionEventToAcp.test.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat/sessionEventToAcp.test.ts | 33 +++++++++++++++++++ .../chat/sessionEventToAcp.ts | 27 +++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.test.ts b/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.test.ts index a4b015d719..7d240be4b5 100644 --- a/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.test.ts +++ b/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.test.ts @@ -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([]); + }); + + 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( diff --git a/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.ts b/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.ts index d35419033e..9424e4b6a5 100644 --- a/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.ts +++ b/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.ts @@ -71,7 +71,13 @@ export function createAgentChatMapper(): AgentChatMapper { let promptId = 0; const seenToolCalls = new Set(); // 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(); return { seedUserMessage(text: string, ts?: number): AcpMessage[] { @@ -80,6 +86,7 @@ export function createAgentChatMapper(): AgentChatMapper { } promptId += 1; pendingOptimistic.push(text); + seenUserTexts.add(text.trim()); return [promptRequestMessage(promptId, text, ts ?? Date.now())]; }, @@ -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)]; } From a88ce7065e7c7785e60e57ac105c608c88bc8cc9 Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 18 Jun 2026 19:28:55 +0200 Subject: [PATCH 3/6] feat(agents): fold edit-with-AI + dock toggle into one split button (#2746) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Dylan Martin Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../AgentBuilderHeaderControls.tsx | 140 +++++++++++++----- .../agent-builder/agentBuilderActions.ts | 76 ++++++++++ 2 files changed, 175 insertions(+), 41 deletions(-) create mode 100644 packages/ui/src/features/agent-applications/agent-builder/agentBuilderActions.ts diff --git a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderHeaderControls.tsx b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderHeaderControls.tsx index d33dc3a572..bbe49b9e1f 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderHeaderControls.tsx +++ b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderHeaderControls.tsx @@ -1,58 +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 { - AGENT_BUILDER_CHAT_ID, - useAgentBuilderStore, -} from "./agentBuilderStore"; +import { headerActionForPage } from "./agentBuilderActions"; +import { useAgentBuilderStore } from "./agentBuilderStore"; /** * The agents-header control cluster — identical across every agents view. - * Authoring lives entirely inside the dock (there is no native "new agent" - * form), so the only header affordance is the dock toggle itself, plus a - * "Following" indicator while the builder is mid-turn with follow mode on. + * + * 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() { 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(page); + const toggleTip = visible + ? "Hide the agent builder (⌘⇧I)" + : "Open the agent builder (⌘⇧I)"; return ( - - {running && followMode ? ( - - - - Following - - - ) : null} - {!visible ? ( - - - - ) : null} - + + + {action ? ( +
+ + + startAgentBuilder(action.prompt, action.agentSlug) + } + > + + {action.label} + + } + /> + + Open the agent builder and start here + + + + + + + } + /> + {toggleTip} + +
+ ) : ( + + + + + } + /> + {toggleTip} + + )} +
+
); } diff --git a/packages/ui/src/features/agent-applications/agent-builder/agentBuilderActions.ts b/packages/ui/src/features/agent-applications/agent-builder/agentBuilderActions.ts new file mode 100644 index 0000000000..3643e8bfab --- /dev/null +++ b/packages/ui/src/features/agent-applications/agent-builder/agentBuilderActions.ts @@ -0,0 +1,76 @@ +import type { AgentBuilderPageContext } from "./agentBuilderStore"; + +export interface AgentBuilderAction { + /** Short button label, e.g. "New agent" / "Explain this session". */ + label: string; + /** Seed prompt sent to the agent builder when clicked. */ + prompt: string; + /** Subject agent for the context envelope (null for fleet-level actions). */ + agentSlug: string | null; +} + +/** + * The contextual agent-builder action for a given view — the AI button's + * content. Drives the abstract header controls so every agents view gets a + * button that fits what you're looking at. Returns null for views with no + * obvious action (just the show/following affordances remain). + */ +export function headerActionForPage( + page: AgentBuilderPageContext, +): AgentBuilderAction | null { + switch (page.kind) { + case "agent-list": + return { + label: "New agent", + prompt: + "Help me create a new agent — walk me through what it should do, then set it up.", + agentSlug: null, + }; + case "agent": + return { + label: "Explain this agent", + prompt: "Explain what this agent does and how it's configured.", + agentSlug: page.slug, + }; + case "agent-config": + return { + label: "Edit configuration", + prompt: "Help me change this agent's configuration.", + agentSlug: page.slug, + }; + case "agent-sessions": + return { + label: "Review sessions", + prompt: + "Review this agent's recent sessions and surface anything notable.", + agentSlug: page.slug, + }; + case "agent-session": + return { + label: "Explain this session", + prompt: "Explain what happened in this session, step by step.", + agentSlug: page.slug, + }; + case "agent-approvals": + return { + label: "Review approvals", + prompt: "Review the pending approval requests for this agent.", + agentSlug: page.slug, + }; + case "agent-memory": + return { + label: "Ask about memory", + prompt: "Summarize what's stored in this agent's memory.", + agentSlug: page.slug, + }; + case "agent-observability": + return { + label: "Ask about performance", + prompt: + "Summarize this agent's spend, volume, and failure rate, and call out anything notable.", + agentSlug: page.slug, + }; + default: + return null; + } +} From 6849d0a0b0ae8f73929a2707d6965de2803c270a Mon Sep 17 00:00:00 2001 From: Dylan Martin Date: Thu, 18 Jun 2026 11:17:58 -0700 Subject: [PATCH 4/6] Update packages/ui/src/features/agent-applications/chat/sessionEventToAcp.test.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../chat/sessionEventToAcp.test.ts | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.test.ts b/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.test.ts index 7d240be4b5..3b049fee24 100644 --- a/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.test.ts +++ b/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.test.ts @@ -49,19 +49,18 @@ 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([]); - }); + 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([]); + }, + ); it("swallows echoes out of order across rapid sends", () => { const mapper = createAgentChatMapper(); From cb35ba0bef396b70931eafb1026e2a73ce16fc8e Mon Sep 17 00:00:00 2001 From: Dylan Martin Date: Thu, 18 Jun 2026 11:42:51 -0700 Subject: [PATCH 5/6] =?UTF-8?q?feat(agents):=20draft=20preview=20=E2=80=94?= =?UTF-8?q?=20chat=20against=20any=20revision=20before=20promoting=20(feat?= =?UTF-8?q?ure=2029)=20(#2744)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Ben White --- packages/api-client/src/posthog-client.ts | 119 +++++++- packages/shared/src/agent-platform-types.ts | 50 +++- .../agent-builder/AgentBuilderDock.tsx | 1 + .../agent-builder/agentBuilderStore.ts | 7 + .../useAgentBuilderClientTools.ts | 16 +- .../chat/chatHistoryStore.ts | 6 + .../components/AgentApplicationDetailView.tsx | 12 +- .../components/AgentChatPane.tsx | 260 ++++++++++++++---- .../components/AgentConfigurationPane.tsx | 12 +- .../components/AgentRevisionBar.tsx | 87 ++++-- .../components/SecretEditor.tsx | 4 +- .../hooks/agentApplicationsKeys.ts | 14 +- .../agent-applications/hooks/useAgentChat.ts | 239 +++++++++++++--- .../hooks/useAgentEnvKeyMutations.ts | 14 +- .../hooks/useAgentEnvKeys.ts | 16 +- .../hooks/useAgentMissingSecrets.ts | 35 +++ .../hooks/useCreateAgentDraftFromRevision.ts | 42 +++ .../hooks/useDecideAgentApproval.ts | 67 ++++- .../agents/applications/$idOrSlug/chat.tsx | 19 +- 19 files changed, 875 insertions(+), 145 deletions(-) create mode 100644 packages/ui/src/features/agent-applications/hooks/useAgentMissingSecrets.ts create mode 100644 packages/ui/src/features/agent-applications/hooks/useCreateAgentDraftFromRevision.ts diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 40d1e43da8..d5ef273c49 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -25,6 +25,7 @@ import type { AgentMemoryTableHeader, AgentMemoryTableRows, AgentMemoryTreeNode, + AgentPreviewToken, AgentRevision, AgentSessionEvent, AgentSessionLogEntry, @@ -842,6 +843,18 @@ function parseAvailableSuggestedReviewersPayload( }; } +/** + * Wraps the ingress preview token in the `parameters.header` shape the fetcher + * merges into request headers without clobbering the auth bearer. Returns + * `undefined` when there is no token so unmodified ingress calls stay byte-for- + * byte identical to today. + */ +function previewTokenHeader( + token: string | null | undefined, +): { header: { "X-Agent-Preview-Token": string } } | undefined { + return token ? { header: { "X-Agent-Preview-Token": token } } : undefined; +} + export class PostHogAPIClient { private api: ReturnType; private _teamId: number | null = null; @@ -4226,6 +4239,62 @@ export class PostHogAPIClient { } } + /** + * Mint a short-lived preview token (HS256 JWT) for a non-live revision. The + * token is sent to the ingress on /run /send /listen /cancel via + * `X-Agent-Preview-Token` (alongside the usual bearer) and authorizes those + * calls to route against this specific revision instead of `live_revision`. + * The response also self-describes the per-trigger ingress URLs the caller + * should hit (`endpoints`) so the client never has to construct preview URLs + * by string-mangling `ingress_base_url`. + * + * Note the Django route: app-level path with the revision as a query param, + * NOT nested under /revisions/{id}/. + */ + async mintAgentPreviewToken( + idOrSlug: string, + revisionId: string, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/preview-token/`; + const url = new URL(`${this.api.baseUrl}${path}`); + url.searchParams.set("revision_id", revisionId); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path, + }); + return (await response.json()) as AgentPreviewToken; + } + + /** + * Atomically create a fresh draft revision under this app, seeded with the + * full bundle of `sourceRevisionId`. The standard "edit an immutable + * revision" exit: ready/live/archived bundles are stamped and locked, so + * iterating on them requires forking to a new draft first. Both ids are + * UUIDs; the app's `slug` is not accepted here (the body needs the UUID). + */ + async createAgentDraftRevisionFrom( + applicationId: string, + sourceRevisionId: string, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(applicationId)}/revisions/new_draft/`; + const url = new URL(`${this.api.baseUrl}${path}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path, + overrides: { + body: JSON.stringify({ + application_id: applicationId, + source_revision_id: sourceRevisionId, + }), + }, + }); + return (await response.json()) as AgentRevision; + } + /** Run a revision lifecycle transition: freeze (draft→ready), promote * (ready→live, demoting the old live), or archive. Returns the updated revision. */ async transitionAgentRevision( @@ -4345,10 +4414,17 @@ export class PostHogAPIClient { return (await response.json()) as { session_id: string }; } - /** The names of env keys currently set on an agent (values never returned). */ - async listAgentEnvKeys(idOrSlug: string): Promise { + /** + * The names of env keys currently set on a revision (values never returned). + * Env keys are scoped to a revision, so each revision carries its own secret + * set. + */ + async listAgentEnvKeys( + idOrSlug: string, + revisionId: string, + ): Promise { const teamId = await this.getTeamId(); - const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/env_keys/`; + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/env_keys/`; const url = new URL(`${this.api.baseUrl}${path}`); const response = await this.api.fetcher.fetch({ method: "get", url, path }); const data = (await response.json()) as { @@ -4358,14 +4434,15 @@ export class PostHogAPIClient { return data.keys ?? data.results ?? []; } - /** Set or rotate one encrypted env key. The value is write-only. */ + /** Set or rotate one encrypted env key on a revision. The value is write-only. */ async setAgentEnvKey( idOrSlug: string, + revisionId: string, key: string, value: string, ): Promise { const teamId = await this.getTeamId(); - const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/env_keys/${encodeURIComponent(key)}/`; + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/env_keys/${encodeURIComponent(key)}/`; const url = new URL(`${this.api.baseUrl}${path}`); await this.api.fetcher.fetch({ method: "put", @@ -4375,10 +4452,14 @@ export class PostHogAPIClient { }); } - /** Clear one encrypted env key. No-op server-side if it isn't set. */ - async clearAgentEnvKey(idOrSlug: string, key: string): Promise { + /** Clear one encrypted env key on a revision. No-op server-side if it isn't set. */ + async clearAgentEnvKey( + idOrSlug: string, + revisionId: string, + key: string, + ): Promise { const teamId = await this.getTeamId(); - const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/env_keys/${encodeURIComponent(key)}/`; + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/env_keys/${encodeURIComponent(key)}/`; const url = new URL(`${this.api.baseUrl}${path}`); await this.api.fetcher.fetch({ method: "delete", url, path }); } @@ -4462,17 +4543,24 @@ export class PostHogAPIClient { // attaches the same bearer regardless of host, so no proxy is needed (unlike // the console, which proxied only because browser EventSource can't set // an Authorization header — `fetch` can). + // + // `previewToken`, when present, scopes the call to a non-live revision via + // `X-Agent-Preview-Token`. The fetcher merges `parameters.header` into the + // built headers (so the bearer survives) — never put preview-token into + // `overrides.headers`, which replaces the whole headers object. /** Start a chat session; returns the new session id. */ async runAgentSession( ingressBaseUrl: string, message: string, + previewToken?: string | null, ): Promise<{ session_id: string; resumed?: boolean }> { const url = new URL(`${ingressBaseUrl.replace(/\/$/, "")}/run`); const response = await this.api.fetcher.fetch({ method: "post", url, path: url.pathname, + parameters: previewTokenHeader(previewToken), overrides: { body: JSON.stringify({ message }) }, }); return (await response.json()) as { session_id: string; resumed?: boolean }; @@ -4483,12 +4571,14 @@ export class PostHogAPIClient { ingressBaseUrl: string, sessionId: string, message: string, + previewToken?: string | null, ): Promise { const url = new URL(`${ingressBaseUrl.replace(/\/$/, "")}/send`); await this.api.fetcher.fetch({ method: "post", url, path: url.pathname, + parameters: previewTokenHeader(previewToken), overrides: { body: JSON.stringify({ session_id: sessionId, message }) }, }); } @@ -4499,6 +4589,7 @@ export class PostHogAPIClient { sessionId: string, callId: string, outcome: { result?: unknown; error?: string }, + previewToken?: string | null, ): Promise { const url = new URL( `${ingressBaseUrl.replace(/\/$/, "")}/client_tool_result`, @@ -4507,6 +4598,7 @@ export class PostHogAPIClient { method: "post", url, path: url.pathname, + parameters: previewTokenHeader(previewToken), overrides: { body: JSON.stringify({ session_id: sessionId, @@ -4528,6 +4620,7 @@ export class PostHogAPIClient { sessionId: string, callId: string, outcome: { result: Record } | { error: string }, + previewToken?: string | null, ): Promise { const url = new URL(`${ingressBaseUrl.replace(/\/$/, "")}/send`); const clientToolResult = @@ -4538,6 +4631,7 @@ export class PostHogAPIClient { method: "post", url, path: url.pathname, + parameters: previewTokenHeader(previewToken), overrides: { body: JSON.stringify({ session_id: sessionId, @@ -4551,12 +4645,14 @@ export class PostHogAPIClient { async cancelAgentSession( ingressBaseUrl: string, sessionId: string, + previewToken?: string | null, ): Promise { const url = new URL(`${ingressBaseUrl.replace(/\/$/, "")}/cancel`); await this.api.fetcher.fetch({ method: "post", url, path: url.pathname, + parameters: previewTokenHeader(previewToken), overrides: { body: JSON.stringify({ session_id: sessionId }) }, }); } @@ -4569,17 +4665,20 @@ export class PostHogAPIClient { ingressBaseUrl: string, sessionId: string, signal?: AbortSignal, + previewToken?: string | null, ): AsyncGenerator { const url = new URL(`${ingressBaseUrl.replace(/\/$/, "")}/listen`); url.searchParams.set("session_id", sessionId); // NB: only `signal` in overrides. Passing `headers` here would replace the // fetcher's Authorization header (it spreads overrides over the built - // headers), which 401s the stream. /listen streams SSE without an explicit - // Accept header. + // headers), which 401s the stream. The preview token rides on + // `parameters.header` — merged in, not replacing. /listen streams SSE + // without an explicit Accept header. const response = await this.api.fetcher.fetch({ method: "get", url, path: url.pathname, + parameters: previewTokenHeader(previewToken), overrides: { signal }, }); if (!response.body) return; diff --git a/packages/shared/src/agent-platform-types.ts b/packages/shared/src/agent-platform-types.ts index d987f05a71..dc79f7fc45 100644 --- a/packages/shared/src/agent-platform-types.ts +++ b/packages/shared/src/agent-platform-types.ts @@ -104,6 +104,40 @@ export interface AgentRevision { updated_at: string; } +// --- Preview tokens -------------------------------------------------------- +// `…/agent_applications/{id}/preview-token/?revision_id=` mints a +// short-lived HS256 JWT that authorizes the ingress to route /run /send /listen +// /cancel against a non-live revision. Sent on those calls via the +// `X-Agent-Preview-Token` header (or `?preview_token=` query for EventSource), +// alongside the usual PostHog bearer (which the fetcher attaches regardless +// of host). +// +// The response is self-describing: `endpoints` carries the per-trigger preview +// URLs the caller should hit directly, so the client never has to derive a +// revision-scoped ingress URL by string-mangling `application.ingress_base_url`. + +/** Per-trigger preview URLs, keyed by trigger type → action → absolute URL. */ +export type AgentPreviewEndpoints = Record>; + +export interface AgentPreviewToken { + /** HS256 JWT bound to (app, revision). Short TTL. */ + token: string; + /** Token TTL in seconds from issue; mint a fresh one before this elapses. */ + expires_in: number; + /** `-` — the slug ingress uses in routing. */ + ingress_slug: string; + /** + * Per-trigger ingress URLs derived from this revision's `spec.triggers[]`. + * Empty when no public agent-ingress URL is configured for the active routing mode. + * Shape: `{ chat: { run, send, listen, cancel, client_tool_result }, slack: {...} }`. + */ + endpoints: AgentPreviewEndpoints; + /** Header/query names + per-trigger accepted auth modes. Opaque to most callers. */ + auth: Record; + /** Server-side proxy alternative — opaque shape; preferred path is direct-to-ingress. */ + preview_proxy: Record; +} + // --- Bundle files ---------------------------------------------------------- // `…/revisions/{id}/bundle/` returns a typed bundle ({ agent_md, skills, tools }); // the client flattens it into these per-file rows keyed by canonical path @@ -530,6 +564,19 @@ export type AgentClientToolResultEvent = AgentSessionEventBase & { data: { call_id: string; result?: unknown; error?: string }; }; +/** + * Draft-preview only. The server fires this on `/listen` ~5s before the + * preview token expires (and then closes the stream): the client mints a + * fresh token and reconnects to the same session. The kind alone is the + * signal — `data` is structurally `Record` (matching + * `AgentClosedEvent`) for the discriminated-union shape, but no fields are + * defined or read. + */ +export type AgentPreviewTokenRequiredEvent = AgentSessionEventBase & { + kind: "preview_token_required"; + data: Record; +}; + export type AgentSessionEvent = | AgentSessionStartedEvent | AgentUserMessageEvent @@ -546,7 +593,8 @@ export type AgentSessionEvent = | AgentFailedEvent | AgentClosedEvent | AgentClientToolCallEvent - | AgentClientToolResultEvent; + | AgentClientToolResultEvent + | AgentPreviewTokenRequiredEvent; /** Discriminator values for {@link AgentSessionEvent}. */ export type AgentSessionEventKind = AgentSessionEvent["kind"]; diff --git a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx index 3157211e83..ed0469ac03 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx +++ b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx @@ -131,6 +131,7 @@ export function AgentBuilderDock() { try { await client.setAgentEnvKey( pendingSecret.agentSlug, + pendingSecret.revisionId, pendingSecret.secret, value, ); diff --git a/packages/ui/src/features/agent-applications/agent-builder/agentBuilderStore.ts b/packages/ui/src/features/agent-applications/agent-builder/agentBuilderStore.ts index f7ad682ddf..01922f7c5c 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/agentBuilderStore.ts +++ b/packages/ui/src/features/agent-applications/agent-builder/agentBuilderStore.ts @@ -44,6 +44,13 @@ export interface PendingSecret { /** The parked tool call to resolve via `/send`. */ callId: string; agentSlug: string; + /** + * Revision the secret is written to. Env keys are revision-scoped, so the + * `set_secret` punch-out must target a specific revision (the one the agent + * is editing). Sourced from the tool args, falling back to the dock's + * current `agent-config` page context. + */ + revisionId: string; /** Env key name, e.g. "ANTHROPIC_KEY". The value is never seen by the agent. */ secret: string; mode?: "set" | "rotate"; diff --git a/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts b/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts index 4a43c0433d..caab1e87da 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts +++ b/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts @@ -17,8 +17,14 @@ export function useAgentBuilderClientTools(): ClientToolHandler { const navigate = useNavigate(); const followMode = useAgentBuilderStore((s) => s.followMode); const setPendingSecret = useAgentBuilderStore((s) => s.setPendingSecret); + const page = useAgentBuilderStore((s) => s.page); const followRef = useRef(followMode); followRef.current = followMode; + // Latest page context without re-creating the handler each render — used to + // resolve the revision a `set_secret` punch-out targets when the agent + // doesn't name one in the tool args. + const pageRef = useRef(page); + pageRef.current = page; return useCallback( (data) => { @@ -26,16 +32,24 @@ export function useAgentBuilderClientTools(): ClientToolHandler { const str = (v: unknown) => (typeof v === "string" ? v : undefined); // set_secret — interactive punch-out. Park the call (defer) and render a - // form; the dock PUTs the key and wakes the session on submit. + // form; the dock PUTs the key and wakes the session on submit. Env keys + // are revision-scoped, so resolve the target revision from the tool args, + // falling back to the revision the user is currently viewing in the + // agent-config page. if (data.tool_id === "set_secret") { const agentSlug = str(args.agent_slug); const secret = str(args.secret); if (!agentSlug) return { error: "missing_arg: agent_slug" }; if (!secret) return { error: "missing_arg: secret" }; + const p = pageRef.current; + const pageRevision = p.kind === "agent-config" ? p.revision : undefined; + const revisionId = str(args.revision_id) ?? pageRevision; + if (!revisionId) return { error: "missing_arg: revision_id" }; const mode = args.mode === "rotate" ? "rotate" : "set"; setPendingSecret({ callId: data.call_id, agentSlug, + revisionId, secret, mode, purpose: str(args.purpose), diff --git a/packages/ui/src/features/agent-applications/chat/chatHistoryStore.ts b/packages/ui/src/features/agent-applications/chat/chatHistoryStore.ts index 3473ef7c6d..5482cd93a9 100644 --- a/packages/ui/src/features/agent-applications/chat/chatHistoryStore.ts +++ b/packages/ui/src/features/agent-applications/chat/chatHistoryStore.ts @@ -15,6 +15,12 @@ export interface PreviewChatEntry { title: string; /** Epoch ms when the chat was started here. */ startedAt: number; + /** + * Revision the chat targets, when the user opened it as a draft preview. + * Undefined for sessions that ran against `live_revision`. Lets the rail + * label drafts and route resumes back to the right preview surface. + */ + revisionId?: string; } interface ChatHistoryState { diff --git a/packages/ui/src/features/agent-applications/components/AgentApplicationDetailView.tsx b/packages/ui/src/features/agent-applications/components/AgentApplicationDetailView.tsx index 9ca31c8d7f..ea96141cbc 100644 --- a/packages/ui/src/features/agent-applications/components/AgentApplicationDetailView.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentApplicationDetailView.tsx @@ -19,8 +19,11 @@ export function AgentApplicationDetailView({ idOrSlug }: { idOrSlug: string }) { application?.id, "agent", ); - const { data: sessions, isLoading: sessionsLoading } = - useAgentApplicationSessions(idOrSlug, { limit: 25 }); + const { + data: sessions, + isLoading: sessionsLoading, + isError: sessionsError, + } = useAgentApplicationSessions(idOrSlug, { limit: 25 }); return ( @@ -57,6 +60,11 @@ export function AgentApplicationDetailView({ idOrSlug }: { idOrSlug: string }) { /> ))}
+ ) : sessionsError ? ( + ) : !sessions || sessions.results.length === 0 ? ( s.cloudRegion); const ingressBaseUrl = resolveIngressBaseUrl( application?.ingress_base_url, @@ -63,9 +90,14 @@ export function AgentChatPane({ idOrSlug }: { idOrSlug: string }) { (t) => rec(t).type === "chat", ); const chat = useAgentChat({ - chatId: `preview:${idOrSlug}`, + // Keyed by revision so a draft preview and the live chat coexist in the + // store without trampling each other. + chatId: isDraftPreview + ? `preview:${idOrSlug}:${revisionId}` + : `preview:${idOrSlug}`, agentSlug: idOrSlug, ingressBaseUrl, + revisionId: isDraftPreview ? revisionId : null, recordHistory: true, }); const { data: pendingApproval } = useAgentChatPendingApproval( @@ -75,6 +107,62 @@ export function AgentChatPane({ idOrSlug }: { idOrSlug: string }) { const chats = useChatHistoryStore((s) => s.byAgent[idOrSlug]) ?? EMPTY_CHATS; const removeChat = useChatHistoryStore((s) => s.remove); + // Partition the rail into "matches the current target" vs everything else + // so a live view doesn't drown in old draft previews (and vice-versa). The + // expander reveals the rest on demand. + const currentRev = isDraftPreview ? (revisionId ?? null) : null; + const { matchingChats, otherChats } = useMemo(() => { + const matching: PreviewChatEntry[] = []; + const other: PreviewChatEntry[] = []; + for (const c of chats) { + if ((c.revisionId ?? null) === currentRev) matching.push(c); + else other.push(c); + } + return { matchingChats: matching, otherChats: other }; + }, [chats, currentRev]); + const [showOthers, setShowOthers] = useState(false); + // Reset the expander whenever the target switches so each surface starts + // focused on its own chats. + const lastRevRef = useRef(currentRev); + if (lastRevRef.current !== currentRev) { + lastRevRef.current = currentRev; + if (showOthers) setShowOthers(false); + } + + // Auto-resume the URL-named session exactly once per param value. Tracked by + // a ref so a re-render or chat.resume identity change can't re-fire on the + // same id. After kicking it off we clear the URL hint so an in-pane "New + // chat" can't be silently undone by a refresh. + const resumedRef = useRef(null); + useEffect(() => { + if (!resumeSessionId || resumedRef.current === resumeSessionId) return; + resumedRef.current = resumeSessionId; + chat.resume(resumeSessionId); + // Anchored to this route so TanStack can resolve the search-fn against the + // chat route's typed shape (the no-`to` form widens to `never`). + navigate({ + to: "/code/agents/applications/$idOrSlug/chat", + params: { idOrSlug }, + search: (prev) => ({ ...prev, session: undefined }), + }); + }, [resumeSessionId, chat.resume, navigate, idOrSlug]); + + // The rail mixes chats from every revision; decide per click whether to + // resume inline (same target) or navigate to a different revision's surface. + const handleRailSelect = (entry: PreviewChatEntry) => { + if ((entry.revisionId ?? null) === currentRev) { + chat.resume(entry.sessionId); + return; + } + navigate({ + to: "/code/agents/applications/$idOrSlug/chat", + params: { idOrSlug }, + search: entry.revisionId + ? { revision: entry.revisionId, session: entry.sessionId } + : { session: entry.sessionId }, + }); + }; + return ( {!ingressBaseUrl ? ( @@ -88,21 +176,29 @@ export function AgentChatPane({ idOrSlug }: { idOrSlug: string }) {
) : ( setShowOthers((v) => !v)} activeSessionId={chat.sessionId} onNewChat={chat.newChat} - onSelect={chat.resume} + onSelect={handleRailSelect} onDelete={(sessionId) => removeChat(idOrSlug, sessionId)} /> @@ -136,10 +232,12 @@ export function AgentChatPane({ idOrSlug }: { idOrSlug: string }) { function PreviewBanner({ revisionId, + isDraft, model, region, }: { revisionId: string | null; + isDraft: boolean; model: string | undefined; region: CloudRegion | null; }) { @@ -147,12 +245,15 @@ function PreviewBanner({ - Live preview — messages run against the currently deployed revision. - Only chats you start here appear in the list. + {isDraft + ? "Draft preview — messages run against an unpublished revision. Promote it to make this the live one." + : "Live preview — messages run against the currently deployed revision. Only chats you start here appear in the list."} {model ? ( @@ -176,15 +277,23 @@ function PreviewBanner({ function ChatHistoryRail({ chats, + otherChats, + showOthers, + onToggleShowOthers, activeSessionId, onNewChat, onSelect, onDelete, }: { chats: PreviewChatEntry[]; + /** Chats from other revisions — hidden behind an expander to keep this surface focused. */ + otherChats: PreviewChatEntry[]; + showOthers: boolean; + onToggleShowOthers: () => void; activeSessionId: string | null; onNewChat: () => void; - onSelect: (sessionId: string) => void; + /** Receives the full entry so the parent can route by revision, not just resume. */ + onSelect: (entry: PreviewChatEntry) => void; onDelete: (sessionId: string) => void; }) { return ( @@ -205,50 +314,101 @@ function ChatHistoryRail({
- {chats.length === 0 ? ( + {chats.length === 0 && (otherChats.length === 0 || !showOthers) ? ( Chats you start here will show up in this list. ) : ( - {chats.map((c) => { - const active = c.sessionId === activeSessionId; - return ( -
- - -
- ); - })} + {chats.map((c) => ( + + ))} + {otherChats.length > 0 ? ( + <> + + {showOthers + ? otherChats.map((c) => ( + + )) + : null} + + ) : null}
)}
); } + +function RailEntry({ + entry, + active, + onSelect, + onDelete, +}: { + entry: PreviewChatEntry; + active: boolean; + onSelect: (entry: PreviewChatEntry) => void; + onDelete: (sessionId: string) => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx index c64c87bc6a..e1e43cde88 100644 --- a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx @@ -328,7 +328,7 @@ export function AgentConfigurationPane({ const { data: revision, isLoading } = useAgentRevision(idOrSlug, revisionId); const { data: bundle } = useAgentRevisionBundle(idOrSlug, revisionId); - const { data: envKeys } = useAgentEnvKeys(idOrSlug); + const { data: envKeys } = useAgentEnvKeys(idOrSlug, revisionId); const spec = revision?.spec ?? null; const setKeys = useMemo(() => envKeys ?? [], [envKeys]); @@ -598,6 +598,7 @@ function DetailBody({ keyName={id} setKeys={ctx.setKeys} idOrSlug={ctx.idOrSlug} + revisionId={ctx.revisionId} /> ); case "limits": @@ -1220,10 +1221,12 @@ function SecretBody({ keyName, setKeys, idOrSlug, + revisionId, }: { keyName: string; setKeys: string[]; idOrSlug: string; + revisionId: string; }) { const isSet = setKeys.includes(keyName); return ( @@ -1234,7 +1237,12 @@ function SecretBody({ value={isSet ? "set" : "not set"} valueColor={isSet ? "var(--green-11)" : "var(--amber-11)"} /> - +
); } diff --git a/packages/ui/src/features/agent-applications/components/AgentRevisionBar.tsx b/packages/ui/src/features/agent-applications/components/AgentRevisionBar.tsx index c4077d50b3..f62ab8cebe 100644 --- a/packages/ui/src/features/agent-applications/components/AgentRevisionBar.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentRevisionBar.tsx @@ -8,8 +8,10 @@ import type { import { Badge } from "@posthog/ui/primitives/Badge"; import { Button } from "@posthog/ui/primitives/Button"; import { AlertDialog, Flex, Popover, Text } from "@radix-ui/themes"; +import { useNavigate } from "@tanstack/react-router"; import { useMemo, useState } from "react"; import { useAgentRevisionLifecycle } from "../hooks/useAgentRevisionLifecycle"; +import { useCreateAgentDraftFromRevision } from "../hooks/useCreateAgentDraftFromRevision"; import { revisionStateColor } from "../utils/format"; type LifecycleAction = "freeze" | "promote" | "archive"; @@ -104,6 +106,8 @@ export function AgentRevisionBar({ onSelectRevision: (id: string) => void; }) { const lifecycle = useAgentRevisionLifecycle(idOrSlug); + const cloneToDraft = useCreateAgentDraftFromRevision(idOrSlug, agent.id); + const navigate = useNavigate(); const [pickerOpen, setPickerOpen] = useState(false); const [filters, setFilters] = useState>( () => new Set(["live", "ready", "draft"]), @@ -183,7 +187,7 @@ export function AgentRevisionBar({ type="button" onClick={() => toggleFilter(f)} aria-pressed={active} - className={`rounded-full border px-2 py-0.5 text-[10.5px] uppercase tracking-wide ${ + className={`rounded-(--radius-2) border px-1.5 py-[3px] text-[10.5px] uppercase leading-none tracking-wide ${ active ? "border-(--accent-7) bg-(--accent-3) text-gray-12" : "border-border text-gray-10 hover:border-(--gray-7)" @@ -212,7 +216,7 @@ export function AgentRevisionBar({ setPickerOpen(false); }} aria-current={r.id === selected.id ? "true" : undefined} - className={`flex w-full items-start gap-2 px-3 py-2 text-left ${ + className={`flex w-full items-center gap-2 px-3 py-2 text-left ${ r.id === selected.id ? "bg-(--accent-3)" : "hover:bg-(--gray-3)" @@ -241,23 +245,68 @@ export function AgentRevisionBar({ - {actions.length > 0 ? ( - - {actions.map((a) => ( - - ))} - - ) : null} + + {/* + * Test — runs this revision through the live ingress with a preview + * token, before it's promoted. The chat tab handles the rest (mint + + * token attach via useAgentChat). Live uses the default Chat tab; + * archived can't be exercised. Label varies by state: "Test draft" + * leans into the unfinished work; for `ready` the bundle is frozen so + * plain "Test" is more accurate. + */} + {selected.state !== "live" && + selected.state !== "archived" && + !isLive ? ( + + ) : null} + {/* + * Clone to draft — fork this revision into a fresh editable draft. + * The standard exit when a ready/live/archived bundle is immutable + * but you want to keep iterating. Pre-selects the new draft so the + * picker lands you in edit mode immediately. + */} + {selected.state !== "draft" ? ( + + ) : null} + {actions.map((a) => ( + + ))} +