diff --git a/application/single_app/functions_v2_agents_catalog.py b/application/single_app/functions_v2_agents_catalog.py
new file mode 100644
index 000000000..5bed04617
--- /dev/null
+++ b/application/single_app/functions_v2_agents_catalog.py
@@ -0,0 +1,117 @@
+# functions_v2_agents_catalog.py
+"""The Agents catalogue, shaped for the V2 interface.
+
+The classic ``/agents`` page reads ``GET /api/agents/catalog``, which lists every
+agent the user can reach in their personal, group and global scopes. The V2 page
+lists only the agents the user may start a chat with. Governance can block a whole
+scope or a single agent, and the chat picker already leaves those out, so the
+catalogue does too rather than offering a Chat button that cannot work.
+
+The classic endpoint keeps its behaviour because the admin Promoted Agents editor
+reads it, and governance has no administrator bypass: filtering it there would
+hide agents from the very control that promotes them.
+
+Only the fields the page draws are sent. Assigned knowledge and the model
+endpoint, deployment and provider stay on the server; the page shows the model
+label. Instructions are sent only when the administrator shows them in the
+details dialog, the same rule the classic endpoint applies.
+
+It imports no Flask and no Azure client, so it can be exercised directly: the
+route passes in the catalogue loader, the governance check, the usage and
+promotion annotators, and the page configuration.
+"""
+
+
+# Every field the V2 page reads, and nothing else. ``instructions`` is handled
+# separately because it depends on an administrator setting.
+V2_CATALOG_AGENT_FIELDS = (
+ "id",
+ "name",
+ "display_name",
+ "description",
+ "agent_type",
+ "is_global",
+ "is_group",
+ "scope_type",
+ "scope_id",
+ "scope_name",
+ "group_id",
+ "group_name",
+ "tags",
+ "icon",
+ "model_label",
+ "actions_to_load",
+ "action_labels",
+ "catalog_key",
+ "usage_count",
+ "usage_count_30_days",
+ "usage_count_all_time",
+ "is_promoted_popular",
+ "promoted_popular_window",
+ "promoted_popular_rank",
+ "promoted_popular_order",
+ "promoted_popular_tag_enabled",
+ "promoted_popular_tag_label",
+)
+
+V2_CATALOG_INSTRUCTIONS_FIELD = "instructions"
+
+
+def catalog_agent_governance_scope(agent):
+ """Return the governance scope of a catalogue record, read as the bootstrap reads it."""
+ return str(agent.get("scope_type") or "").strip().lower() or "personal"
+
+
+def project_v2_catalog_agent(agent, *, include_instructions):
+ """Copy only the fields the V2 page draws from one catalogue record."""
+ projected = {field: agent[field] for field in V2_CATALOG_AGENT_FIELDS if field in agent}
+ if include_instructions and V2_CATALOG_INSTRUCTIONS_FIELD in agent:
+ projected[V2_CATALOG_INSTRUCTIONS_FIELD] = agent[V2_CATALOG_INSTRUCTIONS_FIELD]
+ return projected
+
+
+def build_v2_agents_catalog_payload(
+ *,
+ load_catalog,
+ is_allowed,
+ apply_usage_counts,
+ apply_promotions,
+ page_config,
+):
+ """Return ``{"page": ..., "agents": [...]}`` for the V2 Agents page.
+
+ ``load_catalog()``
+ Returns the accessible catalogue records.
+ ``is_allowed(agent, scope)``
+ Whether governance lets the user chat with the agent. An error it raises
+ propagates, so the page fails closed rather than listing agents that were
+ never checked.
+ ``apply_usage_counts(records)`` and ``apply_promotions(records)``
+ Annotate the records and return them.
+ ``page_config``
+ The normalised display configuration from ``build_agents_page_config``. Its
+ ``show_instructions_in_details`` decides whether instructions are sent, so the
+ flag the page reads and the redaction cannot disagree.
+
+ Governance runs first, so usage is counted and promotions are applied only to
+ agents the user can see: an administrator promoting an agent this user is
+ blocked from cannot put it back on this user's Popular tab.
+ """
+ page = dict(page_config or {})
+ include_instructions = bool(page.get("show_instructions_in_details"))
+
+ allowed = [
+ agent
+ for agent in load_catalog() or []
+ if isinstance(agent, dict) and is_allowed(agent, catalog_agent_governance_scope(agent))
+ ]
+ allowed = apply_usage_counts(allowed)
+ allowed = apply_promotions(allowed)
+
+ return {
+ "page": page,
+ "agents": [
+ project_v2_catalog_agent(agent, include_instructions=include_instructions)
+ for agent in allowed
+ ],
+ }
diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py
index 8d9d4dea8..c98d21a37 100644
--- a/application/single_app/route_backend_v2.py
+++ b/application/single_app/route_backend_v2.py
@@ -193,6 +193,7 @@
build_latest_features_payload,
build_user_latest_features_payload,
)
+from functions_v2_agents_catalog import build_v2_agents_catalog_payload
from functions_workspace_context import (
WorkspaceContextError,
build_group_workspace_context,
@@ -204,10 +205,17 @@
_build_initial_chat_model_selection,
_is_chat_agent_allowed_by_governance,
)
+# Shared with the server-rendered Agents page so both interfaces read the same
+# title, hero colours, disclaimer and instructions setting.
+from route_frontend_agents import build_agents_page_config
# Shared with the server-rendered admin page so both interfaces run the same
# connection tests rather than maintaining two lists of what can be tested.
from route_backend_settings import run_admin_settings_connection_test
-from functions_agent_catalog import build_accessible_agent_catalog
+from functions_agent_catalog import (
+ apply_agent_popular_promotions,
+ apply_agent_usage_counts,
+ build_accessible_agent_catalog,
+)
from functions_ai_notice import get_ai_notice_config, is_ai_notice_dismissed
from functions_model_capabilities import resolve_model_vision_support
from functions_model_endpoint_providers import get_model_endpoint_provider_ui_options
@@ -942,6 +950,48 @@ def v2_support_latest_features():
)
return jsonify({"error": "Failed to load Latest Features."}), 500
+ @bp.route("/api/v2/agents/catalog", methods=["GET"])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @user_required
+ @enabled_required("enable_semantic_kernel")
+ def v2_agents_catalog():
+ """Return the agents this user may chat with, for the V2 Agents page.
+
+ The V2 counterpart of the classic ``/agents`` page, behind the same gates: an
+ application role and Semantic Kernel. Unlike the classic catalogue it leaves out
+ agents governance blocks for this user, as the chat picker does, so every card it
+ draws can start a chat. Usage counts and the administrator's Popular promotions
+ are applied after that filter, and the page configuration is sent alongside so
+ the SPA does not need a second request to draw the hero.
+ """
+ user_id = get_current_user_id()
+ if not user_id:
+ return jsonify({"error": "User not authenticated"}), 401
+
+ try:
+ settings = get_settings()
+ payload = build_v2_agents_catalog_payload(
+ load_catalog=lambda: build_accessible_agent_catalog(user_id, settings=settings),
+ is_allowed=lambda agent, scope: _is_chat_agent_allowed_by_governance(
+ user_id, agent, scope
+ ),
+ apply_usage_counts=apply_agent_usage_counts,
+ apply_promotions=lambda catalog: apply_agent_popular_promotions(
+ catalog, settings=settings
+ ),
+ page_config=build_agents_page_config(sanitize_settings_for_user(settings)),
+ )
+ return jsonify(payload), 200
+ except Exception as exc:
+ log_event(
+ "[AGENTS_CATALOG] Failed to load the V2 agent catalogue.",
+ extra={"user_id": user_id, "error_type": type(exc).__name__},
+ level=logging.ERROR,
+ exceptionTraceback=True,
+ )
+ return jsonify({"error": "Failed to load agents."}), 500
+
@bp.route("/api/v2/bootstrap", methods=["GET"])
@swagger_route(security=get_auth_security())
@login_required
diff --git a/application/v2_ui/src/App.tsx b/application/v2_ui/src/App.tsx
index 72ca68a56..f6e4d3e53 100644
--- a/application/v2_ui/src/App.tsx
+++ b/application/v2_ui/src/App.tsx
@@ -24,7 +24,7 @@ import { AdminActionEditorPage, AdminAgentEditorPage } from './pages/AdminGlobal
import { SettingsPage } from './pages/SettingsPage';
import { WorkspacePage } from './pages/workspace/WorkspacePage';
import { safeSameOriginUrl } from './lib/adminOperations';
-import { PlaceholderPage } from './pages/PlaceholderPage';
+import { AgentsCatalogPage } from './pages/AgentsCatalogPage';
import { GroupWorkspacePage } from './pages/GroupWorkspacePage';
import { GroupDirectoryPage } from './pages/GroupDirectoryPage';
import { PublicWorkspacePage } from './pages/PublicWorkspacePage';
@@ -290,17 +290,7 @@ export function App() {
} />
} />
} />
-
- }
- />
+ } />
} />
} />
} />
diff --git a/application/v2_ui/src/components/agentsCatalog/AgentCatalogParts.tsx b/application/v2_ui/src/components/agentsCatalog/AgentCatalogParts.tsx
new file mode 100644
index 000000000..82d363e47
--- /dev/null
+++ b/application/v2_ui/src/components/agentsCatalog/AgentCatalogParts.tsx
@@ -0,0 +1,44 @@
+// AgentCatalogParts.tsx
+
+import { MessageSquare } from 'lucide-react';
+import { Link } from 'react-router-dom';
+import { agentScopeType } from '../../lib/adminAgents';
+import {
+ agentLinkScope, catalogDisplayName, catalogScopeLabel, promotedBadgeLabel, type CatalogAgent,
+} from '../../lib/agentCatalog';
+import { chatHrefForAgent } from '../../lib/conversationUrl';
+import { GlassButton } from '../ui/primitives';
+import { Pill } from '../workspace/primitives';
+
+export function AgentCatalogBadges({ agent }: { agent: CatalogAgent }) {
+ const promoted = promotedBadgeLabel(agent);
+ return (
+
+
+ {catalogScopeLabel(agent)}
+
+ {promoted ?
{promoted} : null}
+
+ );
+}
+
+export function AgentChatLink({ agent }: { agent: CatalogAgent }) {
+ const scope = agentLinkScope(agent);
+ const label = `Chat with ${catalogDisplayName(agent)}`;
+ if (!scope || !agent.id) {
+ return (
+
+
+ Chat
+
+ );
+ }
+ return (
+
+
+ Chat
+
+ );
+}
diff --git a/application/v2_ui/src/components/agentsCatalog/AgentDetailsModal.tsx b/application/v2_ui/src/components/agentsCatalog/AgentDetailsModal.tsx
new file mode 100644
index 000000000..123cb0da3
--- /dev/null
+++ b/application/v2_ui/src/components/agentsCatalog/AgentDetailsModal.tsx
@@ -0,0 +1,82 @@
+// AgentDetailsModal.tsx
+
+import {
+ catalogAgentTypeLabel, catalogDisplayName, catalogUsageCount, normalizeCatalogText, type CatalogAgent,
+} from '../../lib/agentCatalog';
+import { Modal } from '../ui/Modal';
+import { PlainMarkdown } from '../ui/PlainMarkdown';
+import { GlassButton } from '../ui/primitives';
+import { Pill } from '../workspace/primitives';
+import { AgentIcon } from '../workspaceAgents/AgentIdentityFields';
+import { AgentCatalogBadges, AgentChatLink } from './AgentCatalogParts';
+
+export function AgentDetailsModal({
+ agent, showInstructions, onClose,
+}: {
+ agent: CatalogAgent;
+ showInstructions: boolean;
+ onClose: () => void;
+}) {
+ const actions = agent.action_labels ?? agent.actions_to_load ?? [];
+ const tags = agent.tags ?? [];
+ return (
+ Close >}>
+
+
+
+
+
{catalogDisplayName(agent)}
+ {agent.name ?
{agent.name}
: null}
+
+
+
+
+ {agent.description?.trim() || 'No description available.'}
+
+
+
+
Agent type
+ {catalogAgentTypeLabel(agent)}
+
+
+
Model
+ {normalizeCatalogText(agent.model_label) || 'Default'}
+
+
+
Times used, all time
+ {catalogUsageCount(agent, 'all_time').toLocaleString()}
+
+
+
Times used, last 30 days
+ {catalogUsageCount(agent, '30_days').toLocaleString()}
+
+
+
+ Actions
+ {actions.length ? (
+
+ {actions.map((action, index) => {action} )}
+
+ ) : No actions assigned.
}
+
+
+ Tags
+ {tags.length ? (
+
+ {tags.map((tag, index) =>
{tag} )}
+
+ ) : No tags assigned.
}
+
+ {showInstructions && agent.instructions?.trim() ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/application/v2_ui/src/components/ui/Modal.tsx b/application/v2_ui/src/components/ui/Modal.tsx
index f31933c46..3631f5234 100644
--- a/application/v2_ui/src/components/ui/Modal.tsx
+++ b/application/v2_ui/src/components/ui/Modal.tsx
@@ -209,7 +209,7 @@ export function Modal({
>
{banner ?? (
-
+
{title}
{description ? (
{description}
diff --git a/application/v2_ui/src/lib/agentCatalog.ts b/application/v2_ui/src/lib/agentCatalog.ts
new file mode 100644
index 000000000..caaf5791e
--- /dev/null
+++ b/application/v2_ui/src/lib/agentCatalog.ts
@@ -0,0 +1,292 @@
+// agentCatalog.ts
+
+import { agentScopeType } from './adminAgents';
+import type { AgentLinkScope } from './conversationUrl';
+import { isRecord, type AgentConfiguration } from './workspaceAuthoring';
+import { AGENT_TYPE_LABELS, agentStrings, agentText } from './workspaceAgentAuthoring';
+
+export const AGENTS_CATALOG_ENDPOINT = '/api/v2/agents/catalog';
+export const CATALOG_VIEW_STORAGE_KEY = 'simplechat-agents-catalog-view';
+export type CatalogTab = 'popular' | 'personal' | 'group' | 'enterprise';
+export type CatalogUsageWindow = 'all_time' | '30_days';
+export type CatalogViewMode = 'list' | 'card';
+
+export interface CatalogAgent {
+ id?: string;
+ name?: string;
+ display_name?: string;
+ description?: string;
+ agent_type?: string;
+ is_global?: boolean;
+ is_group?: boolean;
+ scope_type?: string;
+ scope_id?: string;
+ scope_name?: string;
+ group_id?: string;
+ group_name?: string;
+ tags?: string[];
+ icon?: AgentConfiguration['icon'];
+ model_label?: string;
+ actions_to_load?: string[];
+ action_labels?: string[];
+ catalog_key?: string;
+ usage_count?: number | string | null;
+ usage_count_30_days?: number | string | null;
+ usage_count_all_time?: number | string | null;
+ is_promoted_popular?: boolean;
+ promoted_popular_window?: string;
+ promoted_popular_rank?: number | string | null;
+ promoted_popular_order?: string;
+ promoted_popular_tag_enabled?: boolean;
+ promoted_popular_tag_label?: string;
+ instructions?: string;
+}
+
+export interface CatalogPageConfig {
+ title: string;
+ subtitle: string;
+ hero_color_mode: 'single' | 'two_tone';
+ hero_primary_color: string;
+ hero_secondary_color: string;
+ disclaimer_markdown: string;
+ show_instructions_in_details: boolean;
+}
+
+export interface AgentsCatalog {
+ page: CatalogPageConfig;
+ agents: CatalogAgent[];
+}
+
+export const DEFAULT_CATALOG_PAGE: CatalogPageConfig = {
+ title: 'Find your next AI partner',
+ subtitle: 'Explore specialized agents built to accelerate how you work.',
+ hero_color_mode: 'single',
+ hero_primary_color: '#0f172a',
+ hero_secondary_color: '#1e293b',
+ disclaimer_markdown: '',
+ show_instructions_in_details: true,
+};
+
+export function normalizeCatalogText(value: unknown): string {
+ return agentText(value).replace(/\s+/g, ' ').trim();
+}
+
+export function catalogDisplayName(agent: CatalogAgent): string {
+ return normalizeCatalogText(agent.display_name) || normalizeCatalogText(agent.name) || 'Unnamed Agent';
+}
+
+export function catalogScopeLabel(agent: CatalogAgent): string {
+ const scope = agentScopeType(agent);
+ if (scope === 'group') {
+ return normalizeCatalogText(agent.group_name) || normalizeCatalogText(agent.scope_name) || 'Group';
+ }
+ return scope === 'global' ? 'Enterprise' : 'Personal';
+}
+
+export function catalogAgentTypeLabel(agent: CatalogAgent): string {
+ const type = normalizeCatalogText(agent.agent_type).toLowerCase();
+ if (type === 'local' || type === 'aifoundry' || type === 'new_foundry' || type === 'foundry_workflow') {
+ return AGENT_TYPE_LABELS[type];
+ }
+ return normalizeCatalogText(agent.agent_type) || AGENT_TYPE_LABELS.local;
+}
+
+export function catalogAgentKey(agent: CatalogAgent): string {
+ return normalizeCatalogText(agent.catalog_key) || normalizeCatalogText(agent.id) || catalogDisplayName(agent);
+}
+
+export function promotedBadgeLabel(agent: CatalogAgent): string {
+ if (!agent.is_promoted_popular || agent.promoted_popular_tag_enabled === false) {
+ return '';
+ }
+ return (normalizeCatalogText(agent.promoted_popular_tag_label) || 'Promoted').slice(0, 40);
+}
+
+export function normalizePromotionWindow(value: unknown): CatalogUsageWindow | 'both' {
+ const window = normalizeCatalogText(value).toLowerCase().replace(/-/g, '_');
+ if (['all', 'alltime', 'all_time'].includes(window)) {
+ return 'all_time';
+ }
+ if (['30', 'last30', 'last_30_days', '30_days'].includes(window)) {
+ return '30_days';
+ }
+ return 'both';
+}
+
+export function catalogUsageCount(agent: CatalogAgent, window: CatalogUsageWindow): number {
+ const selected = window === '30_days' ? agent.usage_count_30_days : agent.usage_count_all_time;
+ const count = Number(selected ?? agent.usage_count ?? 0);
+ return Number.isFinite(count) ? count : 0;
+}
+
+function promotionRank(agent: CatalogAgent): number {
+ const rank = Number(agent.promoted_popular_rank);
+ return Number.isFinite(rank) ? rank : 1000000;
+}
+
+function byName(left: CatalogAgent, right: CatalogAgent): number {
+ return catalogDisplayName(left).localeCompare(catalogDisplayName(right), undefined, { sensitivity: 'base' });
+}
+
+function dedupeAgents(agents: CatalogAgent[]): CatalogAgent[] {
+ const seen = new Set
();
+ return agents.filter((agent) => {
+ const key = catalogAgentKey(agent);
+ if (seen.has(key)) {
+ return false;
+ }
+ seen.add(key);
+ return true;
+ });
+}
+
+/** Promotions are additional to, not part of, the twelve usage-ranked agents. */
+export function popularAgents(agents: CatalogAgent[], window: CatalogUsageWindow): CatalogAgent[] {
+ const promoted = agents.filter((agent) => {
+ const promotionWindow = normalizePromotionWindow(agent.promoted_popular_window);
+ return agent.is_promoted_popular && (promotionWindow === 'both' || promotionWindow === window);
+ }).sort((left, right) => promotionRank(left) - promotionRank(right) || byName(left, right));
+ const promotedKeys = new Set(promoted.map(catalogAgentKey));
+ const used = agents.filter((agent) => (
+ catalogUsageCount(agent, window) > 0 && !promotedKeys.has(catalogAgentKey(agent))
+ )).sort((left, right) => (
+ catalogUsageCount(right, window) - catalogUsageCount(left, window) || byName(left, right)
+ )).slice(0, 12);
+
+ const order = normalizeCatalogText(promoted[0]?.promoted_popular_order).toLowerCase();
+ if (promoted.length && order !== 'mixed' && order !== 'after') {
+ return dedupeAgents([...promoted, ...used]);
+ }
+ if (order === 'after') {
+ return dedupeAgents([...used, ...promoted]);
+ }
+ return dedupeAgents([...used, ...promoted]).sort((left, right) => (
+ catalogUsageCount(right, window) - catalogUsageCount(left, window)
+ || promotionRank(left) - promotionRank(right)
+ || byName(left, right)
+ ));
+}
+
+export function visibleAgents({
+ agents, tab, window, query, tags,
+}: {
+ agents: CatalogAgent[];
+ tab: CatalogTab;
+ window: CatalogUsageWindow;
+ query: string;
+ tags: string[];
+}): CatalogAgent[] {
+ const search = normalizeCatalogText(query).toLowerCase();
+ const base = search ? agents : tab === 'popular' ? popularAgents(agents, window) : agents.filter((agent) => {
+ const scope = agentScopeType(agent);
+ return scope === (tab === 'enterprise' ? 'global' : tab);
+ });
+ const tagKeys = tags.map((tag) => normalizeCatalogText(tag).toLowerCase());
+ const result = base.filter((agent) => {
+ const agentTags = new Set(agentStrings(agent.tags).map((tag) => normalizeCatalogText(tag).toLowerCase()));
+ if (!tagKeys.every((tag) => agentTags.has(tag))) {
+ return false;
+ }
+ const searchable = [
+ catalogDisplayName(agent), agent.name, agent.description, catalogScopeLabel(agent),
+ agent.model_label, ...agentStrings(agent.tags),
+ ].map(normalizeCatalogText).join(' ').toLowerCase();
+ return !search || searchable.includes(search);
+ });
+ return search || tab !== 'popular' ? result.sort(byName) : result;
+}
+
+export function tagsFor(agents: CatalogAgent[]): string[] {
+ const tags = new Map();
+ for (const agent of agents) {
+ for (const raw of agentStrings(agent.tags)) {
+ const tag = normalizeCatalogText(raw);
+ if (tag && !tags.has(tag.toLowerCase())) {
+ tags.set(tag.toLowerCase(), tag);
+ }
+ }
+ }
+ return [...tags.values()].sort((left, right) => left.localeCompare(right, undefined, { sensitivity: 'base' }));
+}
+
+export function agentLinkScope(agent: CatalogAgent): AgentLinkScope | null {
+ if (!normalizeCatalogText(agent.id)) {
+ return null;
+ }
+ const scope = agentScopeType(agent);
+ if (scope === 'group') {
+ const id = normalizeCatalogText(agent.group_id) || normalizeCatalogText(agent.scope_id);
+ return id ? { kind: 'group', id } : null;
+ }
+ return scope === 'global' ? { kind: 'global' } : { kind: 'personal' };
+}
+
+export function catalogHeroColor(value: unknown, fallback: string): string {
+ const color = agentText(value).trim();
+ return /^#[0-9a-fA-F]{6}$/.test(color) ? color : fallback;
+}
+
+export function parseCatalogViewMode(value: unknown): CatalogViewMode {
+ return value === 'card' ? 'card' : 'list';
+}
+
+function numericField(value: unknown): number | string | null | undefined {
+ return typeof value === 'number' || typeof value === 'string' || value === null ? value : undefined;
+}
+
+function readCatalogAgent(record: Record, showInstructions: boolean): CatalogAgent {
+ const icon = record.icon;
+ return {
+ id: agentText(record.id).trim(),
+ name: agentText(record.name),
+ display_name: agentText(record.display_name),
+ description: agentText(record.description),
+ agent_type: agentText(record.agent_type),
+ is_global: record.is_global === true,
+ is_group: record.is_group === true,
+ scope_type: agentText(record.scope_type),
+ scope_id: agentText(record.scope_id).trim(),
+ scope_name: agentText(record.scope_name),
+ group_id: agentText(record.group_id).trim(),
+ group_name: agentText(record.group_name),
+ tags: agentStrings(record.tags),
+ icon: isRecord(icon) && (icon.kind === 'image' || icon.kind === 'bootstrap') && typeof icon.value === 'string'
+ ? { kind: icon.kind, value: icon.value, mime_type: typeof icon.mime_type === 'string' ? icon.mime_type : undefined }
+ : undefined,
+ model_label: agentText(record.model_label),
+ actions_to_load: agentStrings(record.actions_to_load),
+ // An explicitly empty display-label list is authoritative, as in classic details.
+ action_labels: Array.isArray(record.action_labels) ? agentStrings(record.action_labels) : undefined,
+ catalog_key: agentText(record.catalog_key),
+ usage_count: numericField(record.usage_count),
+ usage_count_30_days: numericField(record.usage_count_30_days),
+ usage_count_all_time: numericField(record.usage_count_all_time),
+ is_promoted_popular: record.is_promoted_popular === true,
+ promoted_popular_window: agentText(record.promoted_popular_window),
+ promoted_popular_rank: numericField(record.promoted_popular_rank),
+ promoted_popular_order: agentText(record.promoted_popular_order),
+ promoted_popular_tag_enabled: record.promoted_popular_tag_enabled !== false,
+ promoted_popular_tag_label: agentText(record.promoted_popular_tag_label),
+ instructions: showInstructions ? agentText(record.instructions) : undefined,
+ };
+}
+
+export function readAgentsCatalog(payload: unknown): AgentsCatalog {
+ if (!isRecord(payload) || !isRecord(payload.page) || !Array.isArray(payload.agents)) {
+ throw new Error('Invalid agent catalogue response.');
+ }
+ const page = payload.page;
+ const showInstructions = page.show_instructions_in_details !== false;
+ return {
+ page: {
+ title: (normalizeCatalogText(page.title) || DEFAULT_CATALOG_PAGE.title).slice(0, 120),
+ subtitle: (normalizeCatalogText(page.subtitle) || DEFAULT_CATALOG_PAGE.subtitle).slice(0, 240),
+ hero_color_mode: page.hero_color_mode === 'two_tone' ? 'two_tone' : 'single',
+ hero_primary_color: catalogHeroColor(page.hero_primary_color, DEFAULT_CATALOG_PAGE.hero_primary_color),
+ hero_secondary_color: catalogHeroColor(page.hero_secondary_color, DEFAULT_CATALOG_PAGE.hero_secondary_color),
+ disclaimer_markdown: agentText(page.disclaimer_markdown).slice(0, 3000),
+ show_instructions_in_details: showInstructions,
+ },
+ agents: payload.agents.filter(isRecord).map((agent) => readCatalogAgent(agent, showInstructions)),
+ };
+}
diff --git a/application/v2_ui/src/lib/latestFeatureShortcuts.ts b/application/v2_ui/src/lib/latestFeatureShortcuts.ts
index d67159438..35abc6a98 100644
--- a/application/v2_ui/src/lib/latestFeatureShortcuts.ts
+++ b/application/v2_ui/src/lib/latestFeatureShortcuts.ts
@@ -5,7 +5,7 @@
// shortcuts name classic pages: `/chats#chatbox`, `/workspace#documents-tab`,
// `/profile?tab=stats`. Followed as written, every one of them would drop a V2 reader into the
// classic interface. Where V2 has rebuilt the page, the shortcut is translated to the V2 route;
-// where it has not -- the agent catalogue, workflow activity -- the page opens as written, the
+// where it has not -- workflow activity -- the page opens as written, the
// same choice notificationLinks.ts makes for a notification. Documentation links leave the site
// and open in a new tab.
//
@@ -44,6 +44,7 @@ export type LatestFeatureShortcutTarget =
| { kind: 'external'; href: string };
const CHAT_PATH = '/chat';
+const AGENTS_PATH = '/agents';
const WORKSPACE_PATH = '/workspace';
const SETTINGS_PATH = '/settings';
const SETTINGS_STATS_PATH = '/settings?tab=stats';
@@ -77,6 +78,7 @@ const WORKSPACE_FEATURE_ACTION_SECTIONS: Readonly> = {
/** Every router path a shortcut may produce. */
const V2_SHORTCUT_PATHS: ReadonlySet = new Set([
CHAT_PATH,
+ AGENTS_PATH,
WORKSPACE_PATH,
SETTINGS_PATH,
SETTINGS_STATS_PATH,
@@ -132,6 +134,8 @@ function v2RouteFor(url: URL): LatestFeatureShortcutRoute | null {
const featureAction = url.searchParams.get('feature_action') ?? '';
switch (path) {
+ case '/agents':
+ return route(AGENTS_PATH);
case '/chats':
case '/chat':
case '/conversations':
diff --git a/application/v2_ui/src/pages/AgentsCatalogPage.tsx b/application/v2_ui/src/pages/AgentsCatalogPage.tsx
new file mode 100644
index 000000000..ee207a32a
--- /dev/null
+++ b/application/v2_ui/src/pages/AgentsCatalogPage.tsx
@@ -0,0 +1,276 @@
+// AgentsCatalogPage.tsx
+
+import { useEffect, useMemo, useState } from 'react';
+import { Bot, Info, LayoutGrid, List, Lock, Plus, RotateCcw } from 'lucide-react';
+import { Link } from 'react-router-dom';
+import { AgentCatalogBadges, AgentChatLink } from '../components/agentsCatalog/AgentCatalogParts';
+import { AgentDetailsModal } from '../components/agentsCatalog/AgentDetailsModal';
+import { PageHeader } from '../components/layout/PageHeader';
+import { PlainMarkdown } from '../components/ui/PlainMarkdown';
+import { EmptyState, GlassButton, GlassPanel } from '../components/ui/primitives';
+import { SectionError, SectionSearch, SectionSkeleton } from '../components/workspace/primitives';
+import { AgentIcon } from '../components/workspaceAgents/AgentIdentityFields';
+import {
+ AGENTS_CATALOG_ENDPOINT, CATALOG_VIEW_STORAGE_KEY, DEFAULT_CATALOG_PAGE, catalogAgentKey,
+ catalogDisplayName, normalizeCatalogText, parseCatalogViewMode, readAgentsCatalog, tagsFor, visibleAgents,
+ type AgentsCatalog, type CatalogAgent, type CatalogTab, type CatalogUsageWindow, type CatalogViewMode,
+} from '../lib/agentCatalog';
+import { api, ApiError } from '../lib/apiClient';
+import { groupWorkspacePath } from '../lib/groupWorkspaceNavigation';
+import { useBootstrapStore } from '../stores/bootstrapStore';
+
+type LoadState = 'loading' | 'ready' | 'unavailable' | 'error';
+const TABS: { value: CatalogTab; label: string }[] = [
+ { value: 'popular', label: 'Popular' },
+ { value: 'personal', label: 'Personal' },
+ { value: 'group', label: 'Group' },
+ { value: 'enterprise', label: 'Enterprise' },
+];
+const SELECTED_CATEGORY = 'bg-accent text-on-accent';
+const CATEGORY_BUTTON = 'rounded-lg px-3 py-2 text-sm font-medium transition-colors';
+
+function initialViewMode(): CatalogViewMode {
+ try {
+ return parseCatalogViewMode(window.localStorage.getItem(CATALOG_VIEW_STORAGE_KEY));
+ } catch (error) {
+ console.warn('Agent catalogue view preference could not be read.', error);
+ return 'list';
+ }
+}
+
+function AgentResult({
+ agent, rank, view, onDetails,
+}: {
+ agent: CatalogAgent;
+ rank: number | null;
+ view: CatalogViewMode;
+ onDetails: () => void;
+}) {
+ const card = view === 'card';
+ return (
+
+
+
+ {rank !== null ? (
+
+ {rank}
+
+ ) : null}
+
+
+
+
{catalogDisplayName(agent)}
+
+
+ {normalizeCatalogText(agent.description) || 'No description available.'}
+
+
{normalizeCatalogText(agent.model_label) || 'Default'}
+ {agent.tags?.length ? (
+
{agent.tags.map(normalizeCatalogText).join(', ')}
+ ) : null}
+
+
+
+
+ );
+}
+
+export function AgentsCatalogPage() {
+ const bootstrap = useBootstrapStore((state) => state.data);
+ const [catalog, setCatalog] = useState({ page: DEFAULT_CATALOG_PAGE, agents: [] });
+ const [loadState, setLoadState] = useState('loading');
+ const [attempt, setAttempt] = useState(0);
+ const [tab, setTab] = useState('popular');
+ const [window, setWindow] = useState('all_time');
+ const [query, setQuery] = useState('');
+ const [selectedTags, setSelectedTags] = useState([]);
+ const [view, setView] = useState(initialViewMode);
+ const [details, setDetails] = useState(null);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ setLoadState('loading');
+ api.get(AGENTS_CATALOG_ENDPOINT, controller.signal).then((payload) => {
+ if (controller.signal.aborted) return;
+ setCatalog(readAgentsCatalog(payload));
+ setLoadState('ready');
+ }).catch((error: unknown) => {
+ if (controller.signal.aborted) return;
+ setLoadState(error instanceof ApiError && [400, 403, 404].includes(error.status) ? 'unavailable' : 'error');
+ });
+ return () => controller.abort();
+ }, [attempt]);
+
+ useEffect(() => {
+ try {
+ globalThis.localStorage.setItem(CATALOG_VIEW_STORAGE_KEY, view);
+ } catch (error) {
+ console.warn('Agent catalogue view preference could not be saved.', error);
+ }
+ }, [view]);
+
+ const searching = Boolean(normalizeCatalogText(query));
+ const popular = !searching && tab === 'popular';
+ const shown = useMemo(() => visibleAgents({
+ agents: catalog.agents, tab, window, query, tags: selectedTags,
+ }), [catalog.agents, tab, window, query, selectedTags]);
+ // Selected tags remain removable even when the current result set has no tags or agents.
+ const tagOptions = useMemo(() => {
+ const options = new Map();
+ for (const tag of [...tagsFor(shown), ...selectedTags]) options.set(tag.toLowerCase(), tag);
+ return [...options.values()].sort((left, right) => left.localeCompare(right, undefined, { sensitivity: 'base' }));
+ }, [shown, selectedTags]);
+ const selectedTagKeys = new Set(selectedTags.map((tag) => tag.toLowerCase()));
+ const resultsTitle = searching ? 'Search results' : TABS.find((entry) => entry.value === tab)?.label ?? 'Agents';
+ const activeGroup = bootstrap?.scope.groups.find((group) => group.id === bootstrap.scope.active_group_id);
+ const personalCreate = tab === 'personal' && bootstrap?.workspace.sections.agents?.enabled;
+ const groupCreate = tab === 'group' && bootstrap?.features.enable_group_workspaces && bootstrap.settings.allow_group_agents === true;
+
+ const selectTab = (next: CatalogTab) => {
+ setTab(next);
+ setQuery('');
+ setSelectedTags([]);
+ };
+ const clearFilters = () => {
+ setQuery('');
+ setSelectedTags([]);
+ };
+
+ let content;
+ if (loadState === 'loading') {
+ content =
;
+ } else if (loadState === 'unavailable') {
+ content = } title="Agents are not available"
+ description="Agents have not been enabled for your account. Your administrators manage this access." />;
+ } else if (loadState === 'error') {
+ content = setAttempt((count) => count + 1)}>
+ Try again
+ } />;
+ } else {
+ const page = catalog.page;
+ const background = page.hero_color_mode === 'two_tone'
+ ? `linear-gradient(120deg, ${page.hero_primary_color}, ${page.hero_secondary_color})`
+ : page.hero_primary_color;
+ content = (
+ <>
+
+
+
{page.title}
+
{page.subtitle}
+
+
+ {page.disclaimer_markdown.trim() ? (
+
+ ) : null}
+
+
+
+
+
+ setView('list')}>
+
+
+ setView('card')}>
+
+
+
+
+
+ {TABS.map((entry) => (
+ selectTab(entry.value)}
+ className={`${CATEGORY_BUTTON} ${!searching && tab === entry.value ? SELECTED_CATEGORY : 'text-text-2 hover:bg-surface-2 hover:text-text-1'}`}>
+ {entry.label}
+
+ ))}
+ {searching ? Search results : null}
+
+ {tagOptions.length ? (
+
+ Tags
+ {tagOptions.map((tag) => (
+ setSelectedTags((current) => selectedTagKeys.has(tag.toLowerCase())
+ ? current.filter((value) => value.toLowerCase() !== tag.toLowerCase()) : [...current, tag])}>
+ {tag}
+
+ ))}
+
+ ) : null}
+
+
+
{resultsTitle}
+
{shown.length} {shown.length === 1 ? 'agent' : 'agents'}
+
+
+ {popular ? (
+
+ {(['all_time', '30_days'] as const).map((value) => (
+ setWindow(value)}
+ className={`${CATEGORY_BUTTON} ${window === value ? SELECTED_CATEGORY : 'text-text-2 hover:text-text-1'}`}>
+ {value === 'all_time' ? 'All time' : 'Last 30 days'}
+
+ ))}
+
+ ) : null}
+ {searching || selectedTags.length ?
Clear filters : null}
+ {!searching && personalCreate ? (
+
+
New agent
+
+ ) : null}
+ {!searching && groupCreate ? (
+
+
New agent
+
+ ) : null}
+
+
+ {shown.length ? (
+
+ {shown.map((agent, index) => (
+
+ setDetails(agent)} />
+
+ ))}
+
+ ) : (
+ }
+ title={catalog.agents.length ? 'No agents match the current view.' : 'No agents are available.'}
+ description={catalog.agents.length ? 'Try another category, search term or tag.' : 'Agents available to your account will appear here.'} />
+ )}
+ >
+ );
+ }
+ return (
+
+
+
+
+ {details ?
setDetails(null)} /> : null}
+
+ );
+}
diff --git a/application/v2_ui/src/pages/PlaceholderPage.tsx b/application/v2_ui/src/pages/PlaceholderPage.tsx
deleted file mode 100644
index bc3395c45..000000000
--- a/application/v2_ui/src/pages/PlaceholderPage.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-// PlaceholderPage.tsx
-// Navigation targets that exist in the V2 rail but whose surfaces have not been rebuilt
-// yet. Rather than hiding the nav entry or dead-ending the user, each links through to the
-// equivalent page in the current interface.
-
-import { ArrowUpRight } from 'lucide-react';
-import { PageHeader } from '../components/layout/PageHeader';
-import { GlassPanel } from '../components/ui/primitives';
-
-export function PlaceholderPage({
- title,
- description,
- classicHref,
- classicLabel,
-}: {
- title: string;
- description: string;
- classicHref: string;
- classicLabel: string;
-}) {
- return (
- <>
-
-
- >
- );
-}
diff --git a/docs/admin/agents-actions.md b/docs/admin/agents-actions.md
index 0bfdb34cc..d00f82497 100644
--- a/docs/admin/agents-actions.md
+++ b/docs/admin/agents-actions.md
@@ -204,6 +204,13 @@ See [Configure model endpoint identity]({{ '/guides/model-endpoint-identity-setu
Controls how the Agents catalog page presents itself. That page is served behind
**Enable Agents**, so none of this applies while agents are off.
+These settings apply to both the classic catalogue and the native V2 **Agents**
+page. In V2, the catalogue omits agents blocked by the user's governance policy
+before adding usage rankings or promotions. It does not show restricted-agent
+cards or request-access controls. The instructions setting also redacts
+instructions from `GET /api/v2/agents/catalog`, not just from its details dialog.
+See the [Agents browsing guide]({{ '/guides/workspace-agents-and-actions/#find-and-start-an-agent' | relative_url }}).
+
The promotion controls exist because the Popular tab ranks agents by how often
people run them, which leaves a newly published agent unable to be found: nothing
becomes popular until it is already popular. Promoting an agent places it in that
diff --git a/docs/explanation/features/REACT_V2_UI.md b/docs/explanation/features/REACT_V2_UI.md
index 3a316cec5..3c6c85807 100644
--- a/docs/explanation/features/REACT_V2_UI.md
+++ b/docs/explanation/features/REACT_V2_UI.md
@@ -12,6 +12,7 @@ opt-in editor representation while leaving classic callers compatible.
**Implemented in version:** 0.261.003
**Personal agent/action authoring:** 0.261.096 (`application/single_app/config.py`)
+**Native Agents catalogue:** 0.261.305 (`application/single_app/config.py`)
**Deployer version:** 1.0.26
### Dependencies
@@ -1060,11 +1061,18 @@ information to make, since the classic profile page held the settings *and* the
stats. With Stats rebuilt, the Profile entry has nothing left to lead to and is gone. The
`/profile?tab=groups` and `/profile?tab=public-workspaces` links inside the Groups and Public
tabs remain: those are the fallback for tabs V2 has not rebuilt, not profile navigation.
-### Not rebuilt yet
-
-The standalone agent catalogue and broader group/public workspace management are
-separate from personal authoring. Their existing fallback and focused delegation
-surfaces remain unchanged.
+## Agents catalogue
+
+The rail's **Agents** entry opens the native catalogue at `/v2/agents`. It includes
+Popular rankings and promotions, personal/group/enterprise categories, cross-scope
+search, tag filters, list/card views, details, and new-chat links. Its dedicated
+`GET /api/v2/agents/catalog` hides governance-blocked agents before applying usage
+or promotions. Latest Features shortcuts to `/agents` stay inside V2 too.
+
+Group Chat links name the selected agent's own group, and chat refreshes its
+authorized catalogue before launch. No restricted-agent discovery or
+request-access controls are added. See [V2 Agents Catalogue](V2_AGENTS_CATALOG.md)
+for configuration, behavior, and coverage.
## My Workspace agent and action editors
diff --git a/docs/explanation/features/V2_AGENTS_CATALOG.md b/docs/explanation/features/V2_AGENTS_CATALOG.md
new file mode 100644
index 000000000..724468b7c
--- /dev/null
+++ b/docs/explanation/features/V2_AGENTS_CATALOG.md
@@ -0,0 +1,131 @@
+# V2 Agents Catalogue
+
+Implemented in version: **0.261.305**, recorded in
+`application/single_app/config.py`.
+
+Refs [#1722](https://github.com/microsoft/simplechat/issues/1722), row 1.
+
+## Overview
+
+The **Agents** navigation entry now opens a native V2 catalogue instead of handing
+users off to the classic interface. It brings together personal agents, agents
+from groups the user belongs to, and enterprise agents. Users can discover an
+agent, review its details, and start a new chat without leaving V2.
+
+Agents blocked by governance are absent, including promoted agents. The catalogue
+uses the same agent-usage policy as the chat picker; it does not advertise
+restricted agents or offer a request-access workflow.
+
+### Dependencies
+
+The page reuses the existing accessible-agent catalogue, governance checks, usage
+counts, and administrator promotion settings. React Router, the shared modal,
+agent icons, and the human-authored Markdown renderer come from the existing V2
+application. No packages, settings keys, or third-party browser assets were added.
+
+## Technical specifications
+
+### API and authorization
+
+`GET /api/v2/agents/catalog` returns `page` display configuration and an `agents`
+array. It requires an authenticated application user and **Enable Agents**
+(`enable_semantic_kernel`).
+
+The server first builds the user's accessible catalogue, then checks each agent
+against agent governance. Only permitted records receive usage counts and
+promotion annotations. An unexpected governance or catalogue failure returns a
+generic error, not unchecked agents or exception text.
+
+An explicit field allowlist exposes identity, scope, descriptions, tags, icons,
+model display labels, action labels, usage counts, and promotion metadata. It
+does not expose assigned knowledge or model endpoint/provider configuration.
+Instructions are included only when **Show Agent Instructions in Details** is on.
+The page configuration is built from sanitized settings.
+
+The classic `/api/agents/catalog` and `/api/agents/popular` contracts are unchanged.
+The administrator's promotion picker still uses the classic catalogue, so this
+user-facing governance filter does not remove promotion candidates from that
+editor.
+
+### Routes and launching
+
+The SPA route is basename-relative `/agents`, currently served at `/v2/agents`.
+Latest Features shortcuts to classic `/agents` are translated to that native
+route.
+
+**Chat** starts a new conversation with the selected agent's ID and scope. Group
+links also carry that agent's own group ID, not the account's active group. The
+chat page refreshes its authorized catalogue before resolving the launch. An
+agent removed or revoked since browsing produces a visible error rather than a
+name-based fallback. Records missing an ID or a group ID remain inspectable but
+cannot be launched.
+
+### Configuration and file structure
+
+Both interfaces use the existing [Agents Page settings](../../admin/agents-actions.md#agents-page-customization-card):
+hero title/subtitle, single or two-tone background, Markdown guidance, instruction
+visibility, and Popular promotions. V2 validates the configured hexadecimal
+colors and adds a dark scrim to keep hero text readable on bright backgrounds.
+
+| File | Responsibility |
+| --- | --- |
+| `functions_v2_agents_catalog.py` | Governance-first payload builder and allowlist |
+| `route_backend_v2.py` | Authenticated catalogue endpoint and error logging |
+| `src/lib/agentCatalog.ts` | Payload parsing, Popular ranking, filtering, and launch scopes |
+| `src/pages/AgentsCatalogPage.tsx` | Native browsing and load states |
+| `src/components/agentsCatalog/` | Shared badges/Chat links and details modal |
+| `src/components/ui/Modal.tsx` | Shared dialog header wrapping for long names |
+| `src/lib/latestFeatureShortcuts.ts` | Native Agents shortcut translation |
+
+Frontend paths in this table are relative to `application/v2_ui`.
+
+## Usage
+
+Open **Agents** from the navigation rail. **Popular** starts with all-time usage;
+switch to **Last 30 days** to see recent usage. Promotions appear in their
+configured windows and placement. They are additional to the twelve
+usage-ranked, nonpromoted agents.
+
+Choose **Personal**, **Group**, or **Enterprise** to browse a scope. Search spans
+all scopes, including names, descriptions, group names, model labels, and tags.
+Clearing the search restores the previous category. Selecting multiple tags
+requires every selected tag; **Clear filters** removes search and tag filters.
+
+Switch between list and card views. The browser remembers the choice using
+`simplechat-agents-catalog-view`, shared with the classic catalogue. Browsing
+still works when browser storage is unavailable.
+
+Use **Details** to review the generated name, scope, type, description, model,
+usage, actions, tags, and permitted instructions. Use **Chat** from a result or
+its details to start a new conversation. Long names, group labels, and tags wrap
+in results and details rather than widening the page on mobile.
+**New agent** opens the native personal
+editor when available; on Group it opens the active visible group's Agents
+section, or the group workspace entry when no active group is available.
+
+## Testing and validation
+
+| Coverage | Test |
+| --- | --- |
+| Governance filtering, annotation order, allowlist, instructions, route guards/error contract | `functional_tests/test_v2_agents_catalog_endpoint.py` |
+| Payload parsing, Popular windows/placement, search/tags, safe display, view mode, exact launch scope | `functional_tests/test_v2_agents_catalog.py` and `test_v2_agents_catalog_logic.ts` |
+| Latest Features native Agents destination | `functional_tests/test_v2_support_menu.py` and `ui_tests/test_v2_support_menu.py` |
+| Real production SPA, keyboard/modal behavior, storage, unavailable/error/retry/loading states, chat refresh, light/dark desktop/mobile overflow | `ui_tests/test_v2_agents_catalog.py` |
+
+The UI suite reuses the Azure-capable Playwright connection fixture and falls
+back to local Playwright when no Azure workspace is configured. It serves the
+real, freshly built SPA with synthetic API responses and rejects unexpected
+requests, including legacy page hand-offs.
+
+### Performance and limitations
+
+Filtering and ranking run locally over one loaded catalogue; changing categories,
+tags, or the usage window does not fetch the catalogue again. This preserves the
+classic aggregate-usage ranking, not a per-user recommendation system.
+
+This change implements only row 1 of the V2 parity issue. The companion legacy
+routing and **New UI only** policy in
+[#1723](https://github.com/microsoft/simplechat/issues/1723) are separate work.
+Restricted-agent discovery and request access remain deferred. Instruction
+redaction applies to this catalogue response; the existing bootstrap contract is
+not changed by this feature.
diff --git a/docs/explanation/features/V2_SUPPORT_MENU.md b/docs/explanation/features/V2_SUPPORT_MENU.md
index f93383812..73fd32bcf 100644
--- a/docs/explanation/features/V2_SUPPORT_MENU.md
+++ b/docs/explanation/features/V2_SUPPORT_MENU.md
@@ -2,6 +2,9 @@
**Implemented in version: 0.261.296**
+**Native Agents shortcut updated in version: 0.261.305**, recorded in
+`application/single_app/config.py`.
+
## Overview
Administrators configure the Support menu in Admin Settings: whether it appears, its name,
@@ -101,6 +104,7 @@ one to a V2 route when V2 has rebuilt the page:
| Classic link | V2 destination |
| --- | --- |
+| `/agents` | `/agents`, the native V2 catalogue |
| `/chats` (any fragment or `feature_action`), `/conversations` | `/chat`, arriving at an empty chat as the rail's Chats link does |
| `/chats#chat-tutorial-launch` | `/chat`, starting the chat guided tour |
| `/workspace#documents-tab`, `#prompts-tab`, `#agents-tab`, `#plugins-tab`, `#workflows-tab`, `#identities-tab`, `#endpoints-tab`, `#sync-tab` | The matching `/workspace/` |
@@ -110,7 +114,7 @@ one to a V2 route when V2 has rebuilt the page:
| `/group_workspaces`, `/public_workspaces`, `/public_directory`, `/approvals` | `/groups`, `/public`, `/public/directory`, `/approvals` |
| `/support/latest-features`, `/support/send-feedback` | The V2 Support pages |
-Anything else on the site, such as the agent catalogue or workflow activity, opens as written
+Anything else on the site, such as workflow activity, opens as written
in the classic interface. http(s) links open in a new tab. Every V2 destination is a constant
from an allowlist, so a router target can never be built from catalogue data (#1698).
Classic and external links go through `safeLatestFeatureHref`, which refuses script URLs,
@@ -197,7 +201,7 @@ Features**. V2 and classic users see the same menu.
- Classic-only behaviour carried in a shortcut, such as `feature_action` opening a dialog,
has no V2 counterpart. The shortcut lands on the closest V2 page.
-- The agent catalogue and workflow activity are not rebuilt in V2, so their shortcuts open
- the classic interface.
+- Workflow activity is not rebuilt in V2, so its shortcuts open the classic
+ interface. Agents shortcuts open the [native V2 catalogue](V2_AGENTS_CATALOG.md).
- No email is sent by the server. The user sends the draft from their own mail app, as on
the classic page.
diff --git a/docs/explanation/features/V2_WORKSPACE_AGENTS_ACTIONS.md b/docs/explanation/features/V2_WORKSPACE_AGENTS_ACTIONS.md
index 8859b7abf..fe6b28be2 100644
--- a/docs/explanation/features/V2_WORKSPACE_AGENTS_ACTIONS.md
+++ b/docs/explanation/features/V2_WORKSPACE_AGENTS_ACTIONS.md
@@ -10,8 +10,12 @@ connected makes it possible to build an assistant without switching interfaces.
Implemented in version: **0.261.096**, recorded in
`application/single_app/config.py`.
-The change is limited to personal workspace management. Group/global management,
-the standalone agent catalogue, and the delegation execution rules are unchanged.
+That authoring release was limited to personal workspace management; group/global
+management and delegation execution rules were unchanged. The standalone
+[V2 Agents catalogue](V2_AGENTS_CATALOG.md) was subsequently implemented in version
+**0.261.305**, recorded in `application/single_app/config.py`. It discovers permitted
+agents across scopes and links to the native personal editor when creation is
+available, without changing authoring permissions.
### Dependencies
diff --git a/docs/guides/workspace-agents-and-actions.md b/docs/guides/workspace-agents-and-actions.md
index d8eaf67dd..7b60b28d1 100644
--- a/docs/guides/workspace-agents-and-actions.md
+++ b/docs/guides/workspace-agents-and-actions.md
@@ -1,10 +1,10 @@
---
layout: page
title: "Build agents and actions in My Workspace"
-description: "Configure a reusable assistant and its approved tools without leaving the V2 workspace."
+description: "Find an available agent or configure a reusable assistant and its approved tools in V2."
section: "Guides"
audience: user
-version: "0.261.122"
+version: "0.261.305"
---
## What this does
@@ -19,8 +19,34 @@ Implemented in version: **0.261.096**, recorded in
Remote-only MCP integration updated in version: **0.261.122**.
-This guide concerns your personal workspace. Group and administrator management
-continue to use their existing interfaces and permissions.
+Catalogue discovery was implemented in version: **0.261.305**, recorded in
+`application/single_app/config.py`.
+
+The authoring examples focus on personal resources. Catalogue discovery spans
+all permitted scopes; editing still depends on each workspace's permissions.
+
+## Find and start an agent
+
+Open **Agents** from the V2 navigation rail to browse agents available to your
+account, rather than build an agent from scratch. **Popular** combines usage
+rankings with administrator promotions; choose **All time** or **Last 30 days**
+depending on whether you want established or recently used agents.
+
+The Personal, Group, and Enterprise categories narrow the catalogue. Search
+looks across every category, including descriptions, group names, models, and
+tags. Multiple tag filters require all selected tags. Clearing search restores
+the category you were browsing; **Clear filters** also removes tag selections.
+
+Use **Details** to review the agent's role, model, actions, and instructions when
+your administrators permit them to be shown. **Chat** starts a new conversation
+with that exact agent. A group agent uses its own group even when another group
+is active. If its access was revoked or it was removed after browsing, chat
+explains that it is no longer available.
+
+Governance-restricted agents are not listed. A promotion does not grant access,
+and this page has no agent-specific request-access workflow. When personal
+creation is available, **New agent** in the Personal category opens the native
+editor; in Group it leads to the active visible group's Agents section.
## Decide what belongs where
diff --git a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py
index 92ff4d304..7dddd623a 100644
--- a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py
+++ b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py
@@ -20,6 +20,7 @@
Access restricted screen and safety warning policy coverage: 0.261.297
Review center ids, bulk, detail and approvals summary policy coverage: 0.261.298
Review center AI assist policy coverage: 0.261.299
+V2 Agents catalogue policy coverage: 0.261.305
This test ensures every SimpleChat route is assigned to a Blueprint-based
security policy or an explicit reviewed route exemption.
@@ -296,6 +297,7 @@
("route_backend_v2.py", "v2_group_workspace_context"): ("login_required", "user_required", "enabled_required"),
("route_backend_v2.py", "v2_public_workspace_context"): ("login_required", "user_required", "enabled_required"),
("route_backend_v2.py", "v2_support_latest_features"): ("login_required", "user_required", "enabled_required"),
+ ("route_backend_v2.py", "v2_agents_catalog"): ("login_required", "user_required", "enabled_required"),
# Global agent and action editors: the organisation's records, Admin role only.
("route_backend_v2_admin_agents_actions.py", "v2_admin_global_agents_list"): ("login_required", "admin_required"),
("route_backend_v2_admin_agents_actions.py", "v2_admin_global_agents_create"): ("login_required", "admin_required"),
diff --git a/functional_tests/test_v2_agents_catalog.py b/functional_tests/test_v2_agents_catalog.py
new file mode 100644
index 000000000..774e23cc8
--- /dev/null
+++ b/functional_tests/test_v2_agents_catalog.py
@@ -0,0 +1,78 @@
+# test_v2_agents_catalog.py
+"""
+Functional tests for native V2 catalogue logic and route wiring.
+Version: 0.261.305
+Implemented in: 0.261.305
+
+Execute the real TypeScript helpers and shared Chat controls through Vite's
+existing esbuild dependency. No browser or Azure service is needed. The separate
+UI suite exercises the production SPA, interactions, and refreshed chat launch.
+"""
+
+import subprocess
+import sys
+from pathlib import Path
+
+from test_support.versioning import assert_app_version_at_least
+
+
+ROOT = Path(__file__).resolve().parents[1]
+V2_DIR = ROOT / "application" / "v2_ui"
+SRC = V2_DIR / "src"
+LOGIC_TEST = Path(__file__).with_name("test_v2_agents_catalog_logic.ts")
+
+
+def test_native_catalogue_wiring():
+ """The SPA and Latest Features use the native catalogue, not a legacy hand-off."""
+ assert_app_version_at_least("0.261.305")
+ app = (SRC / "App.tsx").read_text(encoding="utf-8")
+ assert ' } />' in app
+ assert "PlaceholderPage" not in app
+ assert not (SRC / "pages" / "PlaceholderPage.tsx").exists()
+ shortcut = (SRC / "lib" / "latestFeatureShortcuts.ts").read_text(encoding="utf-8")
+ assert "case '/agents':" in shortcut
+ assert "return route(AGENTS_PATH);" in shortcut
+ return True
+
+
+def test_catalogue_logic():
+ """Run ranking, filtering, display parsing, and scope-aware Chat checks."""
+ esbuild = V2_DIR / "node_modules" / ".bin" / (
+ "esbuild.cmd" if sys.platform == "win32" else "esbuild"
+ )
+ if not esbuild.is_file():
+ raise AssertionError("The V2 toolchain is missing; run npm ci in application/v2_ui.")
+ bundle = V2_DIR / "node_modules" / ".cache-agents-catalog-check.mjs"
+ try:
+ subprocess.run(
+ [
+ str(esbuild), str(LOGIC_TEST), "--bundle", "--platform=node",
+ "--format=esm", "--packages=external", "--define:import.meta.env={}",
+ f"--outfile={bundle}", "--log-level=error",
+ ],
+ cwd=V2_DIR,
+ check=True,
+ )
+ result = subprocess.run(
+ ["node", str(bundle)], cwd=V2_DIR, capture_output=True, text=True,
+ check=False,
+ )
+ finally:
+ bundle.unlink(missing_ok=True)
+ if result.returncode:
+ raise AssertionError(f"Catalogue checks failed:\n{result.stdout}\n{result.stderr}")
+ passed = result.stdout.count(" ok ")
+ assert passed >= 28, f"Only {passed} catalogue checks ran."
+ print(f"{passed} catalogue logic checks passed.")
+ return True
+
+
+if __name__ == "__main__":
+ success = True
+ for test in (test_native_catalogue_wiring, test_catalogue_logic):
+ try:
+ test()
+ except Exception as exc:
+ success = False
+ print(f"FAILED {test.__name__}: {exc}")
+ sys.exit(0 if success else 1)
diff --git a/functional_tests/test_v2_agents_catalog_endpoint.py b/functional_tests/test_v2_agents_catalog_endpoint.py
new file mode 100644
index 000000000..d7c47120a
--- /dev/null
+++ b/functional_tests/test_v2_agents_catalog_endpoint.py
@@ -0,0 +1,350 @@
+# test_v2_agents_catalog_endpoint.py
+"""
+Functional test for the agent catalogue served to the V2 Agents page.
+Version: 0.261.305
+Implemented in: 0.261.305
+
+The classic ``/agents`` page reads ``GET /api/agents/catalog``, which lists every
+agent the user can reach. The V2 page reads ``GET /api/v2/agents/catalog``,
+built by ``functions_v2_agents_catalog``, which lists only the agents the user
+may chat with.
+
+These checks pin what that endpoint promises:
+
+- agents governance blocks are left out, and each is checked against the scope
+ the bootstrap uses, so the page and the chat picker agree;
+- governance runs before usage counts and promotions, so a promotion cannot
+ put a blocked agent back on the Popular tab;
+- a governance error propagates rather than listing unchecked agents;
+- only the fields the page draws are sent, never assigned knowledge or the
+ model endpoint, deployment or provider;
+- instructions follow the administrator's details setting;
+- every field the TypeScript page reads is one the endpoint sends;
+- the route sits on the user blueprint behind the Semantic Kernel gate and
+ never returns exception text.
+"""
+
+import ast
+import re
+import sys
+from pathlib import Path
+
+sys.path.append(str(Path(__file__).resolve().parent))
+
+from test_support.app_stubs import import_app_module
+from test_support.versioning import assert_app_version_at_least
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+APP_ROOT = REPO_ROOT / "application" / "single_app"
+ROUTE_FILE = APP_ROOT / "route_backend_v2.py"
+CATALOG_TS = REPO_ROOT / "application" / "v2_ui" / "src" / "lib" / "agentCatalog.ts"
+CATALOG_INTERFACE_RE = re.compile(r"export interface CatalogAgent \{(?P.*?)\n\}", re.DOTALL)
+INTERFACE_FIELD_RE = re.compile(r"^\s+([a-z_][a-z0-9_]*)\??:", re.MULTILINE)
+
+catalog_module = import_app_module("functions_v2_agents_catalog")
+
+PAGE_CONFIG = {
+ "title": "Find your next AI partner",
+ "subtitle": "Explore specialized agents built to accelerate how you work.",
+ "hero_color_mode": "single",
+ "hero_primary_color": "#0f172a",
+ "hero_secondary_color": "#1e293b",
+ "disclaimer_markdown": "",
+ "show_instructions_in_details": True,
+}
+
+
+def make_agent(agent_id, scope_type, **extra):
+ record = {
+ "id": agent_id,
+ "name": agent_id,
+ "display_name": agent_id.title(),
+ "description": f"{agent_id} description",
+ "scope_type": scope_type,
+ "catalog_key": f"{scope_type}:{agent_id}",
+ "instructions": f"You are {agent_id}.",
+ "assigned_knowledge": {"documents": ["secret-plan.docx"]},
+ "model_id": "gpt-internal",
+ "model_endpoint_id": "endpoint-123",
+ "model_provider": "aoai",
+ "model_label": "GPT",
+ }
+ record.update(extra)
+ return record
+
+
+def build(catalog, *, is_allowed=None, page_config=None, calls=None):
+ calls = calls if calls is not None else {}
+
+ def apply_usage_counts(records):
+ calls["usage"] = [record["id"] for record in records]
+ for record in records:
+ record["usage_count"] = 3
+ return records
+
+ def apply_promotions(records):
+ calls["promotions"] = [record["id"] for record in records]
+ for record in records:
+ if record["id"] == "promoted":
+ record["is_promoted_popular"] = True
+ record["promoted_popular_rank"] = 0
+ return records
+
+ return catalog_module.build_v2_agents_catalog_payload(
+ load_catalog=lambda: catalog,
+ is_allowed=is_allowed or (lambda agent, scope: True),
+ apply_usage_counts=apply_usage_counts,
+ apply_promotions=apply_promotions,
+ page_config=PAGE_CONFIG if page_config is None else page_config,
+ )
+
+
+def test_blocked_agents_are_left_out():
+ """Agents governance blocks never reach the page, checked by their bootstrap scope."""
+ print("\nTesting the governance filter...")
+ assert_app_version_at_least("0.261.305")
+
+ seen = []
+
+ def is_allowed(agent, scope):
+ seen.append((agent["id"], scope))
+ return agent["id"] != "blocked"
+
+ catalog = [
+ make_agent("personal-one", "personal"),
+ make_agent("blocked", "global"),
+ make_agent("group-one", "group", group_id="g1", group_name="Research"),
+ make_agent("unscoped", ""),
+ make_agent("shouty", " GLOBAL "),
+ ]
+ payload = build(catalog, is_allowed=is_allowed)
+
+ assert [agent["id"] for agent in payload["agents"]] == [
+ "personal-one",
+ "group-one",
+ "unscoped",
+ "shouty",
+ ], payload["agents"]
+ assert seen == [
+ ("personal-one", "personal"),
+ ("blocked", "global"),
+ ("group-one", "group"),
+ ("unscoped", "personal"),
+ ("shouty", "global"),
+ ], seen
+
+ print(" Blocked agents are dropped and scopes are read as the bootstrap reads them.")
+ return True
+
+
+def test_governance_runs_before_usage_and_promotions():
+ """Usage and promotions only see agents the user may chat with."""
+ print("\nTesting the order of filtering and annotation...")
+
+ calls = {}
+ catalog = [make_agent("allowed", "personal"), make_agent("promoted", "global")]
+ payload = build(
+ catalog,
+ is_allowed=lambda agent, scope: agent["id"] != "promoted",
+ calls=calls,
+ )
+
+ assert calls["usage"] == ["allowed"], calls
+ assert calls["promotions"] == ["allowed"], calls
+ assert [agent["id"] for agent in payload["agents"]] == ["allowed"], payload["agents"]
+ assert not any(agent.get("is_promoted_popular") for agent in payload["agents"])
+
+ print(" A promoted agent the user is blocked from stays off the page.")
+ return True
+
+
+def test_governance_errors_fail_closed():
+ """An unexpected governance error propagates instead of listing unchecked agents."""
+ print("\nTesting that governance errors propagate...")
+
+ def is_allowed(agent, scope):
+ raise RuntimeError("governance store unavailable")
+
+ try:
+ build([make_agent("anyone", "personal")], is_allowed=is_allowed)
+ except RuntimeError as exc:
+ assert "governance store unavailable" in str(exc)
+ else:
+ raise AssertionError("A governance error was swallowed and the catalogue was returned.")
+
+ print(" The builder raises, so the route answers 500 and lists nothing.")
+ return True
+
+
+def test_only_drawn_fields_are_sent():
+ """Assigned knowledge and model wiring stay on the server."""
+ print("\nTesting the field allowlist...")
+
+ agent = make_agent(
+ "promoted",
+ "group",
+ group_id="g1",
+ group_name="Research",
+ tags=["finance"],
+ icon="bi-robot",
+ actions_to_load=["weather"],
+ action_labels=["Weather"],
+ future_internal_field="should not leak",
+ )
+ payload = build([agent])
+ projected = payload["agents"][0]
+
+ for hidden in (
+ "assigned_knowledge",
+ "model_id",
+ "model_endpoint_id",
+ "model_provider",
+ "future_internal_field",
+ ):
+ assert hidden not in projected, f"{hidden} reached the browser."
+
+ for kept in (
+ "id",
+ "display_name",
+ "scope_type",
+ "group_id",
+ "group_name",
+ "tags",
+ "icon",
+ "model_label",
+ "action_labels",
+ "catalog_key",
+ "usage_count",
+ "is_promoted_popular",
+ "promoted_popular_rank",
+ ):
+ assert kept in projected, f"{kept} was dropped."
+
+ assert set(projected) <= set(catalog_module.V2_CATALOG_AGENT_FIELDS) | {"instructions"}, projected
+
+ print(" Only the allowlisted fields are sent.")
+ return True
+
+
+def test_instructions_follow_the_details_setting():
+ """Instructions are sent only when the details dialog shows them."""
+ print("\nTesting the instructions setting...")
+
+ shown = build([make_agent("helper", "personal")])
+ assert shown["agents"][0]["instructions"] == "You are helper.", shown
+ assert shown["page"] == PAGE_CONFIG, shown["page"]
+
+ hidden_config = dict(PAGE_CONFIG, show_instructions_in_details=False)
+ hidden = build([make_agent("helper", "personal")], page_config=hidden_config)
+ assert "instructions" not in hidden["agents"][0], hidden
+ assert hidden["page"]["show_instructions_in_details"] is False, hidden["page"]
+
+ print(" The flag the page reads and the redaction agree.")
+ return True
+
+
+def test_empty_and_malformed_catalogues():
+ """An empty or partly malformed catalogue still returns a usable payload."""
+ print("\nTesting empty and malformed catalogues...")
+
+ empty = build([])
+ assert empty == {"page": PAGE_CONFIG, "agents": []}, empty
+
+ missing = catalog_module.build_v2_agents_catalog_payload(
+ load_catalog=lambda: None,
+ is_allowed=lambda agent, scope: True,
+ apply_usage_counts=lambda records: records,
+ apply_promotions=lambda records: records,
+ page_config=PAGE_CONFIG,
+ )
+ assert missing["agents"] == [], missing
+
+ mixed = build(["not-an-agent", None, make_agent("real", "personal")])
+ assert [agent["id"] for agent in mixed["agents"]] == ["real"], mixed
+
+ print(" Non-dict records are skipped and nothing raises.")
+ return True
+
+
+def test_page_reads_only_served_fields():
+ """Every field the TypeScript page declares is one the endpoint sends."""
+ print("\nTesting the TypeScript contract...")
+
+ match = CATALOG_INTERFACE_RE.search(CATALOG_TS.read_text(encoding="utf-8"))
+ assert match, "CatalogAgent interface not found in lib/agentCatalog.ts."
+ declared = set(INTERFACE_FIELD_RE.findall(match.group("body")))
+ assert declared, "No fields were read from the CatalogAgent interface."
+
+ served = set(catalog_module.V2_CATALOG_AGENT_FIELDS) | {"instructions"}
+ unserved = sorted(declared - served)
+ assert not unserved, f"The page reads fields the endpoint never sends: {unserved}"
+
+ print(f" {len(declared)} declared field(s) are all served.")
+ return True
+
+
+def test_route_is_user_guarded_and_gated():
+ """The route sits on the user blueprint behind the Semantic Kernel gate."""
+ print("\nTesting the route registration...")
+
+ tree = ast.parse(ROUTE_FILE.read_text(encoding="utf-8"))
+ registrar = next(
+ node
+ for node in ast.walk(tree)
+ if isinstance(node, ast.FunctionDef) and node.name == "register_route_backend_v2"
+ )
+ route = next(
+ (
+ node
+ for node in ast.walk(registrar)
+ if isinstance(node, ast.FunctionDef) and node.name == "v2_agents_catalog"
+ ),
+ None,
+ )
+ assert route, "v2_agents_catalog is not registered on the user blueprint."
+
+ decorators = [ast.unparse(decorator) for decorator in route.decorator_list]
+ assert decorators[0] == "bp.route('/api/v2/agents/catalog', methods=['GET'])", decorators
+ assert decorators[1] == "swagger_route(security=get_auth_security())", decorators
+ assert decorators[2:] == [
+ "login_required",
+ "user_required",
+ "enabled_required('enable_semantic_kernel')",
+ ], decorators
+
+ source = ast.unparse(route)
+ assert "build_v2_agents_catalog_payload" in source, source
+ assert "_is_chat_agent_allowed_by_governance" in source, source
+ assert "build_agents_page_config(sanitize_settings_for_user(settings))" in source, source
+ assert "log_event(" in source and "exceptionTraceback=True" in source, source
+ assert "level=logging.ERROR" in source, source
+ assert "return (jsonify({'error': 'Failed to load agents.'}), 500)" in source, source
+ assert "str(exc)" not in source, "Exception text must not reach the client."
+
+ print(" The route is user guarded, gated, governance filtered and returns no exception text.")
+ return True
+
+
+if __name__ == "__main__":
+ tests = [
+ test_blocked_agents_are_left_out,
+ test_governance_runs_before_usage_and_promotions,
+ test_governance_errors_fail_closed,
+ test_only_drawn_fields_are_sent,
+ test_instructions_follow_the_details_setting,
+ test_empty_and_malformed_catalogues,
+ test_page_reads_only_served_fields,
+ test_route_is_user_guarded_and_gated,
+ ]
+
+ results = []
+ for test in tests:
+ try:
+ results.append(bool(test()))
+ except Exception as exc:
+ print(f"FAILED {test.__name__}: {exc}")
+ results.append(False)
+
+ print(f"\nResults: {sum(results)}/{len(results)} tests passed")
+ sys.exit(0 if all(results) else 1)
diff --git a/functional_tests/test_v2_agents_catalog_logic.ts b/functional_tests/test_v2_agents_catalog_logic.ts
new file mode 100644
index 000000000..f2e011084
--- /dev/null
+++ b/functional_tests/test_v2_agents_catalog_logic.ts
@@ -0,0 +1,296 @@
+// test_v2_agents_catalog_logic.ts
+// Version: 0.261.305
+// Implemented in: 0.261.305
+// Executes the catalogue's real filtering, promotion, parsing and scope-aware Chat rules.
+
+import assert from 'node:assert/strict';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { MemoryRouter } from 'react-router-dom';
+import { AgentCatalogBadges, AgentChatLink } from '../application/v2_ui/src/components/agentsCatalog/AgentCatalogParts';
+import {
+ CATALOG_VIEW_STORAGE_KEY, DEFAULT_CATALOG_PAGE, agentLinkScope, catalogAgentKey, catalogAgentTypeLabel,
+ catalogDisplayName, catalogHeroColor, catalogScopeLabel, catalogUsageCount, normalizePromotionWindow,
+ parseCatalogViewMode, popularAgents, promotedBadgeLabel, readAgentsCatalog, tagsFor, visibleAgents,
+ type CatalogAgent, type CatalogTab, type CatalogUsageWindow,
+} from '../application/v2_ui/src/lib/agentCatalog';
+import { chatHrefForAgent } from '../application/v2_ui/src/lib/conversationUrl';
+
+const checks: [string, () => void][] = [];
+function check(name: string, run: () => void) {
+ checks.push([name, run]);
+}
+
+function agent(id: string, extra: Partial = {}): CatalogAgent {
+ return {
+ id, name: id, display_name: id, scope_type: 'personal', catalog_key: `personal:${id}`,
+ usage_count_all_time: 0, usage_count_30_days: 0, ...extra,
+ };
+}
+
+function ids(agents: CatalogAgent[]): string[] {
+ return agents.map((item) => item.id ?? '');
+}
+
+function visible(agents: CatalogAgent[], extra: {
+ tab?: CatalogTab; window?: CatalogUsageWindow; query?: string; tags?: string[];
+} = {}): CatalogAgent[] {
+ return visibleAgents({ agents, tab: 'popular', window: 'all_time', query: '', tags: [], ...extra });
+}
+
+check('payload parsing normalizes display config and skips non-records', () => {
+ const parsed = readAgentsCatalog({
+ page: { title: ' Find partners ', hero_color_mode: 'bad', hero_primary_color: 'url(evil)' },
+ agents: [null, 42, [], { id: 'real', tags: ['Finance', 42], icon: { kind: 'html', value: '' }),
+ }));
+ assert.ok(html.includes('<img'));
+ assert.ok(html.includes('<script>'));
+ assert.doesNotMatch(html, / {
+ assert.equal(catalogHeroColor(' #AAbB22 ', '#0f172a'), '#AAbB22');
+ for (const value of ['#fff', 'red', 'url(https://evil.test)', '#000000;display:none', null]) {
+ assert.equal(catalogHeroColor(value, '#0f172a'), '#0f172a');
+ }
+});
+
+check('view parsing uses the classic storage key and only accepts list or card', () => {
+ assert.equal(CATALOG_VIEW_STORAGE_KEY, 'simplechat-agents-catalog-view');
+ assert.equal(parseCatalogViewMode('card'), 'card');
+ for (const value of ['list', '', 'grid', null, 42]) assert.equal(parseCatalogViewMode(value), 'list');
+});
+
+check('filtering and ranking never reorder or mutate the API records', () => {
+ const items = [agent('Zulu', { usage_count_all_time: 10 }), agent('alpha', { is_promoted_popular: true })];
+ const before = JSON.stringify(items);
+ popularAgents(items, 'all_time');
+ visible(items, { tab: 'personal' });
+ tagsFor(items);
+ assert.equal(JSON.stringify(items), before);
+});
+
+let failed = 0;
+for (const [name, run] of checks) {
+ try {
+ run();
+ console.log(` ok ${name}`);
+ } catch (error) {
+ failed += 1;
+ console.error(` FAIL ${name}`, error);
+ }
+}
+console.log(`Results: ${checks.length - failed}/${checks.length} catalogue checks passed`);
+process.exitCode = failed ? 1 : 0;
diff --git a/functional_tests/test_v2_support_menu.py b/functional_tests/test_v2_support_menu.py
index b98232d8f..a3d740180 100644
--- a/functional_tests/test_v2_support_menu.py
+++ b/functional_tests/test_v2_support_menu.py
@@ -2,8 +2,9 @@
# test_v2_support_menu.py
"""
Functional test for the V2 Support menu: Latest Features and Send Feedback for end users.
-Version: 0.261.296
+Version: 0.261.305
Implemented in: 0.261.296
+Native agent catalogue shortcuts: 0.261.305
Administrators configure the Support menu in Admin Settings, and the classic navigation
offers it to users. V2 had only a Latest Features link that bounced users out to the
diff --git a/functional_tests/test_v2_support_menu_logic.ts b/functional_tests/test_v2_support_menu_logic.ts
index 55e506183..c0056e786 100644
--- a/functional_tests/test_v2_support_menu_logic.ts
+++ b/functional_tests/test_v2_support_menu_logic.ts
@@ -2,8 +2,9 @@
//
// Runtime test for the V2 Support menu: the rail group, the Latest Features page and the Send
// Feedback page.
-// Version: 0.261.296
+// Version: 0.261.305
// Implemented in: 0.261.296
+// Native agent catalogue shortcuts: 0.261.305
//
// The judgement calls here do not show in a screenshot: which destinations the rail offers
// once a user has hidden Latest Features, where each catalogue shortcut lands in V2, that a
@@ -205,6 +206,9 @@ check('only the offered destinations are drawn', () => {
/* ---------------------------------- shortcuts ---------------------------------- */
const ROUTES: [string, string, Record?][] = [
+ ['/agents', '/agents'],
+ ['/agents/', '/agents'],
+ ['/agents?feature_action=discover#popular', '/agents'],
['/chats#chatbox', '/chat', { freshChat: true }],
['/chats?feature_action=conversation_export', '/chat', { freshChat: true }],
['/chats#chat-tutorial-launch', '/chat', { freshChat: true, tour: 'chat' }],
@@ -252,7 +256,7 @@ check('classic shortcuts land on the matching V2 page', () => {
});
check('pages V2 has not rebuilt open as written', () => {
- for (const href of ['/agents', '/workflow-activity', '/groups/abc', '/admin/safety_violations']) {
+ for (const href of ['/workflow-activity', '/groups/abc', '/admin/safety_violations']) {
assert.deepEqual(resolveLatestFeatureShortcut(href), { kind: 'page', href });
}
});
@@ -326,7 +330,7 @@ check('announcement shortcuts render by kind', () => {
assert.match(html, /href="\/chat"[^>]*>.*Open Chat/);
assert.match(html, /Ask a question from Chat\./);
assert.match(html, /href="\/agents"/);
- assert.match(html, /opens in the classic interface/);
+ assert.doesNotMatch(html, /opens in the classic interface/);
assert.match(html, /href="https:\/\/microsoft\.github\.io\/simplechat\/"[^>]*target="_blank"[^>]*rel="noopener noreferrer"|target="_blank"[^>]*rel="noopener noreferrer"[^>]*href="https:\/\/microsoft\.github\.io\/simplechat\/"/);
assert.match(html, /opens in a new tab/);
assert.doesNotMatch(html, /javascript:/i);
diff --git a/ui_tests/test_v2_agents_catalog.py b/ui_tests/test_v2_agents_catalog.py
new file mode 100644
index 000000000..deb4b7a00
--- /dev/null
+++ b/ui_tests/test_v2_agents_catalog.py
@@ -0,0 +1,413 @@
+# test_v2_agents_catalog.py
+"""
+Browser coverage for the native V2 Agents catalogue (Refs #1722, row 1).
+Version: 0.261.305
+Implemented in: 0.261.305
+
+Load the real production SPA and local assets using the shared Azure-capable
+workspace fixture. Only API responses are synthetic. Cover browsing, Popular
+windows, search/tags, details, view persistence, error recovery, and real chat
+launches for personal/global/group agents. Legacy page requests fail the closed
+fixture boundary. Check light/dark at desktop and phone sizes.
+"""
+
+import copy
+import sys
+from pathlib import Path
+from urllib.parse import urlsplit
+
+import pytest
+from playwright.sync_api import Route, expect
+
+# Shared fixtures retain flat imports from their fixture directory.
+sys.path.insert(0, str(Path(__file__).resolve().parent / "fixtures"))
+
+from ui_tests.fixtures.workspace_authoring import (
+ AGENT_ID, GLOBAL_AGENT_ID, ORIGIN, SPA_INDEX, TARGET_ID, WorkspaceAuthoringFixture,
+ connect_options, # noqa: F401
+)
+
+
+pytestmark = pytest.mark.ui
+CATALOG_ENDPOINT = "/api/v2/agents/catalog"
+ARTIFACTS = Path(__file__).resolve().parent / "artifacts" / "v2_agents_catalog"
+VIEW_KEY = "simplechat-agents-catalog-view"
+GROUP_AGENT_ID = "00000000-0000-4000-8000-000000000111"
+
+
+def _agent(identifier, name, scope="personal", **extra):
+ record = {
+ "id": identifier, "name": name.lower().replace(" ", "-"), "display_name": name,
+ "description": f"{name} helps you work with approved sources.",
+ "agent_type": "local", "scope_type": scope, "is_global": scope == "global",
+ "is_group": scope == "group", "catalog_key": f"{scope}:{identifier}",
+ "model_label": "Workspace GPT", "tags": [], "usage_count_all_time": 0,
+ "usage_count_30_days": 0, "action_labels": ["Search approved sources"],
+ "actions_to_load": ["internal-tool"], "instructions": "Review **carefully**.\n\nUse approved sources.",
+ "icon": {"kind": "bootstrap", "value": "bi-robot"},
+ }
+ record.update(extra)
+ return record
+
+
+def _catalog():
+ return {
+ "page": {
+ "title": "Choose an agent for your next task",
+ "subtitle": "Find a specialist across your personal, group and enterprise agents.",
+ "hero_color_mode": "two_tone",
+ "hero_primary_color": "#173557",
+ "hero_secondary_color": "#42648A",
+ "disclaimer_markdown": "**Verify important answers.** Use only approved sources.",
+ "show_instructions_in_details": True,
+ },
+ "agents": [
+ _agent(AGENT_ID, "Policy reviewer", tags=["Finance", "Review"],
+ usage_count_all_time=200, usage_count_30_days=1,
+ icon={"kind": "bootstrap", "value": "bi-shield-check"}),
+ _agent(TARGET_ID, "Quiet drafter", tags=["Writing"]),
+ _agent(GLOBAL_AGENT_ID, "Knowledge guide", "global", tags=["Knowledge"],
+ is_promoted_popular=True, promoted_popular_window="both",
+ promoted_popular_rank=0, promoted_popular_order="before",
+ promoted_popular_tag_enabled=True, promoted_popular_tag_label="Featured"),
+ _agent(GROUP_AGENT_ID, "Research helper", "group",
+ group_id="group-research", group_name="Research", scope_id="group-research",
+ catalog_key=f"group:group-research:{GROUP_AGENT_ID}",
+ tags=["Finance", "Research"], usage_count_all_time=100, usage_count_30_days=15),
+ _agent(GROUP_AGENT_ID, "Operations helper", "group",
+ group_id="group-operations", group_name="Operations", scope_id="group-operations",
+ catalog_key=f"group:group-operations:{GROUP_AGENT_ID}",
+ tags=["Operations"], usage_count_all_time=10, usage_count_30_days=3),
+ ],
+ }
+
+
+class AgentCatalogFixture(WorkspaceAuthoringFixture):
+ """Reuse the shell, preferences, assets and chat API; add just the catalogue API."""
+
+ def __init__(self, page):
+ super().__init__(page)
+ self.catalog = _catalog()
+ self.chat_agents = copy.deepcopy(self.catalog["agents"])
+ self.document_loads = 0
+ self.groups.extend([
+ {"id": "group-operations", "name": "Operations"},
+ {"id": "group-research", "name": "Research"},
+ ])
+
+ def _bootstrap(self):
+ payload = super()._bootstrap()
+ payload["version"] = "0.261.305"
+ payload["catalogs"]["agents"] = copy.deepcopy(self.chat_agents)
+ payload["features"]["enable_group_workspaces"] = True
+ payload["settings"]["allow_group_agents"] = True
+ payload["scope"]["active_group_id"] = "group-operations"
+ payload["scope"]["active_group_name"] = "Operations"
+ return payload
+
+ def _route(self, route: Route):
+ request = route.request
+ parsed = urlsplit(request.url)
+ if request.method == "GET" and f"{parsed.scheme}://{parsed.netloc}" == ORIGIN:
+ if parsed.path.startswith("/v2/"):
+ self.document_loads += 1
+ if parsed.path == "/v2/agents":
+ route.fulfill(path=str(SPA_INDEX), content_type="text/html")
+ return
+ super()._route(route)
+
+ def _dispatch(self, route, entry):
+ if entry.method == "GET" and entry.path == CATALOG_ENDPOINT:
+ self._json(route, copy.deepcopy(self.catalog))
+ return
+ super()._dispatch(route, entry)
+
+ def screenshots(self, name):
+ ARTIFACTS.mkdir(parents=True, exist_ok=True)
+ self.page.screenshot(path=str(ARTIFACTS / f"{name}.png"), full_page=True)
+
+ def assert_catalogue_fits(self):
+ self.assert_no_overflow()
+ self.assert_content_fits(self.page.get_by_test_id("agents-catalog-scroll"), "Catalogue")
+
+ def assert_content_fits(self, content, label):
+ overflow = content.evaluate(
+ "element => element.scrollWidth - element.clientWidth"
+ )
+ if overflow > 1:
+ nodes = content.evaluate("""
+ root => [...root.querySelectorAll('*')]
+ .filter(element => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 1)
+ .map(element => ({
+ tag: element.tagName,
+ classes: element.className,
+ text: element.textContent.slice(0, 40),
+ overflow: element.scrollWidth - element.clientWidth,
+ wrap: getComputedStyle(element).overflowWrap,
+ whiteSpace: getComputedStyle(element).whiteSpace,
+ }))
+ .slice(-8)
+ """)
+ raise AssertionError(f"{label} scrolls sideways by {overflow}px: {nodes}")
+
+
+@pytest.fixture
+def catalog_ui(page):
+ fixture = AgentCatalogFixture(page)
+ yield fixture
+ fixture.assert_clean()
+
+
+def _rows(page):
+ return page.get_by_test_id("catalog-agent")
+
+
+def _categories(page):
+ return page.get_by_role("group", name="Agent categories")
+
+
+@pytest.mark.parametrize("theme", ["light", "dark"])
+@pytest.mark.parametrize("width,height", [(1440, 900), (390, 844)])
+def test_catalogue_browsing_and_details_fit_both_themes(catalog_ui, theme, width, height):
+ ui, page = catalog_ui, catalog_ui.page
+ ui.open("/agents", theme=theme, width=width, height=height)
+ expect(page.get_by_role("heading", level=1, name="Agents")).to_be_visible()
+ expect(page.get_by_role("heading", name=ui.catalog["page"]["title"])).to_be_visible()
+ expect(page.get_by_label("Agent catalogue disclaimer")).to_contain_text("Verify important answers.")
+ expect(_rows(page)).to_have_count(4)
+ assert _rows(page).locator("h3").all_text_contents() == [
+ "Knowledge guide", "Policy reviewer", "Research helper", "Operations helper",
+ ]
+ expect(_categories(page).get_by_role("button", name="Popular")).to_have_attribute("aria-pressed", "true")
+ expect(page.get_by_text("Featured", exact=True)).to_be_visible()
+ ui.assert_catalogue_fits()
+ ui.screenshots(f"catalogue-{theme}-{width}")
+
+ page.get_by_role("button", name="Last 30 days", exact=True).click()
+ assert _rows(page).locator("h3").all_text_contents() == [
+ "Knowledge guide", "Research helper", "Operations helper", "Policy reviewer",
+ ]
+ _categories(page).get_by_role("button", name="Personal", exact=True).click()
+ expect(_rows(page)).to_have_count(2)
+ expect(page.get_by_role("link", name="New agent", exact=True)).to_have_attribute(
+ "href", "/v2/workspace/agents/new"
+ )
+ opener = page.get_by_role("button", name="Details for Policy reviewer", exact=True)
+ opener.press("Enter")
+ dialog = page.get_by_role("dialog", name="Details for Policy reviewer")
+ expect(dialog).to_be_visible()
+ expect(dialog).to_contain_text("policy-reviewer")
+ expect(dialog).to_contain_text("Workspace GPT")
+ expect(dialog).to_contain_text("Search approved sources")
+ expect(dialog).not_to_contain_text("internal-tool")
+ expect(dialog.get_by_role("heading", name="Instructions")).to_be_visible()
+ expect(dialog).to_contain_text("Review carefully.")
+ expect(dialog.get_by_role("link", name="Chat with Policy reviewer")).to_have_attribute(
+ "href", f"/v2/chat?agent_id={AGENT_ID}&agent_scope=personal&new=1"
+ )
+ page.keyboard.press("Shift+Tab")
+ focused_inside = dialog.evaluate("element => element.contains(document.activeElement)")
+ assert focused_inside, "Keyboard focus left the details dialog."
+ ui.assert_no_overflow()
+ ui.screenshots(f"details-{theme}-{width}")
+ page.keyboard.press("Escape")
+ expect(dialog).to_have_count(0)
+ expect(opener).to_be_focused()
+
+
+def test_search_spans_scopes_restores_the_scope_and_keeps_tags_clearable(catalog_ui):
+ ui, page = catalog_ui, catalog_ui.page
+ ui.open("/agents")
+ _categories(page).get_by_role("button", name="Group", exact=True).click()
+ expect(_rows(page)).to_have_count(2)
+ expect(page.get_by_role("link", name="New agent", exact=True)).to_have_attribute(
+ "href", "/v2/groups/group-operations/agents"
+ )
+ tag_group = page.get_by_role("group", name="Filter agents by tag")
+ tag_group.get_by_role("button", name="Finance", exact=True).click()
+ expect(_rows(page)).to_have_count(1)
+ search = page.get_by_role("searchbox", name="Search agents")
+ search.fill("Policy")
+ expect(page.get_by_role("heading", name="Policy reviewer", exact=True)).to_be_visible()
+ expect(_categories(page).get_by_text("Search results", exact=True)).to_have_attribute("aria-current", "true")
+ expect(_categories(page).get_by_role("button", name="Group", exact=True)).to_have_attribute("aria-pressed", "false")
+ expect(page.get_by_role("link", name="New agent", exact=True)).to_have_count(0)
+ search.fill("")
+ expect(page.get_by_role("heading", name="Research helper", exact=True)).to_be_visible()
+ expect(_categories(page).get_by_role("button", name="Group", exact=True)).to_have_attribute("aria-pressed", "true")
+
+ _categories(page).get_by_role("button", name="Personal", exact=True).click()
+ expect(tag_group.get_by_role("button", name="Finance", exact=True)).to_have_attribute("aria-pressed", "false")
+ tag_group.get_by_role("button", name="Finance", exact=True).click()
+ tag_group.get_by_role("button", name="Review", exact=True).click()
+ search.fill("nothing-matches-this")
+ expect(page.get_by_text("No agents match the current view.", exact=True)).to_be_visible()
+ expect(tag_group.get_by_role("button", name="Finance", exact=True)).to_have_attribute("aria-pressed", "true")
+ expect(tag_group.get_by_role("button", name="Review", exact=True)).to_have_attribute("aria-pressed", "true")
+ tag_group.get_by_role("button", name="Review", exact=True).click()
+ page.get_by_role("button", name="Clear filters", exact=True).click()
+ expect(search).to_have_value("")
+ expect(_rows(page)).to_have_count(2)
+
+
+def test_card_view_is_shared_with_classic_and_survives_reload(catalog_ui):
+ ui, page = catalog_ui, catalog_ui.page
+ ui.open("/agents")
+ page.get_by_role("button", name="Card view", exact=True).click()
+ expect(page.get_by_role("list", name="Agents", exact=True)).to_have_attribute("data-view", "card")
+ stored = page.evaluate("key => localStorage.getItem(key)", VIEW_KEY)
+ assert stored == "card"
+ page.reload(wait_until="networkidle")
+ expect(page.get_by_role("button", name="Card view", exact=True)).to_have_attribute("aria-pressed", "true")
+ expect(page.get_by_role("list", name="Agents", exact=True)).to_have_attribute("data-view", "card")
+ page.get_by_role("button", name="List view", exact=True).click()
+ expect(page.get_by_role("list", name="Agents", exact=True)).to_have_attribute("data-view", "list")
+
+
+def test_view_controls_still_work_when_storage_is_unavailable(catalog_ui):
+ page = catalog_ui.page
+ page.add_init_script("""
+ for (const method of ['getItem', 'setItem']) {
+ const original = Storage.prototype[method];
+ Storage.prototype[method] = function(key, ...args) {
+ if (key === 'simplechat-agents-catalog-view') throw new DOMException('Storage unavailable', 'SecurityError');
+ return original.call(this, key, ...args);
+ };
+ }
+ """)
+ catalog_ui.open("/agents")
+ expect(_rows(page)).to_have_count(4)
+ page.get_by_role("button", name="Card view", exact=True).click()
+ expect(page.get_by_role("list", name="Agents", exact=True)).to_have_attribute("data-view", "card")
+
+
+def test_details_honor_hidden_instructions_and_empty_action_labels(catalog_ui):
+ ui, page = catalog_ui, catalog_ui.page
+ ui.catalog["page"]["show_instructions_in_details"] = False
+ ui.catalog["agents"][0]["action_labels"] = []
+ ui.open("/agents")
+ page.get_by_role("button", name="Details for Policy reviewer", exact=True).click()
+ dialog = page.get_by_role("dialog", name="Details for Policy reviewer")
+ expect(dialog.get_by_role("heading", name="Instructions")).to_have_count(0)
+ expect(dialog).not_to_contain_text("Review carefully.")
+ expect(dialog).to_contain_text("No actions assigned.")
+ expect(dialog).not_to_contain_text("internal-tool")
+
+
+@pytest.mark.parametrize("name,identifier,scope,group_id", [
+ ("Policy reviewer", AGENT_ID, "personal", None),
+ ("Knowledge guide", GLOBAL_AGENT_ID, "global", None),
+ ("Research helper", GROUP_AGENT_ID, "group", "group-research"),
+])
+def test_chat_launch_uses_the_exact_agent_scope(catalog_ui, name, identifier, scope, group_id):
+ ui, page = catalog_ui, catalog_ui.page
+ ui.open("/agents")
+ launch = page.get_by_role("link", name=f"Chat with {name}", exact=True)
+ expected = f"/v2/chat?agent_id={identifier}&agent_scope={scope}"
+ if group_id:
+ expected += f"&agent_scope_id={group_id}"
+ expected += "&new=1"
+ expect(launch).to_have_attribute("href", expected)
+ initial_bootstraps = len([request for request in ui.requests if request.path == "/api/v2/bootstrap"])
+ launch.click()
+ expect(page.locator("#composer-input")).to_be_enabled()
+ refreshed = len([request for request in ui.requests if request.path == "/api/v2/bootstrap"])
+ assert refreshed > initial_bootstraps, "Launch did not refresh the authorized catalogue."
+ page.locator("#composer-input").fill("Review this with the selected agent.")
+ page.get_by_role("button", name="Send message", exact=True).click()
+ expect(page.get_by_text("Workspace review response.", exact=True)).to_be_visible()
+ body = next(request.body for request in reversed(ui.requests) if request.path == "/api/chat/stream")
+ assert body["agent_info"]["id"] == identifier
+ assert body["agent_info"]["display_name"] == name
+ assert body["agent_info"]["is_global"] == (scope == "global")
+ assert body["agent_info"]["is_group"] == (scope == "group")
+ if group_id:
+ assert body["agent_info"]["group_id"] == group_id, "Chat used the active group instead of the agent's group."
+ assert ui.document_loads == 1, "Chat launch left the SPA."
+
+
+def test_a_revoked_agent_fails_visibly_after_the_launch_refresh(catalog_ui):
+ ui, page = catalog_ui, catalog_ui.page
+ ui.open("/agents")
+ ui.chat_agents = [agent for agent in ui.chat_agents if agent["id"] != AGENT_ID]
+ page.get_by_role("link", name="Chat with Policy reviewer", exact=True).click()
+ expect(page.get_by_text("That agent is no longer available in this workspace.", exact=True)).to_be_visible()
+ assert not any(request.path in ("/api/create_conversation", "/api/chat/stream") for request in ui.writes)
+
+
+@pytest.mark.parametrize("status", [400, 403, 404])
+def test_disabled_or_denied_catalogue_is_unavailable_not_empty(catalog_ui, status):
+ catalog_ui.reject_next("GET", CATALOG_ENDPOINT, status=status, error="Unavailable.")
+ catalog_ui.open("/agents")
+ expect(catalog_ui.page.get_by_text("Agents are not available", exact=True)).to_be_visible()
+ expect(catalog_ui.page.get_by_role("button", name="Try again", exact=True)).to_have_count(0)
+ expect(catalog_ui.page.get_by_text("No agents are available.", exact=True)).to_have_count(0)
+
+
+def test_server_failure_offers_retry_instead_of_an_empty_catalogue(catalog_ui):
+ ui, page = catalog_ui, catalog_ui.page
+ ui.reject_next("GET", CATALOG_ENDPOINT, status=500, error="Failed to load agents.")
+ ui.open("/agents")
+ expect(page.get_by_role("alert")).to_contain_text("Agents could not be loaded.")
+ expect(page.get_by_text("No agents are available.", exact=True)).to_have_count(0)
+ page.get_by_role("button", name="Try again", exact=True).click()
+ expect(_rows(page)).to_have_count(4)
+ expect(page.get_by_role("alert")).to_have_count(0)
+
+
+def test_empty_and_malformed_responses_have_different_states(catalog_ui):
+ ui, page = catalog_ui, catalog_ui.page
+ ui.catalog["agents"] = []
+ ui.open("/agents")
+ expect(page.get_by_text("No agents are available.", exact=True)).to_be_visible()
+ ui.catalog = {"agents": []}
+ page.reload(wait_until="networkidle")
+ expect(page.get_by_role("alert")).to_contain_text("Agents could not be loaded.")
+ expect(page.get_by_text("No agents are available.", exact=True)).to_have_count(0)
+
+
+def test_native_navigation_shows_loading_and_never_opens_legacy(catalog_ui):
+ ui, page = catalog_ui, catalog_ui.page
+ ui.open("/workspace/agents")
+ ui.defer_next("GET", CATALOG_ENDPOINT)
+ page.get_by_role("navigation", name="Primary").get_by_role("link", name="Agents", exact=True).click()
+ expect(page.get_by_role("status", name="Loading agents")).to_be_visible()
+ ui.release_responses()
+ expect(_rows(page)).to_have_count(4)
+ expect(page).to_have_url(f"{ORIGIN}/v2/agents")
+ assert ui.document_loads == 1, "The catalogue navigation loaded another document."
+
+
+def test_malformed_agent_can_be_inspected_but_not_launched(catalog_ui):
+ ui, page = catalog_ui, catalog_ui.page
+ ui.catalog["agents"].append(_agent("missing-group", "Incomplete agent", "group"))
+ ui.open("/agents")
+ _categories(page).get_by_role("button", name="Group", exact=True).click()
+ expect(page.get_by_role("button", name="Chat with Incomplete agent", exact=True)).to_be_disabled()
+ page.get_by_role("button", name="Details for Incomplete agent", exact=True).click()
+ expect(page.get_by_role("dialog", name="Details for Incomplete agent")).to_be_visible()
+
+
+def test_long_content_and_markdown_cannot_escape_the_mobile_catalogue(catalog_ui):
+ ui, page = catalog_ui, catalog_ui.page
+ ui.catalog["page"]["title"] = "LongHeadline" * 10
+ ui.catalog["page"]["disclaimer_markdown"] = (
+ "**Read the guidance.**\n\n\n\n"
+ "[Unsafe link](javascript:window.catalogInjected=true)\n\n" + "LongWord" * 45
+ )
+ ui.catalog["agents"][3]["group_name"] = "LongGroupName" * 20
+ ui.catalog["agents"][3]["display_name"] = "LongAgentName" * 20
+ ui.catalog["agents"][3]["tags"] = ["LongTag" * 30]
+ ui.open("/agents", theme="dark", width=390, height=844)
+ ui.assert_catalogue_fits()
+ injected = page.evaluate("window.catalogInjected === true")
+ assert not injected, "Human-authored Markdown executed JavaScript."
+ expect(page.locator("a[href^='javascript:']")).to_have_count(0)
+ page.get_by_role("button", name="Card view", exact=True).click()
+ ui.assert_catalogue_fits()
+ long_name = ui.catalog["agents"][3]["display_name"]
+ page.get_by_role("button", name=f"Details for {long_name}", exact=True).click()
+ dialog = page.get_by_role("dialog", name=f"Details for {long_name}")
+ expect(dialog).to_be_visible()
+ ui.assert_no_overflow()
+ ui.assert_content_fits(dialog, "Details")
diff --git a/ui_tests/test_v2_support_menu.py b/ui_tests/test_v2_support_menu.py
index 855123d68..e8834ce14 100644
--- a/ui_tests/test_v2_support_menu.py
+++ b/ui_tests/test_v2_support_menu.py
@@ -2,8 +2,9 @@
"""
Browser coverage for the V2 Support menu: the rail group, the Latest Features page and the
Send Feedback page.
-Version: 0.261.296
+Version: 0.261.305
Implemented in: 0.261.296
+Native agent catalogue shortcuts: 0.261.305
Administrators configure the Support menu in Admin Settings. The classic navigation offers it
to users as a collapsible section; V2 used to carry only a Latest Features link that left for
@@ -399,7 +400,7 @@ def test_latest_features_page_lists_reads_and_searches(support_ui):
expect(panel).to_contain_text("This matters because trends and outliers")
expect(panel.get_by_text("How to try it")).to_be_visible()
expect(panel.get_by_role("link", name="Open Chat")).to_have_attribute("href", "/v2/chat")
- expect(panel.get_by_role("link", name=re.compile("^Open Agents"))).to_have_attribute("href", "/agents")
+ expect(panel.get_by_role("link", name="Open Agents", exact=True)).to_have_attribute("href", "/v2/agents")
guide = panel.get_by_role("link", name=re.compile("^Read the guide"))
expect(guide).to_have_attribute("target", "_blank")
expect(guide).to_have_attribute("rel", "noopener noreferrer")