From cf4c74b491284b832c69aa9c2cef8687980dfe9e Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 22 Jul 2026 16:14:58 +0200 Subject: [PATCH 01/14] feat(api): align plan catalog with new pricing --- api/ee/src/core/access/entitlements/types.py | 64 ++++++++++--------- .../Modals/PricingModal/assets/constants.ts | 45 ------------- 2 files changed, 33 insertions(+), 76 deletions(-) delete mode 100644 web/ee/src/components/pages/settings/Billing/Modals/PricingModal/assets/constants.ts diff --git a/api/ee/src/core/access/entitlements/types.py b/api/ee/src/core/access/entitlements/types.py index a1ff6ffebb..276f31f141 100644 --- a/api/ee/src/core/access/entitlements/types.py +++ b/api/ee/src/core/access/entitlements/types.py @@ -191,7 +191,7 @@ class Throttle(BaseModel): DEFAULT_CATALOG = [ { "title": "Hobby", - "description": "Great for hobby projects and POCs.", + "description": "For individuals exploring Agenta.", "type": "standard", "plan": DefaultPlan.CLOUD_V0_HOBBY.value, "retention": Retention.WEEKLY.value, @@ -203,17 +203,17 @@ class Throttle(BaseModel): }, }, "features": [ - "Unlimited prompts", - "20 evaluations/month", - "5k traces/month", - "2 seats included", - "1 week retention period", - "Community support via Github", + "2 team members", + "Unlimited projects", + "Unlimited agents and workflows", + "5,000 agent runs / month", + "1-week trace data retention", + "Community support through GitHub Issues", ], }, { "title": "Pro", - "description": "For production projects.", + "description": "For teams running agents in production.", "type": "standard", "plan": DefaultPlan.CLOUD_V0_PRO.value, "retention": Retention.MONTHLY.value, @@ -239,17 +239,18 @@ class Throttle(BaseModel): }, }, "features": [ - "Unlimited prompts", + "Unlimited team members", + "Unlimited projects, agents, and workflows", + "Unlimited schedules and event triggers", + "10,000 agent runs / month included, then $5 per additional 10,000", "Unlimited evaluations", - "10k traces / month included then $5 for every 10k", - "Unlimited seats", - "1 month retention period", - "In-app support", + "1-month trace data retention", + "Community support through GitHub Issues", ], }, { "title": "Business", - "description": "For scale, security, and support.", + "description": "For teams that need governance, compliance, and priority support.", "type": "standard", "plan": DefaultPlan.CLOUD_V0_BUSINESS.value, "retention": Retention.QUARTERLY.value, @@ -264,7 +265,7 @@ class Throttle(BaseModel): "currency": "USD", "tiers": [ { - "limit": 1_000_000, + "limit": 10_000, "amount": 0.00, }, { @@ -276,33 +277,34 @@ class Throttle(BaseModel): }, "features": [ "Everything in Pro", - "Unlimited seats", - "1M traces / month included then $5 for every 10k", - "Multiple workspaces", - "Roles and RBAC", - "Enterprise SSO", - "SOC 2 reports", - "HIPAA BAA [soon]", - "Private Slack Channel", - "Business SLA", - "1 quarter retention period", + "10,000 agent runs / month included, then $5 per additional 10,000", + "Team roles and role-based access control", + "SSO", + "SOC 2 Type II report", + "3-month trace data retention", + "Priority support", + "Private Slack Connect channel", ], }, { "title": "Enterprise", - "description": "For large organizations or custom needs.", + "description": "For organizations that need advanced controls and dedicated support.", "type": "standard", "features": [ "Everything in Business", + "Custom usage and trace data retention", "Custom roles", - "Enterprise SSO", "Audit logs", + "Custom domains", + "HIPAA BAA [soon]", + "Security reviews", + "Custom security and legal terms", "Self-hosting options", "Bring Your Own Cloud (BYOC)", - "Security reviews", + "Deployment and onboarding support", "Dedicated support", - "Custom SLA", - "Custom terms", + "Private Slack Connect channel", + "Custom service-level agreement", ], }, { @@ -516,7 +518,7 @@ class Throttle(BaseModel): period=Period.MONTHLY, ), Counter.TRACES_INGESTED: Quota( - free=1_000_000, + free=10_000, retention=Retention.QUARTERLY, period=Period.MONTHLY, ), diff --git a/web/ee/src/components/pages/settings/Billing/Modals/PricingModal/assets/constants.ts b/web/ee/src/components/pages/settings/Billing/Modals/PricingModal/assets/constants.ts deleted file mode 100644 index 8bf4b40d4c..0000000000 --- a/web/ee/src/components/pages/settings/Billing/Modals/PricingModal/assets/constants.ts +++ /dev/null @@ -1,45 +0,0 @@ -export const PRICING_PLANS_INFO = [ - { - key: "free", - title: "Free", - price: "Free", - description: "Great for hobby projects", - priceDescription: "2 users and 5k traces per month included", - bulletPoints: [ - "2 prompts", - "2 users included", - "5k traces per month included", - "20 evaluations / month included", - ], - }, - { - key: "pro", - title: "Pro", - price: "$49/month", - description: "For production projects", - priceDescription: "2 users and 5k traces per month included", - bulletPoints: [ - "Unlimited prompts", - "3 seats included then $20 per seat", - "Up to 10 seats", - "Unlimited evaluations", - "10k traces / month included", - ], - }, - { - key: "business", - title: "Business", - price: "$49/month", - description: "For teams with security and support needs", - priceDescription: "2 users and 5k traces per month included", - bulletPoints: ["2 prompts", "2 seats", "5k traces", "20 evaluations / month included"], - }, - { - key: "enterprise", - title: "Enterprise", - price: "$49/month", - description: "For teams with security and support needs", - priceDescription: "2 users and 5k traces per month included", - bulletPoints: ["2 prompts", "2 seats", "5k traces", "20 evaluations / month included"], - }, -] From ab77077f8cc3fbc97aff744b4207ed0ea23fe8dc Mon Sep 17 00:00:00 2001 From: mmabrouk <4510758+mmabrouk@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:21:38 +0000 Subject: [PATCH 02/14] v0.105.9 --- api/pyproject.toml | 2 +- api/uv.lock | 6 +++--- clients/python/pyproject.toml | 2 +- clients/python/uv.lock | 2 +- hosting/kubernetes/helm/Chart.yaml | 4 ++-- sdks/python/pyproject.toml | 2 +- sdks/python/uv.lock | 4 ++-- services/pyproject.toml | 2 +- services/uv.lock | 6 +++--- web/ee/package.json | 2 +- web/oss/package.json | 2 +- web/package.json | 2 +- web/packages/agenta-api-client/package.json | 2 +- 13 files changed, 19 insertions(+), 19 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 46091968c6..81f7a23b90 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "api" -version = "0.105.8" +version = "0.105.9" description = "Agenta API" requires-python = ">=3.11,<3.14" authors = [ diff --git a/api/uv.lock b/api/uv.lock index ba12935f45..bcf5931091 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "agenta" -version = "0.105.8" +version = "0.105.9" source = { editable = "../sdks/python" } dependencies = [ { name = "agenta-client" }, @@ -72,7 +72,7 @@ dev = [ [[package]] name = "agenta-client" -version = "0.105.8" +version = "0.105.9" source = { editable = "../clients/python" } dependencies = [ { name = "httpx" }, @@ -273,7 +273,7 @@ wheels = [ [[package]] name = "api" -version = "0.105.8" +version = "0.105.9" source = { virtual = "." } dependencies = [ { name = "agenta" }, diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml index 74074f76c6..e407905800 100644 --- a/clients/python/pyproject.toml +++ b/clients/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agenta-client" -version = "0.105.8" +version = "0.105.9" description = "Fern-generated Python client for the Agenta API." requires-python = ">=3.11,<3.14" authors = [ diff --git a/clients/python/uv.lock b/clients/python/uv.lock index 32b971d85d..4d98690bc1 100644 --- a/clients/python/uv.lock +++ b/clients/python/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11, <3.14" [[package]] name = "agenta-client" -version = "0.105.8" +version = "0.105.9" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/hosting/kubernetes/helm/Chart.yaml b/hosting/kubernetes/helm/Chart.yaml index 465c4b9332..3c95edb427 100644 --- a/hosting/kubernetes/helm/Chart.yaml +++ b/hosting/kubernetes/helm/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: agenta description: A Helm chart for deploying Agenta (OSS or EE) on Kubernetes type: application -version: 0.105.8 -appVersion: "v0.105.8" +version: 0.105.9 +appVersion: "v0.105.9" keywords: - agenta - llm diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 604f8f4674..35a5b6fbb4 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agenta" -version = "0.105.8" +version = "0.105.9" description = "The SDK for agenta is an open-source LLMOps platform." readme = "README.md" requires-python = ">=3.11,<3.14" diff --git a/sdks/python/uv.lock b/sdks/python/uv.lock index 8ed06bcf02..db2723647a 100644 --- a/sdks/python/uv.lock +++ b/sdks/python/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11, <3.14" [[package]] name = "agenta" -version = "0.105.8" +version = "0.105.9" source = { editable = "." } dependencies = [ { name = "agenta-client" }, @@ -85,7 +85,7 @@ dev = [ [[package]] name = "agenta-client" -version = "0.105.8" +version = "0.105.9" source = { editable = "../../clients/python" } dependencies = [ { name = "httpx" }, diff --git a/services/pyproject.toml b/services/pyproject.toml index 5a38b6d0b4..ca561b04fe 100644 --- a/services/pyproject.toml +++ b/services/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "services" -version = "0.105.8" +version = "0.105.9" description = "Agenta Services (Chat & Completion)" requires-python = ">=3.11,<3.14" authors = [ diff --git a/services/uv.lock b/services/uv.lock index 4e5b34419f..1883db6dd2 100644 --- a/services/uv.lock +++ b/services/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "agenta" -version = "0.105.8" +version = "0.105.9" source = { editable = "../sdks/python" } dependencies = [ { name = "agenta-client" }, @@ -72,7 +72,7 @@ dev = [ [[package]] name = "agenta-client" -version = "0.105.8" +version = "0.105.9" source = { editable = "../clients/python" } dependencies = [ { name = "httpx" }, @@ -2353,7 +2353,7 @@ wheels = [ [[package]] name = "services" -version = "0.105.8" +version = "0.105.9" source = { virtual = "." } dependencies = [ { name = "agenta" }, diff --git a/web/ee/package.json b/web/ee/package.json index 072fddb1ea..7d56291d00 100644 --- a/web/ee/package.json +++ b/web/ee/package.json @@ -1,6 +1,6 @@ { "name": "@agenta/ee", - "version": "0.105.8", + "version": "0.105.9", "private": true, "engines": { "node": "24.x" diff --git a/web/oss/package.json b/web/oss/package.json index 7983864776..add1f2b658 100644 --- a/web/oss/package.json +++ b/web/oss/package.json @@ -1,6 +1,6 @@ { "name": "@agenta/oss", - "version": "0.105.8", + "version": "0.105.9", "private": true, "engines": { "node": "24.x" diff --git a/web/package.json b/web/package.json index ed33595ea8..3160471ffa 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "agenta-web", - "version": "0.105.8", + "version": "0.105.9", "workspaces": [ "ee", "oss", diff --git a/web/packages/agenta-api-client/package.json b/web/packages/agenta-api-client/package.json index e7e0afbeb5..2485d3231a 100644 --- a/web/packages/agenta-api-client/package.json +++ b/web/packages/agenta-api-client/package.json @@ -1,6 +1,6 @@ { "name": "@agentaai/api-client", - "version": "0.105.8", + "version": "0.105.9", "private": true, "type": "module", "main": "./dist/index.js", From e0da78b6fb06cf7380f1a3ab34a9f47437ca38ff Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 20 Jul 2026 21:18:01 +0200 Subject: [PATCH 03/14] feat(frontend): create an agent from a website template deep link Capture a ?template= deep-link into localStorage on arrival (mirroring the invite flow), validate the key at runtime against the app template registry, and consume it at whichever first-run surface the new or returning user reaches. The agent is created with the template prompt held behind a Start button (autoSendSeed false). At-most-once creation is enforced with the Web Locks API and a documented best-effort fallback. Capture re-runs after a region redirect so it works on both the US and EU clouds. The website-side link change ships separately. Claude-Session: https://claude.ai/code/session_013Qe1Vf2yvj33BVd5W1q7HB --- .../agent-home/assets/onboardingAnalytics.ts | 7 +- .../hooks/useConsumePendingTemplate.ts | 108 +++++++++++ .../p/[project_id]/apps/index.tsx | 7 + web/oss/src/state/url/auth.ts | 7 + web/oss/src/state/url/template.test.ts | 176 +++++++++++++++++ web/oss/src/state/url/template.ts | 177 ++++++++++++++++++ 6 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 web/oss/src/components/pages/agent-home/hooks/useConsumePendingTemplate.ts create mode 100644 web/oss/src/state/url/template.test.ts create mode 100644 web/oss/src/state/url/template.ts diff --git a/web/oss/src/components/pages/agent-home/assets/onboardingAnalytics.ts b/web/oss/src/components/pages/agent-home/assets/onboardingAnalytics.ts index a472da33ba..679fb41be5 100644 --- a/web/oss/src/components/pages/agent-home/assets/onboardingAnalytics.ts +++ b/web/oss/src/components/pages/agent-home/assets/onboardingAnalytics.ts @@ -24,7 +24,12 @@ export function classifyAgentIntent(message: string): string { return "other" } -export type FirstAgentIntentSource = "template" | "composer" | "skipped" | "browse_templates" +export type FirstAgentIntentSource = + | "template" + | "composer" + | "skipped" + | "browse_templates" + | "website_template" export interface FirstAgentIntentPayload { source: FirstAgentIntentSource diff --git a/web/oss/src/components/pages/agent-home/hooks/useConsumePendingTemplate.ts b/web/oss/src/components/pages/agent-home/hooks/useConsumePendingTemplate.ts new file mode 100644 index 0000000000..c8f5889d89 --- /dev/null +++ b/web/oss/src/components/pages/agent-home/hooks/useConsumePendingTemplate.ts @@ -0,0 +1,108 @@ +import {useEffect, useRef, useState} from "react" + +import {useAtomValue} from "jotai" + +import {usePostHogAg} from "@/oss/lib/helpers/analytics/hooks/usePostHogAg" +import {appIdentifiersAtom} from "@/oss/state/appState" +import { + activeTemplateAtom, + claimTemplate, + clearTemplate, + resolveTemplate, +} from "@/oss/state/url/template" + +import {captureFirstAgentIntent} from "../assets/onboardingAnalytics" + +import {useCreateAgent} from "./useCreateAgent" + +/** + * Consume a pending website template at whichever first-run surface the user reaches. A new user + * lands on the native onboarding playground and a returning user on the agent home page, so this + * hook is mounted above both and consumes the key wherever the user actually is. + * + * Returns `true` while a valid template is being consumed, so the caller can hold the onboarding + * loader instead of flashing the normal surface (which would fire its own redirect and race this). + * + * The steps mirror the plan's consume requirements: validate the key by exact registry lookup + * (an unknown or stale key is ignored and cleared, never creating an agent); wait for a confirmed + * workspace and project; claim the key so it fires at most once; then create the agent with the + * seed held behind a Start button (`autoSendSeed: false`). + */ +export function useConsumePendingTemplate(): boolean { + const pendingKey = useAtomValue(activeTemplateAtom) + const {workspaceId, projectId} = useAtomValue(appIdentifiersAtom) + const posthog = usePostHogAg() + const createAgent = useCreateAgent() + + const startedRef = useRef(false) + const [holding, setHolding] = useState(() => Boolean(pendingKey)) + + useEffect(() => { + if (!pendingKey) { + setHolding(false) + return + } + + const template = resolveTemplate(pendingKey) + if (!template) { + // Unknown or stale key: ignore, clear, and never fall back to another template. + captureFirstAgentIntent(posthog, { + source: "website_template", + properties: {templateId: pendingKey, outcome: "invalid"}, + }) + clearTemplate() + setHolding(false) + return + } + + setHolding(true) + + // Do not create until the workspace and project are real; the effect re-runs when they + // resolve. This surface only renders on a project-scoped route, so both are present here. + if (!workspaceId || !projectId) return + if (startedRef.current) return + startedRef.current = true + + void (async () => { + const won = await claimTemplate(pendingKey) + if (!won) { + // Another tab won the claim; release so this surface renders normally. + setHolding(false) + return + } + + captureFirstAgentIntent(posthog, { + source: "website_template", + properties: { + templateId: template.key, + template: template.name, + templateCategory: template.category, + outcome: "claimed", + }, + intentValue: template.category || template.name, + }) + + try { + await createAgent({ + name: template.name, + seedMessage: template.seedMessage, + autoSendSeed: false, + }) + captureFirstAgentIntent(posthog, { + source: "website_template", + properties: {templateId: template.key, outcome: "created"}, + }) + } catch { + captureFirstAgentIntent(posthog, { + source: "website_template", + properties: {templateId: template.key, outcome: "failed"}, + }) + } finally { + // Clear the pending key; createAgent has navigated to the new agent's playground. + clearTemplate() + } + })() + }, [pendingKey, workspaceId, projectId, posthog, createAgent]) + + return holding +} diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/index.tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/index.tsx index 123e134292..1a529fe46e 100644 --- a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/index.tsx +++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/index.tsx @@ -1,6 +1,7 @@ import dynamic from "next/dynamic" import {PLAYGROUND_NATIVE_ONBOARDING} from "@/oss/components/pages/agent-home/assets/constants" +import {useConsumePendingTemplate} from "@/oss/components/pages/agent-home/hooks/useConsumePendingTemplate" import OnboardingLoader from "@/oss/components/pages/agent-home/PlaygroundOnboarding/OnboardingLoader" const AgentHome = dynamic(() => import("@/oss/components/pages/agent-home")) @@ -14,5 +15,11 @@ const OnboardingEntry = dynamic(() => import("@/oss/components/pages/agent-home/ }) export default function Apps() { + // A website template deep-link is consumed here, above both first-run surfaces, so the create + // happens wherever the user lands. While a valid template is being consumed, hold the loader + // instead of rendering a surface that would fire its own redirect and race the create. + const consumingTemplate = useConsumePendingTemplate() + if (consumingTemplate) return + return PLAYGROUND_NATIVE_ONBOARDING ? : } diff --git a/web/oss/src/state/url/auth.ts b/web/oss/src/state/url/auth.ts index c687ada1b4..242616bea9 100644 --- a/web/oss/src/state/url/auth.ts +++ b/web/oss/src/state/url/auth.ts @@ -13,6 +13,8 @@ import {profileQueryAtom, userAtom} from "@/oss/state/profile/selectors/user" import {sessionExistsAtom, sessionLoadingAtom} from "@/oss/state/session" import {urlAtom} from "@/oss/state/url" +import {captureTemplateFromUrl} from "./template" + const isBrowser = typeof window !== "undefined" export interface InvitePayload { @@ -224,6 +226,11 @@ export const syncAuthStateFromUrl = (nextUrl?: string) => { } store.set(activeInviteAtom, invite ?? null) + // Capture a website template deep-link alongside the invite, from the same URL read. Runs + // again on a regional host after a region redirect, since the query string survives the + // switch but the stored key does not. + captureTemplateFromUrl(url) + if (sessionLoading) { store.set(protectedRouteReadyAtom, false) return diff --git a/web/oss/src/state/url/template.test.ts b/web/oss/src/state/url/template.test.ts new file mode 100644 index 0000000000..292d20dbba --- /dev/null +++ b/web/oss/src/state/url/template.test.ts @@ -0,0 +1,176 @@ +import {getDefaultStore} from "jotai" +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest" + +import {AGENT_TEMPLATES} from "../../components/pages/agent-home/assets/templates" + +import { + activeTemplateAtom, + captureTemplateFromUrl, + claimTemplate, + clearTemplate, + isValidTemplateKey, + parseTemplateFromUrl, + persistTemplateToStorage, + readTemplateFromStorage, + resolveTemplate, + TEMPLATE_TTL_MS, +} from "./template" + +const KNOWN_KEY = AGENT_TEMPLATES[0].key + +class FakeStorage { + private map = new Map() + getItem(key: string) { + return this.map.has(key) ? (this.map.get(key) as string) : null + } + setItem(key: string, value: string) { + this.map.set(key, String(value)) + } + removeItem(key: string) { + this.map.delete(key) + } + clear() { + this.map.clear() + } +} + +const installWindow = (href: string, locks?: unknown) => { + const storage = new FakeStorage() + const state = {} as unknown + const replaceState = vi.fn((_s: unknown, _t: string, url: string) => { + fakeWindow.location.href = new URL(url, "https://cloud.agenta.ai").href + }) + const fakeWindow = { + localStorage: storage, + location: {href: new URL(href, "https://cloud.agenta.ai").href}, + history: {state, replaceState}, + } + vi.stubGlobal("window", fakeWindow) + vi.stubGlobal("navigator", locks ? {locks} : {}) + return fakeWindow +} + +beforeEach(() => { + getDefaultStore().set(activeTemplateAtom, null) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +describe("parseTemplateFromUrl", () => { + it("returns the key when the template param is present", () => { + const url = new URL("https://cloud.agenta.ai/?template=pr-reviewer") + expect(parseTemplateFromUrl(url)).toBe("pr-reviewer") + }) + + it("returns null when the template param is absent", () => { + expect(parseTemplateFromUrl(new URL("https://cloud.agenta.ai/"))).toBeNull() + }) + + it("trims surrounding whitespace and treats blank as absent", () => { + expect(parseTemplateFromUrl(new URL("https://cloud.agenta.ai/?template=%20%20"))).toBeNull() + }) +}) + +describe("registry validation", () => { + it("resolves a known key to its template and validates it", () => { + expect(resolveTemplate(KNOWN_KEY)?.key).toBe(KNOWN_KEY) + expect(isValidTemplateKey(KNOWN_KEY)).toBe(true) + }) + + it("rejects an unknown or stale key without falling back to another template", () => { + expect(resolveTemplate("not-a-real-template")).toBeUndefined() + expect(isValidTemplateKey("not-a-real-template")).toBe(false) + expect(resolveTemplate(null)).toBeUndefined() + expect(isValidTemplateKey("")).toBe(false) + }) +}) + +describe("storage round-trip and TTL", () => { + it("persists then reads back the key", () => { + installWindow("/") + persistTemplateToStorage({key: KNOWN_KEY, capturedAt: Date.now()}) + expect(readTemplateFromStorage()?.key).toBe(KNOWN_KEY) + }) + + it("treats a key past its expiry as absent and removes it", () => { + installWindow("/") + persistTemplateToStorage({key: KNOWN_KEY, capturedAt: Date.now() - TEMPLATE_TTL_MS - 1}) + expect(readTemplateFromStorage()).toBeNull() + // A second read confirms the expired entry was purged, not just filtered. + expect(readTemplateFromStorage()).toBeNull() + }) +}) + +describe("captureTemplateFromUrl", () => { + it("captures the key from the URL and mirrors it into the atom", () => { + installWindow(`/?template=${KNOWN_KEY}`) + const url = new URL(`https://cloud.agenta.ai/?template=${KNOWN_KEY}`) + expect(captureTemplateFromUrl(url)).toBe(KNOWN_KEY) + expect(getDefaultStore().get(activeTemplateAtom)).toBe(KNOWN_KEY) + expect(readTemplateFromStorage()?.key).toBe(KNOWN_KEY) + }) + + it("stamps the capture time once and does not reset it on a later capture", () => { + installWindow(`/?template=${KNOWN_KEY}`) + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")) + const url = new URL(`https://cloud.agenta.ai/?template=${KNOWN_KEY}`) + captureTemplateFromUrl(url) + const first = readTemplateFromStorage()?.capturedAt + + vi.setSystemTime(new Date("2026-01-01T00:05:00Z")) + captureTemplateFromUrl(url) + expect(readTemplateFromStorage()?.capturedAt).toBe(first) + }) + + it("falls back to the stored key when the URL has none (region recapture leaves storage intact)", () => { + installWindow("/") + persistTemplateToStorage({key: KNOWN_KEY, capturedAt: Date.now()}) + expect(captureTemplateFromUrl(new URL("https://cloud.agenta.ai/apps"))).toBe(KNOWN_KEY) + expect(getDefaultStore().get(activeTemplateAtom)).toBe(KNOWN_KEY) + }) +}) + +describe("clearTemplate", () => { + it("removes storage, the atom, and the template URL param so it cannot resurrect", () => { + const win = installWindow(`/apps?template=${KNOWN_KEY}&other=1`) + persistTemplateToStorage({key: KNOWN_KEY, capturedAt: Date.now()}) + getDefaultStore().set(activeTemplateAtom, KNOWN_KEY) + + clearTemplate() + + expect(readTemplateFromStorage()).toBeNull() + expect(getDefaultStore().get(activeTemplateAtom)).toBeNull() + expect(win.location.href).not.toContain("template=") + expect(win.location.href).toContain("other=1") + // A capture after clear must not bring the key back from the URL. + expect(captureTemplateFromUrl(new URL(win.location.href))).toBeNull() + }) +}) + +describe("claimTemplate (at-most-once)", () => { + it("best-effort fallback: only the first caller wins when Web Locks is absent", async () => { + installWindow("/") + expect(await claimTemplate(KNOWN_KEY)).toBe(true) + expect(await claimTemplate(KNOWN_KEY)).toBe(false) + }) + + it("Web Locks path: the lock serialises the claim so only one caller wins", async () => { + const locks = { + request: (_name: string, cb: () => boolean) => Promise.resolve(cb()), + } + installWindow("/", locks) + expect(await claimTemplate(KNOWN_KEY)).toBe(true) + expect(await claimTemplate(KNOWN_KEY)).toBe(false) + }) + + it("a fresh key can be claimed again after the previous template is cleared", async () => { + installWindow("/") + expect(await claimTemplate(KNOWN_KEY)).toBe(true) + clearTemplate() + expect(await claimTemplate(KNOWN_KEY)).toBe(true) + }) +}) diff --git a/web/oss/src/state/url/template.ts b/web/oss/src/state/url/template.ts new file mode 100644 index 0000000000..deec81146c --- /dev/null +++ b/web/oss/src/state/url/template.ts @@ -0,0 +1,177 @@ +import {atom, getDefaultStore} from "jotai" + +import {AGENT_TEMPLATES} from "../../components/pages/agent-home/assets/templates" +import type {AgentTemplate} from "../../components/pages/agent-home/assets/templates" + +// Capture / storage / TTL / validation / claim for a website template deep-link. Kept out of +// auth.ts so that module does not become a registry of unrelated features; auth.ts only calls in. + +const hasWindow = () => typeof window !== "undefined" + +export const TEMPLATE_URL_PARAM = "template" +const TEMPLATE_STORAGE_KEY = "pendingTemplate" +const TEMPLATE_CLAIM_KEY = "pendingTemplateClaim" + +// A real signup can include email verification and a provider round-trip, so the key must outlive +// several minutes; thirty is comfortably longer than any real signup and short enough that a +// forgotten key cannot create an agent later. +export const TEMPLATE_TTL_MS = 30 * 60 * 1000 + +export interface PendingTemplate { + key: string + capturedAt: number +} + +export const activeTemplateAtom = atom(null) + +const validTemplateKeys = new Set(AGENT_TEMPLATES.map((template) => template.key)) + +/** Exact-match lookup against the app registry; an unknown or stale key resolves to undefined. */ +export const resolveTemplate = (key: string | null | undefined): AgentTemplate | undefined => { + if (!key) return undefined + return AGENT_TEMPLATES.find((template) => template.key === key) +} + +export const isValidTemplateKey = (key: string | null | undefined): boolean => + !!key && validTemplateKeys.has(key) + +export const parseTemplateFromUrl = (url: URL): string | null => { + const key = url.searchParams.get(TEMPLATE_URL_PARAM)?.trim() + return key ? key : null +} + +export const readTemplateFromStorage = (): PendingTemplate | null => { + if (!hasWindow()) return null + try { + const raw = window.localStorage.getItem(TEMPLATE_STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) + if ( + parsed && + typeof parsed.key === "string" && + parsed.key.trim() && + typeof parsed.capturedAt === "number" + ) { + // Expiry is measured from capture time, so a key that outlives its window drops itself. + if (Date.now() - parsed.capturedAt > TEMPLATE_TTL_MS) { + window.localStorage.removeItem(TEMPLATE_STORAGE_KEY) + return null + } + return {key: parsed.key.trim(), capturedAt: parsed.capturedAt} + } + } catch (error) { + console.error("Failed to read pending template from storage:", error) + } + return null +} + +export const persistTemplateToStorage = (pending: PendingTemplate | null) => { + if (!hasWindow()) return + try { + if (pending && pending.key) { + window.localStorage.setItem(TEMPLATE_STORAGE_KEY, JSON.stringify(pending)) + } else { + window.localStorage.removeItem(TEMPLATE_STORAGE_KEY) + } + } catch (error) { + console.error("Failed to persist pending template to storage:", error) + } +} + +/** + * Capture the template key from the URL, mirroring the invite capture, and mirror it into the + * shared atom. The capture time is stamped once, on the first sighting of a key, so the TTL + * measures from arrival and a key that survives many navigations still ages. This also runs on a + * regional host after a region redirect: the query string is preserved across the switch but + * localStorage is not shared between hosts, so the key is re-saved under the host the user lands on. + */ +export const captureTemplateFromUrl = (url: URL): string | null => { + const store = getDefaultStore() + const urlKey = parseTemplateFromUrl(url) + + if (urlKey) { + const stored = readTemplateFromStorage() + if (!stored || stored.key !== urlKey) { + persistTemplateToStorage({key: urlKey, capturedAt: Date.now()}) + } + store.set(activeTemplateAtom, urlKey) + return urlKey + } + + const stored = readTemplateFromStorage() + store.set(activeTemplateAtom, stored?.key ?? null) + return stored?.key ?? null +} + +const TEMPLATE_URL_PARAMS = [TEMPLATE_URL_PARAM] + +/** + * Fully forget a pending template: the atom, storage, the claim flag, and the `template` query + * param. Stripping the URL param matters for the same reason it does for invites — the capture + * re-reads the URL on every navigation and would otherwise resurrect the key from a stale param. + */ +export const clearTemplate = () => { + const store = getDefaultStore() + store.set(activeTemplateAtom, null) + persistTemplateToStorage(null) + + if (!hasWindow()) return + try { + window.localStorage.removeItem(TEMPLATE_CLAIM_KEY) + } catch (error) { + console.error("Failed to clear template claim:", error) + } + try { + const url = new URL(window.location.href) + let changed = false + TEMPLATE_URL_PARAMS.forEach((param) => { + if (url.searchParams.has(param)) { + url.searchParams.delete(param) + changed = true + } + }) + if (changed) { + window.history.replaceState( + window.history.state, + "", + `${url.pathname}${url.search}${url.hash}`, + ) + } + } catch (error) { + console.error("Failed to clear template params from URL:", error) + } +} + +/** + * At-most-once claim. localStorage has no atomic compare-and-set, so two tabs could both read a + * key as unclaimed and both create. The Web Locks API serialises the read-claim-write across + * same-origin tabs, so only one caller can move the key from unclaimed to claimed. Where the API + * is unavailable the claim degrades to a best-effort local-storage compare-and-set, and in that + * narrow case the guarantee softens from at-most-once to best-effort. Returns true only for the + * caller that wins the claim. + */ +export const claimTemplate = async (key: string): Promise => { + if (!hasWindow()) return false + + const compareAndSet = (): boolean => { + try { + if (window.localStorage.getItem(TEMPLATE_CLAIM_KEY) === key) return false + window.localStorage.setItem(TEMPLATE_CLAIM_KEY, key) + return true + } catch (error) { + console.error("Failed to claim pending template:", error) + return false + } + } + + const locks = (navigator as Navigator & {locks?: LockManager})?.locks + if (locks?.request) { + try { + return await locks.request(`pendingTemplate:${key}`, compareAndSet) + } catch (error) { + console.error("Web Locks claim failed, falling back to best-effort:", error) + return compareAndSet() + } + } + return compareAndSet() +} From 91b93af9b3f5a8265577c2e2b0371682b9529bc3 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Thu, 23 Jul 2026 01:15:59 +0200 Subject: [PATCH 04/14] fix(frontend): tighten the nav rail and make the app shell lines continuous The collapsed sidebar was 80px wide around 14-16px icons, and the rail painted two different greys because the antd menu drew its own background over the middle. The frame lines were nearly invisible, so the rail edge and the top bars never read as one frame. - Rail is 48px collapsed, one flat colour, darker than the content in dark mode and white in light mode. - New shell tokens (rail background, frame line, scroll thumb) so the rail edge, the breadcrumb bar and the playground header share one line. - Workflow sidebar header rows are 45px, so the rail's lines land on the same y as the content's and carry straight across. - The agent picker loses its own border inside the framed row. - Configuration panel gets a quiet scrollbar: gutter always reserved, so no content shift, thumb only on hover or focus. --- .../src/components/Layout/assets/styles.ts | 2 +- .../Components/MainLayout/index.tsx | 3 +- .../Components/PlaygroundHeader/index.tsx | 2 +- .../ProtectedRoute/ProtectedRoute.tsx | 4 +- .../components/SidebarSkeletonLoader.tsx | 2 +- .../Sidebar/components/WorkflowPicker.tsx | 6 +-- .../Sidebar/engine/SidebarShell.tsx | 22 ++++++++-- .../Sidebar/scopes/workflowScope.tsx | 16 +++++--- web/oss/src/styles/globals.css | 40 +++++++++++++++++++ web/oss/src/styles/theme-variables.css | 12 +++++- web/oss/src/styles/theme/palette.ts | 11 +++++ web/scripts/generate-tailwind-tokens.ts | 6 ++- 12 files changed, 104 insertions(+), 22 deletions(-) diff --git a/web/oss/src/components/Layout/assets/styles.ts b/web/oss/src/components/Layout/assets/styles.ts index 49a9112c52..0867947b98 100644 --- a/web/oss/src/components/Layout/assets/styles.ts +++ b/web/oss/src/components/Layout/assets/styles.ts @@ -27,7 +27,7 @@ export const useStyles = createUseStyles((theme: JSSTheme) => ({ justifyContent: "space-between", width: "100%", padding: "8px 1.5rem", - borderBottom: `1px solid ${theme.colorBorderSecondary}`, + borderBottom: "1px solid var(--ag-shell-line)", }, topRightBar: { display: "flex", diff --git a/web/oss/src/components/Playground/Components/MainLayout/index.tsx b/web/oss/src/components/Playground/Components/MainLayout/index.tsx index 199269a310..e17025d50d 100644 --- a/web/oss/src/components/Playground/Components/MainLayout/index.tsx +++ b/web/oss/src/components/Playground/Components/MainLayout/index.tsx @@ -384,7 +384,8 @@ const PlaygroundMainView = ({ ref={setConfigPanelRef} className={clsx([ { - "grow w-full min-h-0 overflow-y-auto": !isComparisonView, + "ag-scroll-quiet grow w-full min-h-0 overflow-y-auto": + !isComparisonView, "grow w-full min-h-0 overflow-x-auto flex [&::-webkit-scrollbar]:w-0": isComparisonView, }, diff --git a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx index 981430ee82..80ffbb124b 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx @@ -633,7 +633,7 @@ const PlaygroundHeader: React.FC = ({className, ...divPro <>
diff --git a/web/oss/src/components/Sidebar/components/SidebarSkeletonLoader.tsx b/web/oss/src/components/Sidebar/components/SidebarSkeletonLoader.tsx index da9b15c5c0..d006f81bee 100644 --- a/web/oss/src/components/Sidebar/components/SidebarSkeletonLoader.tsx +++ b/web/oss/src/components/Sidebar/components/SidebarSkeletonLoader.tsx @@ -14,7 +14,7 @@ const SidebarSkeletonLoader = () => { className={clsx( "flex flex-col justify-between h-screen border border-r border-solid border-gray-100", { - "w-[80px] items-center": collapsed, + "w-[48px] items-center": collapsed, "w-[236px]": !collapsed, }, )} diff --git a/web/oss/src/components/Sidebar/components/WorkflowPicker.tsx b/web/oss/src/components/Sidebar/components/WorkflowPicker.tsx index 89ab93c29a..11bc077816 100644 --- a/web/oss/src/components/Sidebar/components/WorkflowPicker.tsx +++ b/web/oss/src/components/Sidebar/components/WorkflowPicker.tsx @@ -45,9 +45,9 @@ const WorkflowPicker = memo(({collapsed}: WorkflowPickerProps) => { aria-label="Switch workflow" className={clsx( "flex items-center justify-between overflow-hidden transition-[width,height,padding,gap,border-color] duration-300 ease-in-out", - collapsed - ? "!w-8 !h-8 !p-1 gap-0" - : "w-full pl-2 pr-3 py-3 h-12 gap-2 border border-solid border-gray-200", + // No border when expanded: the header row it sits in is already + // framed by the rail's own line, so a box inside a box reads wrong. + collapsed ? "!w-8 !h-8 !p-1 gap-0" : "w-full h-full pl-1.5 pr-2 gap-2", )} > { state = {hasError: false} @@ -247,7 +255,13 @@ const SidebarShell: React.FC = ({ {renderSlot(section.before, collapsed)} = ({ const bottomSections = visibleSections.filter((section) => section.placement === "bottom") return ( -
+
{renderSlot(scope.header, collapsed, scope.lastPath)} diff --git a/web/oss/src/components/Sidebar/scopes/workflowScope.tsx b/web/oss/src/components/Sidebar/scopes/workflowScope.tsx index 39bfdfdb0f..480456e170 100644 --- a/web/oss/src/components/Sidebar/scopes/workflowScope.tsx +++ b/web/oss/src/components/Sidebar/scopes/workflowScope.tsx @@ -1,7 +1,5 @@ import {useMemo} from "react" -import {Divider} from "antd" - import SidebarBackButton from "../components/SidebarBackButton" import WorkflowPicker from "../components/WorkflowPicker" import type {SidebarScope, SidebarSection, SidebarSlotContext} from "../engine/types" @@ -14,21 +12,27 @@ interface WorkflowScopeOptions { lastPath?: string } +// The two header rows are 45px tall so the rail's lines land on the same y as the +// breadcrumb bar's and the playground header's, and read as one line across the app. const WorkflowSidebarHeader = ({collapsed, lastPath}: SidebarSlotContext) => ( <>
-
+
- ) diff --git a/web/oss/src/styles/globals.css b/web/oss/src/styles/globals.css index 03fa84cde1..26ee690637 100644 --- a/web/oss/src/styles/globals.css +++ b/web/oss/src/styles/globals.css @@ -896,3 +896,43 @@ body { .ag-icon-segmented .ant-segmented-item-label svg { display: block; } + +/* Quiet scrollbars. The gutter is reserved at the thumb's width whether or not the + content overflows, so revealing the thumb never shifts the content, and the thumb + only appears while the pointer or focus is inside the scroller. */ +.ag-scroll-quiet { + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} + +.ag-scroll-quiet:hover, +.ag-scroll-quiet:focus-within { + scrollbar-color: var(--ag-scroll-thumb) transparent; +} + +.ag-scroll-quiet::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.ag-scroll-quiet::-webkit-scrollbar-track { + background: transparent; +} + +.ag-scroll-quiet::-webkit-scrollbar-thumb { + background-color: transparent; + background-clip: content-box; + border: 2px solid transparent; + border-radius: 999px; + transition: background-color 150ms ease; +} + +.ag-scroll-quiet:hover::-webkit-scrollbar-thumb, +.ag-scroll-quiet:focus-within::-webkit-scrollbar-thumb { + background-color: var(--ag-scroll-thumb); +} + +.ag-scroll-quiet::-webkit-scrollbar-thumb:hover { + background-color: var(--ag-scroll-thumb-hover); +} diff --git a/web/oss/src/styles/theme-variables.css b/web/oss/src/styles/theme-variables.css index d45dfa600f..0069da24df 100644 --- a/web/oss/src/styles/theme-variables.css +++ b/web/oss/src/styles/theme-variables.css @@ -197,7 +197,11 @@ --ag-strip-card-border: #eaeff5; --ag-strip-card-border-hover: #bdc7d1; --ag-strip-card-hover-shadow: 0 2px 8px -2px rgba(28, 44, 61, 0.12); - --ag-sidebar-bg: var(--ag-surface-raised); + --ag-shell-rail-bg: #ffffff; + --ag-shell-line: #e3e5e9; + --ag-scroll-thumb: rgba(15, 23, 42, 0.22); + --ag-scroll-thumb-hover: rgba(15, 23, 42, 0.38); + --ag-sidebar-bg: var(--ag-shell-rail-bg); /* --- legacy --ag-c-* shim (light = original hex) --- */ --ag-c-051729: #051729; @@ -507,7 +511,11 @@ --ag-strip-card-border: #232327; --ag-strip-card-border-hover: #3a3a40; --ag-strip-card-hover-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.45); - --ag-sidebar-bg: var(--ag-surface-raised); + --ag-shell-rail-bg: #121316; + --ag-shell-line: #2b2d32; + --ag-scroll-thumb: rgba(255, 255, 255, 0.20); + --ag-scroll-thumb-hover: rgba(255, 255, 255, 0.34); + --ag-sidebar-bg: var(--ag-shell-rail-bg); /* --- legacy --ag-c-* shim (dark aliased to roles where possible) --- */ --ag-c-051729: rgba(255, 255, 255, 0.88); diff --git a/web/oss/src/styles/theme/palette.ts b/web/oss/src/styles/theme/palette.ts index d400cff960..d0c7437d85 100644 --- a/web/oss/src/styles/theme/palette.ts +++ b/web/oss/src/styles/theme/palette.ts @@ -442,6 +442,16 @@ export const templateStrip = { }, } satisfies Record +// App shell — the navigation rail and the frame lines that separate it and the top +// bars from the content. The rail sits one step darker (light: greyer) than the +// content beside it, and every frame line shares one colour so they read as one frame. +export const shell = { + railBg: {light: "#ffffff", dark: "#121316"}, + line: {light: "#e3e5e9", dark: "#2b2d32"}, + scrollThumb: {light: "rgba(15, 23, 42, 0.22)", dark: "rgba(255, 255, 255, 0.20)"}, + scrollThumbHover: {light: "rgba(15, 23, 42, 0.38)", dark: "rgba(255, 255, 255, 0.34)"}, +} satisfies Record + export const palette = { surface, text, @@ -464,6 +474,7 @@ export const palette = { appVariantCell, editorChip, templateStrip, + shell, } export default palette diff --git a/web/scripts/generate-tailwind-tokens.ts b/web/scripts/generate-tailwind-tokens.ts index d8db86a31c..b7533e20d5 100644 --- a/web/scripts/generate-tailwind-tokens.ts +++ b/web/scripts/generate-tailwind-tokens.ts @@ -305,7 +305,11 @@ const FEATURES: [string, FVal][] = [ ["strip-card-border", pairOf(pf.templateStrip.cardBorder)], ["strip-card-border-hover", pairOf(pf.templateStrip.cardBorderHover)], ["strip-card-hover-shadow", pairOf(pf.templateStrip.cardHoverShadow)], - ["sidebar-bg", {light: "var(--ag-surface-raised)", dark: "var(--ag-surface-raised)"}], + ["shell-rail-bg", pairOf(pf.shell.railBg)], + ["shell-line", pairOf(pf.shell.line)], + ["scroll-thumb", pairOf(pf.shell.scrollThumb)], + ["scroll-thumb-hover", pairOf(pf.shell.scrollThumbHover)], + ["sidebar-bg", {light: "var(--ag-shell-rail-bg)", dark: "var(--ag-shell-rail-bg)"}], ] // Emit feature vars. A constant token (light === dark, e.g. surface-accent) is From 2d91a3cef44389ed201ef97c2528a868a52c9861 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Thu, 23 Jul 2026 01:35:23 +0200 Subject: [PATCH 05/14] fix(frontend): make the playground panels meet the divider and shrink cleanly The config and chat panels were separated by a 12px dark channel, and neither header reached it. The config panel's sticky headers also stopped short of the panel edge once the scrollbar gutter was reserved. - The Build/Chat divider is a hairline in the shell's frame colour, so both panels butt against it. The grab zone stays 11px wide and the grip only appears on hover or drag. - Sticky headers stretch across the reserved scrollbar gutter, so they span the panel edge to edge. The gutter width is measured, since it is zero on overlay-scrollbar systems. - The chat panel gets the same quiet scrollbar as the config panel. - Below 320px the config panel drops what it can spare: Configuration becomes Config, Deploy and Commit become icon buttons, section rows keep their icon and summary, and list rows keep their name. It can now be dragged down to 240px. --- .../Components/MainLayout/index.tsx | 24 ++-- .../assets/PlaygroundVariantConfigHeader.tsx | 6 +- web/oss/src/hooks/useScrollbarGutterVar.ts | 27 ++++ web/oss/src/styles/globals.css | 115 +++++++++++++----- .../SchemaControls/agentTemplate/ItemRow.tsx | 9 +- .../section/ConfigAccordionSection.tsx | 4 +- 6 files changed, 142 insertions(+), 43 deletions(-) create mode 100644 web/oss/src/hooks/useScrollbarGutterVar.ts diff --git a/web/oss/src/components/Playground/Components/MainLayout/index.tsx b/web/oss/src/components/Playground/Components/MainLayout/index.tsx index e17025d50d..c7d086828c 100644 --- a/web/oss/src/components/Playground/Components/MainLayout/index.tsx +++ b/web/oss/src/components/Playground/Components/MainLayout/index.tsx @@ -22,6 +22,7 @@ import {chatPanelMaximizedAtom} from "@/oss/components/AgentChatSlice/state/pane // Direct file import — the SessionInspector barrel would statically pull the (dynamic, // open-on-demand) inspector drawer back into this chunk. import PanelSessionInspectorButton from "@/oss/components/SessionInspector/PanelSessionInspectorButton" +import {useScrollbarGutterVar} from "@/oss/hooks/useScrollbarGutterVar" import {routerAppIdAtom} from "@/oss/state/app/selectors/app" import {playgroundEarlyAgentStateAtom} from "@/oss/state/workflow" @@ -268,9 +269,10 @@ const PlaygroundMainView = ({ const animateSplit = justToggled || holdAnimate const variantRefs = useRef<(HTMLDivElement | null)[]>([]) - const {setConfigPanelRef, setGenerationPanelRef} = usePlaygroundScrollSync({ + const {configPanelRef, setConfigPanelRef, setGenerationPanelRef} = usePlaygroundScrollSync({ enabled: isComparisonView, }) + useScrollbarGutterVar(configPanelRef) const handleAddRunnable = useCallback(async () => { const selection = await openEntitySelector({ @@ -365,7 +367,10 @@ const PlaygroundMainView = ({
- Configuration + Configuration + Config ) : ( <> @@ -267,7 +269,7 @@ const PlaygroundVariantConfigHeader = ({ )}
-
+
{extraActions} {hasPresets && onLoadPreset && (