From 82a32c4d5c38b2fe3b1a2edadd704ff40506ed8a Mon Sep 17 00:00:00 2001 From: Ben White Date: Tue, 16 Jun 2026 12:33:42 +0200 Subject: [PATCH 1/2] feat(agents): scaffold agent-applications area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First milestone of porting the agent_platform console into Code. Adds a new `agent-applications` UI feature for deployed agent-platform agents, surfaced as a sub-option of the existing `/code/agents` config landing (alongside Scouts), not a new top-level sidebar tab. - agent-applications feature: list view chrome + empty feature module - routes: /code/agents/applications (layout + index), regenerated tree - ConfigureAgentsSection: "Applications" subsection link card List data, the SSE→ACP chat adapter, and the concierge dock land in later milestones. Generated-By: PostHog Code Task-Id: 3f40d432-67cc-4df1-bc7b-3ee34c7b1d70 --- .../agent-applications.module.ts | 9 ++++ .../components/AgentApplicationsListView.tsx | 53 +++++++++++++++++++ .../components/ConfigureAgentsSection.tsx | 31 ++++++++++- packages/ui/src/router/routeTree.gen.ts | 53 +++++++++++++++++++ .../routes/code/agents/applications.tsx | 5 ++ .../routes/code/agents/applications/index.tsx | 6 +++ 6 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/features/agent-applications/agent-applications.module.ts create mode 100644 packages/ui/src/features/agent-applications/components/AgentApplicationsListView.tsx create mode 100644 packages/ui/src/router/routes/code/agents/applications.tsx create mode 100644 packages/ui/src/router/routes/code/agents/applications/index.tsx diff --git a/packages/ui/src/features/agent-applications/agent-applications.module.ts b/packages/ui/src/features/agent-applications/agent-applications.module.ts new file mode 100644 index 0000000000..e25d466280 --- /dev/null +++ b/packages/ui/src/features/agent-applications/agent-applications.module.ts @@ -0,0 +1,9 @@ +import { ContainerModule } from "inversify"; + +/** + * UI module for the agent-applications feature (deployed agent_platform + * agents). Currently holds no bindings — the chat/concierge contributions and + * any view-state slices are added in later milestones. Registered in + * apps/code/src/renderer/desktop-contributions.ts once it binds a CONTRIBUTION. + */ +export const agentApplicationsUiModule = new ContainerModule(() => {}); diff --git a/packages/ui/src/features/agent-applications/components/AgentApplicationsListView.tsx b/packages/ui/src/features/agent-applications/components/AgentApplicationsListView.tsx new file mode 100644 index 0000000000..47283e2cc2 --- /dev/null +++ b/packages/ui/src/features/agent-applications/components/AgentApplicationsListView.tsx @@ -0,0 +1,53 @@ +import { RobotIcon } from "@phosphor-icons/react"; +import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { Flex, Text } from "@radix-ui/themes"; +import { useMemo } from "react"; + +/** + * Landing for the agent-platform console: the list of deployed agent + * applications plus the fleet overview. M1 renders the chrome only — the + * list, fleet stat strip, and live-now panel are wired to the + * agent_platform REST API in a later milestone. + */ +export function AgentApplicationsListView() { + const headerContent = useMemo( + () => ( + + + + Applications + + + ), + [], + ); + + useSetHeaderContent(headerContent); + + return ( + + + + Applications + + + Deployed agents on the agent platform – their configuration, sessions, + memory, and approvals. + + + +
+
+ No agents yet. +
+
+
+ ); +} diff --git a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx index 5456cf67a5..a45226c61c 100644 --- a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx +++ b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx @@ -1,4 +1,9 @@ -import { ArrowSquareOutIcon, PlugsConnectedIcon } from "@phosphor-icons/react"; +import { + ArrowSquareOutIcon, + CaretRightIcon, + PlugsConnectedIcon, + RobotIcon, +} from "@phosphor-icons/react"; import { REPORT_MODEL_RESOLVER, type ReportModelResolver, @@ -130,6 +135,30 @@ export function ConfigureAgentsSection() { + + + + + + + Manage applications + + + Browse deployed agents, edit their configuration, and open a + live chat with any deployment. + + + + + + + CodeAgentsRoute, } as any) +const CodeAgentsApplicationsRoute = CodeAgentsApplicationsRouteImport.update({ + id: '/applications', + path: '/applications', + getParentRoute: () => CodeAgentsRoute, +} as any) const CodeInboxRunsIndexRoute = CodeInboxRunsIndexRouteImport.update({ id: '/', path: '/', @@ -186,6 +193,12 @@ const CodeInboxPullsIndexRoute = CodeInboxPullsIndexRouteImport.update({ path: '/', getParentRoute: () => CodeInboxPullsRoute, } as any) +const CodeAgentsApplicationsIndexRoute = + CodeAgentsApplicationsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => CodeAgentsApplicationsRoute, + } as any) const WebsiteChannelIdTasksTaskIdRoute = WebsiteChannelIdTasksTaskIdRouteImport.update({ id: '/$channelId/tasks/$taskId', @@ -247,6 +260,7 @@ export interface FileRoutesByFullPath { '/code/': typeof CodeIndexRoute '/settings/': typeof SettingsIndexRoute '/website/': typeof WebsiteIndexRoute + '/code/agents/applications': typeof CodeAgentsApplicationsRouteWithChildren '/code/agents/scouts': typeof CodeAgentsScoutsRouteWithChildren '/code/inbox/agents': typeof CodeInboxAgentsRoute '/code/inbox/pulls': typeof CodeInboxPullsRouteWithChildren @@ -265,6 +279,7 @@ export interface FileRoutesByFullPath { '/code/tasks/pending/$key': typeof CodeTasksPendingKeyRoute '/website/$channelId/dashboards/$dashboardId': typeof WebsiteChannelIdDashboardsDashboardIdRoute '/website/$channelId/tasks/$taskId': typeof WebsiteChannelIdTasksTaskIdRoute + '/code/agents/applications/': typeof CodeAgentsApplicationsIndexRoute '/code/inbox/pulls/': typeof CodeInboxPullsIndexRoute '/code/inbox/reports/': typeof CodeInboxReportsIndexRoute '/code/inbox/runs/': typeof CodeInboxRunsIndexRoute @@ -296,6 +311,7 @@ export interface FileRoutesByTo { '/code/tasks/pending/$key': typeof CodeTasksPendingKeyRoute '/website/$channelId/dashboards/$dashboardId': typeof WebsiteChannelIdDashboardsDashboardIdRoute '/website/$channelId/tasks/$taskId': typeof WebsiteChannelIdTasksTaskIdRoute + '/code/agents/applications': typeof CodeAgentsApplicationsIndexRoute '/code/inbox/pulls': typeof CodeInboxPullsIndexRoute '/code/inbox/reports': typeof CodeInboxReportsIndexRoute '/code/inbox/runs': typeof CodeInboxRunsIndexRoute @@ -317,6 +333,7 @@ export interface FileRoutesById { '/code/': typeof CodeIndexRoute '/settings/': typeof SettingsIndexRoute '/website/': typeof WebsiteIndexRoute + '/code/agents/applications': typeof CodeAgentsApplicationsRouteWithChildren '/code/agents/scouts': typeof CodeAgentsScoutsRouteWithChildren '/code/inbox/agents': typeof CodeInboxAgentsRoute '/code/inbox/pulls': typeof CodeInboxPullsRouteWithChildren @@ -335,6 +352,7 @@ export interface FileRoutesById { '/code/tasks/pending/$key': typeof CodeTasksPendingKeyRoute '/website/$channelId/dashboards/$dashboardId': typeof WebsiteChannelIdDashboardsDashboardIdRoute '/website/$channelId/tasks/$taskId': typeof WebsiteChannelIdTasksTaskIdRoute + '/code/agents/applications/': typeof CodeAgentsApplicationsIndexRoute '/code/inbox/pulls/': typeof CodeInboxPullsIndexRoute '/code/inbox/reports/': typeof CodeInboxReportsIndexRoute '/code/inbox/runs/': typeof CodeInboxRunsIndexRoute @@ -357,6 +375,7 @@ export interface FileRouteTypes { | '/code/' | '/settings/' | '/website/' + | '/code/agents/applications' | '/code/agents/scouts' | '/code/inbox/agents' | '/code/inbox/pulls' @@ -375,6 +394,7 @@ export interface FileRouteTypes { | '/code/tasks/pending/$key' | '/website/$channelId/dashboards/$dashboardId' | '/website/$channelId/tasks/$taskId' + | '/code/agents/applications/' | '/code/inbox/pulls/' | '/code/inbox/reports/' | '/code/inbox/runs/' @@ -406,6 +426,7 @@ export interface FileRouteTypes { | '/code/tasks/pending/$key' | '/website/$channelId/dashboards/$dashboardId' | '/website/$channelId/tasks/$taskId' + | '/code/agents/applications' | '/code/inbox/pulls' | '/code/inbox/reports' | '/code/inbox/runs' @@ -426,6 +447,7 @@ export interface FileRouteTypes { | '/code/' | '/settings/' | '/website/' + | '/code/agents/applications' | '/code/agents/scouts' | '/code/inbox/agents' | '/code/inbox/pulls' @@ -444,6 +466,7 @@ export interface FileRouteTypes { | '/code/tasks/pending/$key' | '/website/$channelId/dashboards/$dashboardId' | '/website/$channelId/tasks/$taskId' + | '/code/agents/applications/' | '/code/inbox/pulls/' | '/code/inbox/reports/' | '/code/inbox/runs/' @@ -645,6 +668,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CodeAgentsScoutsRouteImport parentRoute: typeof CodeAgentsRoute } + '/code/agents/applications': { + id: '/code/agents/applications' + path: '/applications' + fullPath: '/code/agents/applications' + preLoaderRoute: typeof CodeAgentsApplicationsRouteImport + parentRoute: typeof CodeAgentsRoute + } '/code/inbox/runs/': { id: '/code/inbox/runs/' path: '/' @@ -666,6 +696,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CodeInboxPullsIndexRouteImport parentRoute: typeof CodeInboxPullsRoute } + '/code/agents/applications/': { + id: '/code/agents/applications/' + path: '/' + fullPath: '/code/agents/applications/' + preLoaderRoute: typeof CodeAgentsApplicationsIndexRouteImport + parentRoute: typeof CodeAgentsApplicationsRoute + } '/website/$channelId/tasks/$taskId': { id: '/website/$channelId/tasks/$taskId' path: '/$channelId/tasks/$taskId' @@ -747,6 +784,20 @@ const WebsiteRouteChildren: WebsiteRouteChildren = { const WebsiteRouteWithChildren = WebsiteRoute._addFileChildren(WebsiteRouteChildren) +interface CodeAgentsApplicationsRouteChildren { + CodeAgentsApplicationsIndexRoute: typeof CodeAgentsApplicationsIndexRoute +} + +const CodeAgentsApplicationsRouteChildren: CodeAgentsApplicationsRouteChildren = + { + CodeAgentsApplicationsIndexRoute: CodeAgentsApplicationsIndexRoute, + } + +const CodeAgentsApplicationsRouteWithChildren = + CodeAgentsApplicationsRoute._addFileChildren( + CodeAgentsApplicationsRouteChildren, + ) + interface CodeAgentsScoutsSkillNameRouteChildren { CodeAgentsScoutsSkillNameIndexRoute: typeof CodeAgentsScoutsSkillNameIndexRoute } @@ -773,11 +824,13 @@ const CodeAgentsScoutsRouteWithChildren = CodeAgentsScoutsRoute._addFileChildren(CodeAgentsScoutsRouteChildren) interface CodeAgentsRouteChildren { + CodeAgentsApplicationsRoute: typeof CodeAgentsApplicationsRouteWithChildren CodeAgentsScoutsRoute: typeof CodeAgentsScoutsRouteWithChildren CodeAgentsIndexRoute: typeof CodeAgentsIndexRoute } const CodeAgentsRouteChildren: CodeAgentsRouteChildren = { + CodeAgentsApplicationsRoute: CodeAgentsApplicationsRouteWithChildren, CodeAgentsScoutsRoute: CodeAgentsScoutsRouteWithChildren, CodeAgentsIndexRoute: CodeAgentsIndexRoute, } diff --git a/packages/ui/src/router/routes/code/agents/applications.tsx b/packages/ui/src/router/routes/code/agents/applications.tsx new file mode 100644 index 0000000000..a5267ec9e1 --- /dev/null +++ b/packages/ui/src/router/routes/code/agents/applications.tsx @@ -0,0 +1,5 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/agents/applications")({ + component: Outlet, +}); diff --git a/packages/ui/src/router/routes/code/agents/applications/index.tsx b/packages/ui/src/router/routes/code/agents/applications/index.tsx new file mode 100644 index 0000000000..01ee652e95 --- /dev/null +++ b/packages/ui/src/router/routes/code/agents/applications/index.tsx @@ -0,0 +1,6 @@ +import { AgentApplicationsListView } from "@posthog/ui/features/agent-applications/components/AgentApplicationsListView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/agents/applications/")({ + component: AgentApplicationsListView, +}); From 41160a444f61b681d73aa31825c0d4cbaf676067 Mon Sep 17 00:00:00 2001 From: Ben White Date: Tue, 16 Jun 2026 12:54:00 +0200 Subject: [PATCH 2/2] feat(agents): agent_platform API + read surface for applications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M2: wire the agent-applications area to the agent_platform REST API. - shared: agent-platform-types.ts — domain types for applications, revisions, sessions, approvals, fleet (plain TS wire shapes, matching inbox-types/git-types convention); exported as a subpath. - api-client: PostHogAPIClient methods for the read surface (list/get applications, stats, sessions list/detail, approvals list + decide, revisions, fleet stats/live-sessions/approvals) using the raw fetcher, mirroring the signals methods. - ui: query-key factory + TanStack Query hooks via useAuthenticatedQuery; list view now renders a live fleet stat strip + application rows; new per-agent detail route (/code/agents/applications/$idOrSlug) showing an overview stat strip + recent sessions. Follows the inbox pattern (shared types → client methods → UI hooks); no core passthrough service — the core service lands in M3 with the SSE→ACP chat reducer, where there's real orchestration to own. Generated-By: PostHog Code Task-Id: 3f40d432-67cc-4df1-bc7b-3ee34c7b1d70 --- packages/api-client/src/posthog-client.ts | 280 +++++++++++++++++ packages/shared/package.json | 4 + packages/shared/src/agent-platform-types.ts | 297 ++++++++++++++++++ packages/shared/tsup.config.ts | 1 + .../components/AgentApplicationDetailView.tsx | 236 ++++++++++++++ .../components/AgentApplicationsListView.tsx | 197 +++++++++++- .../hooks/agentApplicationsKeys.ts | 20 ++ .../hooks/useAgentApplication.ts | 14 + .../hooks/useAgentApplicationSessions.ts | 25 ++ .../hooks/useAgentApplicationStats.ts | 14 + .../hooks/useAgentApplications.ts | 15 + .../hooks/useAgentFleetStats.ts | 15 + .../agent-applications/utils/format.ts | 52 +++ packages/ui/src/router/routeTree.gen.ts | 56 ++++ .../code/agents/applications/$idOrSlug.tsx | 5 + .../agents/applications/$idOrSlug/index.tsx | 11 + 16 files changed, 1235 insertions(+), 7 deletions(-) create mode 100644 packages/shared/src/agent-platform-types.ts create mode 100644 packages/ui/src/features/agent-applications/components/AgentApplicationDetailView.tsx create mode 100644 packages/ui/src/features/agent-applications/hooks/agentApplicationsKeys.ts create mode 100644 packages/ui/src/features/agent-applications/hooks/useAgentApplication.ts create mode 100644 packages/ui/src/features/agent-applications/hooks/useAgentApplicationSessions.ts create mode 100644 packages/ui/src/features/agent-applications/hooks/useAgentApplicationStats.ts create mode 100644 packages/ui/src/features/agent-applications/hooks/useAgentApplications.ts create mode 100644 packages/ui/src/features/agent-applications/hooks/useAgentFleetStats.ts create mode 100644 packages/ui/src/features/agent-applications/utils/format.ts create mode 100644 packages/ui/src/router/routes/code/agents/applications/$idOrSlug.tsx create mode 100644 packages/ui/src/router/routes/code/agents/applications/$idOrSlug/index.tsx diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index e4e76e63eb..e6864b0fb3 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -12,6 +12,18 @@ import { type DismissalReasonOptionValue, SEAT_PRODUCT_KEY, } from "@posthog/shared"; +import type { + AgentAggregateStats, + AgentApplication, + AgentApplicationSessionDetail, + AgentApplicationSessionsListResponse, + AgentApprovalRequest, + AgentApprovalsListParams, + AgentFleetLiveSessionsResponse, + AgentRevision, + AgentSessionsListParams, + DecideApprovalRequest, +} from "@posthog/shared/agent-platform-types"; import type { ActionabilityJudgmentArtefact, AvailableSuggestedReviewer, @@ -3969,4 +3981,272 @@ export class PostHogAPIClient { } return (await response.json()) as LlmSkillFile; } + + // --- Agent platform ------------------------------------------------------ + // Deployed agents (`agent_platform` Django app). These routes aren't in the + // generated OpenAPI client, so they use the raw fetcher. Applications are + // addressable by UUID or slug in the `{idOrSlug}` segment. + + private agentApplicationsPath(teamId: number): string { + return `/api/projects/${teamId}/agent_applications/`; + } + + /** Lists non-archived agent applications for the current team. */ + async listAgentApplications(): Promise { + const MAX_PAGES = 50; + const teamId = await this.getTeamId(); + const all: AgentApplication[] = []; + let urlPath = `${this.agentApplicationsPath(teamId)}?limit=100`; + for (let i = 0; i < MAX_PAGES; i++) { + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + const page = (await response.json()) as { + results?: AgentApplication[]; + next?: string | null; + }; + all.push(...(page.results ?? [])); + if (!page.next) return all; + const nextUrl = new URL(page.next); + urlPath = `${nextUrl.pathname}${nextUrl.search}`; + } + return all; + } + + /** Fetches a single agent application by UUID or slug; null if not found. */ + async getAgentApplication( + idOrSlug: string, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/`; + const url = new URL(`${this.api.baseUrl}${path}`); + try { + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + return (await response.json()) as AgentApplication; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (msg.includes("[404]") || msg.includes("[403]")) { + return null; + } + throw error; + } + } + + /** Per-application roll-up stats. `since` is an ISO timestamp window start. */ + async getAgentApplicationStats( + idOrSlug: string, + since?: string, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/stats/`; + const url = new URL(`${this.api.baseUrl}${path}`); + if (since) { + url.searchParams.set("since", since); + } + const response = await this.api.fetcher.fetch({ method: "get", url, path }); + return (await response.json()) as AgentAggregateStats; + } + + /** Lists sessions for an application (paginated, filterable by state). */ + async listAgentApplicationSessions( + idOrSlug: string, + params?: AgentSessionsListParams, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/sessions/`; + const url = new URL(`${this.api.baseUrl}${path}`); + if (params?.limit != null) { + url.searchParams.set("limit", String(params.limit)); + } + if (params?.offset != null) { + url.searchParams.set("offset", String(params.offset)); + } + if (params?.state?.length) { + url.searchParams.set("state", params.state.join(",")); + } + if (params?.revision_id) { + url.searchParams.set("revision_id", params.revision_id); + } + if (params?.created_after) { + url.searchParams.set("created_after", params.created_after); + } + if (params?.created_before) { + url.searchParams.set("created_before", params.created_before); + } + const response = await this.api.fetcher.fetch({ method: "get", url, path }); + const data = (await response.json()) as { + results?: AgentApplicationSessionsListResponse["results"]; + count?: number; + }; + return { + results: data.results ?? [], + count: data.count ?? data.results?.length ?? 0, + }; + } + + /** Full session detail incl. transcript; `lastN` trims to trailing messages. */ + async getAgentApplicationSession( + idOrSlug: string, + sessionId: string, + lastN?: number, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/sessions/${encodeURIComponent(sessionId)}/`; + const url = new URL(`${this.api.baseUrl}${path}`); + if (lastN != null) { + url.searchParams.set("last_n", String(lastN)); + } + try { + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + return (await response.json()) as AgentApplicationSessionDetail; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (msg.includes("[404]") || msg.includes("[403]")) { + return null; + } + throw error; + } + } + + /** Lists tool-approval requests for an application (team-admin only). */ + async listAgentApplicationApprovals( + idOrSlug: string, + params?: AgentApprovalsListParams, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/approvals/`; + const url = new URL(`${this.api.baseUrl}${path}`); + if (params?.state) { + url.searchParams.set("state", params.state); + } + if (params?.limit != null) { + url.searchParams.set("limit", String(params.limit)); + } + if (params?.offset != null) { + url.searchParams.set("offset", String(params.offset)); + } + const response = await this.api.fetcher.fetch({ method: "get", url, path }); + const data = (await response.json()) as { + results?: AgentApprovalRequest[]; + }; + return data.results ?? []; + } + + /** Approve or reject a queued tool-approval request. */ + async decideAgentApproval( + idOrSlug: string, + approvalId: string, + body: DecideApprovalRequest, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/approvals/${encodeURIComponent(approvalId)}/decide/`; + const url = new URL(`${this.api.baseUrl}${path}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path, + overrides: { body: JSON.stringify(body) }, + }); + return (await response.json()) as AgentApprovalRequest; + } + + /** Lists revisions for an application (newest first, paginated). */ + async listAgentRevisions(idOrSlug: string): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/`; + const url = new URL(`${this.api.baseUrl}${path}?limit=100`); + const response = await this.api.fetcher.fetch({ method: "get", url, path }); + const data = (await response.json()) as { results?: AgentRevision[] }; + return data.results ?? []; + } + + /** Fetches a single revision by id; null if not found. */ + async getAgentRevision( + idOrSlug: string, + revisionId: string, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/`; + const url = new URL(`${this.api.baseUrl}${path}`); + try { + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + return (await response.json()) as AgentRevision; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (msg.includes("[404]") || msg.includes("[403]")) { + return null; + } + throw error; + } + } + + /** Team-wide fleet roll-up stats. `since` is an ISO timestamp window start. */ + async getAgentFleetStats(since?: string): Promise { + const teamId = await this.getTeamId(); + const path = `/api/projects/${teamId}/agent_fleet/stats/`; + const url = new URL(`${this.api.baseUrl}${path}`); + if (since) { + url.searchParams.set("since", since); + } + const response = await this.api.fetcher.fetch({ method: "get", url, path }); + return (await response.json()) as AgentAggregateStats; + } + + /** Live (non-terminal) sessions across every agent on the team. */ + async listAgentFleetLiveSessions( + limit?: number, + ): Promise { + const teamId = await this.getTeamId(); + const path = `/api/projects/${teamId}/agent_fleet/live_sessions/`; + const url = new URL(`${this.api.baseUrl}${path}`); + if (limit != null) { + url.searchParams.set("limit", String(limit)); + } + const response = await this.api.fetcher.fetch({ method: "get", url, path }); + const data = (await response.json()) as { + results?: AgentFleetLiveSessionsResponse["results"]; + }; + return { results: data.results ?? [] }; + } + + /** All tool-approval requests across the team (team-admin only). */ + async listAgentFleetApprovals( + params?: AgentApprovalsListParams, + ): Promise { + const teamId = await this.getTeamId(); + const path = `/api/projects/${teamId}/agent_fleet/approvals/`; + const url = new URL(`${this.api.baseUrl}${path}`); + if (params?.state) { + url.searchParams.set("state", params.state); + } + if (params?.agent_id) { + url.searchParams.set("agent_id", params.agent_id); + } + if (params?.limit != null) { + url.searchParams.set("limit", String(params.limit)); + } + if (params?.offset != null) { + url.searchParams.set("offset", String(params.offset)); + } + const response = await this.api.fetcher.fetch({ method: "get", url, path }); + const data = (await response.json()) as { + results?: AgentApprovalRequest[]; + }; + return data.results ?? []; + } } diff --git a/packages/shared/package.json b/packages/shared/package.json index 81656c5b26..3f0a2c8d02 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,6 +12,10 @@ "types": "./dist/analytics-events.d.ts", "import": "./dist/analytics-events.js" }, + "./agent-platform-types": { + "types": "./dist/agent-platform-types.d.ts", + "import": "./dist/agent-platform-types.js" + }, "./domain-types": { "types": "./dist/domain-types.d.ts", "import": "./dist/domain-types.js" diff --git a/packages/shared/src/agent-platform-types.ts b/packages/shared/src/agent-platform-types.ts new file mode 100644 index 0000000000..dda8ece094 --- /dev/null +++ b/packages/shared/src/agent-platform-types.ts @@ -0,0 +1,297 @@ +// Domain types for the agent_platform product surface (deployed agents, +// their revisions, sessions, approvals, and fleet rollups). These mirror the +// PostHog Cloud REST serializers (Django app `agent_platform`) and are the wire +// shapes returned by the corresponding PostHogAPIClient methods. Field names +// stay snake_case to match the JSON exactly, as with the other shared wire +// types (see inbox-types.ts). + +// --- Enums ----------------------------------------------------------------- + +export type AgentSessionState = + | "queued" + | "running" + | "completed" + | "closed" + | "cancelled" + | "failed"; + +export type AgentSessionPrincipalKind = + | "anonymous" + | "service" + | "internal" + | "shared_secret" + | "slack"; + +export type AgentRevisionState = "draft" | "ready" | "live" | "archived"; + +export type AgentApprovalRequestState = + | "queued" + | "approving" + | "dispatched" + | "dispatched_failed" + | "rejected" + | "expired"; + +export type AgentApprovalDecision = "approve" | "reject"; + +// --- Applications ---------------------------------------------------------- + +/** Resolved creator (from `created_by_id`), or null if unset/deleted. */ +export interface AgentApplicationCreator { + id?: number; + first_name?: string; + email?: string; +} + +export interface AgentApplication { + id: string; + team_id: number; + name: string; + /** Globally-unique URL identifier; server-minted unless explicitly allowed. */ + slug?: string; + description?: string; + live_revision: string | null; + archived?: boolean; + archived_at: string | null; + created_by_id: number | null; + created_by: AgentApplicationCreator | null; + created_at: string; + updated_at: string; + /** Slack Event Subscriptions request URL; null without a public ingress URL. */ + slack_events_url: string | null; + /** Slack Interactivity request URL; null without a public ingress URL. */ + slack_interactivity_url: string | null; + /** Mode-aware base URL the agent's trigger routes hang off; null without ingress. */ + ingress_base_url: string | null; +} + +/** Per-application or team-wide roll-up stats. */ +export interface AgentAggregateStats { + liveCount: number; + sessionsInWindowCount: number; + spendInWindowUsd: number; + lastActivityAt: string | null; + failedInWindowCount: number; + pendingApprovalsCount: number; +} + +// --- Revisions ------------------------------------------------------------- + +/** + * The agent spec carried on a revision. Fully typed elaboration (triggers, + * tools, mcps, skills, limits) lands with the config editor milestone; for now + * the known top-level fields are surfaced and the rest passes through. + */ +export interface AgentSpec { + model: string; + triggers?: unknown[]; + tools?: unknown[]; + mcps?: unknown[]; + skills?: unknown[]; + integrations?: string[]; + secrets?: string[]; + limits?: { + max_turns?: number; + max_tool_calls?: number; + max_wall_seconds?: number; + }; + entrypoint?: string; + reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh"; + [key: string]: unknown; +} + +export interface AgentRevision { + id: string; + application: string; + parent_revision?: string | null; + state: AgentRevisionState; + bundle_uri?: string; + bundle_sha256: string | null; + spec?: AgentSpec; + created_by_id: number | null; + created_by: AgentApplicationCreator | null; + created_at: string; + updated_at: string; +} + +// --- Sessions -------------------------------------------------------------- + +export interface AgentSessionUsageTotal { + tokens_in: number; + tokens_out: number; + cache_read: number; + cache_write: number; + cost_input: number; + cost_output: number; + cost_cache_read: number; + cost_cache_write: number; + cost_total: number; +} + +export interface AgentSessionPrincipal { + kind: AgentSessionPrincipalKind; + /** Stable principal id (PAT id, slack user id, …); absent for anonymous. */ + id?: string; + team_id?: number; +} + +/** Trigger-specific metadata stamped at session creation; shape varies by kind. */ +export type AgentSessionTriggerMetadata = Record; + +export interface AgentSessionSummary { + id: string; + application_id: string; + revision_id: string; + state: AgentSessionState; + external_key: string | null; + trigger_metadata?: AgentSessionTriggerMetadata | null; + principal: AgentSessionPrincipal | null; + /** Count of messages in the conversation. */ + turns: number; + /** Last assistant text (~120 chars); null before any assistant turn. */ + preview: string | null; + usage_total: AgentSessionUsageTotal; + retry_count: number; + created_at: string; + updated_at: string; +} + +export interface AgentApplicationSessionsListResponse { + results: AgentSessionSummary[]; + count: number; +} + +// --- Conversation transcript (stored shape on a session) ------------------- +// The SSE→ACP adapter (chat milestone) consumes these; for the read surface +// they back the session-detail transcript. + +export interface AgentConversationUserMessage { + role: "user"; + /** String shorthand, or array of {type:'text'|'image', …} parts. */ + content: unknown; + /** Epoch milliseconds. */ + timestamp: number; +} + +export interface AgentConversationAssistantMessage { + role: "assistant"; + /** Array of text/thinking/toolCall parts. */ + content: unknown[]; + timestamp: number; + api?: string; + provider?: string; + model?: string; + usage?: Record; + stopReason?: string; + errorMessage?: string; +} + +export interface AgentConversationToolResultMessage { + role: "tool"; + toolCallId: string; + toolName: string; + /** Array of {type:'text'|'image', …} parts. */ + content: unknown[]; + isError: boolean; + timestamp: number; +} + +export type AgentConversationMessage = + | AgentConversationUserMessage + | AgentConversationAssistantMessage + | AgentConversationToolResultMessage; + +export interface AgentApplicationSessionDetail { + id: string; + application_id: string; + revision_id: string; + team_id: number; + state: AgentSessionState; + external_key: string | null; + trigger_metadata?: AgentSessionTriggerMetadata | null; + principal: AgentSessionPrincipal | null; + usage_total: AgentSessionUsageTotal; + conversation: AgentConversationMessage[]; + /** Messages that arrived while a turn was in flight. */ + pending_inputs: AgentConversationMessage[]; + retry_count: number; + created_at: string; + updated_at: string; + /** True when `last_n` was supplied AND the full conversation exceeded it. */ + conversation_trimmed: boolean; + /** Total messages in the untrimmed conversation; present only when trimmed. */ + conversation_total_turns?: number; +} + +// --- Fleet ----------------------------------------------------------------- + +export interface AgentFleetLiveSessionSummary { + id: string; + application_id: string; + revision_id: string; + team_id: number; + state: AgentSessionState; + external_key: string | null; + trigger_metadata?: AgentSessionTriggerMetadata | null; + principal: AgentSessionPrincipal | null; + turns: number; + preview: string | null; + usage_total: AgentSessionUsageTotal; + created_at: string; + updated_at: string; +} + +export interface AgentFleetLiveSessionsResponse { + results: AgentFleetLiveSessionSummary[]; +} + +// --- Approvals ------------------------------------------------------------- + +export interface AgentApprovalRequest { + id: string; + session_id: string; + application_id: string; + team_id: number; + revision_id: string; + turn: number; + tool_call_id: string; + tool_name: string; + proposed_args: Record; + decided_args: Record | null; + assistant_message: Record; + approver_scope: Record; + state: AgentApprovalRequestState; + decision_by: string | null; + decision_at: string | null; + decision_reason: string | null; + dispatch_outcome: Record | null; + created_at: string; + expires_at: string; +} + +/** Body for POST …/approvals/{id}/decide/. */ +export interface DecideApprovalRequest { + decision: AgentApprovalDecision; + /** Honoured only when the tool's approval_policy.allow_edit is true. */ + edited_args?: Record; + reason?: string; +} + +// --- Query params ---------------------------------------------------------- + +export interface AgentSessionsListParams { + limit?: number; + offset?: number; + /** Comma-separated states accepted server-side; pass an array, joined by the client. */ + state?: AgentSessionState[]; + revision_id?: string; + created_after?: string; + created_before?: string; +} + +export interface AgentApprovalsListParams { + state?: AgentApprovalRequestState; + agent_id?: string; + limit?: number; + offset?: number; +} diff --git a/packages/shared/tsup.config.ts b/packages/shared/tsup.config.ts index f1685d8101..742102c8e1 100644 --- a/packages/shared/tsup.config.ts +++ b/packages/shared/tsup.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ entry: [ "src/index.ts", + "src/agent-platform-types.ts", "src/analytics-events.ts", "src/constants.ts", "src/deeplink.ts", diff --git a/packages/ui/src/features/agent-applications/components/AgentApplicationDetailView.tsx b/packages/ui/src/features/agent-applications/components/AgentApplicationDetailView.tsx new file mode 100644 index 0000000000..de0122d6a0 --- /dev/null +++ b/packages/ui/src/features/agent-applications/components/AgentApplicationDetailView.tsx @@ -0,0 +1,236 @@ +import { ArrowLeftIcon, RobotIcon } from "@phosphor-icons/react"; +import { formatRelativeTimeShort } from "@posthog/shared"; +import type { + AgentSessionPrincipal, + AgentSessionSummary, +} from "@posthog/shared/agent-platform-types"; +import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { Badge } from "@posthog/ui/primitives/Badge"; +import { Flex, Text } from "@radix-ui/themes"; +import { Link } from "@tanstack/react-router"; +import { type ReactNode, useMemo } from "react"; +import { useAgentApplication } from "../hooks/useAgentApplication"; +import { useAgentApplicationSessions } from "../hooks/useAgentApplicationSessions"; +import { useAgentApplicationStats } from "../hooks/useAgentApplicationStats"; +import { formatSpendUsd, sessionStateColor } from "../utils/format"; + +/** + * Per-agent detail: overview + stat strip + recent sessions. The chat surface + * and config editor land in later milestones. + */ +export function AgentApplicationDetailView({ idOrSlug }: { idOrSlug: string }) { + const { + data: application, + isLoading, + isError, + } = useAgentApplication(idOrSlug); + const { data: stats } = useAgentApplicationStats(idOrSlug); + const { data: sessions, isLoading: sessionsLoading } = + useAgentApplicationSessions(idOrSlug, { limit: 25 }); + + const title = application?.name ?? idOrSlug; + const headerContent = useMemo( + () => ( + + + + {title} + + + ), + [title], + ); + useSetHeaderContent(headerContent); + + return ( + + + + + Applications + + + + {title} + + {application ? ( + + {application.live_revision ? "Live" : "Draft"} + + ) : null} + + {application?.description?.trim() ? ( + + {application.description} + + ) : null} + + +
+
+ {isLoading ? ( +
+ ) : isError || !application ? ( + + ) : ( + + + +
+ + Recent sessions + + {sessionsLoading ? ( + + {[0, 1, 2].map((i) => ( +
+ ))} + + ) : !sessions || sessions.results.length === 0 ? ( + + ) : ( + + {sessions.results.map((session) => ( + + ))} + + )} +
+
+ )} +
+
+ + ); +} + +function StatStrip({ + liveCount, + sessionsInWindowCount, + spendInWindowUsd, + failedInWindowCount, +}: { + liveCount: number; + sessionsInWindowCount: number; + spendInWindowUsd: number; + failedInWindowCount: number; +}) { + return ( + + + + + + + ); +} + +function Stat({ + label, + value, + last, +}: { + label: string; + value: string; + last?: boolean; +}) { + return ( + + + {label} + + + {value} + + + ); +} + +function principalLabel(principal: AgentSessionPrincipal | null): string { + if (!principal) return "anonymous"; + return principal.kind; +} + +function SessionRow({ session }: { session: AgentSessionSummary }) { + return ( + + + + + {session.state} + + + {session.preview?.trim() ? session.preview : "No assistant output"} + + + + {principalLabel(session.principal)} · {session.turns} turns ·{" "} + {formatSpendUsd(session.usage_total.cost_total)} + + + + {formatRelativeTimeShort(session.updated_at)} + + + ); +} + +function EmptyState({ + title, + description, +}: { + title: string; + description: ReactNode; +}) { + return ( + + {title} + + {description} + + + ); +} diff --git a/packages/ui/src/features/agent-applications/components/AgentApplicationsListView.tsx b/packages/ui/src/features/agent-applications/components/AgentApplicationsListView.tsx index 47283e2cc2..6198458e3b 100644 --- a/packages/ui/src/features/agent-applications/components/AgentApplicationsListView.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentApplicationsListView.tsx @@ -1,13 +1,17 @@ -import { RobotIcon } from "@phosphor-icons/react"; +import { CaretRightIcon, RobotIcon } from "@phosphor-icons/react"; +import type { AgentApplication } from "@posthog/shared/agent-platform-types"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { Badge } from "@posthog/ui/primitives/Badge"; import { Flex, Text } from "@radix-ui/themes"; -import { useMemo } from "react"; +import { Link } from "@tanstack/react-router"; +import { type ReactNode, useMemo } from "react"; +import { useAgentApplications } from "../hooks/useAgentApplications"; +import { useAgentFleetStats } from "../hooks/useAgentFleetStats"; +import { formatSpendUsd } from "../utils/format"; /** - * Landing for the agent-platform console: the list of deployed agent - * applications plus the fleet overview. M1 renders the chrome only — the - * list, fleet stat strip, and live-now panel are wired to the - * agent_platform REST API in a later milestone. + * Landing for the agent-platform console: a fleet stat strip plus the list of + * deployed agent applications. Each row links to the per-agent detail view. */ export function AgentApplicationsListView() { const headerContent = useMemo( @@ -27,6 +31,14 @@ export function AgentApplicationsListView() { useSetHeaderContent(headerContent); + const { + data: applications, + isLoading, + isError, + error, + } = useAgentApplications(); + const { data: fleetStats } = useAgentFleetStats(); + return (
- No agents yet. + + + + {isLoading ? ( + + ) : isError ? ( + + ) : !applications || applications.length === 0 ? ( + + ) : ( + + {applications.map((app) => ( + + ))} + + )} +
); } + +interface FleetStatStripProps { + liveCount: number; + sessionsInWindowCount: number; + spendInWindowUsd: number; + failedInWindowCount: number; + pendingApprovalsCount: number; +} + +function FleetStatStrip({ + liveCount, + sessionsInWindowCount, + spendInWindowUsd, + failedInWindowCount, + pendingApprovalsCount, +}: FleetStatStripProps) { + return ( + + + + + 0 ? "red" : undefined} + /> + 0 ? "amber" : undefined} + last + /> + + ); +} + +function Stat({ + label, + value, + emphasize, + last, +}: { + label: string; + value: string; + emphasize?: "red" | "amber"; + last?: boolean; +}) { + const valueColor = + emphasize === "red" + ? "text-(--red-11)" + : emphasize === "amber" + ? "text-(--amber-11)" + : "text-gray-12"; + return ( + + + {label} + + + {value} + + + ); +} + +function ApplicationRow({ application }: { application: AgentApplication }) { + const isLive = application.live_revision != null; + return ( + + + + + + + {application.name} + + + {isLive ? "Live" : "Draft"} + + + + {application.description?.trim() + ? application.description + : (application.slug ?? application.id)} + + + + + + ); +} + +function ApplicationsSkeleton() { + return ( + + {[0, 1, 2].map((i) => ( +
+ ))} + + ); +} + +function EmptyState({ + title, + description, +}: { + title: string; + description: ReactNode; +}) { + return ( + + {title} + + {description} + + + ); +} diff --git a/packages/ui/src/features/agent-applications/hooks/agentApplicationsKeys.ts b/packages/ui/src/features/agent-applications/hooks/agentApplicationsKeys.ts new file mode 100644 index 0000000000..4f9b605b36 --- /dev/null +++ b/packages/ui/src/features/agent-applications/hooks/agentApplicationsKeys.ts @@ -0,0 +1,20 @@ +export const agentApplicationsKeys = { + list: (projectId: number | null) => + ["agent-applications", "list", projectId] as const, + detail: (projectId: number | null, idOrSlug: string) => + ["agent-applications", "detail", projectId, idOrSlug] as const, + stats: (projectId: number | null, idOrSlug: string) => + ["agent-applications", "stats", projectId, idOrSlug] as const, + sessions: (projectId: number | null, idOrSlug: string) => + ["agent-applications", "sessions", projectId, idOrSlug] as const, + session: (projectId: number | null, idOrSlug: string, sessionId: string) => + ["agent-applications", "session", projectId, idOrSlug, sessionId] as const, + approvals: (projectId: number | null, idOrSlug: string) => + ["agent-applications", "approvals", projectId, idOrSlug] as const, + revisions: (projectId: number | null, idOrSlug: string) => + ["agent-applications", "revisions", projectId, idOrSlug] as const, + fleetStats: (projectId: number | null) => + ["agent-applications", "fleet", "stats", projectId] as const, + fleetLiveSessions: (projectId: number | null) => + ["agent-applications", "fleet", "live-sessions", projectId] as const, +}; diff --git a/packages/ui/src/features/agent-applications/hooks/useAgentApplication.ts b/packages/ui/src/features/agent-applications/hooks/useAgentApplication.ts new file mode 100644 index 0000000000..74a7951c41 --- /dev/null +++ b/packages/ui/src/features/agent-applications/hooks/useAgentApplication.ts @@ -0,0 +1,14 @@ +import type { AgentApplication } from "@posthog/shared/agent-platform-types"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { useAuthStateValue } from "../../auth/store"; +import { agentApplicationsKeys } from "./agentApplicationsKeys"; + +/** Fetches a single agent application by UUID or slug. */ +export function useAgentApplication(idOrSlug: string) { + const projectId = useAuthStateValue((state) => state.currentProjectId); + return useAuthenticatedQuery( + agentApplicationsKeys.detail(projectId, idOrSlug), + (client) => client.getAgentApplication(idOrSlug), + { enabled: !!projectId && !!idOrSlug, staleTime: 30_000 }, + ); +} diff --git a/packages/ui/src/features/agent-applications/hooks/useAgentApplicationSessions.ts b/packages/ui/src/features/agent-applications/hooks/useAgentApplicationSessions.ts new file mode 100644 index 0000000000..e149eca16f --- /dev/null +++ b/packages/ui/src/features/agent-applications/hooks/useAgentApplicationSessions.ts @@ -0,0 +1,25 @@ +import type { + AgentApplicationSessionsListResponse, + AgentSessionsListParams, +} from "@posthog/shared/agent-platform-types"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { useAuthStateValue } from "../../auth/store"; +import { agentApplicationsKeys } from "./agentApplicationsKeys"; + +const EMPTY: AgentApplicationSessionsListResponse = { results: [], count: 0 }; + +/** Lists sessions for an agent application. */ +export function useAgentApplicationSessions( + idOrSlug: string, + params?: AgentSessionsListParams, +) { + const projectId = useAuthStateValue((state) => state.currentProjectId); + return useAuthenticatedQuery( + [...agentApplicationsKeys.sessions(projectId, idOrSlug), params ?? null], + (client) => + projectId + ? client.listAgentApplicationSessions(idOrSlug, params) + : Promise.resolve(EMPTY), + { enabled: !!projectId && !!idOrSlug, staleTime: 15_000 }, + ); +} diff --git a/packages/ui/src/features/agent-applications/hooks/useAgentApplicationStats.ts b/packages/ui/src/features/agent-applications/hooks/useAgentApplicationStats.ts new file mode 100644 index 0000000000..21a66d0fd7 --- /dev/null +++ b/packages/ui/src/features/agent-applications/hooks/useAgentApplicationStats.ts @@ -0,0 +1,14 @@ +import type { AgentAggregateStats } from "@posthog/shared/agent-platform-types"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { useAuthStateValue } from "../../auth/store"; +import { agentApplicationsKeys } from "./agentApplicationsKeys"; + +/** Per-application roll-up stats (live, sessions, spend, failures). */ +export function useAgentApplicationStats(idOrSlug: string) { + const projectId = useAuthStateValue((state) => state.currentProjectId); + return useAuthenticatedQuery( + agentApplicationsKeys.stats(projectId, idOrSlug), + (client) => client.getAgentApplicationStats(idOrSlug), + { enabled: !!projectId && !!idOrSlug, staleTime: 30_000 }, + ); +} diff --git a/packages/ui/src/features/agent-applications/hooks/useAgentApplications.ts b/packages/ui/src/features/agent-applications/hooks/useAgentApplications.ts new file mode 100644 index 0000000000..b6230d000d --- /dev/null +++ b/packages/ui/src/features/agent-applications/hooks/useAgentApplications.ts @@ -0,0 +1,15 @@ +import type { AgentApplication } from "@posthog/shared/agent-platform-types"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { useAuthStateValue } from "../../auth/store"; +import { agentApplicationsKeys } from "./agentApplicationsKeys"; + +/** Lists the deployed agent applications for the current project. */ +export function useAgentApplications() { + const projectId = useAuthStateValue((state) => state.currentProjectId); + return useAuthenticatedQuery( + agentApplicationsKeys.list(projectId), + (client) => + projectId ? client.listAgentApplications() : Promise.resolve([]), + { enabled: !!projectId, staleTime: 30_000 }, + ); +} diff --git a/packages/ui/src/features/agent-applications/hooks/useAgentFleetStats.ts b/packages/ui/src/features/agent-applications/hooks/useAgentFleetStats.ts new file mode 100644 index 0000000000..72c3dd0f32 --- /dev/null +++ b/packages/ui/src/features/agent-applications/hooks/useAgentFleetStats.ts @@ -0,0 +1,15 @@ +import type { AgentAggregateStats } from "@posthog/shared/agent-platform-types"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { useAuthStateValue } from "../../auth/store"; +import { agentApplicationsKeys } from "./agentApplicationsKeys"; + +/** Team-wide fleet roll-up stats (live count, spend, failures, approvals). */ +export function useAgentFleetStats() { + const projectId = useAuthStateValue((state) => state.currentProjectId); + return useAuthenticatedQuery( + agentApplicationsKeys.fleetStats(projectId), + (client) => + projectId ? client.getAgentFleetStats() : Promise.resolve(null), + { enabled: !!projectId, staleTime: 30_000 }, + ); +} diff --git a/packages/ui/src/features/agent-applications/utils/format.ts b/packages/ui/src/features/agent-applications/utils/format.ts new file mode 100644 index 0000000000..1d2985d974 --- /dev/null +++ b/packages/ui/src/features/agent-applications/utils/format.ts @@ -0,0 +1,52 @@ +import type { + AgentRevisionState, + AgentSessionState, +} from "@posthog/shared/agent-platform-types"; + +/** Formats a USD spend value for the fleet / agent stat strips. */ +export function formatSpendUsd(value: number | null | undefined): string { + if (value == null) return "$0"; + if (value === 0) return "$0"; + if (value < 0.01) return "<$0.01"; + return `$${value.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; +} + +/** Radix Badge colour for a session lifecycle state. */ +export function sessionStateColor( + state: AgentSessionState, +): "green" | "blue" | "gray" | "red" | "amber" { + switch (state) { + case "running": + return "blue"; + case "queued": + return "amber"; + case "completed": + case "closed": + return "green"; + case "failed": + return "red"; + case "cancelled": + return "gray"; + default: + return "gray"; + } +} + +/** Radix Badge colour for a revision lifecycle state. */ +export function revisionStateColor( + state: AgentRevisionState, +): "green" | "blue" | "gray" | "amber" { + switch (state) { + case "live": + return "green"; + case "ready": + return "blue"; + case "draft": + return "amber"; + default: + return "gray"; + } +} diff --git a/packages/ui/src/router/routeTree.gen.ts b/packages/ui/src/router/routeTree.gen.ts index cb60982d74..44b973a7ca 100644 --- a/packages/ui/src/router/routeTree.gen.ts +++ b/packages/ui/src/router/routeTree.gen.ts @@ -46,7 +46,9 @@ import { Route as CodeInboxRunsReportIdRouteImport } from './routes/code/inbox/r import { Route as CodeInboxReportsReportIdRouteImport } from './routes/code/inbox/reports.$reportId' import { Route as CodeInboxPullsReportIdRouteImport } from './routes/code/inbox/pulls.$reportId' import { Route as CodeAgentsScoutsSkillNameRouteImport } from './routes/code/agents/scouts.$skillName' +import { Route as CodeAgentsApplicationsIdOrSlugRouteImport } from './routes/code/agents/applications/$idOrSlug' import { Route as CodeAgentsScoutsSkillNameIndexRouteImport } from './routes/code/agents/scouts.$skillName.index' +import { Route as CodeAgentsApplicationsIdOrSlugIndexRouteImport } from './routes/code/agents/applications/$idOrSlug/index' const WebsiteRoute = WebsiteRouteImport.update({ id: '/website', @@ -238,12 +240,24 @@ const CodeAgentsScoutsSkillNameRoute = path: '/$skillName', getParentRoute: () => CodeAgentsScoutsRoute, } as any) +const CodeAgentsApplicationsIdOrSlugRoute = + CodeAgentsApplicationsIdOrSlugRouteImport.update({ + id: '/$idOrSlug', + path: '/$idOrSlug', + getParentRoute: () => CodeAgentsApplicationsRoute, + } as any) const CodeAgentsScoutsSkillNameIndexRoute = CodeAgentsScoutsSkillNameIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => CodeAgentsScoutsSkillNameRoute, } as any) +const CodeAgentsApplicationsIdOrSlugIndexRoute = + CodeAgentsApplicationsIdOrSlugIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => CodeAgentsApplicationsIdOrSlugRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -272,6 +286,7 @@ export interface FileRoutesByFullPath { '/code/agents/': typeof CodeAgentsIndexRoute '/code/inbox/': typeof CodeInboxIndexRoute '/website/$channelId/': typeof WebsiteChannelIdIndexRoute + '/code/agents/applications/$idOrSlug': typeof CodeAgentsApplicationsIdOrSlugRouteWithChildren '/code/agents/scouts/$skillName': typeof CodeAgentsScoutsSkillNameRouteWithChildren '/code/inbox/pulls/$reportId': typeof CodeInboxPullsReportIdRoute '/code/inbox/reports/$reportId': typeof CodeInboxReportsReportIdRoute @@ -283,6 +298,7 @@ export interface FileRoutesByFullPath { '/code/inbox/pulls/': typeof CodeInboxPullsIndexRoute '/code/inbox/reports/': typeof CodeInboxReportsIndexRoute '/code/inbox/runs/': typeof CodeInboxRunsIndexRoute + '/code/agents/applications/$idOrSlug/': typeof CodeAgentsApplicationsIdOrSlugIndexRoute '/code/agents/scouts/$skillName/': typeof CodeAgentsScoutsSkillNameIndexRoute } export interface FileRoutesByTo { @@ -315,6 +331,7 @@ export interface FileRoutesByTo { '/code/inbox/pulls': typeof CodeInboxPullsIndexRoute '/code/inbox/reports': typeof CodeInboxReportsIndexRoute '/code/inbox/runs': typeof CodeInboxRunsIndexRoute + '/code/agents/applications/$idOrSlug': typeof CodeAgentsApplicationsIdOrSlugIndexRoute '/code/agents/scouts/$skillName': typeof CodeAgentsScoutsSkillNameIndexRoute } export interface FileRoutesById { @@ -345,6 +362,7 @@ export interface FileRoutesById { '/code/agents/': typeof CodeAgentsIndexRoute '/code/inbox/': typeof CodeInboxIndexRoute '/website/$channelId/': typeof WebsiteChannelIdIndexRoute + '/code/agents/applications/$idOrSlug': typeof CodeAgentsApplicationsIdOrSlugRouteWithChildren '/code/agents/scouts/$skillName': typeof CodeAgentsScoutsSkillNameRouteWithChildren '/code/inbox/pulls/$reportId': typeof CodeInboxPullsReportIdRoute '/code/inbox/reports/$reportId': typeof CodeInboxReportsReportIdRoute @@ -356,6 +374,7 @@ export interface FileRoutesById { '/code/inbox/pulls/': typeof CodeInboxPullsIndexRoute '/code/inbox/reports/': typeof CodeInboxReportsIndexRoute '/code/inbox/runs/': typeof CodeInboxRunsIndexRoute + '/code/agents/applications/$idOrSlug/': typeof CodeAgentsApplicationsIdOrSlugIndexRoute '/code/agents/scouts/$skillName/': typeof CodeAgentsScoutsSkillNameIndexRoute } export interface FileRouteTypes { @@ -387,6 +406,7 @@ export interface FileRouteTypes { | '/code/agents/' | '/code/inbox/' | '/website/$channelId/' + | '/code/agents/applications/$idOrSlug' | '/code/agents/scouts/$skillName' | '/code/inbox/pulls/$reportId' | '/code/inbox/reports/$reportId' @@ -398,6 +418,7 @@ export interface FileRouteTypes { | '/code/inbox/pulls/' | '/code/inbox/reports/' | '/code/inbox/runs/' + | '/code/agents/applications/$idOrSlug/' | '/code/agents/scouts/$skillName/' fileRoutesByTo: FileRoutesByTo to: @@ -430,6 +451,7 @@ export interface FileRouteTypes { | '/code/inbox/pulls' | '/code/inbox/reports' | '/code/inbox/runs' + | '/code/agents/applications/$idOrSlug' | '/code/agents/scouts/$skillName' id: | '__root__' @@ -459,6 +481,7 @@ export interface FileRouteTypes { | '/code/agents/' | '/code/inbox/' | '/website/$channelId/' + | '/code/agents/applications/$idOrSlug' | '/code/agents/scouts/$skillName' | '/code/inbox/pulls/$reportId' | '/code/inbox/reports/$reportId' @@ -470,6 +493,7 @@ export interface FileRouteTypes { | '/code/inbox/pulls/' | '/code/inbox/reports/' | '/code/inbox/runs/' + | '/code/agents/applications/$idOrSlug/' | '/code/agents/scouts/$skillName/' fileRoutesById: FileRoutesById } @@ -752,6 +776,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CodeAgentsScoutsSkillNameRouteImport parentRoute: typeof CodeAgentsScoutsRoute } + '/code/agents/applications/$idOrSlug': { + id: '/code/agents/applications/$idOrSlug' + path: '/$idOrSlug' + fullPath: '/code/agents/applications/$idOrSlug' + preLoaderRoute: typeof CodeAgentsApplicationsIdOrSlugRouteImport + parentRoute: typeof CodeAgentsApplicationsRoute + } '/code/agents/scouts/$skillName/': { id: '/code/agents/scouts/$skillName/' path: '/' @@ -759,6 +790,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CodeAgentsScoutsSkillNameIndexRouteImport parentRoute: typeof CodeAgentsScoutsSkillNameRoute } + '/code/agents/applications/$idOrSlug/': { + id: '/code/agents/applications/$idOrSlug/' + path: '/' + fullPath: '/code/agents/applications/$idOrSlug/' + preLoaderRoute: typeof CodeAgentsApplicationsIdOrSlugIndexRouteImport + parentRoute: typeof CodeAgentsApplicationsIdOrSlugRoute + } } } @@ -784,12 +822,30 @@ const WebsiteRouteChildren: WebsiteRouteChildren = { const WebsiteRouteWithChildren = WebsiteRoute._addFileChildren(WebsiteRouteChildren) +interface CodeAgentsApplicationsIdOrSlugRouteChildren { + CodeAgentsApplicationsIdOrSlugIndexRoute: typeof CodeAgentsApplicationsIdOrSlugIndexRoute +} + +const CodeAgentsApplicationsIdOrSlugRouteChildren: CodeAgentsApplicationsIdOrSlugRouteChildren = + { + CodeAgentsApplicationsIdOrSlugIndexRoute: + CodeAgentsApplicationsIdOrSlugIndexRoute, + } + +const CodeAgentsApplicationsIdOrSlugRouteWithChildren = + CodeAgentsApplicationsIdOrSlugRoute._addFileChildren( + CodeAgentsApplicationsIdOrSlugRouteChildren, + ) + interface CodeAgentsApplicationsRouteChildren { + CodeAgentsApplicationsIdOrSlugRoute: typeof CodeAgentsApplicationsIdOrSlugRouteWithChildren CodeAgentsApplicationsIndexRoute: typeof CodeAgentsApplicationsIndexRoute } const CodeAgentsApplicationsRouteChildren: CodeAgentsApplicationsRouteChildren = { + CodeAgentsApplicationsIdOrSlugRoute: + CodeAgentsApplicationsIdOrSlugRouteWithChildren, CodeAgentsApplicationsIndexRoute: CodeAgentsApplicationsIndexRoute, } diff --git a/packages/ui/src/router/routes/code/agents/applications/$idOrSlug.tsx b/packages/ui/src/router/routes/code/agents/applications/$idOrSlug.tsx new file mode 100644 index 0000000000..1a2e3a3ca8 --- /dev/null +++ b/packages/ui/src/router/routes/code/agents/applications/$idOrSlug.tsx @@ -0,0 +1,5 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/agents/applications/$idOrSlug")({ + component: Outlet, +}); diff --git a/packages/ui/src/router/routes/code/agents/applications/$idOrSlug/index.tsx b/packages/ui/src/router/routes/code/agents/applications/$idOrSlug/index.tsx new file mode 100644 index 0000000000..1bf4568855 --- /dev/null +++ b/packages/ui/src/router/routes/code/agents/applications/$idOrSlug/index.tsx @@ -0,0 +1,11 @@ +import { AgentApplicationDetailView } from "@posthog/ui/features/agent-applications/components/AgentApplicationDetailView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/agents/applications/$idOrSlug/")({ + component: AgentApplicationDetailRoute, +}); + +function AgentApplicationDetailRoute() { + const { idOrSlug } = Route.useParams(); + return ; +}