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/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 ce6578da76..fe38cdec12 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "agenta" -version = "0.105.8" -description = "Agenta — the open-source workspace for your agents. Build agents through chat, improve them with feedback, and share them with your team." +version = "0.105.9" +description = "Agenta is the open-source workspace for your agents. Build agents through chat, improve them with feedback, and share them with your team." readme = "README.md" requires-python = ">=3.11,<3.14" license = "MIT" 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/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"], - }, -] 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/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 904ed729ba..14b0753ed4 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -756,6 +756,9 @@ const AgentConversation = ({ () => contextWindowForModel(harnessCapabilities, modelKey.harness, modelKey.model), [harnessCapabilities, modelKey.harness, modelKey.model], ) + // Feature flag: the context-budget meter is hidden from the composer for now. The + // component and its logic stay wired up; flip this to `true` to bring the UI back. + const showContextBudget = false // ── Playground-native onboarding ────────────────────────────────────────── // This chat panel IS the onboarding surface while the agent is ephemeral: the empty state shows the @@ -2278,9 +2281,11 @@ const AgentConversation = ({ aria-label="Attach files" /> - {/* Only meaningful in a real conversation — hidden during + {/* Context-budget meter temporarily hidden from the UI. + Logic is retained — flip `showContextBudget` to re-enable. + Only meaningful in a real conversation, so still gated on onboarding (no turns / no usage yet). */} - {!onboardingActive ? ( + {showContextBudget && !onboardingActive ? ( ({ 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/OverlayScrollbar/index.tsx b/web/oss/src/components/OverlayScrollbar/index.tsx new file mode 100644 index 0000000000..e7cd42b30b --- /dev/null +++ b/web/oss/src/components/OverlayScrollbar/index.tsx @@ -0,0 +1,151 @@ +"use client" + +import {useCallback, useEffect, useRef, useState} from "react" + +interface OverlayScrollbarProps { + /** The scroll container this thumb drives. Must be inside a positioned ancestor. */ + target: HTMLElement | null +} + +interface Metrics { + /** Thumb offset from the scroller's top edge, in pixels. */ + top: number + height: number + /** The scroller's own offset inside the positioned ancestor. */ + trackTop: number + trackHeight: number +} + +const MIN_THUMB_HEIGHT = 28 +const SCROLL_FLASH_MS = 700 + +/** + * A scrollbar drawn on top of the content instead of beside it. + * + * A native scrollbar takes layout width, which shortens every full-width row in the panel by the + * scrollbar's size. This one floats, so rows still span the panel edge to edge. It shows while the + * pointer is anywhere in the panel (CSS `group-hover`) or for a moment after a scroll, and it can + * be dragged. + * + * Render it as a sibling of the scroller, inside a `relative group` ancestor. + */ +const OverlayScrollbar = ({target}: OverlayScrollbarProps) => { + const [metrics, setMetrics] = useState(null) + const [scrolling, setScrolling] = useState(false) + const [dragging, setDragging] = useState(false) + const flashTimerRef = useRef | null>(null) + + const measure = useCallback(() => { + if (!target) { + setMetrics(null) + return + } + const {scrollHeight, clientHeight, scrollTop, offsetTop} = target + const scrollable = scrollHeight - clientHeight + if (scrollable <= 1) { + setMetrics(null) + return + } + const height = Math.max(MIN_THUMB_HEIGHT, (clientHeight / scrollHeight) * clientHeight) + const top = (scrollTop / scrollable) * (clientHeight - height) + setMetrics({top, height, trackTop: offsetTop, trackHeight: clientHeight}) + }, [target]) + + useEffect(() => { + if (!target) return + + const onScroll = () => { + measure() + setScrolling(true) + if (flashTimerRef.current) clearTimeout(flashTimerRef.current) + flashTimerRef.current = setTimeout(() => setScrolling(false), SCROLL_FLASH_MS) + } + + // The scroller keeps its own size while the content grows, and its children are swapped + // as the panel loads, so a ResizeObserver on the child would go stale. Remeasure on any + // subtree change instead, batched to one frame. + let frame = 0 + const scheduleMeasure = () => { + if (frame) return + frame = requestAnimationFrame(() => { + frame = 0 + measure() + }) + } + + measure() + target.addEventListener("scroll", onScroll, {passive: true}) + + const resizeObserver = new ResizeObserver(scheduleMeasure) + resizeObserver.observe(target) + const mutationObserver = new MutationObserver(scheduleMeasure) + mutationObserver.observe(target, {childList: true, subtree: true}) + + return () => { + target.removeEventListener("scroll", onScroll) + resizeObserver.disconnect() + mutationObserver.disconnect() + if (frame) cancelAnimationFrame(frame) + if (flashTimerRef.current) clearTimeout(flashTimerRef.current) + } + }, [target, measure]) + + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + if (!target || !metrics) return + event.preventDefault() + const startY = event.clientY + const startScroll = target.scrollTop + const scrollable = target.scrollHeight - target.clientHeight + const travel = metrics.trackHeight - metrics.height + setDragging(true) + + const onMove = (moveEvent: PointerEvent) => { + if (travel <= 0) return + const delta = ((moveEvent.clientY - startY) / travel) * scrollable + target.scrollTop = startScroll + delta + } + // pointercancel too: a cancelled stream (touch interruption, app switch) never fires + // pointerup, which would leave the drag state on and the listeners attached. + const onUp = () => { + setDragging(false) + window.removeEventListener("pointermove", onMove) + window.removeEventListener("pointerup", onUp) + window.removeEventListener("pointercancel", onUp) + } + window.addEventListener("pointermove", onMove) + window.addEventListener("pointerup", onUp) + window.addEventListener("pointercancel", onUp) + }, + [target, metrics], + ) + + if (!metrics) return null + + return ( +
+
+
+ ) +} + +export default OverlayScrollbar diff --git a/web/oss/src/components/Playground/Components/MainLayout/index.tsx b/web/oss/src/components/Playground/Components/MainLayout/index.tsx index 199269a310..c18ddf7f5f 100644 --- a/web/oss/src/components/Playground/Components/MainLayout/index.tsx +++ b/web/oss/src/components/Playground/Components/MainLayout/index.tsx @@ -21,6 +21,7 @@ import AgentChatSkeleton from "@/oss/components/AgentChatSlice/components/AgentC import {chatPanelMaximizedAtom} from "@/oss/components/AgentChatSlice/state/panelLayout" // Direct file import — the SessionInspector barrel would statically pull the (dynamic, // open-on-demand) inspector drawer back into this chunk. +import OverlayScrollbar from "@/oss/components/OverlayScrollbar" import PanelSessionInspectorButton from "@/oss/components/SessionInspector/PanelSessionInspectorButton" import {routerAppIdAtom} from "@/oss/state/app/selectors/app" import {playgroundEarlyAgentStateAtom} from "@/oss/state/workflow" @@ -268,7 +269,7 @@ const PlaygroundMainView = ({ const animateSplit = justToggled || holdAnimate const variantRefs = useRef<(HTMLDivElement | null)[]>([]) - const {setConfigPanelRef, setGenerationPanelRef} = usePlaygroundScrollSync({ + const {configPanelRef, setConfigPanelRef, setGenerationPanelRef} = usePlaygroundScrollSync({ enabled: isComparisonView, }) @@ -367,7 +368,9 @@ const PlaygroundMainView = ({ size={configCollapsed ? 0 : undefined} min="20%" max={configMaxSize} - className="!h-full" + // antd panels default to overflow:auto; the section inside owns scrolling, and + // a transient overflow leaves Chrome's thin panel scrollbar stuck full-height. + className="!h-full !overflow-hidden" collapsible={splitCollapsible} key={`${splitterKey}-splitter-panel-config`} > @@ -375,7 +378,7 @@ const PlaygroundMainView = ({ The notice lives OUTSIDE the scroller, so it sits at the pane's bottom edge regardless of content height or scroll position. */}
+ {!isComparisonView ? ( + + ) : null} {!isComparisonView && isAgentConfig && primaryConfigId ? ( ) : null} @@ -445,6 +452,8 @@ const PlaygroundMainView = ({ = ({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/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..1da20fb231 --- /dev/null +++ b/web/oss/src/components/pages/agent-home/hooks/useConsumePendingTemplate.ts @@ -0,0 +1,118 @@ +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, + completeTemplateClaim, + 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 pending = useAtomValue(activeTemplateAtom) + const pendingKey = pending?.key + const capturedAt = pending?.capturedAt + const {workspaceId, projectId} = useAtomValue(appIdentifiersAtom) + const posthog = usePostHogAg() + const createAgent = useCreateAgent() + + const startedRef = useRef(null) + const [holding, setHolding] = useState(() => Boolean(pending)) + + useEffect(() => { + if (!pendingKey || capturedAt === undefined) { + startedRef.current = null + setHolding(false) + return + } + + const pendingGeneration = {key: pendingKey, capturedAt} + const generationId = pendingKey + ":" + capturedAt + const template = resolveTemplate(pendingKey) + if (!template) { + captureFirstAgentIntent(posthog, { + source: "website_template", + properties: {templateId: pendingKey, outcome: "invalid"}, + }) + clearTemplate(pendingGeneration) + setHolding(false) + return + } + + setHolding(true) + + // Do not create until the workspace and project are real; the effect re-runs when they + // resolve. Scope the latch to this capture generation so a later template can proceed + // while the same page component remains mounted. + if (!workspaceId || !projectId) return + if (startedRef.current === generationId) return + startedRef.current = generationId + + void (async () => { + const won = await claimTemplate(pendingGeneration) + if (!won) { + if (startedRef.current === generationId) startedRef.current = null + 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 { + const created = await createAgent({ + name: template.name, + seedMessage: template.seedMessage, + autoSendSeed: false, + }) + captureFirstAgentIntent(posthog, { + source: "website_template", + properties: { + templateId: template.key, + outcome: created ? "created" : "failed", + }, + }) + } catch { + captureFirstAgentIntent(posthog, { + source: "website_template", + properties: {templateId: template.key, outcome: "failed"}, + }) + } finally { + await completeTemplateClaim(pendingGeneration) + clearTemplate(pendingGeneration) + if (startedRef.current === generationId) startedRef.current = null + } + })() + }, [pendingKey, capturedAt, workspaceId, projectId, posthog, createAgent]) + + return holding +} diff --git a/web/oss/src/components/pages/agent-home/hooks/useCreateAgent.ts b/web/oss/src/components/pages/agent-home/hooks/useCreateAgent.ts index 76479359ed..6a331b0a4a 100644 --- a/web/oss/src/components/pages/agent-home/hooks/useCreateAgent.ts +++ b/web/oss/src/components/pages/agent-home/hooks/useCreateAgent.ts @@ -59,7 +59,7 @@ export function useCreateAgent() { onCommitted, autoSendSeed, }: CreateAgentParams = {}) => { - if (inFlightRef.current) return + if (inFlightRef.current) return false inFlightRef.current = true try { const agentName = name?.trim() || "New agent" @@ -71,7 +71,7 @@ export function useCreateAgent() { })) if (!ephemeralId) { message.error("Couldn't start agent creation — please retry") - return + return false } // Slug must be unique per project — the drawer used to collect a user-typed name, so @@ -87,7 +87,7 @@ export function useCreateAgent() { }) if (!result.success) { message.error(extractApiErrorMessage(result.error)) - return + return false } const appId = result.workflow?.workflow_id ?? result.workflow?.id @@ -96,7 +96,7 @@ export function useCreateAgent() { message.error( "Agent created, but couldn't open its playground — find it under Agents", ) - return + return false } if (seedMessage?.trim()) { @@ -113,8 +113,10 @@ export function useCreateAgent() { } else { void router.push(`${baseAppURL}/${appId}/playground?revisions=${revisionId}`) } + return true } catch (error) { message.error(extractApiErrorMessage(error)) + return false } finally { inFlightRef.current = false } 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..b53fc1b98a --- /dev/null +++ b/web/oss/src/state/url/template.test.ts @@ -0,0 +1,233 @@ +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, + completeTemplateClaim, + 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 +} + +const capturePending = (): {key: string; capturedAt: number} => { + captureTemplateFromUrl(new URL("https://cloud.agenta.ai/?template=" + KNOWN_KEY)) + const pending = getDefaultStore().get(activeTemplateAtom) + if (!pending) throw new Error("Expected a pending template") + return pending +} + +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)?.key).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("does not renew an expired key while the stale parameter remains in the URL", () => { + const win = installWindow("/?template=" + KNOWN_KEY) + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")) + const staleUrl = new URL("https://cloud.agenta.ai/?template=" + KNOWN_KEY) + captureTemplateFromUrl(staleUrl) + + vi.setSystemTime(new Date("2026-01-01T00:31:00Z")) + expect(captureTemplateFromUrl(staleUrl)).toBeNull() + expect(readTemplateFromStorage()).toBeNull() + expect(getDefaultStore().get(activeTemplateAtom)).toBeNull() + expect(win.location.href).not.toContain("template=") + }) + + 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)?.key).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, {key: KNOWN_KEY, capturedAt: Date.now()}) + + 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("/") + const pending = capturePending() + expect(await claimTemplate(pending)).toBe(true) + expect(await claimTemplate(pending)).toBe(false) + }) + + it("Web Locks path: the lock serialises the claim so only one caller wins", async () => { + const locks = { + request: (_name: string, cb: () => unknown) => Promise.resolve(cb()), + } + installWindow("/", locks) + const pending = capturePending() + expect(await claimTemplate(pending)).toBe(true) + expect(await claimTemplate(pending)).toBe(false) + }) + + it("keeps a completed generation unavailable to a late tab after pending state clears", async () => { + installWindow("/") + const pending = capturePending() + expect(await claimTemplate(pending)).toBe(true) + await completeTemplateClaim(pending) + clearTemplate(pending) + + expect(await claimTemplate(pending)).toBe(false) + }) + + it("allows a newly captured generation of the same key", async () => { + installWindow("/") + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")) + const first = capturePending() + expect(await claimTemplate(first)).toBe(true) + await completeTemplateClaim(first) + clearTemplate(first) + + vi.setSystemTime(new Date("2026-01-01T00:00:01Z")) + const second = capturePending() + expect(second.capturedAt).not.toBe(first.capturedAt) + expect(await claimTemplate(second)).toBe(true) + }) + + it("expires an abandoned claim before a later visit with the same key", async () => { + const win = installWindow("/?template=" + KNOWN_KEY) + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")) + const first = capturePending() + expect(await claimTemplate(first)).toBe(true) + + vi.setSystemTime(new Date("2026-01-01T00:31:00Z")) + const staleUrl = new URL(win.location.href) + expect(captureTemplateFromUrl(staleUrl)).toBeNull() + + const second = capturePending() + expect(await claimTemplate(second)).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..6de0d80409 --- /dev/null +++ b/web/oss/src/state/url/template.ts @@ -0,0 +1,260 @@ +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 +} + +interface TemplateClaim extends PendingTemplate { + status: "claimed" | "completed" + updatedAt: 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 +} + +const parsePendingTemplate = (raw: string | null): PendingTemplate | null => { + if (!raw) return null + try { + const parsed = JSON.parse(raw) + if ( + parsed && + typeof parsed.key === "string" && + parsed.key.trim() && + typeof parsed.capturedAt === "number" + ) { + return {key: parsed.key.trim(), capturedAt: parsed.capturedAt} + } + } catch (error) { + console.error("Failed to parse pending template from storage:", error) + } + return null +} + +const readStoredTemplateRecord = (): PendingTemplate | null => { + if (!hasWindow()) return null + try { + return parsePendingTemplate(window.localStorage.getItem(TEMPLATE_STORAGE_KEY)) + } catch (error) { + console.error("Failed to read pending template from storage:", error) + return null + } +} + +const isExpired = (pending: PendingTemplate): boolean => + Date.now() - pending.capturedAt > TEMPLATE_TTL_MS + +const samePendingTemplate = ( + left: PendingTemplate | null, + right: PendingTemplate | null, +): boolean => !!left && !!right && left.key === right.key && left.capturedAt === right.capturedAt + +const removeTemplateParamFromCurrentUrl = (expectedKey?: string) => { + if (!hasWindow()) return + try { + const url = new URL(window.location.href) + if (expectedKey && parseTemplateFromUrl(url) !== expectedKey) return + if (!url.searchParams.has(TEMPLATE_URL_PARAM)) return + url.searchParams.delete(TEMPLATE_URL_PARAM) + window.history.replaceState(window.history.state, "", url.pathname + url.search + url.hash) + } catch (error) { + console.error("Failed to clear template params from URL:", error) + } +} + +export const readTemplateFromStorage = (): PendingTemplate | null => { + const pending = readStoredTemplateRecord() + if (!pending) return null + if (!isExpired(pending)) return pending + + persistTemplateToStorage(null) + removeTemplateParamFromCurrentUrl(pending.key) + 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) + const storedRecord = readStoredTemplateRecord() + + if (storedRecord && isExpired(storedRecord)) { + persistTemplateToStorage(null) + if (urlKey === storedRecord.key) { + removeTemplateParamFromCurrentUrl(storedRecord.key) + store.set(activeTemplateAtom, null) + return null + } + } + + if (urlKey) { + const stored = storedRecord && !isExpired(storedRecord) ? storedRecord : null + const pending = stored?.key === urlKey ? stored : {key: urlKey, capturedAt: Date.now()} + if (!samePendingTemplate(stored, pending)) persistTemplateToStorage(pending) + store.set(activeTemplateAtom, pending) + return urlKey + } + + const stored = readTemplateFromStorage() + store.set(activeTemplateAtom, stored) + return stored?.key ?? null +} + +/** + * Fully forget a pending template and its URL parameter. The completed claim is deliberately + * retained for a bounded time so a late tab that captured the same generation cannot recreate it. + * Passing the expected generation prevents an older async consumer from clearing a newer visit. + */ +export const clearTemplate = (expected?: PendingTemplate) => { + const store = getDefaultStore() + const stored = readStoredTemplateRecord() + const shouldClear = !expected || samePendingTemplate(stored, expected) + if (shouldClear) persistTemplateToStorage(null) + + const active = store.get(activeTemplateAtom) + if (!expected || samePendingTemplate(active, expected)) store.set(activeTemplateAtom, null) + + if (shouldClear) removeTemplateParamFromCurrentUrl(expected?.key) +} + +const readTemplateClaim = (): TemplateClaim | null => { + if (!hasWindow()) return null + try { + const raw = window.localStorage.getItem(TEMPLATE_CLAIM_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) + if ( + parsed && + typeof parsed.key === "string" && + typeof parsed.capturedAt === "number" && + (parsed.status === "claimed" || parsed.status === "completed") && + typeof parsed.updatedAt === "number" + ) { + if (Date.now() - parsed.updatedAt <= TEMPLATE_TTL_MS) return parsed as TemplateClaim + } + window.localStorage.removeItem(TEMPLATE_CLAIM_KEY) + } catch (error) { + console.error("Failed to read template claim:", error) + } + return null +} + +const writeTemplateClaim = (claim: TemplateClaim) => { + window.localStorage.setItem(TEMPLATE_CLAIM_KEY, JSON.stringify(claim)) +} + +/** + * 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 (pending: PendingTemplate): Promise => { + if (!hasWindow()) return false + + const compareAndSet = (): boolean => { + try { + const currentPending = readStoredTemplateRecord() + if (!samePendingTemplate(currentPending, pending) || isExpired(pending)) return false + + const existingClaim = readTemplateClaim() + if (samePendingTemplate(existingClaim, pending)) return false + + writeTemplateClaim({...pending, status: "claimed", updatedAt: Date.now()}) + 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:" + pending.key, compareAndSet) + } catch (error) { + console.error("Web Locks claim failed, falling back to best-effort:", error) + return compareAndSet() + } + } + return compareAndSet() +} + +/** Keep the winning generation visible to late tabs until its bounded claim expires. */ +export const completeTemplateClaim = async (pending: PendingTemplate): Promise => { + if (!hasWindow()) return + + const complete = () => { + try { + const claim = readTemplateClaim() + if (!claim || !samePendingTemplate(claim, pending)) return + writeTemplateClaim({...claim, status: "completed", updatedAt: Date.now()}) + } catch (error) { + console.error("Failed to complete template claim:", error) + } + } + + const locks = (navigator as Navigator & {locks?: LockManager})?.locks + if (locks?.request) { + try { + await locks.request("pendingTemplate:" + pending.key, complete) + return + } catch (error) { + console.error("Web Locks completion failed, falling back to best-effort:", error) + } + } + complete() +} diff --git a/web/oss/src/styles/globals.css b/web/oss/src/styles/globals.css index 03fa84cde1..21d2830416 100644 --- a/web/oss/src/styles/globals.css +++ b/web/oss/src/styles/globals.css @@ -214,8 +214,8 @@ body { handle. */ .playground-splitter-agent > .ant-splitter-bar { background: var(--ag-surface-gutter) !important; - flex-basis: 12px !important; - width: 12px !important; + flex-basis: 9px !important; + width: 9px !important; } /* ── Agent Playground surface classes ────────────────────────────────────────────────────────── @@ -896,3 +896,56 @@ 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); +} + +/* Scrollers whose sticky headers have to reach the panel edge. A reserved gutter would cut + every header short by the scrollbar's width, and the content box is the only place the + browser will paint, so this panel keeps no scrollbar at all: full-width rows, and no shift + when the content grows past the viewport. */ +.ag-scroll-no-bar { + scrollbar-width: none; +} + +.ag-scroll-no-bar::-webkit-scrollbar { + width: 0; + height: 0; +} 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/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", diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx index 06afe68817..989fb904ab 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx @@ -32,6 +32,7 @@ import { harnessAllowsModel, harnessSupportsUserMcp, modelIdFromConfig, + modelLabel, providerForModel, vaultModelGroups, vaultPickedProviderFamily, @@ -354,8 +355,13 @@ export function useModelHarness({ [harness, setSection], ) + // Prefer the harness catalog's label ("Sonnet") over the stored id ("sonnet"), so the summary + // names the model the way the picker did. const modelSummary = - [enumLabel(harnessProps.kind, harness.kind), enumLabel(props.llm, modelId)] + [ + enumLabel(harnessProps.kind, harness.kind), + modelLabel(capabilities, harnessValue, modelId) ?? enumLabel(props.llm, modelId), + ] .filter(Boolean) .join(" · ") || undefined diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/connectionUtils.ts b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/connectionUtils.ts index 2630fd5ba8..527e648a90 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/connectionUtils.ts +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/connectionUtils.ts @@ -323,6 +323,21 @@ export function buildModelOptionGroups( })) } +/** + * The catalog's display label for a picked model id (`label`, then `name`). Null when the catalog + * has neither, so a caller can try the schema's own title before falling back to the raw id; the + * picker has no schema to consult, which is why it ends at `id` instead. + */ +export function modelLabel( + capabilities: HarnessCapabilitiesMap | null | undefined, + harness: string | null | undefined, + modelId: string | null | undefined, +): string | null { + if (!modelId) return null + const hit = capsFor(capabilities, harness)?.model_catalog?.find((e) => e.id === modelId) + return hit?.label ?? hit?.name ?? null +} + /** * The provider family that owns a picked model id, derived from the harness's published models * (the group the id sits in). Returns null when the id is not in any group (e.g. a stale id under 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