From 3e3cbeb16c0fb3ddbc4879bae392ab5633600d93 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Wed, 19 Aug 2026 20:57:08 +0200 Subject: [PATCH 1/5] feat(web): Agent Sessions list under Explore, behind the agent_tracing flag Consumes aiSessionListQuery end to end: a dashboard-only internal endpoint (/internal/ai-sessions/list, session-authorized, rowSchema-decoded) and an Explore > Agent Sessions page listing sessions with vendor, services, trace/span counts, errors and duration. No detail page yet. Gating is the webAnalytics-rollout pattern: an agentTracing flag decoded from Clerk org publicMetadata ("agent_tracing"), threaded as an optional parameter through navGroups/paletteNavItems so the sidebar and command palette go dark together, and checked in the route component after isLoaded so entitled orgs don't see a not-found flash. The flag hides the surface only; the data stays org-scoped through CurrentTenant like every other warehouse read. Co-Authored-By: Claude Fable 5 --- .../src/routes/internal/ai-sessions.http.ts | 41 +++++ apps/api/src/runtime/http-graph.ts | 5 +- apps/web/src/api/warehouse/ai-sessions.ts | 42 +++++ .../agent-sessions/agent-sessions-list.tsx | 172 ++++++++++++++++++ .../command-palette/command-palette.tsx | 2 +- .../src/components/dashboard/app-sidebar.tsx | 6 +- .../components/dashboard/nav-items.test.ts | 19 ++ .../web/src/components/dashboard/nav-items.ts | 30 +-- .../lib/organization-feature-flags.test.ts | 16 +- .../web/src/lib/organization-feature-flags.ts | 5 + .../services/atoms/warehouse-query-atoms.ts | 5 + apps/web/src/routeTree.gen.ts | 21 +++ apps/web/src/routes/agent-sessions/index.tsx | 141 ++++++++++++++ packages/domain/src/http/ai-sessions.ts | 62 +++++++ packages/domain/src/http/index.ts | 1 + packages/domain/src/http/internal-api.ts | 2 + 16 files changed, 550 insertions(+), 20 deletions(-) create mode 100644 apps/api/src/routes/internal/ai-sessions.http.ts create mode 100644 apps/web/src/api/warehouse/ai-sessions.ts create mode 100644 apps/web/src/components/agent-sessions/agent-sessions-list.tsx create mode 100644 apps/web/src/routes/agent-sessions/index.tsx create mode 100644 packages/domain/src/http/ai-sessions.ts diff --git a/apps/api/src/routes/internal/ai-sessions.http.ts b/apps/api/src/routes/internal/ai-sessions.http.ts new file mode 100644 index 000000000..ef03dec16 --- /dev/null +++ b/apps/api/src/routes/internal/ai-sessions.http.ts @@ -0,0 +1,41 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { CurrentTenant, ListAiSessionsResponse, MapleInternalApi } from "@maple/domain/http" +import { Effect } from "effect" +import { CH } from "@maple/query-engine" +import * as Integrations from "@maple/query-engine-integrations" +import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" + +/** + * Dashboard-only AI agent session reads. + * + * Serves the Agent Sessions page (behind the `agent_tracing` org rollout flag). + * The flag hides the surface, not the data — scoping is `CurrentTenant`, like + * every other warehouse read. + */ +export const HttpAiSessionsInternalLive = HttpApiBuilder.group( + MapleInternalApi, + "aiSessionsInternal", + (handlers) => + Effect.gen(function* () { + const warehouse = yield* WarehouseQueryService + + return handlers.handle("list", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId }) + const compiled = CH.compile( + Integrations.aiSessionListQuery({ limit: payload.limit }), + { orgId: tenant.orgId, startTime: payload.startTime, endTime: payload.endTime }, + { rowSchema: Integrations.aiSessionListRowSchema }, + ) + // The row schema already coerces the UInt64 aggregates, so no + // per-field Number() at this edge (unlike listReplays). + const rows = yield* warehouse.compiledQuery(tenant, compiled, { + profile: "list", + context: "listAiSessions", + }) + return new ListAiSessionsResponse({ data: rows.map((row) => ({ ...row })) }) + }), + ) + }), +) diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index e016a3364..86c29ec83 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -6,6 +6,7 @@ import { HttpApiBuilder, HttpApiScalar } from "effect/unstable/httpapi" import { API_CORS_OPTIONS } from "@/http/api-cors" import { McpLive } from "@/mcp/app" import { Env } from "@/platform/Env" +import { HttpAiSessionsInternalLive } from "@/routes/internal/ai-sessions.http" import { HttpAiTriageLive } from "@/routes/internal/ai-triage.http" import { HttpAuthLive, HttpAuthPublicLive } from "@/routes/v1/auth.http" import { HttpBillingLive } from "@/routes/internal/billing.http" @@ -97,7 +98,9 @@ const ApiRoutes = HttpApiBuilder.layer(MapleApi).pipe( * which is generated from `MapleApi`. */ const ApiInternalRoutes = HttpApiBuilder.layer(MapleInternalApi).pipe( - Layer.provide(Layer.mergeAll(HttpQueryEngineLive, HttpSessionReplaysInternalLive)), + Layer.provide( + Layer.mergeAll(HttpQueryEngineLive, HttpSessionReplaysInternalLive, HttpAiSessionsInternalLive), + ), Layer.provide( Layer.mergeAll(HttpAiTriageLive, HttpBillingLive, HttpChatLive, HttpDemoLive, HttpDigestLive), ), diff --git a/apps/web/src/api/warehouse/ai-sessions.ts b/apps/web/src/api/warehouse/ai-sessions.ts new file mode 100644 index 000000000..f5a2d0c67 --- /dev/null +++ b/apps/web/src/api/warehouse/ai-sessions.ts @@ -0,0 +1,42 @@ +import { Clock, Effect, Schema } from "effect" +import { ListAiSessionsRequest } from "@maple/domain/http" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" +import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils" + +import { formatWarehouseDateTime } from "@maple/query-engine" + +const ListAiSessionsInput = Schema.Struct({ + startTime: Schema.optional(WarehouseDateTimeString), + endTime: Schema.optional(WarehouseDateTimeString), + limit: Schema.optional(Schema.Number), +}) +export type ListAiSessionsInput = Schema.Schema.Type + +const defaultTimeRange = (nowMs: number) => { + return { + startTime: formatWarehouseDateTime(nowMs - 24 * 60 * 60 * 1000), + endTime: formatWarehouseDateTime(nowMs), + } +} + +export const listAiSessions = Effect.fn("AiSessions.list")(function* ({ + data, +}: { + data: ListAiSessionsInput +}) { + const input = yield* decodeInput(ListAiSessionsInput, data ?? {}, "listAiSessions") + const fallback = defaultTimeRange(yield* Clock.currentTimeMillis) + const result = yield* runWarehouseQuery("listAiSessions", () => + Effect.gen(function* () { + const client = yield* MapleInternalAtomClient + return yield* client.aiSessionsInternal.list({ + payload: new ListAiSessionsRequest({ + startTime: input.startTime ?? fallback.startTime, + endTime: input.endTime ?? fallback.endTime, + limit: input.limit ?? 50, + }), + }) + }), + ) + return { data: result.data } +}) diff --git a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx new file mode 100644 index 000000000..3bf63abca --- /dev/null +++ b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx @@ -0,0 +1,172 @@ +import { formatRelativeTimeOrDate, toEpochMs } from "@maple/ui/lib/time-format" +import { cn } from "@maple/ui/lib/utils" +import { formatSessionDuration, gradientFor } from "@maple/ui/lib/replay-format" +import { ChatBubbleSparkleIcon } from "@/components/icons" + +/** The wire row from `listAiSessions` — one AI agent session, newest first. */ +export interface AgentSessionRow { + readonly sessionId: string + readonly vendorId: string + readonly vendorVersion: string + readonly traceCount: number + readonly spanCount: number + readonly errorSpanCount: number + readonly serviceNames: ReadonlyArray + readonly startTime: string + readonly endTime: string + readonly durationMs: number +} + +/** + * Vendor ids the ingest gateway stamps → display names. Anything unlisted shows + * its raw id, minus the `unknown:` prefix the gateway uses for dialect-detected + * spans with no framework identity. + */ +const VENDOR_LABELS = new Map([ + ["eve", "eve"], + ["vercel_ai_sdk", "Vercel AI SDK"], + ["openinference-openai", "OpenInference · OpenAI"], +]) + +function vendorLabel(vendorId: string): string { + return VENDOR_LABELS.get(vendorId) ?? vendorId.replace(/^unknown:/, "") +} + +function absoluteTs(startTime: string): string { + const parsed = toEpochMs(startTime) + return Number.isNaN(parsed) ? startTime : new Date(parsed).toLocaleString() +} + +interface AgentSessionsListProps { + sessions: ReadonlyArray + /** The request's limit — rows at the cap mean older sessions were cut off. */ + limit: number +} + +export function AgentSessionsList({ sessions, limit }: AgentSessionsListProps) { + if (sessions.length === 0) { + return ( +
+
+ +
+

No agent sessions yet

+

+ Trace your AI agents with a supported framework or OpenTelemetry{" "} + gen_ai{" "} + spans, and their sessions will show up here. +

+
+ ) + } + + return ( +
+ {sessions.map((session) => { + const hasErrors = session.errorSpanCount > 0 + const vendor = vendorLabel(session.vendorId) + const secondary = + session.vendorVersion && session.vendorVersion !== "0" + ? `${vendor} · v${session.vendorVersion}` + : vendor + return ( +
+ {/* Errored sessions get a left accent so they can be picked out + while scanning — same signal as the replays list. */} + {hasErrors && ( + + )} + +
+ {(vendor[0] ?? "?").toUpperCase()} +
+ + {/* Identity lane: session id, framework underneath */} +
+
+ + {session.sessionId} + + {/* On phones the right-hand lanes are gone, so the timestamp + anchors the top-right corner of the stacked row. */} + + {formatRelativeTimeOrDate(session.startTime)} + +
+
{secondary}
+ {hasErrors && ( +
+ +
+ )} +
+ + {/* Services lane */} +
+ + {session.serviceNames.join(" · ")} + +
+ + {/* Activity lane: duration + traces/spans */} +
+ + {formatSessionDuration(session.durationMs)} + + + {session.traceCount} trace{session.traceCount === 1 ? "" : "s"} ·{" "} + {session.spanCount} span{session.spanCount === 1 ? "" : "s"} + +
+ + {/* Signal lane: error chip */} +
+ +
+ + {/* Time lane */} +
+ + {formatRelativeTimeOrDate(session.startTime)} + +
+
+ ) + })} + + {sessions.length >= limit && ( +

+ Showing the {limit.toLocaleString()} most recent sessions — narrow the time range to see + older ones +

+ )} +
+ ) +} + +function SessionBadges({ session }: { session: AgentSessionRow }) { + if (session.errorSpanCount === 0) return null + return ( + + + {session.errorSpanCount} error{session.errorSpanCount === 1 ? "" : "s"} + + ) +} diff --git a/apps/web/src/components/command-palette/command-palette.tsx b/apps/web/src/components/command-palette/command-palette.tsx index 376f75dd2..5fad4fc87 100644 --- a/apps/web/src/components/command-palette/command-palette.tsx +++ b/apps/web/src/components/command-palette/command-palette.tsx @@ -142,7 +142,7 @@ function PaletteContent({ // the K8s lists and the integration pages are all reachable by name here, // which is what lets the sidebar fold them into two sections. const navigation: PaletteEntry[] = [ - ...paletteNavItems().map((item) => ({ + ...paletteNavItems(featureFlags).map((item) => ({ id: item.id, title: item.title, group: "Navigation" as const, diff --git a/apps/web/src/components/dashboard/app-sidebar.tsx b/apps/web/src/components/dashboard/app-sidebar.tsx index 884cdb45b..5b5345beb 100644 --- a/apps/web/src/components/dashboard/app-sidebar.tsx +++ b/apps/web/src/components/dashboard/app-sidebar.tsx @@ -57,6 +57,7 @@ import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" import { clearSelfHostedSessionToken } from "@/lib/services/common/self-hosted-auth" import { useDashboardsRead } from "@/hooks/use-dashboard-store" import { useDashboardPreferences } from "@/hooks/use-dashboard-preferences" +import { useOrganizationFeatureFlags } from "@/hooks/use-organization-feature-flags" /** * A 2px lane is reserved on every row so icons share one vertical line whether @@ -539,7 +540,10 @@ function FooterCluster() { // (instead of bare useRouterState) keeps it quiet during loader/pending ticks. export const AppSidebar = memo(function AppSidebar() { const currentPath = useRouterState({ select: (s) => s.location.pathname }) - const groups = navGroups() + // Fails closed while Clerk loads, so a flagged row arrives a beat late rather + // than flashing and vanishing — the trade `navGroups` documents. + const { flags } = useOrganizationFeatureFlags() + const groups = navGroups(flags) return ( diff --git a/apps/web/src/components/dashboard/nav-items.test.ts b/apps/web/src/components/dashboard/nav-items.test.ts index 7f1e121b9..90c56668c 100644 --- a/apps/web/src/components/dashboard/nav-items.test.ts +++ b/apps/web/src/components/dashboard/nav-items.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest" import { isNavItemActive, isPathActive, navGroups, paletteNavItems, type NavItem } from "./nav-items" +import { ENABLED_ORGANIZATION_FEATURE_FLAGS } from "@/lib/organization-feature-flags" function findItem(title: string): NavItem { const item = navGroups() @@ -85,6 +86,24 @@ describe("navGroups", () => { } }) + it("shows Agent Sessions only behind the agentTracing flag", () => { + // No flags (or flags still loading) hides the row — a row that appears and + // then vanishes is worse than one that arrives a beat late. + const withoutFlags = findItem("Explore").subItems?.map((sub) => sub.href) + expect(withoutFlags).not.toContain("/agent-sessions") + + const explore = navGroups(ENABLED_ORGANIZATION_FEATURE_FLAGS) + .flatMap((group) => group.items) + .find((item) => item.title === "Explore") + expect(explore?.subItems?.map((sub) => sub.href)).toContain("/agent-sessions") + // The palette derives from navGroups, so the flag must gate both surfaces + // together — findable by name exactly when the sidebar shows it. + expect(paletteNavItems(ENABLED_ORGANIZATION_FEATURE_FLAGS).map((entry) => entry.href)).toContain( + "/agent-sessions", + ) + expect(paletteNavItems().map((entry) => entry.href)).not.toContain("/agent-sessions") + }) + it("keeps Infrastructure's preview to four marks once repeats collapse", () => { // Six glyphs beside "Infrastructure" overflow the 16rem sidebar and // truncate the label to "Infrastruct…". The three k8s pages sharing one diff --git a/apps/web/src/components/dashboard/nav-items.ts b/apps/web/src/components/dashboard/nav-items.ts index eba92f62c..abd2ed31b 100644 --- a/apps/web/src/components/dashboard/nav-items.ts +++ b/apps/web/src/components/dashboard/nav-items.ts @@ -2,6 +2,7 @@ import { BellIcon, ChartBarHorizontalIcon, ChartLineIcon, + ChatBubbleSparkleIcon, CircleWarningIcon, CloudflareIcon, ComputerIcon, @@ -18,6 +19,7 @@ import { ServerIcon, } from "@/components/icons" import { PLANETSCALE_COLOR } from "@/components/infra/planetscale/metrics" +import type { OrganizationFeatureFlags } from "@/lib/organization-feature-flags" export interface NavSubItem { title: string @@ -101,7 +103,7 @@ const infrastructureItem: NavItem = { * (see `NavRow`) — a section named "Explore" says nothing about the four * signals it hides. */ -const exploreItem: NavItem = { +const exploreItem = (flags?: OrganizationFeatureFlags): NavItem => ({ title: "Explore", href: "/traces", icon: LayersIcon, @@ -110,24 +112,28 @@ const exploreItem: NavItem = { { title: "Logs", href: "/logs", icon: FileIcon }, { title: "Metrics", href: "/metrics", icon: ChartLineIcon }, { title: "Replays", href: "/replays", icon: PlayRotateClockwiseIcon }, + // Behind the `agent_tracing` rollout flag. The parameter is optional on + // purpose: a caller with no organization context yet hides the row rather + // than flashing it (see `navGroups`). + ...(flags?.agentTracing + ? [{ title: "Agent Sessions", href: "/agent-sessions", icon: ChatBubbleSparkleIcon }] + : []), ], -} +}) /** * The sidebar's information architecture, and the single source the command * palette flattens. Anomalies is reachable at /anomalies but stays out of both * until the detector has been validated against production baselines. * - * Takes no flags: no row is behind a staged rollout right now (Web Analytics was - * the last, and shipped to everyone). Re-gating one means taking an - * `OrganizationFeatureFlags` parameter back and threading it from - * `useOrganizationFeatureFlags` — and making it *optional*, so a caller with no - * organization context yet hides the row rather than flashing it. A row that - * appears and then vanishes is worse than one that arrives a beat late. + * `flags` is *optional*, so a caller with no organization context yet hides a + * flagged row rather than flashing it — a row that appears and then vanishes is + * worse than one that arrives a beat late. Agent Sessions is the one row behind + * a staged rollout right now (`agentTracing`). */ -export function navGroups(): NavGroup[] { +export function navGroups(flags?: OrganizationFeatureFlags): NavGroup[] { const analyzeItems: NavItem[] = [ - exploreItem, + exploreItem(flags), { title: "Web Analytics", href: "/analytics", icon: ChartBarHorizontalIcon }, { title: "Dashboards", href: "/dashboards", icon: GridSquareCirclePlusIcon }, ] @@ -188,7 +194,7 @@ export interface PaletteNavEntry { * are the entries that keep muscle memory working, and they were never in the * palette before this. */ -export function paletteNavItems(): PaletteNavEntry[] { +export function paletteNavItems(flags?: OrganizationFeatureFlags): PaletteNavEntry[] { const entries: PaletteNavEntry[] = [] const seen = new Set() const push = (entry: PaletteNavEntry) => { @@ -198,7 +204,7 @@ export function paletteNavItems(): PaletteNavEntry[] { entries.push(entry) } - for (const group of navGroups()) { + for (const group of navGroups(flags)) { for (const item of group.items) { push({ id: `nav:${item.title}`, title: item.title, href: item.href, icon: item.icon }) for (const sub of item.subItems ?? []) { diff --git a/apps/web/src/lib/organization-feature-flags.test.ts b/apps/web/src/lib/organization-feature-flags.test.ts index 0528e2640..bd398dc6b 100644 --- a/apps/web/src/lib/organization-feature-flags.test.ts +++ b/apps/web/src/lib/organization-feature-flags.test.ts @@ -6,31 +6,37 @@ describe("organizationFeatureFlagsFrom", () => { expect( organizationFeatureFlagsFrom({ aiautotriage: true, + agent_tracing: true, unrelated_metadata: "preserved by Clerk, ignored here", }), - ).toEqual({ aiAutoTriage: true }) + ).toEqual({ aiAutoTriage: true, agentTracing: true }) }) // `webanalytics` was a rollout flag until Web Analytics shipped to everyone. // Orgs still carry the key in Clerk metadata, and a retired flag must decode // as an ignored extra rather than failing the whole struct — which would take - // `aiAutoTriage` down with it and fail closed for the orgs that have it on. + // the live flags down with it and fail closed for the orgs that have them on. it("ignores a retired flag still present in metadata", () => { expect(organizationFeatureFlagsFrom({ aiautotriage: true, webanalytics: true })).toEqual({ aiAutoTriage: true, + agentTracing: false, }) }) it("disables a missing or malformed flag", () => { - expect(organizationFeatureFlagsFrom({})).toEqual({ aiAutoTriage: false }) + expect(organizationFeatureFlagsFrom({})).toEqual({ aiAutoTriage: false, agentTracing: false }) // The string "true" is the shape a hand-edited Clerk dashboard field // produces, and it must not read as enabled. - expect(organizationFeatureFlagsFrom({ aiautotriage: "true" })).toEqual({ + expect(organizationFeatureFlagsFrom({ aiautotriage: "true", agent_tracing: "true" })).toEqual({ aiAutoTriage: false, + agentTracing: false, }) }) it("fails closed when public metadata is unavailable", () => { - expect(organizationFeatureFlagsFrom(undefined)).toEqual({ aiAutoTriage: false }) + expect(organizationFeatureFlagsFrom(undefined)).toEqual({ + aiAutoTriage: false, + agentTracing: false, + }) }) }) diff --git a/apps/web/src/lib/organization-feature-flags.ts b/apps/web/src/lib/organization-feature-flags.ts index 7decf7337..51ee2b57d 100644 --- a/apps/web/src/lib/organization-feature-flags.ts +++ b/apps/web/src/lib/organization-feature-flags.ts @@ -19,9 +19,12 @@ const DisabledByDefaultFeatureFlag = Schema.Unknown.pipe( */ export const OrganizationFeatureFlags = Schema.Struct({ aiAutoTriage: DisabledByDefaultFeatureFlag, + /** Gates the Agent Sessions page under Explore (AI agent trace sessions). */ + agentTracing: DisabledByDefaultFeatureFlag, }).pipe( Schema.encodeKeys({ aiAutoTriage: "aiautotriage", + agentTracing: "agent_tracing", }), ) @@ -32,6 +35,7 @@ const decodeOrganizationFeatureFlags = Schema.decodeUnknownOption(OrganizationFe /** Every rollout off — the value for malformed metadata, and for the pre-load window. */ export const DISABLED_ORGANIZATION_FEATURE_FLAGS: OrganizationFeatureFlags = { aiAutoTriage: false, + agentTracing: false, } /** @@ -42,6 +46,7 @@ export const DISABLED_ORGANIZATION_FEATURE_FLAGS: OrganizationFeatureFlags = { */ export const ENABLED_ORGANIZATION_FEATURE_FLAGS: OrganizationFeatureFlags = { aiAutoTriage: true, + agentTracing: true, } /** Decode Clerk metadata, falling back to every rollout disabled for non-object input. */ diff --git a/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts b/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts index a811130f4..85126e334 100644 --- a/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts +++ b/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts @@ -110,6 +110,7 @@ import { getSessionTraceSummaries, listReplays, } from "@/api/warehouse/replays" +import { listAiSessions } from "@/api/warehouse/ai-sessions" import { getWebAnalyticsBreakdowns, getWebAnalyticsPages, @@ -246,6 +247,10 @@ export const listReplaysResultAtom = makeQueryAtomFamily(listReplays, { staleTime: 30_000, }) +export const listAiSessionsResultAtom = makeQueryAtomFamily(listAiSessions, { + staleTime: 30_000, +}) + export const replaysFacetsResultAtom = makeQueryAtomFamily(getReplaysFacets, { staleTime: 30_000, }) diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 4a09d706c..9b179ed36 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as ServiceMapRouteImport } from './routes/service-map' import { Route as SettingsRouteImport } from './routes/settings' import { Route as SignInRouteImport } from './routes/sign-in' import { Route as SignUpRouteImport } from './routes/sign-up' +import { Route as AgentSessionsIndexRouteImport } from './routes/agent-sessions/index' import { Route as AlertsIndexRouteImport } from './routes/alerts/index' import { Route as AlertsRuleIdRouteImport } from './routes/alerts/$ruleId' import { Route as AlertsCreateRouteImport } from './routes/alerts/create' @@ -166,6 +167,11 @@ const SignUpRoute = SignUpRouteImport.update({ path: '/sign-up', getParentRoute: () => rootRouteImport, } as any) +const AgentSessionsIndexRoute = AgentSessionsIndexRouteImport.update({ + id: '/agent-sessions/', + path: '/agent-sessions/', + getParentRoute: () => rootRouteImport, +} as any) const AlertsIndexRoute = AlertsIndexRouteImport.update({ id: '/alerts/', path: '/alerts/', @@ -485,6 +491,7 @@ export interface FileRoutesByFullPath { '/services/$serviceName': typeof ServicesServiceNameRoute '/share/$token': typeof ShareTokenRoute '/traces/$traceId': typeof TracesTraceIdRoute + '/agent-sessions/': typeof AgentSessionsIndexRoute '/alerts/': typeof AlertsIndexRoute '/analytics/': typeof AnalyticsIndexRoute '/anomalies/': typeof AnomaliesIndexRoute @@ -557,6 +564,7 @@ export interface FileRoutesByTo { '/services/$serviceName': typeof ServicesServiceNameRoute '/share/$token': typeof ShareTokenRoute '/traces/$traceId': typeof TracesTraceIdRoute + '/agent-sessions': typeof AgentSessionsIndexRoute '/alerts': typeof AlertsIndexRoute '/analytics': typeof AnalyticsIndexRoute '/anomalies': typeof AnomaliesIndexRoute @@ -631,6 +639,7 @@ export interface FileRoutesById { '/services/$serviceName': typeof ServicesServiceNameRoute '/share/$token': typeof ShareTokenRoute '/traces/$traceId': typeof TracesTraceIdRoute + '/agent-sessions/': typeof AgentSessionsIndexRoute '/alerts/': typeof AlertsIndexRoute '/analytics/': typeof AnalyticsIndexRoute '/anomalies/': typeof AnomaliesIndexRoute @@ -706,6 +715,7 @@ export interface FileRouteTypes { | '/services/$serviceName' | '/share/$token' | '/traces/$traceId' + | '/agent-sessions/' | '/alerts/' | '/analytics/' | '/anomalies/' @@ -778,6 +788,7 @@ export interface FileRouteTypes { | '/services/$serviceName' | '/share/$token' | '/traces/$traceId' + | '/agent-sessions' | '/alerts' | '/analytics' | '/anomalies' @@ -851,6 +862,7 @@ export interface FileRouteTypes { | '/services/$serviceName' | '/share/$token' | '/traces/$traceId' + | '/agent-sessions/' | '/alerts/' | '/analytics/' | '/anomalies/' @@ -919,6 +931,7 @@ export interface RootRouteChildren { ServicesServiceNameRoute: typeof ServicesServiceNameRoute ShareTokenRoute: typeof ShareTokenRoute TracesTraceIdRoute: typeof TracesTraceIdRoute + AgentSessionsIndexRoute: typeof AgentSessionsIndexRoute AlertsIndexRoute: typeof AlertsIndexRoute AnalyticsIndexRoute: typeof AnalyticsIndexRoute AnomaliesIndexRoute: typeof AnomaliesIndexRoute @@ -1068,6 +1081,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SignUpRouteImport parentRoute: typeof rootRouteImport } + '/agent-sessions/': { + id: '/agent-sessions/' + path: '/agent-sessions' + fullPath: '/agent-sessions/' + preLoaderRoute: typeof AgentSessionsIndexRouteImport + parentRoute: typeof rootRouteImport + } '/alerts/': { id: '/alerts/' path: '/alerts' @@ -1518,6 +1538,7 @@ const rootRouteChildren: RootRouteChildren = { ServicesServiceNameRoute: ServicesServiceNameRoute, ShareTokenRoute: ShareTokenRoute, TracesTraceIdRoute: TracesTraceIdRoute, + AgentSessionsIndexRoute: AgentSessionsIndexRoute, AlertsIndexRoute: AlertsIndexRoute, AnalyticsIndexRoute: AnalyticsIndexRoute, AnomaliesIndexRoute: AnomaliesIndexRoute, diff --git a/apps/web/src/routes/agent-sessions/index.tsx b/apps/web/src/routes/agent-sessions/index.tsx new file mode 100644 index 000000000..79be10bd9 --- /dev/null +++ b/apps/web/src/routes/agent-sessions/index.tsx @@ -0,0 +1,141 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { Schema } from "effect" + +import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { AgentSessionsList } from "@/components/agent-sessions/agent-sessions-list" +import { NotFoundError } from "@/components/route-error" +import { QueryErrorState } from "@/components/common/query-error-state" +import { Result, useAtomValue } from "@/lib/effect-atom" +import { listAiSessionsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" +import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" +import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" +import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" +import type { TimeRange } from "@/components/time-range-picker/types" +import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" +import { useOrganizationFeatureFlags } from "@/hooks/use-organization-feature-flags" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { ToolbarStat } from "@maple/ui/components/toolbar" + +const agentSessionsSearchSchema = Schema.Struct({ + ...TimeRangeSearchFields, +}) + +const AGENT_SESSIONS_LIMIT = 50 + +export const Route = createFileRoute("/agent-sessions/")({ + component: AgentSessionsPage, + validateSearch: Schema.toStandardSchemaV1(agentSessionsSearchSchema), +}) + +/** + * Behind the `agent_tracing` org rollout flag. The gate lives in the component + * (not `beforeLoad`) because router context carries no flags, and it checks + * `isLoaded` first so an entitled org doesn't get a not-found flash while Clerk + * answers. The warehouse read lives in the gated content component, so an + * unflagged org never fires the query. + */ +function AgentSessionsPage() { + const { flags, isLoaded } = useOrganizationFeatureFlags() + if (!isLoaded) return null + if (!flags.agentTracing) return + return +} + +function AgentSessionsPageContent() { + const search = Route.useSearch() + const navigate = useNavigate({ from: Route.fullPath }) + + const handleTimeChange = (range: TimeRange, options?: { replace?: boolean }) => { + navigate({ + replace: options?.replace, + search: (prev) => applyTimeRangeSearch(prev, range), + }) + } + + return ( + + + + + + + + + + + ) +} + +/** + * Split from the page so `useEffectiveTimeRange` runs inside + * `PageRefreshProvider` — the refresh button re-resolves a preset window only + * for hooks that can see the provider's refresh version. + */ +function AgentSessionsBody({ + onTimeChange, +}: { + onTimeChange: (range: TimeRange, options?: { replace?: boolean }) => void +}) { + const search = Route.useSearch() + const { startTime, endTime } = useEffectiveTimeRange( + search.startTime, + search.endTime, + search.timePreset ?? "24h", + ) + const result = useAtomValue( + listAiSessionsResultAtom({ data: { startTime, endTime, limit: AGENT_SESSIONS_LIMIT } }), + ) + const sessions = Result.isSuccess(result) ? result.value.data : [] + + const headerActions = ( +
+
+ +
+ +
+ ) + + return ( + <> + + + {headerActions} + + + + {Result.builder(result) + .onInitial(() => ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+ )) + .onError((error) => ( + + )) + .onSuccess((value) => ( + + )) + .render()} +
+ + ) +} diff --git a/packages/domain/src/http/ai-sessions.ts b/packages/domain/src/http/ai-sessions.ts new file mode 100644 index 000000000..c7bba040c --- /dev/null +++ b/packages/domain/src/http/ai-sessions.ts @@ -0,0 +1,62 @@ +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { Schema } from "effect" +import { TinybirdDateTime } from "../query-engine" +import { SessionAuthorization } from "./current-tenant" +import { QueryEngineExecutionError, QueryEngineTimeoutError } from "./query-engine" +import { warehouseHttpErrors } from "./warehouse" + +// AI agent session endpoint schemas +// +// Backed by the `maple_ai.*` span attributes the ingest gateway stamps at +// decode time; a session is resolved at trace granularity by +// `aiSessionListQuery` in the query-engine integrations layer. The Agent +// Sessions page is behind the `agent_tracing` org rollout flag and these +// shapes exist for it alone, so they live in the internal tier where they can +// follow the UI. + +export class ListAiSessionsRequest extends Schema.Class("ListAiSessionsRequest")({ + startTime: TinybirdDateTime, + endTime: TinybirdDateTime, + // `Schema.optional`, not `optionalKey`: the web client constructs the payload + // JS-side and passes explicit `undefined` for an unset field. See the note in + // session-replay.ts (and CLAUDE.md, optional vs optionalKey). + limit: Schema.optional(Schema.Number), +}) {} + +export const AiSessionListItem = Schema.Struct({ + sessionId: Schema.String, + /** Vendor of the earliest session-bearing span, e.g. `eve`, `vercel_ai_sdk`. */ + vendorId: Schema.String, + vendorVersion: Schema.String, + traceCount: Schema.Number, + /** All spans of all the session's traces, including non-AI infrastructure spans. */ + spanCount: Schema.Number, + errorSpanCount: Schema.Number, + /** Every service touched by the session's traces. */ + serviceNames: Schema.Array(Schema.String), + /** Warehouse datetime literals, e.g. `2026-08-19 10:33:25.825000000`. */ + startTime: Schema.String, + endTime: Schema.String, + durationMs: Schema.Number, +}) + +export class ListAiSessionsResponse extends Schema.Class("ListAiSessionsResponse")({ + data: Schema.Array(AiSessionListItem), +}) {} + +const aiSessionEndpointErrors = [ + QueryEngineExecutionError, + QueryEngineTimeoutError, + ...warehouseHttpErrors, +] as const + +export class AiSessionsInternalApiGroup extends HttpApiGroup.make("aiSessionsInternal") + .add( + HttpApiEndpoint.post("list", "/list", { + payload: ListAiSessionsRequest, + success: ListAiSessionsResponse, + error: aiSessionEndpointErrors, + }), + ) + .prefix("/internal/ai-sessions") + .middleware(SessionAuthorization) {} diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index e223ea76d..1a4c263ad 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -1,5 +1,6 @@ export * from "./api" export * from "./internal-api" +export * from "./ai-sessions" export * from "./ai-triage" export * from "./investigations" export * from "./anomalies" diff --git a/packages/domain/src/http/internal-api.ts b/packages/domain/src/http/internal-api.ts index 8e7064bd6..1a8cda437 100644 --- a/packages/domain/src/http/internal-api.ts +++ b/packages/domain/src/http/internal-api.ts @@ -1,4 +1,5 @@ import { HttpApi, OpenApi } from "effect/unstable/httpapi" +import { AiSessionsInternalApiGroup } from "./ai-sessions" import { AiTriageApiGroup } from "./ai-triage" import { BillingApiGroup } from "./billing" import { ChatApiGroup } from "./chat" @@ -35,6 +36,7 @@ import { V1SchemaErrors, V1UnexpectedErrors } from "./v1-boundary" * split costs the frontend nothing. */ export class MapleInternalApi extends HttpApi.make("MapleInternalApi") + .add(AiSessionsInternalApiGroup) .add(AiTriageApiGroup) .add(BillingApiGroup) .add(ChatApiGroup) From 56146e6df296d6ac75ca8d2d14e6f26c6ebc6ef7 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Wed, 19 Aug 2026 21:39:54 +0200 Subject: [PATCH 2/5] fix(web): drop the counterfeit vendor-logo avatars from the agent sessions list The gradient-initial circle was copied from the replays list, where it encodes a person's identity; an initial for a framework just reads as a fake vendor logo. Rows now lead with the session id, framework named in text below. Co-Authored-By: Claude Fable 5 --- .../agent-sessions/agent-sessions-list.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx index 3bf63abca..11b386a4d 100644 --- a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx +++ b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx @@ -1,6 +1,5 @@ import { formatRelativeTimeOrDate, toEpochMs } from "@maple/ui/lib/time-format" -import { cn } from "@maple/ui/lib/utils" -import { formatSessionDuration, gradientFor } from "@maple/ui/lib/replay-format" +import { formatSessionDuration } from "@maple/ui/lib/replay-format" import { ChatBubbleSparkleIcon } from "@/components/icons" /** The wire row from `listAiSessions` — one AI agent session, newest first. */ @@ -80,14 +79,9 @@ export function AgentSessionsList({ sessions, limit }: AgentSessionsListProps) { )} -
- {(vendor[0] ?? "?").toUpperCase()} -
+ {/* No leading avatar: the replays list uses one because a gradient + initial encodes a *person*; an initial for a framework just reads + as a counterfeit vendor logo. The framework is named in text. */} {/* Identity lane: session id, framework underneath */}
From 95c0666c1890132d474d5a44f3438359b02c5d50 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Wed, 19 Aug 2026 22:03:21 +0200 Subject: [PATCH 3/5] fix(web): apply review findings to the agent sessions PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes, none changing behavior for entitled orgs on preset ranges: - Vendor labels: cover the gateway's stamped vendor ids with brand casing (Claude Agent SDK, Google ADK, LiteLLM, ...) and fall back to Title Case so a newly stamped vendor degrades to a readable name, not a raw id. - Reload on an absolute time range: the atom key never rolls there, so plain useAtomValue made the button a no-op; useRetainedRefreshableResultValue wires the refresh subscription (and retains the last success across rolls). - Sidebar: the Clerk-backed flag read moves into a small memoized child so a Clerk resource tick redraws the nav groups, not the whole sidebar shell the outer memo exists to protect. - Flag hook: memoize the returned state so `flags` keeps its identity across Clerk ticks that leave metadata untouched (consumers use it in deps). - Error contract: declare warehouseReadHttpErrors — exactly what a compiled read can fail with - instead of inheriting the replay union's legacy QueryEngine wrappers and token-mint errors this endpoint cannot produce. - Handler: drop the redundant per-row spread; the row schema already decodes the response shape. - Tests: assert the flag-off state with the fully-populated disabled object production actually passes, and run the icon-preview invariant with flags on so it covers the flagged child. - PageRefreshProvider: no preset default while an absolute range is active, mirroring the picker's own expression. Co-Authored-By: Claude Fable 5 --- .../src/routes/internal/ai-sessions.http.ts | 7 ++-- apps/web/src/api/warehouse/ai-sessions.ts | 2 +- .../agent-sessions/agent-sessions-list.tsx | 33 ++++++++++++--- .../src/components/dashboard/app-sidebar.tsx | 23 +++++++---- .../components/dashboard/nav-items.test.ts | 40 +++++++++++-------- .../hooks/use-organization-feature-flags.ts | 18 ++++++--- apps/web/src/routes/agent-sessions/index.tsx | 17 ++++++-- packages/domain/src/http/ai-sessions.ts | 18 ++++----- 8 files changed, 106 insertions(+), 52 deletions(-) diff --git a/apps/api/src/routes/internal/ai-sessions.http.ts b/apps/api/src/routes/internal/ai-sessions.http.ts index ef03dec16..4b1b22d5a 100644 --- a/apps/api/src/routes/internal/ai-sessions.http.ts +++ b/apps/api/src/routes/internal/ai-sessions.http.ts @@ -28,13 +28,14 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group( { orgId: tenant.orgId, startTime: payload.startTime, endTime: payload.endTime }, { rowSchema: Integrations.aiSessionListRowSchema }, ) - // The row schema already coerces the UInt64 aggregates, so no - // per-field Number() at this edge (unlike listReplays). + // The row schema already coerces the UInt64 aggregates and decodes + // exactly the response's fields, so rows pass through unmapped + // (unlike listReplays, which re-brands and re-coerces per field). const rows = yield* warehouse.compiledQuery(tenant, compiled, { profile: "list", context: "listAiSessions", }) - return new ListAiSessionsResponse({ data: rows.map((row) => ({ ...row })) }) + return new ListAiSessionsResponse({ data: rows }) }), ) }), diff --git a/apps/web/src/api/warehouse/ai-sessions.ts b/apps/web/src/api/warehouse/ai-sessions.ts index f5a2d0c67..a260b0e25 100644 --- a/apps/web/src/api/warehouse/ai-sessions.ts +++ b/apps/web/src/api/warehouse/ai-sessions.ts @@ -19,7 +19,7 @@ const defaultTimeRange = (nowMs: number) => { } } -export const listAiSessions = Effect.fn("AiSessions.list")(function* ({ +export const listAiSessions = Effect.fn("AiSessions.listAiSessions")(function* ({ data, }: { data: ListAiSessionsInput diff --git a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx index 11b386a4d..0d11d0b90 100644 --- a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx +++ b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx @@ -17,18 +17,41 @@ export interface AgentSessionRow { } /** - * Vendor ids the ingest gateway stamps → display names. Anything unlisted shows - * its raw id, minus the `unknown:` prefix the gateway uses for dialect-detected - * spans with no framework identity. + * Vendor ids the ingest gateway stamps (`AI_VENDORS` in + * apps/ingest/src/ai_session.rs) → brand names. Listed here are the ids whose + * brand casing the title-case fallback below can't derive — acronyms (SDK, + * ADK), camel brands (LiteLLM, DSPy), and deliberately lowercase ones (eve, + * smolagents). Anything unlisted falls back to Title Case with the `unknown:` + * dialect prefix stripped, so a newly stamped vendor degrades to a readable + * name instead of a raw id. */ const VENDOR_LABELS = new Map([ + ["claude_agent_sdk", "Claude Agent SDK"], + ["crewai", "CrewAI"], + ["dspy", "DSPy"], + ["effect_ai", "Effect AI"], ["eve", "eve"], - ["vercel_ai_sdk", "Vercel AI SDK"], + ["google_adk", "Google ADK"], + ["langchain", "LangChain"], + ["litellm", "LiteLLM"], + ["llamaindex", "LlamaIndex"], + ["openai_agents_sdk", "OpenAI Agents SDK"], ["openinference-openai", "OpenInference · OpenAI"], + ["pydantic_ai", "Pydantic AI"], + ["smolagents", "smolagents"], + ["spring_ai", "Spring AI"], + ["vercel_ai_sdk", "Vercel AI SDK"], ]) function vendorLabel(vendorId: string): string { - return VENDOR_LABELS.get(vendorId) ?? vendorId.replace(/^unknown:/, "") + const known = VENDOR_LABELS.get(vendorId) + if (known) return known + return vendorId + .replace(/^unknown:/, "") + .split(/[_-]+/) + .filter(Boolean) + .map((word) => word[0]!.toUpperCase() + word.slice(1)) + .join(" ") } function absoluteTs(startTime: string): string { diff --git a/apps/web/src/components/dashboard/app-sidebar.tsx b/apps/web/src/components/dashboard/app-sidebar.tsx index 5b5345beb..bbe3f2783 100644 --- a/apps/web/src/components/dashboard/app-sidebar.tsx +++ b/apps/web/src/components/dashboard/app-sidebar.tsx @@ -534,16 +534,27 @@ function FooterCluster() { ) } +// The flag read lives in this small child rather than in `AppSidebar`: memo +// gates props, not context, so a Clerk hook in the shell would subscribe the +// whole sidebar to Clerk's provider and rerender it on every resource tick. +// Confined here, a tick redraws only the nav groups — the subtree that already +// redraws on every navigation. +const SidebarNavGroups = memo(function SidebarNavGroups({ currentPath }: { currentPath: string }) { + // Fails closed while Clerk loads, so a flagged row arrives a beat late rather + // than flashing and vanishing — the trade `navGroups` documents. + const { flags } = useOrganizationFeatureFlags() + const groups = navGroups(flags) + return groups.map((group) => ( + + )) +}) + // Memoized: DashboardLayout renders this inside every page, so without memo the // sidebar's ~500-fiber subtree rerenders on every page-level state change // (refresh version bumps, search-param updates, query settles). The selector // (instead of bare useRouterState) keeps it quiet during loader/pending ticks. export const AppSidebar = memo(function AppSidebar() { const currentPath = useRouterState({ select: (s) => s.location.pathname }) - // Fails closed while Clerk loads, so a flagged row arrives a beat late rather - // than flashing and vanishing — the trade `navGroups` documents. - const { flags } = useOrganizationFeatureFlags() - const groups = navGroups(flags) return ( @@ -552,9 +563,7 @@ export const AppSidebar = memo(function AppSidebar() { - {groups.map((group) => ( - - ))} + {/* The rule belongs between Settings and the account cluster, not above diff --git a/apps/web/src/components/dashboard/nav-items.test.ts b/apps/web/src/components/dashboard/nav-items.test.ts index 90c56668c..f112c3f5e 100644 --- a/apps/web/src/components/dashboard/nav-items.test.ts +++ b/apps/web/src/components/dashboard/nav-items.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest" import { isNavItemActive, isPathActive, navGroups, paletteNavItems, type NavItem } from "./nav-items" -import { ENABLED_ORGANIZATION_FEATURE_FLAGS } from "@/lib/organization-feature-flags" - -function findItem(title: string): NavItem { - const item = navGroups() +import { + DISABLED_ORGANIZATION_FEATURE_FLAGS, + ENABLED_ORGANIZATION_FEATURE_FLAGS, + type OrganizationFeatureFlags, +} from "@/lib/organization-feature-flags" + +function findItem(title: string, flags?: OrganizationFeatureFlags): NavItem { + const item = navGroups(flags) .flatMap((group) => group.items) .find((candidate) => candidate.title === title) if (!item) throw new Error(`no nav item titled ${title}`) @@ -78,30 +82,34 @@ describe("navGroups", () => { // The closed row previews its children by drawing their glyphs (see // `NavRow`), and draws nothing at all unless *every* child has one — so // dropping an icon here silently removes the preview rather than - // rendering a gap. + // rendering a gap. Runs with every flag on so the invariant also covers + // flagged children (Agent Sessions), not just the unconditional rows. for (const title of ["Explore", "Infrastructure"]) { - const item = findItem(title) + const item = findItem(title, ENABLED_ORGANIZATION_FEATURE_FLAGS) expect(item.subItems?.length).toBeGreaterThan(0) expect(item.subItems?.every((sub) => sub.icon)).toBe(true) } }) it("shows Agent Sessions only behind the agentTracing flag", () => { - // No flags (or flags still loading) hides the row — a row that appears and - // then vanishes is worse than one that arrives a beat late. - const withoutFlags = findItem("Explore").subItems?.map((sub) => sub.href) - expect(withoutFlags).not.toContain("/agent-sessions") - - const explore = navGroups(ENABLED_ORGANIZATION_FEATURE_FLAGS) - .flatMap((group) => group.items) - .find((item) => item.title === "Explore") - expect(explore?.subItems?.map((sub) => sub.href)).toContain("/agent-sessions") + // The off state is asserted with the shape production actually passes: a + // fully-populated all-false object (what the hook returns while Clerk + // loads and for unentitled orgs), not just an absent argument. A presence + // check instead of `flags?.agentTracing` must fail here. + for (const off of [undefined, DISABLED_ORGANIZATION_FEATURE_FLAGS]) { + expect(findItem("Explore", off).subItems?.map((sub) => sub.href)).not.toContain( + "/agent-sessions", + ) + expect(paletteNavItems(off).map((entry) => entry.href)).not.toContain("/agent-sessions") + } + + const explore = findItem("Explore", ENABLED_ORGANIZATION_FEATURE_FLAGS) + expect(explore.subItems?.map((sub) => sub.href)).toContain("/agent-sessions") // The palette derives from navGroups, so the flag must gate both surfaces // together — findable by name exactly when the sidebar shows it. expect(paletteNavItems(ENABLED_ORGANIZATION_FEATURE_FLAGS).map((entry) => entry.href)).toContain( "/agent-sessions", ) - expect(paletteNavItems().map((entry) => entry.href)).not.toContain("/agent-sessions") }) it("keeps Infrastructure's preview to four marks once repeats collapse", () => { diff --git a/apps/web/src/hooks/use-organization-feature-flags.ts b/apps/web/src/hooks/use-organization-feature-flags.ts index 19eb0aa8c..f77413179 100644 --- a/apps/web/src/hooks/use-organization-feature-flags.ts +++ b/apps/web/src/hooks/use-organization-feature-flags.ts @@ -1,4 +1,5 @@ import { useOrganization } from "@clerk/clerk-react" +import { useMemo } from "react" import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" import { @@ -31,12 +32,17 @@ export interface OrganizationFeatureFlagsState { */ function useClerkOrganizationFeatureFlags(): OrganizationFeatureFlagsState { const { organization, isLoaded } = useOrganization() - return { - flags: isLoaded - ? organizationFeatureFlagsFrom(organization?.publicMetadata) - : DISABLED_ORGANIZATION_FEATURE_FLAGS, - isLoaded, - } + const metadata = organization?.publicMetadata + // Memoized so the state (and the decoded flags object) keeps its identity + // across Clerk resource ticks that leave the metadata untouched — consumers + // put `flags` in dependency arrays. + return useMemo( + () => ({ + flags: isLoaded ? organizationFeatureFlagsFrom(metadata) : DISABLED_ORGANIZATION_FEATURE_FLAGS, + isLoaded, + }), + [metadata, isLoaded], + ) } /** Self-hosted: nothing to load, and every rollout is on. Touches no Clerk hook. */ diff --git a/apps/web/src/routes/agent-sessions/index.tsx b/apps/web/src/routes/agent-sessions/index.tsx index 79be10bd9..0d0a8dbf9 100644 --- a/apps/web/src/routes/agent-sessions/index.tsx +++ b/apps/web/src/routes/agent-sessions/index.tsx @@ -5,13 +5,14 @@ import { DashboardLayout } from "@/components/layout/dashboard-layout" import { AgentSessionsList } from "@/components/agent-sessions/agent-sessions-list" import { NotFoundError } from "@/components/route-error" import { QueryErrorState } from "@/components/common/query-error-state" -import { Result, useAtomValue } from "@/lib/effect-atom" +import { Result } from "@/lib/effect-atom" import { listAiSessionsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" import type { TimeRange } from "@/components/time-range-picker/types" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" +import { useRetainedRefreshableResultValue } from "@/hooks/use-retained-refreshable-result-value" import { useOrganizationFeatureFlags } from "@/hooks/use-organization-feature-flags" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { ToolbarStat } from "@maple/ui/components/toolbar" @@ -32,7 +33,11 @@ export const Route = createFileRoute("/agent-sessions/")({ * (not `beforeLoad`) because router context carries no flags, and it checks * `isLoaded` first so an entitled org doesn't get a not-found flash while Clerk * answers. The warehouse read lives in the gated content component, so an - * unflagged org never fires the query. + * unflagged org never fires the query — which is also why there is no route + * `loader` warming the atom the way `/replays` does: a loader runs regardless + * of the flag, so the prefetch-on-hover win would cost every unflagged org a + * warehouse query. Entitled orgs pay full latency on mount instead; revisit + * when the flag retires. */ function AgentSessionsPage() { const { flags, isLoaded } = useOrganizationFeatureFlags() @@ -53,7 +58,9 @@ function AgentSessionsPageContent() { } return ( - + // No preset default while an absolute range is active — mirrors the + // picker's own presetValue expression below. + @@ -82,7 +89,9 @@ function AgentSessionsBody({ search.endTime, search.timePreset ?? "24h", ) - const result = useAtomValue( + // Refreshable (not plain useAtomValue): on an absolute time range the atom + // key never rolls, so Reload only works through the refresh subscription. + const result = useRetainedRefreshableResultValue( listAiSessionsResultAtom({ data: { startTime, endTime, limit: AGENT_SESSIONS_LIMIT } }), ) const sessions = Result.isSuccess(result) ? result.value.data : [] diff --git a/packages/domain/src/http/ai-sessions.ts b/packages/domain/src/http/ai-sessions.ts index c7bba040c..64a9dcb91 100644 --- a/packages/domain/src/http/ai-sessions.ts +++ b/packages/domain/src/http/ai-sessions.ts @@ -2,8 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import { Schema } from "effect" import { TinybirdDateTime } from "../query-engine" import { SessionAuthorization } from "./current-tenant" -import { QueryEngineExecutionError, QueryEngineTimeoutError } from "./query-engine" -import { warehouseHttpErrors } from "./warehouse" +import { warehouseReadHttpErrors } from "./warehouse" // AI agent session endpoint schemas // @@ -17,9 +16,9 @@ import { warehouseHttpErrors } from "./warehouse" export class ListAiSessionsRequest extends Schema.Class("ListAiSessionsRequest")({ startTime: TinybirdDateTime, endTime: TinybirdDateTime, - // `Schema.optional`, not `optionalKey`: the web client constructs the payload - // JS-side and passes explicit `undefined` for an unset field. See the note in - // session-replay.ts (and CLAUDE.md, optional vs optionalKey). + // `Schema.optional`, not `optionalKey` — matches the `ListReplaysRequest` + // optional-payload contract for JS-constructed clients (see the note in + // session-replay.ts and CLAUDE.md, optional vs optionalKey). limit: Schema.optional(Schema.Number), }) {} @@ -44,11 +43,10 @@ export class ListAiSessionsResponse extends Schema.Class data: Schema.Array(AiSessionListItem), }) {} -const aiSessionEndpointErrors = [ - QueryEngineExecutionError, - QueryEngineTimeoutError, - ...warehouseHttpErrors, -] as const +// Exactly what a compiled warehouse read can fail with — not the wider +// `sessionReplayEndpointErrors` union, whose extra members (the legacy +// QueryEngine wrappers, token-mint errors) this endpoint can never produce. +const aiSessionEndpointErrors = warehouseReadHttpErrors export class AiSessionsInternalApiGroup extends HttpApiGroup.make("aiSessionsInternal") .add( From 0c6dee0fd58190b23e61b6038a129d0f00359b98 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Wed, 19 Aug 2026 22:17:46 +0200 Subject: [PATCH 4/5] feat(web): framework and service filters on the agent sessions list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the vendorIds/serviceNames filters aiSessionListQuery already supports: optional arrays on the internal contract, passed through the handler, with single-select URL params (vendor, service) driving them from a Replays-style filter sidebar. There is no facets warehouse query yet, so option lists and counts derive client-side from the unfiltered window's rows — served by the same atom entry the unfiltered list already occupies (undefined filter fields drop from the cache key), so the sidebar costs no extra query until a filter is active. Options therefore only see what the list limit returned; that is the seam a real facets aggregation replaces when windows outgrow it. A selected value absent from the window stays checkable at count 0. Co-Authored-By: Claude Fable 5 --- .../src/routes/internal/ai-sessions.http.ts | 6 +- apps/web/src/api/warehouse/ai-sessions.ts | 4 + .../agent-sessions-filter-sidebar.tsx | 113 ++++++++++++++++++ .../agent-sessions/agent-sessions-list.tsx | 2 +- apps/web/src/routes/agent-sessions/index.tsx | 94 +++++++++------ packages/domain/src/http/ai-sessions.ts | 5 + 6 files changed, 185 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/components/agent-sessions/agent-sessions-filter-sidebar.tsx diff --git a/apps/api/src/routes/internal/ai-sessions.http.ts b/apps/api/src/routes/internal/ai-sessions.http.ts index 4b1b22d5a..a1d7a4c0a 100644 --- a/apps/api/src/routes/internal/ai-sessions.http.ts +++ b/apps/api/src/routes/internal/ai-sessions.http.ts @@ -24,7 +24,11 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group( const tenant = yield* CurrentTenant.Context yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId }) const compiled = CH.compile( - Integrations.aiSessionListQuery({ limit: payload.limit }), + Integrations.aiSessionListQuery({ + limit: payload.limit, + vendorIds: payload.vendorIds, + serviceNames: payload.serviceNames, + }), { orgId: tenant.orgId, startTime: payload.startTime, endTime: payload.endTime }, { rowSchema: Integrations.aiSessionListRowSchema }, ) diff --git a/apps/web/src/api/warehouse/ai-sessions.ts b/apps/web/src/api/warehouse/ai-sessions.ts index a260b0e25..95c538dcb 100644 --- a/apps/web/src/api/warehouse/ai-sessions.ts +++ b/apps/web/src/api/warehouse/ai-sessions.ts @@ -9,6 +9,8 @@ const ListAiSessionsInput = Schema.Struct({ startTime: Schema.optional(WarehouseDateTimeString), endTime: Schema.optional(WarehouseDateTimeString), limit: Schema.optional(Schema.Number), + vendorIds: Schema.optional(Schema.Array(Schema.String)), + serviceNames: Schema.optional(Schema.Array(Schema.String)), }) export type ListAiSessionsInput = Schema.Schema.Type @@ -34,6 +36,8 @@ export const listAiSessions = Effect.fn("AiSessions.listAiSessions")(function* ( startTime: input.startTime ?? fallback.startTime, endTime: input.endTime ?? fallback.endTime, limit: input.limit ?? 50, + vendorIds: input.vendorIds, + serviceNames: input.serviceNames, }), }) }), diff --git a/apps/web/src/components/agent-sessions/agent-sessions-filter-sidebar.tsx b/apps/web/src/components/agent-sessions/agent-sessions-filter-sidebar.tsx new file mode 100644 index 000000000..e72594688 --- /dev/null +++ b/apps/web/src/components/agent-sessions/agent-sessions-filter-sidebar.tsx @@ -0,0 +1,113 @@ +import { getRouteApi } from "@tanstack/react-router" + +import { Result } from "@/lib/effect-atom" +import { + FilterSection, + SearchableFilterSection, + type FilterOption, +} from "@/components/filters/filter-section" +import { + FilterSidebarBody, + FilterSidebarError, + FilterSidebarFrame, + FilterSidebarHeader, + FilterSidebarLoading, +} from "@/components/filters/filter-sidebar" +import { vendorLabel, type AgentSessionRow } from "./agent-sessions-list" + +const routeApi = getRouteApi("/agent-sessions/") + +/** + * Facet counts derived client-side from the unfiltered list rows — sessions per + * vendor and per touched service within the current window. There is no facets + * warehouse query yet, so the counts (and the option lists) only see what the + * list's own limit returned; good enough while a window holds tens of sessions, + * and the seam to replace with a real aggregation when it doesn't. + */ +function facetCounts( + rows: ReadonlyArray, + pick: (row: AgentSessionRow) => ReadonlyArray, +): FilterOption[] { + const counts = new Map() + for (const row of rows) { + for (const name of pick(row)) { + counts.set(name, (counts.get(name) ?? 0) + 1) + } + } + return [...counts.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) +} + +/** A selected value absent from the current window stays checkable (count 0). */ +function withSelected(options: FilterOption[], selected?: string): FilterOption[] { + if (selected && !options.some((o) => o.name === selected)) { + return [{ name: selected, count: 0 }, ...options] + } + return options +} + +interface AgentSessionsFilterSidebarProps { + /** The UNFILTERED window's sessions, so option lists survive an active filter. */ + optionsResult: Result.Result<{ readonly data: ReadonlyArray }, unknown> +} + +export function AgentSessionsFilterSidebar({ optionsResult }: AgentSessionsFilterSidebarProps) { + const navigate = routeApi.useNavigate() + const search = routeApi.useSearch() + + // Single-value params: take the last toggled option (switching values + // replaces the prior one; unchecking the only one clears it). + const setSingle = (key: "vendor" | "service", values: string[]) => { + navigate({ search: (prev) => ({ ...prev, [key]: values.at(-1) ?? undefined }) }) + } + + const clearAllFilters = () => { + navigate({ search: (prev) => ({ ...prev, vendor: undefined, service: undefined }) }) + } + + const hasActiveFilters = !!search.vendor || !!search.service + + return Result.builder(optionsResult) + .onInitial(() => ) + .onError((error) => ) + .onSuccess((value, result) => { + const vendors = withSelected( + facetCounts(value.data, (row) => [row.vendorId]), + search.vendor, + ) + const services = withSelected( + facetCounts(value.data, (row) => row.serviceNames), + search.service, + ) + + return ( + + + + setSingle("vendor", vals)} + getOptionLabel={vendorLabel} + /> + + setSingle("service", vals)} + /> + + {vendors.length === 0 && services.length === 0 && ( +

+ No sessions in the selected time range +

+ )} +
+
+ ) + }) + .render() +} diff --git a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx index 0d11d0b90..78166aaf5 100644 --- a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx +++ b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx @@ -43,7 +43,7 @@ const VENDOR_LABELS = new Map([ ["vercel_ai_sdk", "Vercel AI SDK"], ]) -function vendorLabel(vendorId: string): string { +export function vendorLabel(vendorId: string): string { const known = VENDOR_LABELS.get(vendorId) if (known) return known return vendorId diff --git a/apps/web/src/routes/agent-sessions/index.tsx b/apps/web/src/routes/agent-sessions/index.tsx index 0d0a8dbf9..630327e5a 100644 --- a/apps/web/src/routes/agent-sessions/index.tsx +++ b/apps/web/src/routes/agent-sessions/index.tsx @@ -3,9 +3,10 @@ import { Schema } from "effect" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { AgentSessionsList } from "@/components/agent-sessions/agent-sessions-list" +import { AgentSessionsFilterSidebar } from "@/components/agent-sessions/agent-sessions-filter-sidebar" import { NotFoundError } from "@/components/route-error" import { QueryErrorState } from "@/components/common/query-error-state" -import { Result } from "@/lib/effect-atom" +import { Result, useAtomValue } from "@/lib/effect-atom" import { listAiSessionsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" @@ -18,6 +19,9 @@ import { Skeleton } from "@maple/ui/components/ui/skeleton" import { ToolbarStat } from "@maple/ui/components/toolbar" const agentSessionsSearchSchema = Schema.Struct({ + /** Vendor id as stamped by the gateway (e.g. `eve`), not the display label. */ + vendor: Schema.optional(Schema.String), + service: Schema.optional(Schema.String), ...TimeRangeSearchFields, }) @@ -64,9 +68,7 @@ function AgentSessionsPageContent() { - - - +
@@ -76,7 +78,8 @@ function AgentSessionsPageContent() { /** * Split from the page so `useEffectiveTimeRange` runs inside * `PageRefreshProvider` — the refresh button re-resolves a preset window only - * for hooks that can see the provider's refresh version. + * for hooks that can see the provider's refresh version. Renders the + * `Filters | Content` siblings, so both share one resolved window. */ function AgentSessionsBody({ onTimeChange, @@ -89,11 +92,24 @@ function AgentSessionsBody({ search.endTime, search.timePreset ?? "24h", ) + const window = { startTime, endTime, limit: AGENT_SESSIONS_LIMIT } // Refreshable (not plain useAtomValue): on an absolute time range the atom // key never rolls, so Reload only works through the refresh subscription. const result = useRetainedRefreshableResultValue( - listAiSessionsResultAtom({ data: { startTime, endTime, limit: AGENT_SESSIONS_LIMIT } }), + listAiSessionsResultAtom({ + data: { + ...window, + vendorIds: search.vendor ? [search.vendor] : undefined, + serviceNames: search.service ? [search.service] : undefined, + }, + }), ) + // The sidebar's option lists come from the UNFILTERED window, so picking a + // vendor doesn't erase the others from the list. With no filter active this + // is the same atom entry as `result` (undefined fields drop from the cache + // key), so it costs nothing; plain useAtomValue keeps it off the Reload + // subscription — options refetch when the window rolls, which is enough. + const optionsResult = useAtomValue(listAiSessionsResultAtom({ data: window })) const sessions = Result.isSuccess(result) ? result.value.data : [] const headerActions = ( @@ -113,38 +129,42 @@ function AgentSessionsBody({ return ( <> - - - {headerActions} - - - - {Result.builder(result) - .onInitial(() => ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
- -
- - + + + + + + + {headerActions} + + + + {Result.builder(result) + .onInitial(() => ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+ + +
+
- -
- ))} -
- )) - .onError((error) => ( - - )) - .onSuccess((value) => ( - - )) - .render()} - + ))} +
+ )) + .onError((error) => ( + + )) + .onSuccess((value) => ( + + )) + .render()} + + ) } diff --git a/packages/domain/src/http/ai-sessions.ts b/packages/domain/src/http/ai-sessions.ts index 64a9dcb91..fdcf3f0e8 100644 --- a/packages/domain/src/http/ai-sessions.ts +++ b/packages/domain/src/http/ai-sessions.ts @@ -20,6 +20,11 @@ export class ListAiSessionsRequest extends Schema.Class(" // optional-payload contract for JS-constructed clients (see the note in // session-replay.ts and CLAUDE.md, optional vs optionalKey). limit: Schema.optional(Schema.Number), + // Both filters land on the session-detection subquery, so `serviceNames` + // means "the session-bearing spans came from this service", not "the trace + // touched it" — see `aiSessionListQuery`. + vendorIds: Schema.optional(Schema.Array(Schema.String)), + serviceNames: Schema.optional(Schema.Array(Schema.String)), }) {} export const AiSessionListItem = Schema.Struct({ From 8bfc719e04b4f40213b9587203ad4b9a8f3492a6 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Wed, 19 Aug 2026 22:37:19 +0200 Subject: [PATCH 5/5] feat(web): serve agent session filters from a real facets query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar derived its option lists and counts from the unfiltered list atom, so it only ever saw the page of rows the list's own limit returned — a window holding more sessions than the limit hid options entirely and under-counted the rest. `aiSessionFacetsQuery` now backs them end to end: a `facets` endpoint on the internal AI-sessions group, `getAiSessionsFacets` on the web client, and an `aiSessionsFacetsResultAtom` the route reads instead of a second list read. The request carries the window and nothing else. Keeping the facets unfiltered is what lets a reader switch between vendors without the unselected ones disappearing, and the counts are distinct sessions over the whole window rather than over one page. Co-Authored-By: Claude Fable 5 --- .../src/routes/internal/ai-sessions.http.ts | 78 +++++++++++++------ apps/web/src/api/warehouse/ai-sessions.ts | 31 +++++++- .../agent-sessions-filter-sidebar.tsx | 52 ++++--------- .../services/atoms/warehouse-query-atoms.ts | 6 +- apps/web/src/routes/agent-sessions/index.tsx | 18 +++-- packages/domain/src/http/ai-sessions.ts | 30 +++++++ 6 files changed, 146 insertions(+), 69 deletions(-) diff --git a/apps/api/src/routes/internal/ai-sessions.http.ts b/apps/api/src/routes/internal/ai-sessions.http.ts index a1d7a4c0a..039f5642b 100644 --- a/apps/api/src/routes/internal/ai-sessions.http.ts +++ b/apps/api/src/routes/internal/ai-sessions.http.ts @@ -1,5 +1,10 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" -import { CurrentTenant, ListAiSessionsResponse, MapleInternalApi } from "@maple/domain/http" +import { + CurrentTenant, + ListAiSessionsFacetsResponse, + ListAiSessionsResponse, + MapleInternalApi, +} from "@maple/domain/http" import { Effect } from "effect" import { CH } from "@maple/query-engine" import * as Integrations from "@maple/query-engine-integrations" @@ -19,28 +24,53 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group( Effect.gen(function* () { const warehouse = yield* WarehouseQueryService - return handlers.handle("list", ({ payload }) => - Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context - yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId }) - const compiled = CH.compile( - Integrations.aiSessionListQuery({ - limit: payload.limit, - vendorIds: payload.vendorIds, - serviceNames: payload.serviceNames, - }), - { orgId: tenant.orgId, startTime: payload.startTime, endTime: payload.endTime }, - { rowSchema: Integrations.aiSessionListRowSchema }, - ) - // The row schema already coerces the UInt64 aggregates and decodes - // exactly the response's fields, so rows pass through unmapped - // (unlike listReplays, which re-brands and re-coerces per field). - const rows = yield* warehouse.compiledQuery(tenant, compiled, { - profile: "list", - context: "listAiSessions", - }) - return new ListAiSessionsResponse({ data: rows }) - }), - ) + return handlers + .handle("list", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId }) + const compiled = CH.compile( + Integrations.aiSessionListQuery({ + limit: payload.limit, + vendorIds: payload.vendorIds, + serviceNames: payload.serviceNames, + }), + { orgId: tenant.orgId, startTime: payload.startTime, endTime: payload.endTime }, + { rowSchema: Integrations.aiSessionListRowSchema }, + ) + // The row schema already coerces the UInt64 aggregates and decodes + // exactly the response's fields, so rows pass through unmapped + // (unlike listReplays, which re-brands and re-coerces per field). + const rows = yield* warehouse.compiledQuery(tenant, compiled, { + profile: "list", + context: "listAiSessions", + }) + return new ListAiSessionsResponse({ data: rows }) + }), + ) + .handle("facets", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId }) + const compiled = CH.compileUnion( + Integrations.aiSessionFacetsQuery(), + { orgId: tenant.orgId, startTime: payload.startTime, endTime: payload.endTime }, + { rowSchema: Integrations.aiSessionFacetsRowSchema }, + ) + const rows = yield* warehouse.compiledQuery(tenant, compiled, { + profile: "list", + context: "aiSessionsFacets", + }) + // One UNION ALL result carrying both dimensions, split by facetType. + const pick = (facetType: string) => + rows + .filter((row) => row.facetType === facetType) + .map((row) => ({ name: row.name, count: row.count })) + return new ListAiSessionsFacetsResponse({ + vendors: pick("vendor"), + services: pick("service"), + }) + }), + ) }), ) diff --git a/apps/web/src/api/warehouse/ai-sessions.ts b/apps/web/src/api/warehouse/ai-sessions.ts index 95c538dcb..dc4dc9a9c 100644 --- a/apps/web/src/api/warehouse/ai-sessions.ts +++ b/apps/web/src/api/warehouse/ai-sessions.ts @@ -1,5 +1,5 @@ import { Clock, Effect, Schema } from "effect" -import { ListAiSessionsRequest } from "@maple/domain/http" +import { ListAiSessionsFacetsRequest, ListAiSessionsRequest } from "@maple/domain/http" import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils" @@ -44,3 +44,32 @@ export const listAiSessions = Effect.fn("AiSessions.listAiSessions")(function* ( ) return { data: result.data } }) + +// List facets (filter sidebar option counts) + +const AiSessionsFacetsInput = Schema.Struct({ + startTime: Schema.optional(WarehouseDateTimeString), + endTime: Schema.optional(WarehouseDateTimeString), +}) +export type AiSessionsFacetsInput = Schema.Schema.Type + +export const getAiSessionsFacets = Effect.fn("AiSessions.aiSessionsFacets")(function* ({ + data, +}: { + data: AiSessionsFacetsInput +}) { + const input = yield* decodeInput(AiSessionsFacetsInput, data ?? {}, "aiSessionsFacets") + const fallback = defaultTimeRange(yield* Clock.currentTimeMillis) + const result = yield* runWarehouseQuery("aiSessionsFacets", () => + Effect.gen(function* () { + const client = yield* MapleInternalAtomClient + return yield* client.aiSessionsInternal.facets({ + payload: new ListAiSessionsFacetsRequest({ + startTime: input.startTime ?? fallback.startTime, + endTime: input.endTime ?? fallback.endTime, + }), + }) + }), + ) + return { vendors: result.vendors, services: result.services } +}) diff --git a/apps/web/src/components/agent-sessions/agent-sessions-filter-sidebar.tsx b/apps/web/src/components/agent-sessions/agent-sessions-filter-sidebar.tsx index e72594688..e5c3ffe7a 100644 --- a/apps/web/src/components/agent-sessions/agent-sessions-filter-sidebar.tsx +++ b/apps/web/src/components/agent-sessions/agent-sessions-filter-sidebar.tsx @@ -13,32 +13,10 @@ import { FilterSidebarHeader, FilterSidebarLoading, } from "@/components/filters/filter-sidebar" -import { vendorLabel, type AgentSessionRow } from "./agent-sessions-list" +import { vendorLabel } from "./agent-sessions-list" const routeApi = getRouteApi("/agent-sessions/") -/** - * Facet counts derived client-side from the unfiltered list rows — sessions per - * vendor and per touched service within the current window. There is no facets - * warehouse query yet, so the counts (and the option lists) only see what the - * list's own limit returned; good enough while a window holds tens of sessions, - * and the seam to replace with a real aggregation when it doesn't. - */ -function facetCounts( - rows: ReadonlyArray, - pick: (row: AgentSessionRow) => ReadonlyArray, -): FilterOption[] { - const counts = new Map() - for (const row of rows) { - for (const name of pick(row)) { - counts.set(name, (counts.get(name) ?? 0) + 1) - } - } - return [...counts.entries()] - .map(([name, count]) => ({ name, count })) - .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) -} - /** A selected value absent from the current window stays checkable (count 0). */ function withSelected(options: FilterOption[], selected?: string): FilterOption[] { if (selected && !options.some((o) => o.name === selected)) { @@ -48,11 +26,21 @@ function withSelected(options: FilterOption[], selected?: string): FilterOption[ } interface AgentSessionsFilterSidebarProps { - /** The UNFILTERED window's sessions, so option lists survive an active filter. */ - optionsResult: Result.Result<{ readonly data: ReadonlyArray }, unknown> + /** + * Distinct sessions per option, aggregated over the whole window rather than + * over the page of rows the list returned. Deliberately unfiltered, so + * selecting one option leaves the others visible and countable. + */ + facetsResult: Result.Result< + { + readonly vendors: ReadonlyArray + readonly services: ReadonlyArray + }, + unknown + > } -export function AgentSessionsFilterSidebar({ optionsResult }: AgentSessionsFilterSidebarProps) { +export function AgentSessionsFilterSidebar({ facetsResult }: AgentSessionsFilterSidebarProps) { const navigate = routeApi.useNavigate() const search = routeApi.useSearch() @@ -68,18 +56,12 @@ export function AgentSessionsFilterSidebar({ optionsResult }: AgentSessionsFilte const hasActiveFilters = !!search.vendor || !!search.service - return Result.builder(optionsResult) + return Result.builder(facetsResult) .onInitial(() => ) .onError((error) => ) .onSuccess((value, result) => { - const vendors = withSelected( - facetCounts(value.data, (row) => [row.vendorId]), - search.vendor, - ) - const services = withSelected( - facetCounts(value.data, (row) => row.serviceNames), - search.service, - ) + const vendors = withSelected([...value.vendors], search.vendor) + const services = withSelected([...value.services], search.service) return ( diff --git a/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts b/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts index 85126e334..20eacda9d 100644 --- a/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts +++ b/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts @@ -110,7 +110,7 @@ import { getSessionTraceSummaries, listReplays, } from "@/api/warehouse/replays" -import { listAiSessions } from "@/api/warehouse/ai-sessions" +import { getAiSessionsFacets, listAiSessions } from "@/api/warehouse/ai-sessions" import { getWebAnalyticsBreakdowns, getWebAnalyticsPages, @@ -251,6 +251,10 @@ export const listAiSessionsResultAtom = makeQueryAtomFamily(listAiSessions, { staleTime: 30_000, }) +export const aiSessionsFacetsResultAtom = makeQueryAtomFamily(getAiSessionsFacets, { + staleTime: 30_000, +}) + export const replaysFacetsResultAtom = makeQueryAtomFamily(getReplaysFacets, { staleTime: 30_000, }) diff --git a/apps/web/src/routes/agent-sessions/index.tsx b/apps/web/src/routes/agent-sessions/index.tsx index 630327e5a..95197c86c 100644 --- a/apps/web/src/routes/agent-sessions/index.tsx +++ b/apps/web/src/routes/agent-sessions/index.tsx @@ -7,7 +7,10 @@ import { AgentSessionsFilterSidebar } from "@/components/agent-sessions/agent-se import { NotFoundError } from "@/components/route-error" import { QueryErrorState } from "@/components/common/query-error-state" import { Result, useAtomValue } from "@/lib/effect-atom" -import { listAiSessionsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" +import { + aiSessionsFacetsResultAtom, + listAiSessionsResultAtom, +} from "@/lib/services/atoms/warehouse-query-atoms" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" @@ -104,12 +107,11 @@ function AgentSessionsBody({ }, }), ) - // The sidebar's option lists come from the UNFILTERED window, so picking a - // vendor doesn't erase the others from the list. With no filter active this - // is the same atom entry as `result` (undefined fields drop from the cache - // key), so it costs nothing; plain useAtomValue keeps it off the Reload - // subscription — options refetch when the window rolls, which is enough. - const optionsResult = useAtomValue(listAiSessionsResultAtom({ data: window })) + // The sidebar's counts come from the UNFILTERED window, so picking a vendor + // doesn't erase the others from the list. Plain useAtomValue keeps this off + // the Reload subscription — the facets refetch when the window rolls, which + // is enough. + const facetsResult = useAtomValue(aiSessionsFacetsResultAtom({ data: { startTime, endTime } })) const sessions = Result.isSuccess(result) ? result.value.data : [] const headerActions = ( @@ -130,7 +132,7 @@ function AgentSessionsBody({ return ( <> - + diff --git a/packages/domain/src/http/ai-sessions.ts b/packages/domain/src/http/ai-sessions.ts index fdcf3f0e8..acbc0d598 100644 --- a/packages/domain/src/http/ai-sessions.ts +++ b/packages/domain/src/http/ai-sessions.ts @@ -48,6 +48,29 @@ export class ListAiSessionsResponse extends Schema.Class data: Schema.Array(AiSessionListItem), }) {} +export class ListAiSessionsFacetsRequest extends Schema.Class( + "ListAiSessionsFacetsRequest", +)({ + // The window and nothing else: the facets are deliberately unfiltered, so + // picking a vendor doesn't erase the other vendors from the sidebar. + startTime: TinybirdDateTime, + endTime: TinybirdDateTime, +}) {} + +export const AiSessionFacetItem = Schema.Struct({ + name: Schema.String, + count: Schema.Number, +}) + +export class ListAiSessionsFacetsResponse extends Schema.Class( + "ListAiSessionsFacetsResponse", +)({ + /** Distinct sessions per vendor id, matching what `vendorIds` selects. */ + vendors: Schema.Array(AiSessionFacetItem), + /** Distinct sessions per service name, matching what `serviceNames` selects. */ + services: Schema.Array(AiSessionFacetItem), +}) {} + // Exactly what a compiled warehouse read can fail with — not the wider // `sessionReplayEndpointErrors` union, whose extra members (the legacy // QueryEngine wrappers, token-mint errors) this endpoint can never produce. @@ -61,5 +84,12 @@ export class AiSessionsInternalApiGroup extends HttpApiGroup.make("aiSessionsInt error: aiSessionEndpointErrors, }), ) + .add( + HttpApiEndpoint.post("facets", "/facets", { + payload: ListAiSessionsFacetsRequest, + success: ListAiSessionsFacetsResponse, + error: aiSessionEndpointErrors, + }), + ) .prefix("/internal/ai-sessions") .middleware(SessionAuthorization) {}