diff --git a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/layout.tsx b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/layout.tsx
index 8df56dac..41624e3a 100644
--- a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/layout.tsx
+++ b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/layout.tsx
@@ -1,3 +1,5 @@
+import { PageSelectionProvider } from "@/components/page-selection"
+import { getSelectionHistory } from "@/data/page-selection"
import { CodingActivity } from "@/components/blocks/coding/git-actions"
import { Suspense } from "react"
import type { Route } from "next"
@@ -141,8 +143,21 @@ async function WorkspaceContent({
)
}
+ const selectionScope = JSON.stringify([
+ result.scope.organizationSession.session.user.id,
+ result.scope.organization.id,
+ result.workspace.id,
+ ])
+ const selectionHistory = (await getSelectionHistory()).filter(
+ ([scope]) => scope === selectionScope
+ )
return (
- <>
+
- >
+
)
}
diff --git a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/lens-filters.tsx b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/lens-filters.tsx
index b489fb53..75dfb30b 100644
--- a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/lens-filters.tsx
+++ b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/lens-filters.tsx
@@ -1,6 +1,7 @@
"use client"
import * as React from "react"
+import { useSelectResource } from "@/components/page-selection"
import { BotIcon, MessageSquareQuote } from "lucide-react"
import { useRouter } from "@bprogress/next/app"
import { usePathname, useSearchParams } from "next/navigation"
@@ -34,6 +35,7 @@ export function LensFilters({
sessions,
to,
}: LensFiltersProps) {
+ const { select, pending: selectionPending } = useSelectResource(true)
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
@@ -59,14 +61,14 @@ export function LensFilters({
return (
{sessions ? (
}
>
-
+
}>
-
+
@@ -91,58 +82,49 @@ export default async function McpPage({
async function Filters({
searchParams,
- workspaceId,
+ selection,
}: {
searchParams: Promise
- workspaceId: string
+ selection: Promise
}) {
- const params = await searchParams
- const range = params.range
- const scope = await getMcpScope({
- agents: listAgentsCachedQuery(undefined, workspaceId),
- agentName: params.agentName,
- })
- if (scope.error) {
- return
- }
-
+ const [{ range }, scope] = await Promise.all([searchParams, selection])
+ if (scope.error) return
return (
-
+ <>
+
+
+ >
)
}
async function Graph({
searchParams,
workspaceId,
+ selection,
}: {
searchParams: Promise
workspaceId: string
+ selection: Promise
}) {
const params = await searchParams
const range = params.range
- const scope = await getMcpScope({
- agents: listAgentsCachedQuery(undefined, workspaceId),
- agentName: params.agentName,
- })
+ const scope = await selection
if (scope.error) {
return
}
- if (!scope.selectedAgentName) {
- if (params.agentName && scope.agents.length > 0) {
- return
- }
+ if (!scope.selected.agent_name) {
return
}
const result = await getMcpGraphAction(
- { agentName: scope.selectedAgentName },
+ { agentName: scope.selected.agent_name },
{ from: range.from, to: range.to },
workspaceId
)
@@ -202,30 +184,3 @@ function EmptyState({ message }: { message: string }) {
)
}
-
-async function getMcpScope({
- agents,
- agentName,
-}: {
- agents: Promise
- agentName?: string
-}): Promise {
- const result = await agents
- if (result.error) {
- return {
- agents: undefined,
- selectedAgentName: undefined,
- error: result.error,
- }
- }
-
- const selectedAgentName = agentName
- ? result.agents.find((agent) => agent.name === agentName)?.name
- : result.agents[0]?.name
-
- return {
- agents: result.agents,
- selectedAgentName,
- error: undefined,
- }
-}
diff --git a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/runtime-telemetry/runtime-telemetry-page.tsx b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/runtime-telemetry/runtime-telemetry-page.tsx
index d357225e..1c9b13f6 100644
--- a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/runtime-telemetry/runtime-telemetry-page.tsx
+++ b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/runtime-telemetry/runtime-telemetry-page.tsx
@@ -6,7 +6,8 @@ import { EventsChart } from "@/components/events-chart"
import { EventsChartSkeleton } from "@/components/events-chart-skeleton"
import { Skeleton } from "@/components/ui/skeleton"
import * as z from "zod"
-import { listAgentsCachedQuery } from "@/data/agent.queries"
+import { resolvePageSelection, type ResolvedPageSelection } from "@/data/page-selection"
+import { RememberPageSelection } from "@/components/page-selection"
import type { Error } from "@/lib/gateway/client"
import type {
FileTelemetryActionResponse,
@@ -86,6 +87,13 @@ async function WorkspaceTelemetry({
const search = telemetrySearchParamsSchema.parse(await searchParams)
const resolved = { ...search, range: lensDateRange(search.from, search.to) }
+ const selection = resolvePageSelection(
+ workspace,
+ config.value === "process"
+ ? "lens/runtime-telemetry"
+ : `lens/runtime-telemetry/${config.value}`,
+ search
+ )
const basePath = `/orgs/${workspace.scope.organization.slug}/workspaces/${workspace.workspace.slug}/lens/runtime-telemetry`
const workspaceId = workspace.workspace.id
@@ -101,7 +109,7 @@ async function WorkspaceTelemetry({
}
>
-
+
@@ -113,6 +121,7 @@ async function WorkspaceTelemetry
({
@@ -128,23 +137,23 @@ async function TelemetryContent({
config,
searchParams: params,
workspaceId,
+ selection,
}: {
config: TelemetryPageConfig
searchParams: ResolvedTelemetrySearchParams
workspaceId: string
+ selection: Promise
}) {
- const { agentName, agents, error, pageToken, range } = await resolveTelemetryPageState(
- params,
- workspaceId
- )
+ const { selected, requested, error } = await selection
+ const agentName = selected.agent_name
+ const range = params.range
+ const pageToken =
+ selected.agent_name === requested.agent_name ? params.telemetry_page_token : undefined
if (error) {
return
}
if (!agentName) {
- if (params.agent_name && agents.length > 0) {
- return
- }
return
}
@@ -227,26 +236,23 @@ async function Table({
async function Filters({
searchParams: params,
- workspaceId,
+ selection,
}: {
searchParams: ResolvedTelemetrySearchParams
- workspaceId: string
+ selection: Promise
}) {
- const { agents, error, selectedAgentName, range } = await resolveTelemetryPageState(
- params,
- workspaceId
- )
- if (error) {
- return
- }
-
+ const state = await selection
+ if (state.error) return
return (
-
+ <>
+
+
+ >
)
}
@@ -266,44 +272,6 @@ function EmptyState({ message }: { message: string }) {
)
}
-type TelemetryPageState = {
- agentName?: string
- agents: NonNullable>["agents"]>
- error?: Error
- pageToken?: string
- range: LensDateRange
- selectedAgentName?: string
-}
-
type ResolvedTelemetrySearchParams = z.output & {
range: LensDateRange
}
-
-async function resolveTelemetryPageState(
- search: ResolvedTelemetrySearchParams,
- workspaceId: string
-) {
- const agentsResult = await listAgentsCachedQuery(undefined, workspaceId)
- if (agentsResult.error) {
- return {
- agents: [],
- error: agentsResult.error,
- pageToken: search.telemetry_page_token,
- range: search.range,
- } satisfies TelemetryPageState
- }
-
- const agents = agentsResult.agents
- const selectedAgent = search.agent_name
- ? agents.find((agent) => agent.name === search.agent_name)
- : agents[0]
-
- return {
- agentName: selectedAgent?.name,
- agents,
- error: undefined,
- pageToken: search.telemetry_page_token,
- range: search.range,
- selectedAgentName: selectedAgent?.name,
- } satisfies TelemetryPageState
-}
diff --git a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/runtime-telemetry/telemetry-tabs.tsx b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/runtime-telemetry/telemetry-tabs.tsx
index 6180c86a..9efb6812 100644
--- a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/runtime-telemetry/telemetry-tabs.tsx
+++ b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/runtime-telemetry/telemetry-tabs.tsx
@@ -9,6 +9,9 @@ export function TelemetryTabs({ basePath }: { basePath: string }) {
const href = (tab: "process" | "file" | "network"): Route => {
const next = new URLSearchParams(searchParams.toString())
+ next.delete("agent_name")
+ next.delete("telemetry_page_token")
+ next.delete("telemetry_token_stack")
const query = next.toString()
const path = tab === "process" ? basePath : `${basePath}/${tab}`
diff --git a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/traces/page.tsx b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/traces/page.tsx
index f8127f58..8096a426 100644
--- a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/traces/page.tsx
+++ b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/traces/page.tsx
@@ -6,13 +6,9 @@ import { Alert, AlertDescription } from "@/components/ui/alert"
import { EventsChart } from "@/components/events-chart"
import { EventsChartSkeleton } from "@/components/events-chart-skeleton"
import { Skeleton } from "@/components/ui/skeleton"
-import { listAgentsCachedQuery } from "@/data/agent.queries"
-import {
- getTraceChartAction,
- listTraceSessionFilterAction,
- listTraceSessionsAction,
-} from "@/data/lens.actions"
-import type { ListAgentActionResponse, TraceSessionFilterItem } from "@/data/types"
+import { resolvePageSelection, type ResolvedPageSelection } from "@/data/page-selection"
+import { RememberPageSelection } from "@/components/page-selection"
+import { getTraceChartAction, listTraceSessionsAction } from "@/data/lens.actions"
import { LensFilters } from "@/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/lens-filters"
import {
lensDateRange,
@@ -22,7 +18,6 @@ import { TracesSkeleton } from "@/app/(scoped)/orgs/[orgSlug]/workspaces/[worksp
import { TracesTable } from "@/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/lens/traces/traces-table"
import { searchParamStringSchema, type SearchParamStringInput } from "@/lib/search-params"
import { getWorkspaceScope } from "@/data/workspaces"
-import type { WorkspaceType } from "@/lib/gateway/client"
export const metadata: Metadata = {
title: "Traces",
@@ -48,36 +43,6 @@ type TracesSearchParams = {
limit?: SearchParamStringInput
}
-type TraceScopeSuccess = {
- agents: NonNullable
- sessions: TraceSessionFilterItem[]
- selectedAgentName?: string
- selectedSessionId?: string
- error: undefined
-}
-
-type TraceScopeFailure = {
- agents: undefined
- sessions: undefined
- selectedAgentName?: undefined
- selectedSessionId?: undefined
- error: NonNullable
-}
-
-type TraceScope = TraceScopeSuccess | TraceScopeFailure
-
-function traceScopeFailure(
- error: NonNullable
-): TraceScopeFailure {
- return {
- agents: undefined,
- sessions: undefined,
- selectedAgentName: undefined,
- selectedSessionId: undefined,
- error,
- }
-}
-
export default async function TracesPage({
params,
searchParams,
@@ -96,14 +61,10 @@ export default async function TracesPage({
const resolved = resolveTracesSearchParams(searchParams)
const workspaceId = workspace.workspace.id
- const agents = listAgentsCachedQuery(undefined, workspaceId)
const scope = resolved.then((params) =>
- getTraceScope({
- agents,
- agentName: params.agentName,
- sessionID: params.sessionID,
- workspaceId,
- workspaceType: workspace.workspace.type,
+ resolvePageSelection(workspace, "lens/traces", {
+ agent_name: params.agentName,
+ session_id: params.sessionID,
})
)
@@ -138,7 +99,7 @@ async function Filters({
scope: scopePromise,
}: {
searchParams: Promise
- scope: Promise
+ scope: Promise
}) {
const params = await searchParams
const scope = await scopePromise
@@ -147,14 +108,17 @@ async function Filters({
}
return (
-
+ <>
+
+
+ >
)
}
@@ -165,7 +129,7 @@ async function Chart({
}: {
searchParams: Promise
workspaceId: string
- scope: Promise
+ scope: Promise
}) {
const params = await searchParams
const scope = await scopePromise
@@ -173,17 +137,17 @@ async function Chart({
return null
}
- if (!scope.selectedAgentName) {
+ if (!scope.selected.agent_name) {
return null
}
- const sessionID = scope.selectedSessionId
+ const sessionID = scope.selected.session_id
if (!sessionID) {
return null
}
const result = await getTraceChartAction(
{
- agentName: scope.selectedAgentName,
+ agentName: scope.selected.agent_name,
sessionID,
},
{
@@ -206,7 +170,7 @@ async function Traces({
}: {
searchParams: Promise
workspaceId: string
- scope: Promise
+ scope: Promise
}) {
const params = await searchParams
const scope = await scopePromise
@@ -214,24 +178,25 @@ async function Traces({
return
}
- if (!scope.selectedAgentName) {
- if (params.agentName && scope.agents.length > 0) {
- return
- }
+ if (!scope.selected.agent_name) {
return
}
- if (!scope.selectedSessionId) {
+ if (!scope.selected.session_id) {
return
}
const result = await listTraceSessionsAction(
{
- agentName: scope.selectedAgentName,
- sessionID: scope.selectedSessionId,
+ agentName: scope.selected.agent_name,
+ sessionID: scope.selected.session_id,
},
{
limit: params.limit,
- page_token: params.pageToken,
+ page_token:
+ scope.selected.agent_name === scope.requested.agent_name &&
+ scope.selected.session_id === scope.requested.session_id
+ ? params.pageToken
+ : undefined,
started_after: params.range.after,
started_before: params.range.before,
},
@@ -281,56 +246,3 @@ function EmptyState({ message }: { message: string }) {
)
}
-
-async function getTraceScope({
- agents,
- agentName,
- sessionID,
- workspaceId,
- workspaceType,
-}: {
- agents: Promise
- agentName?: string
- sessionID?: string
- workspaceId: string
- workspaceType: WorkspaceType
-}): Promise {
- const agentResult = await agents
- if (agentResult.error) {
- return traceScopeFailure(agentResult.error)
- }
-
- const selectedAgentName = agentName
- ? agentResult.agents.find((agent) => agent.name === agentName)?.name
- : agentResult.agents[0]?.name
- if (!selectedAgentName) {
- return {
- agents: agentResult.agents,
- sessions: [],
- selectedAgentName: undefined,
- selectedSessionId: undefined,
- error: undefined,
- }
- }
-
- const sessionResult = await listTraceSessionFilterAction(
- selectedAgentName,
- workspaceId,
- workspaceType
- )
- if (sessionResult.error) {
- return traceScopeFailure(sessionResult.error)
- }
-
- const selectedSessionId = sessionResult.data.some((session) => session.sessionId === sessionID)
- ? sessionID
- : sessionResult.data[0]?.sessionId
-
- return {
- agents: agentResult.agents,
- sessions: sessionResult.data,
- selectedAgentName,
- selectedSessionId,
- error: undefined,
- }
-}
diff --git a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/secrets/page.tsx b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/secrets/page.tsx
index 24461bb1..93dc1513 100644
--- a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/secrets/page.tsx
+++ b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/secrets/page.tsx
@@ -7,7 +7,8 @@ import {
AdministrationPageHeader,
AdministrationState,
} from "@/components/administration"
-import { listAgentsCachedQuery } from "@/data/agent.queries"
+import { resolvePageSelection } from "@/data/page-selection"
+import { RememberPageSelection } from "@/components/page-selection"
import {
deleteSecretFormAction,
putSecretFormAction,
@@ -50,39 +51,41 @@ async function WorkspaceSecrets({
if (workspace.kind !== "ready") {
notFound()
}
- const agents = await listAgentsCachedQuery(undefined, workspace.workspace.id)
- if (agents.error) {
+ const parsed = secretsSearchParamsSchema.parse(search)
+ const state = await resolvePageSelection(workspace, "secrets", parsed)
+ if (state.error) {
return (
)
}
- const readableAgents = agents.agents.filter((agent) => agent.capabilities.read_secrets)
+ const readableAgents = state.agents
const firstReadableAgent = readableAgents[0]
if (!firstReadableAgent) {
return (
+
)
}
- const parsed = secretsSearchParamsSchema.parse(search)
const selectedAgent =
- readableAgents.find((agent) => agent.name === parsed.agent_name) ?? firstReadableAgent
+ readableAgents.find((agent) => agent.name === state.selected.agent_name) ?? firstReadableAgent
const writableAgent =
readableAgents.find(
- (agent) => agent.name === parsed.agent_name && agent.capabilities.write_secrets
+ (agent) => agent.name === state.selected.agent_name && agent.capabilities.write_secrets
) ?? readableAgents.find((agent) => agent.capabilities.write_secrets)
const result = await listSecretsCachedQuery(selectedAgent.name, workspace.workspace.id, {
limit: 50,
- page_token: parsed.page_token,
+ page_token:
+ state.selected.agent_name === state.requested.agent_name ? parsed.page_token : undefined,
sort_by: parsed.sort_by,
sort_order: parsed.sort_order,
})
@@ -118,6 +121,7 @@ async function WorkspaceSecrets({
}
title="Secrets"
/>
+
{
- const query = params.toString()
- router.replace(query === "" ? pathname : `${pathname}?${query}`)
- })
- }
+ const { pending, select } = useSelectResource(true)
return (
diff --git a/web/data/page-selection.ts b/web/data/page-selection.ts
new file mode 100644
index 00000000..04252e22
--- /dev/null
+++ b/web/data/page-selection.ts
@@ -0,0 +1,255 @@
+import "server-only"
+
+import { cookies } from "next/headers"
+import { cache } from "react"
+import type {
+ Agent,
+ Error,
+ Workflow,
+ WorkflowRunDetail,
+ WorkflowRunSummary,
+ WorkflowSchedule,
+ WorkflowSummary,
+ WorkflowWebhookTrigger,
+} from "@/lib/gateway/client"
+import {
+ readSelectionHistory,
+ restoreSelection,
+ selectionCookie,
+ type PageSelection,
+ type SelectionPage,
+} from "@/lib/page-selection"
+import { listAgentsCachedQuery } from "@/data/agent.queries"
+import { getWorkflowCachedQuery, listWorkflowSummariesCachedQuery } from "@/data/workflow.queries"
+import { listWorkflowSchedulesCachedQuery } from "@/data/workflow-schedule.queries"
+import { listWorkflowWebhookTriggersCachedQuery } from "@/data/workflow-trigger.queries"
+import { getWorkflowRunCachedQuery, listWorkflowRunsCachedQuery } from "@/data/workflow-run.queries"
+import { listTraceSessionFilterAction } from "@/data/lens.actions"
+import type { TraceSessionFilterItem } from "@/data/types"
+import type { getWorkspaceScope } from "@/data/workspaces"
+
+type WorkspaceScope = Extract
>, { kind: "ready" }>
+
+export type ResolvedPageSelection = {
+ selected: PageSelection
+ requested: PageSelection
+ agents: Agent[]
+ workflows: WorkflowSummary[]
+ schedules: WorkflowSchedule[]
+ webhookTriggers: WorkflowWebhookTrigger[]
+ workflowRuns: WorkflowRunSummary[]
+ workflow?: Workflow
+ workflowRun?: WorkflowRunDetail
+ sessions: TraceSessionFilterItem[]
+ error?: Error
+}
+
+/** Reuse the request snapshot without including preferences in resource cache keys. */
+export const getSelectionHistory = cache(async () => {
+ const jar = await cookies()
+ return readSelectionHistory(jar.get(selectionCookie)?.value)
+})
+
+/** Resolve each parent once so filters, content, and mutation targets agree. */
+export async function resolvePageSelection(
+ workspace: WorkspaceScope,
+ page: SelectionPage,
+ search: PageSelection
+): Promise {
+ const workspaceId = workspace.workspace.id
+ const scope = JSON.stringify([
+ workspace.scope.organizationSession.session.user.id,
+ workspace.scope.organization.id,
+ workspaceId,
+ ])
+ const [history, agentsResult] = await Promise.all([
+ getSelectionHistory(),
+ listAgentsCachedQuery(undefined, workspaceId),
+ ])
+ const requested = restoreSelection(history, scope, page, search)
+ const state: ResolvedPageSelection = {
+ selected: {},
+ requested,
+ agents: [],
+ workflows: [],
+ schedules: [],
+ webhookTriggers: [],
+ workflowRuns: [],
+ sessions: [],
+ }
+ if (agentsResult.error) return { ...state, error: agentsResult.error }
+ state.agents = agentsResult.agents
+ if (
+ requested.agent_name &&
+ !state.agents.some((agent) => agent.name === requested.agent_name) &&
+ agentsResult.hasNextPage
+ ) {
+ const result = await listAgentsCachedQuery(
+ { agent_name: [requested.agent_name], limit: 1 },
+ workspaceId
+ )
+ if (result.error) {
+ if (result.error.code !== "invalid_request") return { ...state, error: result.error }
+ } else {
+ state.agents = [...state.agents, ...result.agents]
+ }
+ }
+ if (page === "secrets") {
+ state.agents = state.agents.filter((agent) => agent.capabilities.read_secrets)
+ let pageToken = agentsResult.nextPageToken
+ while (!state.agents.length && pageToken) {
+ const result = await listAgentsCachedQuery({ page_token: pageToken }, workspaceId)
+ if (result.error) return { ...state, error: result.error }
+ state.agents = result.agents.filter((agent) => agent.capabilities.read_secrets)
+ pageToken = result.nextPageToken
+ }
+ }
+ const agent = state.agents.find((agent) => agent.name === requested.agent_name) ?? state.agents[0]
+ if (!agent) return state
+ state.selected.agent_name = agent.name
+ // A fallback parent must not inherit descendants from the missing resource.
+ const children =
+ requested.agent_name !== undefined && agent.name !== requested.agent_name ? {} : requested
+
+ if (page === "lens/traces") {
+ const result = await listTraceSessionFilterAction(
+ agent.name,
+ workspaceId,
+ workspace.workspace.type
+ )
+ if (result.error) return { ...state, error: result.error }
+ state.sessions = result.data
+ state.selected.session_id = (
+ state.sessions.find((session) => session.sessionId === children.session_id) ??
+ state.sessions[0]
+ )?.sessionId
+ return state
+ }
+
+ if (page === "workflows/triggers" || page === "workflows/triggers/runs") {
+ state.selected.type = children.type === "webhook" ? "webhook" : "schedule"
+ if (page === "workflows/triggers") return state
+
+ const triggerSelection =
+ children.type !== undefined && children.type !== state.selected.type ? {} : children
+ if (state.selected.type === "webhook") {
+ let pageToken: string | undefined
+ do {
+ const result = await listWorkflowWebhookTriggersCachedQuery(agent.name, workspaceId, {
+ limit: 200,
+ page_token: pageToken,
+ })
+ if (result.error) return { ...state, error: result.error }
+ state.webhookTriggers.push(...result.webhookTriggers)
+ pageToken = result.nextPageToken || undefined
+ } while (
+ pageToken &&
+ triggerSelection.workflow_name &&
+ triggerSelection.webhook_api_key_id &&
+ !state.webhookTriggers.some(
+ (trigger) =>
+ trigger.workflow_name === triggerSelection.workflow_name &&
+ trigger.api_key_id === triggerSelection.webhook_api_key_id
+ )
+ )
+ const trigger =
+ state.webhookTriggers.find(
+ (trigger) =>
+ trigger.workflow_name === triggerSelection.workflow_name &&
+ trigger.api_key_id === triggerSelection.webhook_api_key_id
+ ) ?? state.webhookTriggers[0]
+ state.selected.workflow_name = trigger?.workflow_name
+ state.selected.webhook_api_key_id = trigger?.api_key_id
+ return state
+ }
+
+ let pageToken: string | undefined
+ do {
+ const result = await listWorkflowSchedulesCachedQuery(agent.name, workspaceId, {
+ limit: 200,
+ page_token: pageToken,
+ })
+ if (result.error) return { ...state, error: result.error }
+ state.schedules.push(...result.workflowSchedules)
+ pageToken = result.nextPageToken || undefined
+ } while (
+ pageToken &&
+ triggerSelection.schedule_name &&
+ !state.schedules.some((schedule) => schedule.name === triggerSelection.schedule_name)
+ )
+ const schedule =
+ state.schedules.find((schedule) => schedule.name === triggerSelection.schedule_name) ??
+ state.schedules[0]
+ state.selected.schedule_name = schedule?.name
+ state.selected.workflow_name = schedule?.workflow_name
+ return state
+ }
+
+ if (page !== "workflows/graphs" && page !== "workflows/triggers/runs/graph") return state
+ const workflows = await listWorkflowSummariesCachedQuery(agent.name, workspaceId)
+ if (workflows.error) return { ...state, error: workflows.error }
+ state.workflows = workflows.summaries
+ let workflow =
+ state.workflows.find((workflow) => workflow.workflow_name === children.workflow_name) ??
+ state.workflows[0]
+ let runName = children.run_name
+ // A resource can disappear after its options were listed. Retry one fallback,
+ // then surface a persistent error rather than chasing a changing dataset.
+ for (let attempt = 0; attempt < 2; attempt++) {
+ if (!workflow) return state
+ const workflowName = workflow.workflow_name
+ if (page === "workflows/triggers/runs/graph" && state.selected.workflow_name !== workflowName) {
+ if (children.workflow_name !== undefined && workflowName !== children.workflow_name)
+ runName = undefined
+ const runs = await listWorkflowRunsCachedQuery(agent.name, workflowName, workspaceId, {
+ limit: 200,
+ })
+ if (runs.error) return { ...state, error: runs.error }
+ state.workflowRuns = runs.workflowRuns
+ if (runName && !state.workflowRuns.some((run) => run.name === runName) && runs.hasNextPage) {
+ const result = await getWorkflowRunCachedQuery(
+ agent.name,
+ workflowName,
+ runName,
+ workspaceId
+ )
+ if (
+ result.error &&
+ result.error.code !== "not_found" &&
+ result.error.code !== "invalid_request"
+ )
+ return { ...state, error: result.error }
+ if (result.workflowRun) state.workflowRuns = [...state.workflowRuns, result.workflowRun]
+ }
+ }
+ state.selected.workflow_name = workflowName
+ const run = state.workflowRuns.find((run) => run.name === runName) ?? state.workflowRuns[0]
+ state.selected.run_name = run?.name
+ if (page === "workflows/triggers/runs/graph" && !run) return state
+ const [detail, runDetail] = await Promise.all([
+ getWorkflowCachedQuery(agent.name, workflowName, workspaceId),
+ run ? getWorkflowRunCachedQuery(agent.name, workflowName, run.name, workspaceId) : undefined,
+ ])
+ if (detail.error?.code === "not_found" && attempt === 0) {
+ state.workflows = state.workflows.filter((item) => item.workflow_name !== workflowName)
+ workflow = state.workflows[0]
+ state.selected.workflow_name = undefined
+ state.selected.run_name = undefined
+ state.workflowRuns = []
+ runName = undefined
+ continue
+ }
+ if (detail.error) return { ...state, error: detail.error }
+ if (runDetail?.error?.code === "not_found" && attempt === 0) {
+ state.workflowRuns = state.workflowRuns.filter((item) => item.name !== run?.name)
+ state.selected.run_name = undefined
+ runName = undefined
+ continue
+ }
+ if (runDetail?.error) return { ...state, error: runDetail.error }
+ state.workflow = detail.workflow
+ state.workflowRun = runDetail?.workflowRun
+ return state
+ }
+ return state
+}
diff --git a/web/data/types.ts b/web/data/types.ts
index 42043856..fbfe44a6 100644
--- a/web/data/types.ts
+++ b/web/data/types.ts
@@ -396,3 +396,8 @@ export type ProviderModelItem = {
providerID: string
variants?: string[]
}
+
+export type WorkflowActionScope = {
+ basePath: string
+ workspaceId: string
+}
diff --git a/web/data/workflow-run.actions.ts b/web/data/workflow-run.actions.ts
index a12b9c44..be10eeb5 100644
--- a/web/data/workflow-run.actions.ts
+++ b/web/data/workflow-run.actions.ts
@@ -6,7 +6,7 @@ import * as z from "zod"
import { createWorkflowRun, deleteWorkflowRun, type Error } from "@/lib/gateway/client"
import { workflowRunsTag } from "@/data/cache"
import { getGatewayServerClient } from "@/lib/gateway/server-client"
-import type { WorkflowActionScope } from "@/data/workflow.actions"
+import type { WorkflowActionScope } from "@/data/types"
export type DeleteWorkflowRunActionState = {
success: boolean
diff --git a/web/data/workflow-schedule.actions.ts b/web/data/workflow-schedule.actions.ts
index c043f3f5..4905312a 100644
--- a/web/data/workflow-schedule.actions.ts
+++ b/web/data/workflow-schedule.actions.ts
@@ -27,7 +27,7 @@ import type {
WorkflowInputContractResult,
} from "@/data/types"
import { getGatewayServerClient } from "@/lib/gateway/server-client"
-import type { WorkflowActionScope } from "@/data/workflow.actions"
+import type { WorkflowActionScope } from "@/data/types"
type ParsedScheduleForm = {
data: CreateWorkflowScheduleFormValues
diff --git a/web/data/workflow.actions.ts b/web/data/workflow.actions.ts
deleted file mode 100644
index 28ecd039..00000000
--- a/web/data/workflow.actions.ts
+++ /dev/null
@@ -1,329 +0,0 @@
-"use server"
-
-import type { Route } from "next"
-import { redirect } from "next/navigation"
-import { listAgentsCachedQuery } from "@/data/agent.queries"
-import { listWorkflowSchedulesCachedQuery } from "@/data/workflow-schedule.queries"
-import { listWorkflowWebhookTriggersCachedQuery } from "@/data/workflow-trigger.queries"
-import {
- workflowFiltersFormSchema,
- workflowRunGraphFiltersFormSchema,
- workflowRunFiltersFormSchema,
- workflowTriggerFiltersFormSchema,
-} from "@/data/workflow.schema"
-import { listWorkflowSummariesCachedQuery } from "@/data/workflow.queries"
-import { listWorkflowRunsCachedQuery } from "@/data/workflow-run.queries"
-
-export type WorkflowActionScope = {
- basePath: string
- workspaceId: string
-}
-
-export async function selectWorkflowFiltersAction(scope: WorkflowActionScope, formData: FormData) {
- const agentsResult = await listAgentsCachedQuery(undefined, scope.workspaceId)
- if (agentsResult.error || !agentsResult.agents || agentsResult.agents.length === 0) {
- redirect(workflowsPath(scope))
- }
-
- const parsed = workflowFiltersFormSchema.safeParse(Object.fromEntries(formData))
- if (!parsed.success) {
- redirect(workflowsPath(scope))
- }
-
- const agent =
- agentsResult.agents.find((currentAgent) => currentAgent.name === parsed.data.agent_name) ??
- agentsResult.agents[0]
- if (!agent) {
- redirect(workflowsPath(scope))
- }
-
- const workflowsResult = await listWorkflowSummariesCachedQuery(agent.name, scope.workspaceId)
- if (workflowsResult.error || !workflowsResult.summaries) {
- redirect(workflowsPath(scope, { agentName: agent.name }))
- }
-
- const workflow =
- workflowsResult.summaries.find(
- (currentWorkflow) => currentWorkflow.workflow_name === parsed.data.workflow_name
- ) ?? workflowsResult.summaries[0]
- if (!workflow) {
- redirect(workflowsPath(scope, { agentName: agent.name }))
- }
-
- redirect(
- workflowsPath(scope, {
- agentName: agent.name,
- workflowName: workflow.workflow_name,
- })
- )
-}
-
-export async function selectWorkflowTriggerFiltersAction(
- scope: WorkflowActionScope,
- formData: FormData
-) {
- const agentsResult = await listAgentsCachedQuery(undefined, scope.workspaceId)
- if (agentsResult.error || !agentsResult.agents || agentsResult.agents.length === 0) {
- redirect(workflowTriggersPath(scope))
- }
-
- const parsed = workflowTriggerFiltersFormSchema.safeParse(Object.fromEntries(formData))
- if (!parsed.success) {
- redirect(workflowTriggersPath(scope))
- }
-
- const agent =
- agentsResult.agents.find((currentAgent) => currentAgent.name === parsed.data.agent_name) ??
- agentsResult.agents[0]
- if (!agent) {
- redirect(workflowTriggersPath(scope))
- }
-
- redirect(
- workflowTriggersPath(scope, {
- agentName: agent.name,
- type: parsed.data.type,
- })
- )
-}
-
-export async function selectWorkflowRunsFiltersAction(
- scope: WorkflowActionScope,
- formData: FormData
-) {
- const agentsResult = await listAgentsCachedQuery(undefined, scope.workspaceId)
- if (agentsResult.error || !agentsResult.agents || agentsResult.agents.length === 0) {
- redirect(workflowRunsPath(scope))
- }
-
- const parsed = workflowRunFiltersFormSchema.safeParse(Object.fromEntries(formData))
- if (!parsed.success) {
- redirect(workflowRunsPath(scope))
- }
-
- const agent =
- agentsResult.agents.find((currentAgent) => currentAgent.name === parsed.data.agent_name) ??
- agentsResult.agents[0]
- if (!agent) {
- redirect(workflowRunsPath(scope))
- }
-
- if (parsed.data.type === "schedule") {
- const schedulesResult = await listWorkflowSchedulesCachedQuery(agent.name, scope.workspaceId, {
- limit: 200,
- })
- if (schedulesResult.error || !schedulesResult.workflowSchedules) {
- redirect(workflowTriggersPath(scope, { agentName: agent.name, type: "schedule" }))
- }
-
- const schedule =
- schedulesResult.workflowSchedules.find(
- (currentSchedule) => currentSchedule.name === parsed.data.schedule_name
- ) ?? schedulesResult.workflowSchedules[0]
- if (!schedule) {
- redirect(workflowTriggersPath(scope, { agentName: agent.name, type: "schedule" }))
- }
-
- redirect(
- workflowRunsPath(scope, {
- agentName: agent.name,
- type: "schedule",
- workflowName: schedule.workflow_name,
- scheduleName: schedule.name,
- })
- )
- }
-
- const webhookTriggersResult = await listWorkflowWebhookTriggersCachedQuery(
- agent.name,
- scope.workspaceId,
- {
- limit: 200,
- }
- )
- if (webhookTriggersResult.error || !webhookTriggersResult.webhookTriggers) {
- redirect(workflowTriggersPath(scope, { agentName: agent.name, type: "webhook" }))
- }
-
- const webhookTrigger =
- webhookTriggersResult.webhookTriggers.find(
- (currentTrigger) =>
- currentTrigger.workflow_name === parsed.data.workflow_name &&
- currentTrigger.api_key_id === parsed.data.webhook_api_key_id
- ) ?? webhookTriggersResult.webhookTriggers[0]
- if (!webhookTrigger) {
- redirect(workflowTriggersPath(scope, { agentName: agent.name, type: "webhook" }))
- }
-
- redirect(
- workflowRunsPath(scope, {
- agentName: agent.name,
- type: "webhook",
- workflowName: webhookTrigger.workflow_name,
- webhookApiKeyId: webhookTrigger.api_key_id,
- })
- )
-}
-
-export async function selectWorkflowRunGraphFiltersAction(
- scope: WorkflowActionScope,
- formData: FormData
-) {
- const agentsResult = await listAgentsCachedQuery(undefined, scope.workspaceId)
- if (agentsResult.error || !agentsResult.agents || agentsResult.agents.length === 0) {
- redirect(workflowRunGraphPath(scope))
- }
-
- const parsed = workflowRunGraphFiltersFormSchema.safeParse(Object.fromEntries(formData))
- if (!parsed.success) {
- redirect(workflowRunGraphPath(scope))
- }
-
- const agent =
- agentsResult.agents.find((currentAgent) => currentAgent.name === parsed.data.agent_name) ??
- agentsResult.agents[0]
- if (!agent) {
- redirect(workflowRunGraphPath(scope))
- }
-
- const workflowsResult = await listWorkflowSummariesCachedQuery(agent.name, scope.workspaceId)
- if (workflowsResult.error || !workflowsResult.summaries) {
- redirect(workflowRunGraphPath(scope, { agentName: agent.name }))
- }
-
- const workflow =
- workflowsResult.summaries.find(
- (currentWorkflow) => currentWorkflow.workflow_name === parsed.data.workflow_name
- ) ?? workflowsResult.summaries[0]
- if (!workflow) {
- redirect(workflowRunGraphPath(scope, { agentName: agent.name }))
- }
-
- const runsResult = await listWorkflowRunsCachedQuery(
- agent.name,
- workflow.workflow_name,
- scope.workspaceId,
- {
- limit: 200,
- }
- )
- if (runsResult.error || !runsResult.workflowRuns) {
- redirect(
- workflowRunGraphPath(scope, {
- agentName: agent.name,
- workflowName: workflow.workflow_name,
- })
- )
- }
-
- const run =
- runsResult.workflowRuns.find((currentRun) => currentRun.name === parsed.data.run_name) ??
- runsResult.workflowRuns[0]
- if (!run) {
- redirect(
- workflowRunGraphPath(scope, {
- agentName: agent.name,
- workflowName: workflow.workflow_name,
- })
- )
- }
-
- redirect(
- workflowRunGraphPath(scope, {
- agentName: agent.name,
- workflowName: workflow.workflow_name,
- runName: run.name,
- })
- )
-}
-
-function workflowsPath(
- scope: WorkflowActionScope,
- { agentName, workflowName }: { agentName?: string; workflowName?: string } = {}
-): Route {
- const params = new URLSearchParams()
- if (agentName) {
- params.set("agent_name", agentName)
- }
- if (workflowName) {
- params.set("workflow_name", workflowName)
- }
-
- const query = params.toString()
- const path = `${scope.basePath}/workflows/graphs`
- return query === "" ? (path as Route) : (`${path}?${query}` as Route)
-}
-
-function workflowTriggersPath(
- scope: WorkflowActionScope,
- { agentName, type }: { agentName?: string; type?: "schedule" | "webhook" } = {}
-): Route {
- const params = new URLSearchParams()
- if (agentName) {
- params.set("agent_name", agentName)
- }
- if (type) {
- params.set("type", type)
- }
-
- const query = params.toString()
- const path = `${scope.basePath}/workflows/triggers`
- return query === "" ? (path as Route) : (`${path}?${query}` as Route)
-}
-
-function workflowRunsPath(
- scope: WorkflowActionScope,
- input?: {
- agentName: string
- type: "schedule" | "webhook"
- workflowName: string
- scheduleName?: string
- webhookApiKeyId?: string
- }
-): Route {
- const path = `${scope.basePath}/workflows/triggers/runs`
- if (!input) {
- return path as Route
- }
-
- const params = new URLSearchParams()
- params.set("agent_name", input.agentName)
- params.set("type", input.type)
- params.set("workflow_name", input.workflowName)
- if (input.scheduleName) {
- params.set("schedule_name", input.scheduleName)
- }
- if (input.webhookApiKeyId) {
- params.set("webhook_api_key_id", input.webhookApiKeyId)
- }
-
- return `${path}?${params.toString()}` as Route
-}
-
-function workflowRunGraphPath(
- scope: WorkflowActionScope,
- {
- agentName,
- workflowName,
- runName,
- }: {
- agentName?: string
- workflowName?: string
- runName?: string
- } = {}
-): Route {
- const params = new URLSearchParams()
- if (agentName) {
- params.set("agent_name", agentName)
- }
- if (workflowName) {
- params.set("workflow_name", workflowName)
- }
- if (runName) {
- params.set("run_name", runName)
- }
-
- const query = params.toString()
- const path = `${scope.basePath}/workflows/triggers/runs/graph`
- return query === "" ? (path as Route) : (`${path}?${query}` as Route)
-}
diff --git a/web/data/workflow.schema.ts b/web/data/workflow.schema.ts
deleted file mode 100644
index 8380bc30..00000000
--- a/web/data/workflow.schema.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { z } from "zod"
-
-export const workflowFiltersFormSchema = z.object({
- agent_name: z.string({ error: "Select an agent." }).min(1, "Select an agent."),
- workflow_name: z.string({ error: "Workflow name must be text." }).default(""),
-})
-
-export type WorkflowFiltersFormInput = z.input
-export type WorkflowFiltersFormValues = z.infer
-
-const workflowTriggerTypeSchema = z.enum(["schedule", "webhook"], {
- error: "Select a trigger type.",
-})
-
-export const workflowTriggerFiltersFormSchema = z.object({
- agent_name: z.string({ error: "Select an agent." }).min(1, "Select an agent."),
- type: workflowTriggerTypeSchema,
-})
-
-export type WorkflowTriggerFiltersFormValues = z.infer
-
-export const workflowRunFiltersFormSchema = z.object({
- agent_name: z.string({ error: "Select an agent." }).min(1, "Select an agent."),
- type: workflowTriggerTypeSchema,
- workflow_name: z.string({ error: "Workflow name must be text." }).default(""),
- schedule_name: z.string({ error: "Schedule name must be text." }).default(""),
- webhook_api_key_id: z.string({ error: "Webhook API key must be text." }).default(""),
-})
-
-export type WorkflowRunFiltersFormInput = z.input
-export type WorkflowRunFiltersFormValues = z.infer
-
-export const workflowRunGraphFiltersFormSchema = z.object({
- agent_name: z.string({ error: "Select an agent." }).min(1, "Select an agent."),
- workflow_name: z.string({ error: "Workflow name must be text." }).default(""),
- run_name: z.string({ error: "Run name must be text." }).default(""),
-})
-
-export type WorkflowRunGraphFiltersFormInput = z.input
-export type WorkflowRunGraphFiltersFormValues = z.infer
diff --git a/web/lib/page-selection.test.ts b/web/lib/page-selection.test.ts
new file mode 100644
index 00000000..1b2b8f90
--- /dev/null
+++ b/web/lib/page-selection.test.ts
@@ -0,0 +1,176 @@
+import assert from "node:assert/strict"
+import { describe, test } from "node:test"
+import {
+ encodeSelectionHistory,
+ readSelectionHistory,
+ rememberSelection,
+ restoreSelection,
+ selectionCookie,
+ selectionURL,
+ type SelectionHistory,
+} from "./page-selection"
+
+const scope = JSON.stringify(["user", "organization", "workspace"])
+const history: SelectionHistory = [
+ [scope, "workflows/graphs", { agent_name: "b", workflow_name: "b-two" }],
+ [scope, "workflows/graphs", { agent_name: "a", workflow_name: "a-two" }],
+ [
+ scope,
+ "workflows/triggers/runs",
+ { agent_name: "b", type: "webhook", workflow_name: "b-two", webhook_api_key_id: "key" },
+ ],
+ [
+ scope,
+ "workflows/triggers/runs",
+ { agent_name: "b", type: "schedule", workflow_name: "b-one", schedule_name: "daily" },
+ ],
+]
+
+describe("selection restoration", () => {
+ test("keeps pages, accounts, workspaces and parent histories independent", () => {
+ assert.deepEqual(restoreSelection(history, scope, "workflows/graphs", {}), {
+ agent_name: "b",
+ workflow_name: "b-two",
+ })
+ assert.deepEqual(restoreSelection(history, scope, "workflows/graphs", { agent_name: "a" }), {
+ agent_name: "a",
+ workflow_name: "a-two",
+ })
+ assert.equal(
+ restoreSelection(history, "another scope", "workflows/graphs", {}).agent_name,
+ undefined
+ )
+ assert.equal(restoreSelection(history, scope, "secrets", {}).agent_name, undefined)
+ assert.equal(
+ restoreSelection(history, scope, "workflows/graphs", { agent_name: "c" }).workflow_name,
+ undefined
+ )
+ })
+
+ test("explicit links win, including invalid and explicitly empty values", () => {
+ assert.equal(
+ restoreSelection(history, scope, "workflows/graphs", {
+ agent_name: "b",
+ workflow_name: "explicit",
+ }).workflow_name,
+ "explicit"
+ )
+ assert.equal(
+ restoreSelection(history, scope, "workflows/graphs", { agent_name: "" }).agent_name,
+ ""
+ )
+ assert.equal(
+ restoreSelection(history, scope, "workflows/graphs", { agent_name: "deleted" }).workflow_name,
+ undefined
+ )
+ })
+
+ test("restores the trigger kind before its composite identity", () => {
+ assert.deepEqual(restoreSelection(history, scope, "workflows/triggers/runs", {}), {
+ agent_name: "b",
+ type: "webhook",
+ workflow_name: "b-two",
+ webhook_api_key_id: "key",
+ })
+ assert.deepEqual(
+ restoreSelection(history, scope, "workflows/triggers/runs", {
+ agent_name: "b",
+ type: "schedule",
+ }),
+ { agent_name: "b", type: "schedule", workflow_name: "b-one", schedule_name: "daily" }
+ )
+ assert.equal(
+ restoreSelection(history, scope, "workflows/triggers/runs", {
+ agent_name: "b",
+ type: "webhook",
+ workflow_name: "explicit",
+ }).webhook_api_key_id,
+ undefined
+ )
+ })
+
+ test("replaces only the selected parent's child and removes confirmed missing branches", () => {
+ const updated = rememberSelection(
+ history,
+ scope,
+ "workflows/graphs",
+ { agent_name: "a", workflow_name: "a-three" },
+ { agent_name: "a", workflow_name: "a-three" }
+ )
+ assert.equal(restoreSelection(updated, scope, "workflows/graphs", {}).workflow_name, "a-three")
+ assert.equal(
+ restoreSelection(updated, scope, "workflows/graphs", { agent_name: "b" }).workflow_name,
+ "b-two"
+ )
+ const repaired = rememberSelection(
+ updated,
+ scope,
+ "workflows/graphs",
+ { agent_name: "b", workflow_name: "b-one" },
+ { agent_name: "a", workflow_name: "a-three" }
+ )
+ assert.equal(
+ restoreSelection(repaired, scope, "workflows/graphs", { agent_name: "a" }).workflow_name,
+ undefined
+ )
+ assert.equal(restoreSelection(repaired, scope, "workflows/triggers/runs", {}).type, "webhook")
+ })
+
+ test("empty options clear the invalid branch without inventing a selection", () => {
+ const next = rememberSelection(
+ history,
+ scope,
+ "workflows/graphs",
+ {},
+ { agent_name: "b", workflow_name: "b-two" }
+ )
+ assert.equal(
+ next.some(([, page, value]) => page === "workflows/graphs" && value.agent_name === "b"),
+ false
+ )
+ assert.equal(
+ next.some(([, page, value]) => page === "workflows/graphs" && value.agent_name === "a"),
+ true
+ )
+ })
+})
+
+describe("bounded browser storage", () => {
+ test("round trips identifiers and rejects malformed data at the boundary", () => {
+ assert.deepEqual(readSelectionHistory(encodeSelectionHistory(history)), history)
+ for (const raw of [
+ undefined,
+ "%",
+ "{}",
+ "%5B42%5D",
+ encodeURIComponent(JSON.stringify([[scope, "unknown", {}]])),
+ "x".repeat(4000),
+ ]) {
+ assert.deepEqual(readSelectionHistory(raw), [])
+ }
+ })
+
+ test("evicts oldest history using encoded bytes, retaining the newest fitting entry", () => {
+ const large: SelectionHistory = Array.from({ length: 100 }, (_, index) => [
+ scope,
+ "workflows/graphs",
+ { agent_name: `agent-${index}`, workflow_name: "世界".repeat(15) },
+ ])
+ const encoded = encodeSelectionHistory(large)
+ assert.ok(encoded.length + selectionCookie.length <= 3500)
+ const decoded = readSelectionHistory(encoded)
+ assert.ok(decoded.length > 0)
+ assert.ok(decoded.length < large.length)
+ assert.deepEqual(decoded[0], large[0])
+ assert.equal(large.length, 100)
+ })
+})
+
+test("selection URLs remove old descendants and cursors while preserving unrelated filters", () => {
+ const search = new URLSearchParams(
+ "agent_name=a&workflow_name=old&run_name=old&page_token=p&token_stack=s&telemetry_page_token=t&telemetry_token_stack=u&from=2026-09-01&sort_by=name"
+ )
+ const url = selectionURL("/page", search, { agent_name: "b", workflow_name: "new" }, true)
+ assert.equal(url, "/page?from=2026-09-01&sort_by=name&agent_name=b&workflow_name=new")
+ assert.equal(search.get("page_token"), "p")
+})
diff --git a/web/lib/page-selection.ts b/web/lib/page-selection.ts
new file mode 100644
index 00000000..34daac61
--- /dev/null
+++ b/web/lib/page-selection.ts
@@ -0,0 +1,168 @@
+import { z } from "zod"
+
+export const selectionCookie = "agentz-selection-v1"
+
+export const selectionPages = [
+ "workflows/graphs",
+ "workflows/triggers",
+ "workflows/triggers/runs",
+ "workflows/triggers/runs/graph",
+ "lens/traces",
+ "lens/mcp",
+ "lens/runtime-telemetry",
+ "lens/runtime-telemetry/file",
+ "lens/runtime-telemetry/network",
+ "secrets",
+] as const
+
+export type SelectionPage = (typeof selectionPages)[number]
+
+const selectionSchema = z.object({
+ agent_name: z.string().optional(),
+ workflow_name: z.string().optional(),
+ type: z.string().optional(),
+ schedule_name: z.string().optional(),
+ webhook_api_key_id: z.string().optional(),
+ run_name: z.string().optional(),
+ session_id: z.string().optional(),
+})
+
+export type PageSelection = z.infer
+export type SelectionField = keyof PageSelection
+
+const historySchema = z.array(z.tuple([z.string(), z.enum(selectionPages), selectionSchema]))
+export type SelectionHistory = z.infer
+
+/** Cookie data is the only untyped boundary; malformed preferences are disposable. */
+export function readSelectionHistory(value: string | undefined): SelectionHistory {
+ if (!value || value.length + selectionCookie.length > 3500) return []
+ try {
+ return historySchema.parse(JSON.parse(decodeURIComponent(value)))
+ } catch {
+ return []
+ }
+}
+
+/** Groups keep composite identities together and children under their own parent. */
+export function selectionGroups(page: SelectionPage, selection: PageSelection): SelectionField[][] {
+ switch (page) {
+ case "workflows/graphs":
+ return [["agent_name"], ["workflow_name"]]
+ case "workflows/triggers":
+ return [["agent_name"], ["type"]]
+ case "workflows/triggers/runs":
+ return [
+ ["agent_name"],
+ ["type"],
+ selection.type === "webhook"
+ ? ["workflow_name", "webhook_api_key_id"]
+ : ["schedule_name", "workflow_name"],
+ ]
+ case "workflows/triggers/runs/graph":
+ return [["agent_name"], ["workflow_name"], ["run_name"]]
+ case "lens/traces":
+ return [["agent_name"], ["session_id"]]
+ default:
+ return [["agent_name"]]
+ }
+}
+
+/** Explicit groups win; omitted children come from the most recent matching parent. */
+export function restoreSelection(
+ history: SelectionHistory,
+ scope: string,
+ page: SelectionPage,
+ requested: PageSelection
+): PageSelection {
+ const selection: PageSelection = { ...requested }
+ let candidates = history.filter(([key, route]) => key === scope && route === page)
+ // Resolve type before choosing the schedule/webhook identity group.
+ const groups = selectionGroups(page, {
+ ...selection,
+ type:
+ selection.type ??
+ candidates.find(
+ ([, , value]) => !selection.agent_name || value.agent_name === selection.agent_name
+ )?.[2].type,
+ })
+ for (const fields of groups) {
+ if (!fields.some((field) => selection[field] !== undefined)) {
+ const saved = candidates[0]?.[2]
+ for (const field of fields) selection[field] = saved?.[field]
+ }
+ candidates = candidates.filter(([, , saved]) =>
+ fields.every((field) => saved[field] === selection[field])
+ )
+ }
+ return selection
+}
+
+/** Keep one child choice per parent, repairing only branches validated by the page. */
+export function rememberSelection(
+ history: SelectionHistory,
+ scope: string,
+ page: SelectionPage,
+ selected: PageSelection,
+ requested: PageSelection
+): SelectionHistory {
+ const groups = selectionGroups(page, requested)
+ const invalid = groups.findIndex((fields) =>
+ fields.some((field) => requested[field] !== undefined && requested[field] !== selected[field])
+ )
+ const parents = selectionGroups(page, selected).slice(0, -1).flat()
+ const retained = history.filter(([key, route, saved]) => {
+ if (key !== scope || route !== page) return true
+ if (
+ invalid >= 0 &&
+ groups
+ .slice(0, invalid + 1)
+ .flat()
+ .every((field) => saved[field] === requested[field])
+ )
+ return false
+ return !parents.every((field) => saved[field] === selected[field])
+ })
+ if (selected.agent_name) retained.unshift([scope, page, selected])
+ return retained
+}
+
+/** Bound the entire cookie, including histories from other accounts and workspaces. */
+export function encodeSelectionHistory(history: SelectionHistory): string {
+ const entries = [...history]
+ let encoded = encodeURIComponent(JSON.stringify(entries))
+ while (entries.length && selectionCookie.length + encoded.length > 3500) {
+ // Preserve each page's latest default before keeping older parent histories.
+ const duplicate = entries.findLastIndex(([scope, page], index) =>
+ entries.slice(0, index).some(([key, route]) => key === scope && route === page)
+ )
+ entries.splice(duplicate > 0 ? duplicate : entries.length - 1, 1)
+ encoded = encodeURIComponent(JSON.stringify(entries))
+ }
+ return encoded
+}
+
+/** Selection changes invalidate cursors, while unrelated filters remain in the URL. */
+export function selectionURL(
+ pathname: string,
+ search: URLSearchParams,
+ selection: PageSelection,
+ resetPage: boolean
+): string {
+ const next = new URLSearchParams(search)
+ for (const field of Object.keys(selectionSchema.shape)) next.delete(field)
+ for (const [field, value] of Object.entries(selection)) {
+ if (value) next.set(field, value)
+ }
+ if (resetPage) {
+ for (const key of [
+ "page_token",
+ "token_stack",
+ "telemetry_page_token",
+ "telemetry_token_stack",
+ ]) {
+ next.delete(key)
+ }
+ }
+ const query = next.toString()
+ return query ? `${pathname}?${query}` : pathname
+}