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 ? ( select({ agent_name })} + disabled={agents.length === 0 || pending} > - export default function WorkflowsPage( props: PageProps<"/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/graphs"> ) { @@ -44,125 +41,45 @@ async function WorkspaceWorkflows({ notFound() } const parsed = workflowsSearchParamsSchema.parse(search) - const actionScope = { - basePath: `/orgs/${workspace.scope.organization.slug}/workspaces/${workspace.workspace.slug}`, - workspaceId: workspace.workspace.id, - } + const selection = resolvePageSelection(workspace, "workflows/graphs", parsed) return (
- }> - - - }> - + + + + + } + > +
) } -async function Filters({ - actionScope, - searchParams, -}: { - actionScope: { basePath: string; workspaceId: string } - searchParams: ResolvedSearchParams -}) { - const agents = listAgentsCachedQuery(undefined, actionScope.workspaceId) - const selectedAgentName = searchParams.agent_name - const selectedWorkflowName = searchParams.workflow_name - const agentsResult = await agents - if (agentsResult.error) { - return - } - - const selectedAgent = - agentsResult.agents.find((agent) => agent.name === selectedAgentName) ?? agentsResult.agents[0] - if (!selectedAgent) { - return ( +async function WorkflowContent({ selection }: { selection: Promise }) { + const { selected, requested, agents, workflows, workflow, error } = await selection + if (error) return + return ( + <> + - ) - } - - const workflowsResult = await listWorkflowSummariesCachedQuery( - selectedAgent.name, - actionScope.workspaceId - ) - const workflows = workflowsResult.summaries ?? [] - const selectedWorkflow = - workflows.find((workflow) => workflow.workflow_name === selectedWorkflowName) ?? workflows[0] - - return ( - - ) -} - -async function WorkflowContent({ - searchParams, - workspaceId, -}: { - searchParams: ResolvedSearchParams - workspaceId: string -}) { - const agents = listAgentsCachedQuery(undefined, workspaceId) - const selectedAgentName = searchParams.agent_name - const selectedWorkflowName = searchParams.workflow_name - const agentsResult = await agents - if (agentsResult.error) { - return - } - - const selectedAgent = - agentsResult.agents?.find((agent) => agent.name === selectedAgentName) ?? - agentsResult.agents?.[0] - if (!selectedAgent) { - return - } - - const workflowsResult = await listWorkflowSummariesCachedQuery(selectedAgent.name, workspaceId) - if (workflowsResult.error) { - return - } - - const selectedWorkflow = - workflowsResult.summaries?.find( - (workflow) => workflow.workflow_name === selectedWorkflowName - ) ?? workflowsResult.summaries?.[0] - if (!selectedWorkflow) { - return - } - - const workflowResult = await getWorkflowCachedQuery( - selectedAgent.name, - selectedWorkflow.workflow_name, - workspaceId - ) - if (workflowResult.error) { - return - } - if (!workflowResult.workflow) { - return - } - - return ( - + {!selected.agent_name ? ( + + ) : !workflow ? ( + + ) : ( + + )} + ) } diff --git a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/graphs/workflows-filters.tsx b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/graphs/workflows-filters.tsx index ac2b8a95..1bb3ec52 100644 --- a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/graphs/workflows-filters.tsx +++ b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/graphs/workflows-filters.tsx @@ -1,16 +1,8 @@ "use client" -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { useProgress } from "@bprogress/next" -import { Controller, useForm } from "react-hook-form" -import { BotIcon, Workflow } from "lucide-react" +import { BotIcon, WorkflowIcon } from "lucide-react" import type { Agent, WorkflowSummary } from "@/lib/gateway/client" -import { - workflowFiltersFormSchema, - type WorkflowFiltersFormInput, - type WorkflowFiltersFormValues, -} from "@/data/workflow.schema" +import { useSelectResource } from "@/components/page-selection" import { Select, SelectContent, @@ -20,123 +12,69 @@ import { SelectValue, } from "@/components/ui/select" -type WorkflowsFiltersProps = { - agents: Agent[] - selectedAgentName?: string - workflows: WorkflowSummary[] - selectedWorkflowName?: string - action: (formData: FormData) => void | Promise -} - export function WorkflowsFilters({ agents, selectedAgentName, workflows, selectedWorkflowName, - action, -}: WorkflowsFiltersProps) { - const [pending, startTransition] = React.useTransition() - const progress = useProgress() - const form = useForm({ - resolver: zodResolver(workflowFiltersFormSchema), - defaultValues: { - agent_name: selectedAgentName ?? "", - workflow_name: selectedWorkflowName ?? "", - }, - }) - - React.useEffect(() => { - if (pending) { - progress.start(undefined, 100) - return - } - - progress.stop() - }, [pending, progress]) - - function submitSelection(values: WorkflowFiltersFormValues) { - const formData = new FormData() - formData.set("agent_name", values.agent_name) - if (values.workflow_name) { - formData.set("workflow_name", values.workflow_name) - } - - startTransition(() => { - void action(formData) - }) - } +}: { + agents: Agent[] + selectedAgentName?: string + workflows: WorkflowSummary[] + selectedWorkflowName?: string +}) { + const { pending, select } = useSelectResource() return ( -
+
- ( - - )} - /> - ( - - )} - /> + +
- +
) } diff --git a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/page.tsx b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/page.tsx index 907c8911..5bd73032 100644 --- a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/page.tsx +++ b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/page.tsx @@ -5,7 +5,6 @@ import { AdministrationLoadingState, AdministrationPageHeader } from "@/componen import { Alert, AlertDescription } from "@/components/ui/alert" import * as z from "zod" import { Skeleton } from "@/components/ui/skeleton" -import { listAgentsCachedQuery } from "@/data/agent.queries" import { listWebhookAPIKeyDisplaysCachedQuery } from "@/data/api-key.queries" import { triggerWorkflowRunAction } from "@/data/workflow-run.actions" import { @@ -16,10 +15,9 @@ import { } from "@/data/workflow-schedule.actions" import { listWorkflowSchedulesCachedQuery } from "@/data/workflow-schedule.queries" import { listWorkflowWebhookTriggersCachedQuery } from "@/data/workflow-trigger.queries" -import { - selectWorkflowTriggerFiltersAction, - type WorkflowActionScope, -} from "@/data/workflow.actions" +import { resolvePageSelection, type ResolvedPageSelection } from "@/data/page-selection" +import { RememberPageSelection } from "@/components/page-selection" +import type { WorkflowActionScope } from "@/data/types" import { listWorkflowSummariesCachedQuery } from "@/data/workflow.queries" import { getWorkspaceScope } from "@/data/workspaces" import { NewScheduleButton } from "./new-schedule-button" @@ -64,6 +62,7 @@ async function WorkspaceTriggers({ notFound() } const parsed = workflowTriggersSearchParamsSchema.parse(search) + const selection = resolvePageSelection(workspace, "workflows/triggers", parsed) const actionScope: WorkflowActionScope = { basePath: `/orgs/${workspace.scope.organization.slug}/workspaces/${workspace.workspace.slug}`, workspaceId: workspace.workspace.id, @@ -73,87 +72,58 @@ async function WorkspaceTriggers({
}> - + }> + } title="Triggers" /> }> - + }> - +
) } -async function Filters({ - actionScope, - searchParams, -}: { - actionScope: WorkflowActionScope - searchParams: ResolvedSearchParams -}) { - const requestedAgentName = searchParams.agent_name - const requestedType = searchParams.type - const selectedType = requestedType === "webhook" ? "webhook" : "schedule" - const agentsResult = await listAgentsCachedQuery(undefined, actionScope.workspaceId) - if (agentsResult.error) { - return - } - - const selectedAgent = - agentsResult.agents.find((agent) => agent.name === requestedAgentName) ?? agentsResult.agents[0] - if (!selectedAgent) { - return ( +async function Filters({ selection }: { selection: Promise }) { + const state = await selection + if (state.error) return + return ( + <> + - ) - } - - return ( - + ) } async function Triggers({ actionScope, searchParams, + selection, }: { actionScope: WorkflowActionScope searchParams: ResolvedSearchParams + selection: Promise }) { - const requestedAgentName = searchParams.agent_name - const requestedType = searchParams.type - const selectedType = requestedType === "webhook" ? "webhook" : "schedule" - const pageToken = searchParams.page_token - const agentsResult = await listAgentsCachedQuery(undefined, actionScope.workspaceId) - if (agentsResult.error) { - return - } - - const selectedAgent = - agentsResult.agents?.find((agent) => agent.name === requestedAgentName) ?? - agentsResult.agents?.[0] - if (!selectedAgent) { - return - } + const { selected, requested, error } = await selection + if (error) return + if (!selected.agent_name) return + const selectedType = selected.type + const pageToken = + selected.agent_name === requested.agent_name && selected.type === requested.type + ? searchParams.page_token + : undefined if (selectedType === "webhook") { const triggersResult = await listWorkflowWebhookTriggersCachedQuery( - selectedAgent.name, + selected.agent_name, actionScope.workspaceId, { limit: 50, page_token: pageToken } ) @@ -179,7 +149,7 @@ async function Triggers({ return ( }) { - const requestedAgentName = searchParams.agent_name - const requestedType = searchParams.type - const selectedType = requestedType === "webhook" ? "webhook" : "schedule" - if (selectedType === "webhook") { - return null - } - - const agentsResult = await listAgentsCachedQuery(undefined, actionScope.workspaceId) - if (agentsResult.error) { - return null - } - - const selectedAgent = - agentsResult.agents?.find((agent) => agent.name === requestedAgentName) ?? - agentsResult.agents?.[0] - if (!selectedAgent) { - return null - } + const { selected, error } = await selection + if (error || !selected.agent_name || selected.type === "webhook") return null const workflowsResult = await listWorkflowSummariesCachedQuery( - selectedAgent.name, + selected.agent_name, actionScope.workspaceId ) return ( ) } @@ -293,10 +247,6 @@ function TableSkeleton() { ) } -function HeaderButtonSkeleton() { - return -} - function ErrorPanel({ message }: { message: string }) { return ( diff --git a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/runs/graph/page.tsx b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/runs/graph/page.tsx index ef3f175c..ea25d5b5 100644 --- a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/runs/graph/page.tsx +++ b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/runs/graph/page.tsx @@ -5,13 +5,8 @@ import { AdministrationPageHeader } from "@/components/administration" import { Alert, AlertDescription } from "@/components/ui/alert" import * as z from "zod" import { Skeleton } from "@/components/ui/skeleton" -import { listAgentsCachedQuery } from "@/data/agent.queries" -import { - selectWorkflowRunGraphFiltersAction, - type WorkflowActionScope, -} from "@/data/workflow.actions" -import { getWorkflowCachedQuery, listWorkflowSummariesCachedQuery } from "@/data/workflow.queries" -import { getWorkflowRunCachedQuery, listWorkflowRunsCachedQuery } from "@/data/workflow-run.queries" +import { resolvePageSelection } from "@/data/page-selection" +import { RememberPageSelection } from "@/components/page-selection" import { getWorkspaceScope } from "@/data/workspaces" import { searchParamStringSchema } from "@/lib/search-params" import { WorkflowRunGraphFilters } from "./workflow-run-graph-filters" @@ -50,130 +45,35 @@ async function WorkflowRunGraphContent({ notFound() } const parsed = workflowRunGraphSearchParamsSchema.parse(search) - const actionScope: WorkflowActionScope = { - basePath: `/orgs/${workspace.scope.organization.slug}/workspaces/${workspace.workspace.slug}`, - workspaceId: workspace.workspace.id, - } - - const agentsResult = await listAgentsCachedQuery(undefined, actionScope.workspaceId) - if (agentsResult.error) { - return - } - - const selectedAgent = - agentsResult.agents.find((agent) => agent.name === parsed.agent_name) ?? agentsResult.agents[0] - if (!selectedAgent) { - return ( - <> - - - - ) - } - - const workflowsResult = await listWorkflowSummariesCachedQuery( - selectedAgent.name, - actionScope.workspaceId - ) - if (workflowsResult.error) { - return - } - - const selectedWorkflow = - workflowsResult.summaries.find((workflow) => workflow.workflow_name === parsed.workflow_name) ?? - workflowsResult.summaries[0] - if (!selectedWorkflow) { - return ( - <> - - - - ) - } - - const runsResult = await listWorkflowRunsCachedQuery( - selectedAgent.name, - selectedWorkflow.workflow_name, - actionScope.workspaceId, - { - limit: 200, - } - ) - if (runsResult.error) { - return - } - - const selectedRun = - runsResult.workflowRuns.find((run) => run.name === parsed.run_name) ?? - runsResult.workflowRuns[0] - if (!selectedRun) { - return ( - <> - - - - ) - } - - const [workflowResult, runResult] = await Promise.all([ - getWorkflowCachedQuery( - selectedAgent.name, - selectedWorkflow.workflow_name, - actionScope.workspaceId - ), - getWorkflowRunCachedQuery( - selectedAgent.name, - selectedWorkflow.workflow_name, - selectedRun.name, - actionScope.workspaceId - ), - ]) - if (workflowResult.error) { - return - } - if (runResult.error) { - return - } - if (!workflowResult.workflow) { - return - } - + const state = await resolvePageSelection(workspace, "workflows/triggers/runs/graph", parsed) + if (state.error) return + const { selected, workflow, workflowRun } = state return ( <> + - + {!selected.agent_name ? ( + + ) : !selected.workflow_name ? ( + + ) : !workflow || !workflowRun ? ( + + ) : ( + + )} ) } diff --git a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/runs/graph/workflow-run-graph-filters.tsx b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/runs/graph/workflow-run-graph-filters.tsx index cab58fdd..28765eed 100644 --- a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/runs/graph/workflow-run-graph-filters.tsx +++ b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/runs/graph/workflow-run-graph-filters.tsx @@ -1,16 +1,8 @@ "use client" -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { useProgress } from "@bprogress/next" -import { BotIcon, GitBranchIcon, WorkflowIcon } from "lucide-react" -import { Controller, useForm } from "react-hook-form" -import type { Agent, WorkflowRunSummary, WorkflowSummary } from "@/lib/gateway/client" -import { - workflowRunGraphFiltersFormSchema, - type WorkflowRunGraphFiltersFormInput, - type WorkflowRunGraphFiltersFormValues, -} from "@/data/workflow.schema" +import { BotIcon, WorkflowIcon, GitBranchIcon } from "lucide-react" +import type { Agent, WorkflowSummary, WorkflowRunSummary } from "@/lib/gateway/client" +import { useSelectResource } from "@/components/page-selection" import { Select, SelectContent, @@ -20,179 +12,97 @@ import { SelectValue, } from "@/components/ui/select" -type WorkflowRunGraphFiltersProps = { - action: (formData: FormData) => void | Promise - agents: Agent[] - selectedAgentName?: string - selectedRunName?: string - selectedWorkflowName?: string - workflowRuns: WorkflowRunSummary[] - workflows: WorkflowSummary[] -} - export function WorkflowRunGraphFilters({ - action, agents, selectedAgentName, - selectedRunName, + workflows, selectedWorkflowName, workflowRuns, - workflows, -}: WorkflowRunGraphFiltersProps) { - const [pending, startTransition] = React.useTransition() - const progress = useProgress() - const form = useForm< - WorkflowRunGraphFiltersFormInput, - unknown, - WorkflowRunGraphFiltersFormValues - >({ - resolver: zodResolver(workflowRunGraphFiltersFormSchema), - defaultValues: { - agent_name: selectedAgentName ?? "", - workflow_name: selectedWorkflowName ?? "", - run_name: selectedRunName ?? "", - }, - }) - const { reset } = form - - React.useEffect(() => { - reset({ - agent_name: selectedAgentName ?? "", - workflow_name: selectedWorkflowName ?? "", - run_name: selectedRunName ?? "", - }) - }, [reset, selectedAgentName, selectedRunName, selectedWorkflowName]) - - React.useEffect(() => { - if (pending) { - progress.start(undefined, 100) - return - } - - progress.stop() - }, [pending, progress]) - - function submitSelection(values: WorkflowRunGraphFiltersFormValues) { - const formData = new FormData() - formData.set("agent_name", values.agent_name) - if (values.workflow_name) { - formData.set("workflow_name", values.workflow_name) - } - if (values.run_name) { - formData.set("run_name", values.run_name) - } - - startTransition(() => { - void action(formData) - }) - } + selectedRunName, +}: { + agents: Agent[] + selectedAgentName?: string + workflows: WorkflowSummary[] + selectedWorkflowName?: string + workflowRuns: WorkflowRunSummary[] + selectedRunName?: string +}) { + const { pending, select } = useSelectResource() return ( -
+
- ( - - )} - /> - ( - - )} - /> - ( - - )} - /> + + +
- +
) } diff --git a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/runs/page.tsx b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/runs/page.tsx index d9d0bf8f..7e926a4e 100644 --- a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/runs/page.tsx +++ b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/runs/page.tsx @@ -5,13 +5,12 @@ import { AdministrationPageHeader } from "@/components/administration" import { Alert, AlertDescription } from "@/components/ui/alert" import * as z from "zod" import { Skeleton } from "@/components/ui/skeleton" -import { listAgentsCachedQuery } from "@/data/agent.queries" import { listWebhookAPIKeyDisplaysCachedQuery } from "@/data/api-key.queries" -import { selectWorkflowRunsFiltersAction, type WorkflowActionScope } from "@/data/workflow.actions" +import { resolvePageSelection, type ResolvedPageSelection } from "@/data/page-selection" +import { RememberPageSelection } from "@/components/page-selection" +import type { WorkflowActionScope } from "@/data/types" import { deleteWorkflowRunAction } from "@/data/workflow-run.actions" import { listWorkflowRunsCachedQuery } from "@/data/workflow-run.queries" -import { listWorkflowSchedulesCachedQuery } from "@/data/workflow-schedule.queries" -import { listWorkflowWebhookTriggersCachedQuery } from "@/data/workflow-trigger.queries" import { getWorkspaceScope } from "@/data/workspaces" import { RunsFilters } from "./runs-filters" import { RunsTable } from "./runs-table" @@ -62,6 +61,7 @@ async function WorkflowRunsContent({ notFound() } const parsed = workflowRunsSearchParamsSchema.parse(search) + const selection = resolvePageSelection(workspace, "workflows/triggers/runs", parsed) const actionScope: WorkflowActionScope = { basePath: `/orgs/${workspace.scope.organization.slug}/workspaces/${workspace.workspace.slug}`, workspaceId: workspace.workspace.id, @@ -70,237 +70,99 @@ async function WorkflowRunsContent({ return ( <> }> - + }> - + ) } async function Filters({ - actionScope, - searchParams, + workspaceId, + selection, }: { - actionScope: WorkflowActionScope - searchParams: ResolvedSearchParams + workspaceId: string + selection: Promise }) { - const requestedAgentName = searchParams.agent_name - const requestedType = searchParams.type - const selectedType = requestedType === "webhook" ? "webhook" : "schedule" - const requestedWorkflowName = searchParams.workflow_name - const requestedScheduleName = searchParams.schedule_name - const requestedWebhookAPIKeyID = searchParams.webhook_api_key_id - const agentsResult = await listAgentsCachedQuery(undefined, actionScope.workspaceId) - if (agentsResult.error) { - return - } - - const selectedAgent = - agentsResult.agents.find((agent) => agent.name === requestedAgentName) ?? agentsResult.agents[0] - if (!selectedAgent) { - return ( - - ) - } - - if (selectedType === "webhook") { - const triggersResult = await listWorkflowWebhookTriggersCachedQuery( - selectedAgent.name, - actionScope.workspaceId, - { limit: 200 } - ) - if (triggersResult.error) { - return - } - - const webhookKeyDisplaysByID = await listWebhookAPIKeyDisplaysCachedQuery( - actionScope.workspaceId - ) - - const selectedTrigger = - triggersResult.webhookTriggers.find( - (trigger) => - trigger.workflow_name === requestedWorkflowName && - trigger.api_key_id === requestedWebhookAPIKeyID - ) ?? triggersResult.webhookTriggers[0] - - return ( + const state = await selection + if (state.error) return + const displays = + state.selected.type === "webhook" ? await listWebhookAPIKeyDisplaysCachedQuery(workspaceId) : {} + return ( + <> + { - const apiKey = webhookKeyDisplaysByID[trigger.api_key_id] - return { - apiKeyId: trigger.api_key_id, - label: `${trigger.workflow_name} - ${apiKey?.name || apiKey?.display || "Deleted key"}`, - workflowName: trigger.workflow_name, - } - })} + agents={state.agents} + schedules={state.schedules} + selectedAgentName={state.selected.agent_name} + selectedType={state.selected.type ?? "schedule"} + selectedScheduleName={state.selected.schedule_name} + selectedWorkflowName={state.selected.workflow_name} + selectedWebhookAPIKeyID={state.selected.webhook_api_key_id} + webhookTriggers={state.webhookTriggers.map((trigger) => ({ + ...trigger, + label: `${trigger.workflow_name} - ${displays[trigger.api_key_id]?.name || displays[trigger.api_key_id]?.display || "Deleted key"}`, + }))} /> - ) - } - - const schedulesResult = await listWorkflowSchedulesCachedQuery( - selectedAgent.name, - actionScope.workspaceId, - { limit: 200 } - ) - if (schedulesResult.error) { - return - } - - const selectedSchedule = - schedulesResult.workflowSchedules.find((schedule) => schedule.name === requestedScheduleName) ?? - schedulesResult.workflowSchedules[0] - - return ( - + ) } async function Runs({ actionScope, searchParams, + selection, }: { actionScope: WorkflowActionScope searchParams: ResolvedSearchParams + selection: Promise }) { - const requestedAgentName = searchParams.agent_name - const requestedType = searchParams.type - const selectedType = requestedType === "webhook" ? "webhook" : "schedule" - const requestedWorkflowName = searchParams.workflow_name - const requestedScheduleName = searchParams.schedule_name - const requestedWebhookAPIKeyID = searchParams.webhook_api_key_id - const pageToken = searchParams.page_token - const agentsResult = await listAgentsCachedQuery(undefined, actionScope.workspaceId) - if (agentsResult.error) { - return - } - - const selectedAgent = - agentsResult.agents?.find((agent) => agent.name === requestedAgentName) ?? - agentsResult.agents?.[0] - if (!selectedAgent) { - return - } - - if (selectedType === "webhook") { - const triggersResult = await listWorkflowWebhookTriggersCachedQuery( - selectedAgent.name, - actionScope.workspaceId, - { limit: 200 } - ) - if (triggersResult.error || !triggersResult.webhookTriggers) { - return ( - - ) - } - - const selectedTrigger = - triggersResult.webhookTriggers.find( - (trigger) => - trigger.workflow_name === requestedWorkflowName && - trigger.api_key_id === requestedWebhookAPIKeyID - ) ?? triggersResult.webhookTriggers[0] - if (!selectedTrigger) { - return - } - - const result = await listWorkflowRunsCachedQuery( - selectedAgent.name, - selectedTrigger.workflow_name, - actionScope.workspaceId, - { - limit: 25, - page_token: pageToken, - trigger_type: "Webhook", - webhook_api_key_id: selectedTrigger.api_key_id, - } - ) - if (result.error) { - return - } - + const { selected, requested, error } = await selection + if (error) return + if (!selected.agent_name) return + if (!selected.workflow_name) return ( - ) - } - - const schedulesResult = await listWorkflowSchedulesCachedQuery( - selectedAgent.name, - actionScope.workspaceId, - { limit: 200 } - ) - if (schedulesResult.error || !schedulesResult.workflowSchedules) { - return - } - - const selectedSchedule = - schedulesResult.workflowSchedules.find((schedule) => schedule.name === requestedScheduleName) ?? - schedulesResult.workflowSchedules[0] - if (!selectedSchedule) { - return - } - + const pageToken = + selected.agent_name === requested.agent_name && + selected.type === requested.type && + selected.workflow_name === requested.workflow_name && + selected.schedule_name === requested.schedule_name && + selected.webhook_api_key_id === requested.webhook_api_key_id + ? searchParams.page_token + : undefined const result = await listWorkflowRunsCachedQuery( - selectedAgent.name, - selectedSchedule.workflow_name, + selected.agent_name, + selected.workflow_name, actionScope.workspaceId, { limit: 25, page_token: pageToken, - schedule_name: selectedSchedule.name, - trigger_type: "Schedule", + ...(selected.type === "webhook" + ? { trigger_type: "Webhook", webhook_api_key_id: selected.webhook_api_key_id } + : { trigger_type: "Schedule", schedule_name: selected.schedule_name }), } ) - if (result.error) { - return - } - + if (result.error) return return ( ) } diff --git a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/runs/runs-filters.tsx b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/runs/runs-filters.tsx index 96de54b3..a59cf089 100644 --- a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/runs/runs-filters.tsx +++ b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/runs/runs-filters.tsx @@ -1,16 +1,8 @@ "use client" -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { useProgress } from "@bprogress/next" -import { Controller, useForm } from "react-hook-form" -import { BotIcon, CalendarSync, Webhook, Workflow } from "lucide-react" -import type { Agent, WorkflowSchedule } from "@/lib/gateway/client" -import { - workflowRunFiltersFormSchema, - type WorkflowRunFiltersFormInput, - type WorkflowRunFiltersFormValues, -} from "@/data/workflow.schema" +import { BotIcon, CalendarSync, Webhook } from "lucide-react" +import type { Agent, WorkflowSchedule, WorkflowWebhookTrigger } from "@/lib/gateway/client" +import { useSelectResource } from "@/components/page-selection" import { Select, SelectContent, @@ -20,24 +12,6 @@ import { SelectValue, } from "@/components/ui/select" -type WebhookTriggerOption = { - apiKeyId: string - label: string - workflowName: string -} - -type RunsFiltersProps = { - agents: Agent[] - selectedAgentName?: string - selectedType: "schedule" | "webhook" - schedules: WorkflowSchedule[] - selectedScheduleName?: string - selectedWorkflowName?: string - selectedWebhookAPIKeyID?: string - webhookTriggers: WebhookTriggerOption[] - action: (formData: FormData) => void | Promise -} - export function RunsFilters({ agents, selectedAgentName, @@ -47,210 +21,135 @@ export function RunsFilters({ selectedWorkflowName, selectedWebhookAPIKeyID, webhookTriggers, - action, -}: RunsFiltersProps) { - const [pending, startTransition] = React.useTransition() - const progress = useProgress() - const form = useForm({ - resolver: zodResolver(workflowRunFiltersFormSchema), - defaultValues: { - agent_name: selectedAgentName ?? "", - type: selectedType, - workflow_name: selectedWorkflowName ?? "", - schedule_name: selectedScheduleName ?? "", - webhook_api_key_id: selectedWebhookAPIKeyID ?? "", - }, - }) - - React.useEffect(() => { - if (pending) { - progress.start(undefined, 100) - return - } - - progress.stop() - }, [pending, progress]) - - function submitSelection(values: WorkflowRunFiltersFormValues) { - const formData = new FormData() - formData.set("agent_name", values.agent_name) - formData.set("type", values.type) - if (values.workflow_name) { - formData.set("workflow_name", values.workflow_name) - } - if (values.schedule_name) { - formData.set("schedule_name", values.schedule_name) - } - if (values.webhook_api_key_id) { - formData.set("webhook_api_key_id", values.webhook_api_key_id) - } - - startTransition(() => { - void action(formData) - }) - } +}: { + agents: Agent[] + selectedAgentName?: string + selectedType: string + schedules: WorkflowSchedule[] + selectedScheduleName?: string + selectedWorkflowName?: string + selectedWebhookAPIKeyID?: string + webhookTriggers: (WorkflowWebhookTrigger & { label: string })[] +}) { + const { pending, select } = useSelectResource() return ( -
+
- ( - - )} - /> - ( - - )} - /> - {selectedType === "schedule" ? ( - ( - - )} - /> - ) : ( + + + {selectedType === "webhook" ? ( + ) : ( + )}
- +
) } diff --git a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/triggers-filters.tsx b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/triggers-filters.tsx index f888b2af..4fb107c0 100644 --- a/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/triggers-filters.tsx +++ b/web/app/(scoped)/orgs/[orgSlug]/workspaces/[workspaceSlug]/workflows/triggers/triggers-filters.tsx @@ -1,15 +1,8 @@ "use client" -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { useProgress } from "@bprogress/next" -import { Controller, useForm } from "react-hook-form" import { BotIcon, CalendarSync, Webhook } from "lucide-react" import type { Agent } from "@/lib/gateway/client" -import { - workflowTriggerFiltersFormSchema, - type WorkflowTriggerFiltersFormValues, -} from "@/data/workflow.schema" +import { useSelectResource } from "@/components/page-selection" import { Select, SelectContent, @@ -19,122 +12,67 @@ import { SelectValue, } from "@/components/ui/select" -type TriggersFiltersProps = { - action: (formData: FormData) => void | Promise - agents: Agent[] - selectedAgentName?: string - selectedType: WorkflowTriggerFiltersFormValues["type"] -} - export function TriggersFilters({ - action, agents, selectedAgentName, selectedType, -}: TriggersFiltersProps) { - const [pending, startTransition] = React.useTransition() - const progress = useProgress() - const form = useForm({ - resolver: zodResolver(workflowTriggerFiltersFormSchema), - defaultValues: { - agent_name: selectedAgentName ?? "", - type: selectedType, - }, - }) - - React.useEffect(() => { - if (pending) { - progress.start(undefined, 100) - return - } - - progress.stop() - }, [pending, progress]) - - function submitSelection(values: WorkflowTriggerFiltersFormValues) { - const formData = new FormData() - formData.set("agent_name", values.agent_name) - formData.set("type", values.type) - - startTransition(() => { - void action(formData) - }) - } +}: { + agents: Agent[] + selectedAgentName?: string + selectedType: string +}) { + const { pending, select } = useSelectResource() return ( -
+
- ( - - )} - /> - ( - - )} - /> + +
- +
) } diff --git a/web/components/blocks/sidebar/lens.tsx b/web/components/blocks/sidebar/lens.tsx index 591a4ac8..a0312028 100644 --- a/web/components/blocks/sidebar/lens.tsx +++ b/web/components/blocks/sidebar/lens.tsx @@ -1,7 +1,7 @@ "use client" import type { Route } from "next" -import Link from "next/link" +import { SelectionLink } from "@/components/page-selection" import { usePathname } from "next/navigation" import { Cable, ChevronRightIcon, RouteIcon, Search, Server } from "lucide-react" import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" @@ -56,10 +56,10 @@ export function NavLens({ rootPath }: { rootPath: string }) { return ( - + ) diff --git a/web/components/blocks/sidebar/navigation-link.tsx b/web/components/blocks/sidebar/navigation-link.tsx index be9950e5..34635d76 100644 --- a/web/components/blocks/sidebar/navigation-link.tsx +++ b/web/components/blocks/sidebar/navigation-link.tsx @@ -1,7 +1,7 @@ "use client" import type { Route } from "next" -import Link from "next/link" +import { SelectionLink } from "@/components/page-selection" import { usePathname } from "next/navigation" import type { ReactNode } from "react" import { SidebarMenuButton, useSidebar } from "@/components/ui/sidebar" @@ -32,14 +32,14 @@ export function SidebarNavigationLink({ return ( - setOpenMobile(false)} > {children} {label} - + ) } diff --git a/web/components/page-selection.tsx b/web/components/page-selection.tsx new file mode 100644 index 00000000..e3121691 --- /dev/null +++ b/web/components/page-selection.tsx @@ -0,0 +1,208 @@ +"use client" + +import { + createContext, + useContext, + useEffect, + useEffectEvent, + useState, + useRef, + useTransition, + type ComponentProps, + type ReactNode, +} from "react" +import type { Route } from "next" +import Link from "next/link" +import { usePathname, useSearchParams } from "next/navigation" +import { useRouter } from "@bprogress/next/app" +import { + encodeSelectionHistory, + readSelectionHistory, + rememberSelection, + restoreSelection, + selectionCookie, + selectionGroups, + selectionPages, + selectionURL, + type PageSelection, + type SelectionHistory, + type SelectionPage, +} from "@/lib/page-selection" + +type SelectionContextValue = { + scope: string + basePath: string + history: SelectionHistory + read: () => SelectionHistory + remember: (page: SelectionPage, selected: PageSelection, requested: PageSelection) => void +} + +const SelectionContext = createContext(null) + +/** Share defaults with navigation links without changing the active page in other tabs. */ +export function PageSelectionProvider({ + scope, + basePath, + initialHistory, + children, +}: { + scope: string + basePath: string + initialHistory: SelectionHistory + children: ReactNode +}) { + const [history, setHistory] = useState(initialHistory) + const channel = useRef(null) + const storageUnavailable = useRef(false) + + function read() { + if (storageUnavailable.current) return history + try { + const value = document.cookie + .split("; ") + .find((entry) => entry.startsWith(`${selectionCookie}=`)) + ?.slice(selectionCookie.length + 1) + return value ? readSelectionHistory(value) : history + } catch { + return history + } + } + + const refresh = useEffectEvent(() => setHistory(read())) + useEffect(() => { + const update = () => refresh() + try { + channel.current = new BroadcastChannel(selectionCookie) + channel.current.addEventListener("message", update) + } catch { + // Focus still refreshes link defaults when cross-tab messaging is unavailable. + } + window.addEventListener("focus", update) + update() + return () => { + channel.current?.close() + channel.current = null + window.removeEventListener("focus", update) + } + }, []) + + function remember(page: SelectionPage, selected: PageSelection, requested: PageSelection) { + const next = rememberSelection(read(), scope, page, selected, requested) + const value = encodeSelectionHistory(next) + try { + document.cookie = `${selectionCookie}=${value}; Path=/; Max-Age=15552000; SameSite=Lax${location.protocol === "https:" ? "; Secure" : ""}` + storageUnavailable.current = !document.cookie + .split("; ") + .includes(`${selectionCookie}=${value}`) + channel.current?.postMessage(null) + } catch { + // Keep this tab usable when browser persistence is disabled. + storageUnavailable.current = true + } + setHistory(next) + } + + return ( + + {children} + + ) +} + +/** Record only committed, validated server selections; prefetch never writes preferences. */ +export function RememberPageSelection({ + selected, + requested, +}: { + selected: PageSelection + requested: PageSelection +}) { + const context = useContext(SelectionContext) + const pathname = usePathname() + const selectedKey = JSON.stringify(selected) + const requestedKey = JSON.stringify(requested) + const commit = useEffectEvent(() => { + if (!context || document.visibilityState === "hidden") return + const page = selectionPages.find((page) => pathname === `${context.basePath}/${page}`) + if (!page || location.pathname !== pathname) return + const search = new URLSearchParams(location.search) + if ( + Object.entries(requested).some(([key, value]) => search.has(key) && search.get(key) !== value) + ) + return + context.remember(page, selected, requested) + const changed = selectionGroups(page, requested) + .flat() + .some((key) => requested[key] !== selected[key]) + const href = selectionURL(pathname, search, selected, changed) + location.hash + if (`${location.pathname}${location.search}${location.hash}` !== href) + window.history.replaceState(null, "", href) + }) + useEffect(() => { + commit() + }, [pathname, selectedKey, requestedKey]) + return null +} + +/** Keep prefetched URLs and the eventual click aligned with the latest defaults. */ +export function SelectionLink({ + href, + onNavigate, + ...props +}: Omit, "href"> & { href: Route }) { + const context = useContext(SelectionContext) + const router = useRouter() + function destination(history: SelectionHistory | undefined): Route { + if (!context || !history) return href + const url = new URL(href, "http://selection.local") + const page = selectionPages.find((page) => url.pathname === `${context.basePath}/${page}`) + if (!page) return href + const requested: PageSelection = {} + for (const field of selectionGroups(page, { + type: url.searchParams.get("type") ?? undefined, + }).flat()) { + const value = url.searchParams.get(field) + if (value !== null) requested[field] = value + } + const selected = restoreSelection(history, context.scope, page, requested) + return (selectionURL(url.pathname, url.searchParams, selected, false) + url.hash) as Route + } + const target = destination(context?.history) + return ( + { + onNavigate?.(event) + const latest = destination(context?.read()) + if (latest !== target) { + event.preventDefault() + router.push(latest) + } + }} + /> + ) +} + +/** Resource changes are GET navigation; the server validates before rendering any content. */ +export function useSelectResource(replace = false) { + const context = useContext(SelectionContext) + const pathname = usePathname() + const search = useSearchParams() + const router = useRouter() + const [pending, startTransition] = useTransition() + + function select(requested: PageSelection) { + const page = + context && selectionPages.find((page) => pathname === `${context.basePath}/${page}`) + const selected = + context && page ? restoreSelection(context.read(), context.scope, page, requested) : requested + const href = selectionURL(pathname, search, selected, true) + startTransition(() => { + if (replace) router.replace(href) + else router.push(href) + }) + } + + return { pending, select } +} diff --git a/web/components/route-tabs.tsx b/web/components/route-tabs.tsx index f4a040fe..587f9c9e 100644 --- a/web/components/route-tabs.tsx +++ b/web/components/route-tabs.tsx @@ -1,7 +1,7 @@ "use client" import type { Route } from "next" -import Link from "next/link" +import { SelectionLink } from "@/components/page-selection" import { usePathname, useSearchParams } from "next/navigation" import { tabsListVariants } from "@/components/ui/tabs" import { cn } from "@/lib/utils" @@ -53,7 +53,7 @@ export function RouteTabs({ label, tabs }: { label: string; tabs: readonly Route {tab.label} ) : ( - {tab.label} - + ) )}
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 +}