diff --git a/README.md b/README.md index bc32a401..fc72ac29 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,6 @@ agent-control/ ├── engine/ # Evaluation engine (agent-control-engine) ├── models/ # Shared models (agent-control-models) ├── evaluators/ # Evaluator implementations (agent-control-evaluators) -├── ui/ # Next.js dashboard └── examples/ # Usage examples ``` diff --git a/models/src/agent_control_models/__init__.py b/models/src/agent_control_models/__init__.py index d39230e2..2e65ec4c 100644 --- a/models/src/agent_control_models/__init__.py +++ b/models/src/agent_control_models/__init__.py @@ -62,6 +62,7 @@ ) from .policy import Policy from .server import ( + AgentRef, AgentSummary, ControlSummary, CreateEvaluatorConfigRequest, @@ -129,6 +130,7 @@ "get_error_title", "ERROR_TITLES", # Server models + "AgentRef", "AgentSummary", "ControlSummary", "CreateEvaluatorConfigRequest", diff --git a/models/src/agent_control_models/server.py b/models/src/agent_control_models/server.py index 1341134c..35308de3 100644 --- a/models/src/agent_control_models/server.py +++ b/models/src/agent_control_models/server.py @@ -248,6 +248,13 @@ class ListAgentsResponse(BaseModel): # ============================================================================= +class AgentRef(BaseModel): + """Reference to an agent (for listing which agents use a control).""" + + agent_id: str = Field(..., description="Agent UUID") + agent_name: str = Field(..., description="Agent name") + + class ControlSummary(BaseModel): """Summary of a control for list responses.""" @@ -259,6 +266,7 @@ class ControlSummary(BaseModel): step_types: list[str] | None = Field(None, description="Step types in scope") stages: list[str] | None = Field(None, description="Evaluation stages in scope") tags: list[str] = Field(default_factory=list, description="Control tags") + used_by_agent: AgentRef | None = Field(None, description="Agent using this control") class ListControlsResponse(BaseModel): diff --git a/server/src/agent_control_server/endpoints/agents.py b/server/src/agent_control_server/endpoints/agents.py index 32c7a027..ac7848bb 100644 --- a/server/src/agent_control_server/endpoints/agents.py +++ b/server/src/agent_control_server/endpoints/agents.py @@ -155,6 +155,7 @@ async def _validate_policy_controls_for_agent( async def list_agents( cursor: str | None = None, limit: int = _DEFAULT_PAGINATION_LIMIT, + name: str | None = None, db: AsyncSession = Depends(get_async_db), ) -> ListAgentsResponse: """ @@ -166,6 +167,7 @@ async def list_agents( Args: cursor: Optional cursor for pagination (UUID of last agent from previous page) limit: Pagination limit (default 20, max 100) + name: Optional name filter (case-insensitive partial match) db: Database session (injected) Returns: @@ -174,14 +176,24 @@ async def list_agents( # Clamp limit limit = min(max(1, limit), _MAX_PAGINATION_LIMIT) - # Get total count - count_result = await db.execute(select(func.count()).select_from(Agent)) + # Build base filter for name search + name_filter = Agent.name.ilike(f"%{name}%") if name else None + + # Get total count (with name filter if provided) + count_query = select(func.count()).select_from(Agent) + if name_filter is not None: + count_query = count_query.where(name_filter) + count_result = await db.execute(count_query) total = count_result.scalar() or 0 # Build query with cursor-based pagination # Order by created_at DESC, then by UUID DESC for stable ordering query = select(Agent).order_by(Agent.created_at.desc(), Agent.agent_uuid.desc()) + # Apply name filter if provided + if name_filter is not None: + query = query.where(name_filter) + # If cursor provided, filter to get items after the cursor if cursor: try: diff --git a/server/src/agent_control_server/endpoints/controls.py b/server/src/agent_control_server/endpoints/controls.py index 545d6dad..6a933374 100644 --- a/server/src/agent_control_server/endpoints/controls.py +++ b/server/src/agent_control_server/endpoints/controls.py @@ -2,6 +2,7 @@ from agent_control_models import ControlDefinition from agent_control_models.errors import ErrorCode, ValidationErrorItem from agent_control_models.server import ( + AgentRef, ControlSummary, CreateControlRequest, CreateControlResponse, @@ -29,7 +30,7 @@ NotFoundError, ) from ..logging_utils import get_logger -from ..models import Agent, AgentData, Control, policy_controls +from ..models import Agent, AgentData, Control, Policy, policy_controls from ..services.evaluator_utils import parse_evaluator_ref, validate_config_against_schema # Pagination constants @@ -544,6 +545,31 @@ async def list_controls( if has_more: controls = controls[:-1] + # Build mapping of control_id -> agent that uses it + # Traversal: Control -> policy_controls -> Policy -> Agent + control_agent_map: dict[int, AgentRef | None] = {ctrl.id: None for ctrl in controls} + if controls: + control_ids = [ctrl.id for ctrl in controls] + agents_query = ( + select( + policy_controls.c.control_id, + Agent.agent_uuid, + Agent.name, + ) + .select_from(policy_controls) + .join(Policy, policy_controls.c.policy_id == Policy.id) + .join(Agent, Agent.policy_id == Policy.id) + .where(policy_controls.c.control_id.in_(control_ids)) + ) + agents_result = await db.execute(agents_query) + for row in agents_result.all(): + control_id, agent_uuid, agent_name = row + # Take the first agent found (1 control = 1 agent) + if control_agent_map[control_id] is None: + control_agent_map[control_id] = AgentRef( + agent_id=str(agent_uuid), agent_name=agent_name + ) + # Build summaries (filtering already done at DB level) summaries: list[ControlSummary] = [] for ctrl in controls: @@ -560,6 +586,7 @@ async def list_controls( step_types=scope.get("step_types"), stages=scope.get("stages"), tags=data.get("tags", []), + used_by_agent=control_agent_map.get(ctrl.id), ) ) diff --git a/ui/AGENTS.md b/ui/AGENTS.md index ced1b235..4170abd9 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -54,6 +54,45 @@ pnpm fetch-api-types # regenerate API types from server (must be running on :80 - Each folder exports: `form.tsx` (React component), `types.ts` (form types), `index.ts` (re-exports) - Registry in `evaluators/index.ts` maps evaluator names to form components +### Reusable components (`core/components/`) +- Create reusable components that encapsulate common patterns and logic +- **Best practice**: When creating wrapper components around Mantine components, extend the underlying component's props using `Omit` to exclude overridden props, then spread `...rest` to forward all other props +- This provides full flexibility while maintaining type safety + +**Example: SearchInput component pattern** +```typescript +import type { TextInputProps } from "@mantine/core"; +import { TextInput } from "@mantine/core"; + +interface SearchInputProps + extends Omit { + queryKey: string; // Required prop specific to this component +} + +export function SearchInput({ + queryKey, + placeholder = "Search...", + w = 250, + ...rest // Forward all other TextInput props +}: SearchInputProps) { + // Component logic... + return ( + + ); +} +``` + +**Benefits:** +- Full type safety for all underlying component props +- No need to explicitly define every prop in the wrapper interface +- Easy to extend with new props from the underlying component +- Maintains backward compatibility when underlying component adds new props + ## Common changes ### Add a new evaluator form diff --git a/ui/src/core/api/client.ts b/ui/src/core/api/client.ts index 6263f4e4..850330ae 100644 --- a/ui/src/core/api/client.ts +++ b/ui/src/core/api/client.ts @@ -80,6 +80,19 @@ export const api = { list: () => apiClient.GET("/api/v1/evaluators"), }, controls: { + list: (params?: { + cursor?: number; + limit?: number; + name?: string; + enabled?: boolean; + step_type?: string; + stage?: string; + execution?: string; + tag?: string; + }) => + apiClient.GET("/api/v1/controls", { + params: params ? { query: params } : undefined, + }), create: (data: CreateControlRequest) => apiClient.PUT("/api/v1/controls", { body: data }), getData: (controlId: number) => diff --git a/ui/src/core/api/types.ts b/ui/src/core/api/types.ts index 288c996b..adc074a4 100644 --- a/ui/src/core/api/types.ts +++ b/ui/src/core/api/types.ts @@ -58,6 +58,9 @@ export type ListAgentsResponse = components["schemas"]["ListAgentsResponse"]; // Evaluator types export type EvaluatorInfo = components["schemas"]["EvaluatorInfo"]; export type EvaluatorsResponse = Record; +export type EvaluatorConfigItem = components["schemas"]["EvaluatorConfigItem"]; +export type ListEvaluatorConfigsResponse = + components["schemas"]["ListEvaluatorConfigsResponse"]; // Request/Response types export type InitAgentRequest = components["schemas"]["InitAgentRequest"]; @@ -89,6 +92,15 @@ export type SetControlDataResponse = components["schemas"]["SetControlDataResponse"]; export type GetControlDataResponse = components["schemas"]["GetControlDataResponse"]; +export type ControlSummary = components["schemas"]["ControlSummary"]; +export type ListControlsResponse = components["schemas"]["ListControlsResponse"]; + +// AgentRef - reference to an agent (for used_by_agents) +// Note: This will be in generated types after running pnpm fetch-api-types +export interface AgentRef { + agent_id: string; + agent_name: string; +} // Helper type to extract query parameters from operations type ExtractQueryParams = T extends { parameters: { query?: infer Q } } diff --git a/ui/src/core/components/search-input.tsx b/ui/src/core/components/search-input.tsx new file mode 100644 index 00000000..18a62590 --- /dev/null +++ b/ui/src/core/components/search-input.tsx @@ -0,0 +1,58 @@ +import type { TextInputProps } from "@mantine/core"; +import { TextInput } from "@mantine/core"; +import { IconSearch, IconX } from "@tabler/icons-react"; +import { useMemo } from "react"; + +import { useQueryParam } from "@/core/hooks/use-query-param"; + +interface SearchInputProps + extends Omit { + /** Query parameter key to sync with URL (e.g., "search", "q", "store_q") */ + queryKey: string; +} + +/** + * Reusable search input component that syncs with URL query parameters. + * Includes a clear button (X icon) when there's text. + * Accepts all TextInput props for full customization. + * + * @example + * + */ +export function SearchInput({ + queryKey, + placeholder = "Search...", + w = 250, + ...rest +}: SearchInputProps) { + const [searchQuery, setSearchQuery] = useQueryParam(queryKey); + + const showClearButton = useMemo(() => { + return searchQuery.length > 0; + }, [searchQuery]); + + const handleClear = () => { + setSearchQuery(""); + }; + + return ( + } + rightSection={ + showClearButton ? ( + + ) : null + } + value={searchQuery} + onChange={(e) => setSearchQuery(e.currentTarget.value)} + w={w} + {...rest} + /> + ); +} diff --git a/ui/src/core/page-components/agent-detail/edit-control/evaluators/index.ts b/ui/src/core/evaluators/index.ts similarity index 100% rename from ui/src/core/page-components/agent-detail/edit-control/evaluators/index.ts rename to ui/src/core/evaluators/index.ts diff --git a/ui/src/core/page-components/agent-detail/edit-control/evaluators/json/form.tsx b/ui/src/core/evaluators/json/form.tsx similarity index 100% rename from ui/src/core/page-components/agent-detail/edit-control/evaluators/json/form.tsx rename to ui/src/core/evaluators/json/form.tsx diff --git a/ui/src/core/page-components/agent-detail/edit-control/evaluators/json/index.ts b/ui/src/core/evaluators/json/index.ts similarity index 100% rename from ui/src/core/page-components/agent-detail/edit-control/evaluators/json/index.ts rename to ui/src/core/evaluators/json/index.ts diff --git a/ui/src/core/page-components/agent-detail/edit-control/evaluators/json/types.ts b/ui/src/core/evaluators/json/types.ts similarity index 100% rename from ui/src/core/page-components/agent-detail/edit-control/evaluators/json/types.ts rename to ui/src/core/evaluators/json/types.ts diff --git a/ui/src/core/page-components/agent-detail/edit-control/evaluators/list/form.tsx b/ui/src/core/evaluators/list/form.tsx similarity index 100% rename from ui/src/core/page-components/agent-detail/edit-control/evaluators/list/form.tsx rename to ui/src/core/evaluators/list/form.tsx diff --git a/ui/src/core/page-components/agent-detail/edit-control/evaluators/list/index.ts b/ui/src/core/evaluators/list/index.ts similarity index 100% rename from ui/src/core/page-components/agent-detail/edit-control/evaluators/list/index.ts rename to ui/src/core/evaluators/list/index.ts diff --git a/ui/src/core/page-components/agent-detail/edit-control/evaluators/list/types.ts b/ui/src/core/evaluators/list/types.ts similarity index 100% rename from ui/src/core/page-components/agent-detail/edit-control/evaluators/list/types.ts rename to ui/src/core/evaluators/list/types.ts diff --git a/ui/src/core/page-components/agent-detail/edit-control/evaluators/luna2/form.tsx b/ui/src/core/evaluators/luna2/form.tsx similarity index 100% rename from ui/src/core/page-components/agent-detail/edit-control/evaluators/luna2/form.tsx rename to ui/src/core/evaluators/luna2/form.tsx diff --git a/ui/src/core/page-components/agent-detail/edit-control/evaluators/luna2/index.ts b/ui/src/core/evaluators/luna2/index.ts similarity index 100% rename from ui/src/core/page-components/agent-detail/edit-control/evaluators/luna2/index.ts rename to ui/src/core/evaluators/luna2/index.ts diff --git a/ui/src/core/page-components/agent-detail/edit-control/evaluators/luna2/types.ts b/ui/src/core/evaluators/luna2/types.ts similarity index 100% rename from ui/src/core/page-components/agent-detail/edit-control/evaluators/luna2/types.ts rename to ui/src/core/evaluators/luna2/types.ts diff --git a/ui/src/core/page-components/agent-detail/edit-control/evaluators/regex/form.tsx b/ui/src/core/evaluators/regex/form.tsx similarity index 100% rename from ui/src/core/page-components/agent-detail/edit-control/evaluators/regex/form.tsx rename to ui/src/core/evaluators/regex/form.tsx diff --git a/ui/src/core/page-components/agent-detail/edit-control/evaluators/regex/index.ts b/ui/src/core/evaluators/regex/index.ts similarity index 100% rename from ui/src/core/page-components/agent-detail/edit-control/evaluators/regex/index.ts rename to ui/src/core/evaluators/regex/index.ts diff --git a/ui/src/core/page-components/agent-detail/edit-control/evaluators/regex/types.ts b/ui/src/core/evaluators/regex/types.ts similarity index 100% rename from ui/src/core/page-components/agent-detail/edit-control/evaluators/regex/types.ts rename to ui/src/core/evaluators/regex/types.ts diff --git a/ui/src/core/page-components/agent-detail/edit-control/evaluators/sql/form.tsx b/ui/src/core/evaluators/sql/form.tsx similarity index 100% rename from ui/src/core/page-components/agent-detail/edit-control/evaluators/sql/form.tsx rename to ui/src/core/evaluators/sql/form.tsx diff --git a/ui/src/core/page-components/agent-detail/edit-control/evaluators/sql/index.ts b/ui/src/core/evaluators/sql/index.ts similarity index 100% rename from ui/src/core/page-components/agent-detail/edit-control/evaluators/sql/index.ts rename to ui/src/core/evaluators/sql/index.ts diff --git a/ui/src/core/page-components/agent-detail/edit-control/evaluators/sql/types.ts b/ui/src/core/evaluators/sql/types.ts similarity index 100% rename from ui/src/core/page-components/agent-detail/edit-control/evaluators/sql/types.ts rename to ui/src/core/evaluators/sql/types.ts diff --git a/ui/src/core/page-components/agent-detail/edit-control/evaluators/types.ts b/ui/src/core/evaluators/types.ts similarity index 100% rename from ui/src/core/page-components/agent-detail/edit-control/evaluators/types.ts rename to ui/src/core/evaluators/types.ts diff --git a/ui/src/core/hooks/query-hooks/use-agents-infinite.ts b/ui/src/core/hooks/query-hooks/use-agents-infinite.ts index 079232d8..1b03d95f 100644 --- a/ui/src/core/hooks/query-hooks/use-agents-infinite.ts +++ b/ui/src/core/hooks/query-hooks/use-agents-infinite.ts @@ -5,17 +5,25 @@ import type { ListAgentsResponse } from "@/core/api/types"; const AGENTS_PAGE_SIZE = 10; +export interface UseAgentsInfiniteParams { + name?: string; + enabled?: boolean; +} + /** * Infinite query hook to fetch agents with cursor-based pagination - * Loads more agents as user scrolls + * Supports server-side filtering by name */ -export function useAgentsInfinite() { +export function useAgentsInfinite(params?: UseAgentsInfiniteParams) { + const { enabled = true, ...filterParams } = params ?? {}; + return useInfiniteQuery({ - queryKey: ["agents", "infinite"], + queryKey: ["agents", "infinite", filterParams], queryFn: async ({ pageParam }: { pageParam: string | undefined }) => { const { data, error } = await api.agents.list({ cursor: pageParam, limit: AGENTS_PAGE_SIZE, + ...filterParams, }); if (error) throw error; return data; @@ -25,5 +33,6 @@ export function useAgentsInfinite() { return lastPage.pagination.next_cursor ?? undefined; }, initialPageParam: undefined, + enabled, }); } diff --git a/ui/src/core/hooks/query-hooks/use-controls-infinite.ts b/ui/src/core/hooks/query-hooks/use-controls-infinite.ts new file mode 100644 index 00000000..35a95a87 --- /dev/null +++ b/ui/src/core/hooks/query-hooks/use-controls-infinite.ts @@ -0,0 +1,46 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; + +import { api } from "@/core/api/client"; +import type { ListControlsResponse } from "@/core/api/types"; + +const CONTROLS_PAGE_SIZE = 10; + +export interface UseControlsInfiniteParams { + name?: string; + enabled?: boolean; + step_type?: string; + stage?: string; + execution?: string; + tag?: string; +} + +/** + * Infinite query hook to fetch controls with cursor-based pagination + * Supports server-side filtering by name and other params + */ +export function useControlsInfinite(params?: UseControlsInfiniteParams) { + const { enabled = true, ...filterParams } = params ?? {}; + + return useInfiniteQuery({ + queryKey: ["controls", "infinite", filterParams], + queryFn: async ({ pageParam }: { pageParam: number | undefined }) => { + const { data, error } = await api.controls.list({ + cursor: pageParam, + limit: CONTROLS_PAGE_SIZE, + ...filterParams, + }); + if (error) throw error; + return data; + }, + getNextPageParam: (lastPage: ListControlsResponse) => { + // Return undefined if no more pages (stops infinite query) + if (!lastPage.pagination.has_more) return undefined; + // Use the last control's ID as cursor for next page + const controls = lastPage.controls; + if (controls.length === 0) return undefined; + return controls[controls.length - 1].id; + }, + initialPageParam: undefined, + enabled, + }); +} diff --git a/ui/src/core/hooks/query-hooks/use-controls.ts b/ui/src/core/hooks/query-hooks/use-controls.ts new file mode 100644 index 00000000..ea82368b --- /dev/null +++ b/ui/src/core/hooks/query-hooks/use-controls.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; + +import { api } from "@/core/api/client"; + +export interface UseControlsParams { + cursor?: number; + limit?: number; + name?: string; + enabled?: boolean; + step_type?: string; + stage?: string; + execution?: string; + tag?: string; +} + +export function useControls(params?: UseControlsParams) { + return useQuery({ + queryKey: ["controls", "list", params], + queryFn: async () => { + const { data, error } = await api.controls.list(params); + if (error) { + throw new Error("Failed to load controls"); + } + return data; + }, + }); +} diff --git a/ui/src/core/hooks/use-infinite-scroll.ts b/ui/src/core/hooks/use-infinite-scroll.ts new file mode 100644 index 00000000..a085fdae --- /dev/null +++ b/ui/src/core/hooks/use-infinite-scroll.ts @@ -0,0 +1,56 @@ +import { useEffect, useRef } from "react"; + +interface UseInfiniteScrollOptions { + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => void; +} + +/** + * Custom hook for infinite scroll with IntersectionObserver. + * Works inside ScrollArea components by using a ref for the scroll container. + * + * @returns sentinelRef - attach to a div at the bottom of scrollable content + * @returns scrollContainerRef - attach to ScrollArea's viewportRef + * + * @example + * const { sentinelRef, scrollContainerRef } = useInfiniteScroll({ + * hasNextPage, + * isFetchingNextPage, + * fetchNextPage, + * }); + * + * + * + *
+ * + */ +export function useInfiniteScroll({ + hasNextPage, + isFetchingNextPage, + fetchNextPage, +}: UseInfiniteScrollOptions) { + const sentinelRef = useRef(null); + const scrollContainerRef = useRef(null); + + useEffect(() => { + if (!sentinelRef.current || !hasNextPage || isFetchingNextPage) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + fetchNextPage(); + } + }, + { + root: scrollContainerRef.current, + threshold: 0.1, + } + ); + + observer.observe(sentinelRef.current); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + return { sentinelRef, scrollContainerRef }; +} diff --git a/ui/src/core/hooks/use-query-param.ts b/ui/src/core/hooks/use-query-param.ts new file mode 100644 index 00000000..e2c33b33 --- /dev/null +++ b/ui/src/core/hooks/use-query-param.ts @@ -0,0 +1,63 @@ +import { useRouter } from "next/router"; +import { useCallback, useMemo, useRef } from "react"; + +interface UseQueryParamOptions { + /** Default value when param is not in URL */ + defaultValue?: string; + /** Whether to use shallow routing (no data fetching, default: true) */ + shallow?: boolean; +} + +/** + * Sync a state value with a URL query parameter. + * Enables shareable URLs and preserves state on refresh/back navigation. + * + * @param key - The query parameter key (e.g., "search" for ?search=value) + * @param options - Configuration options + * @returns [value, setValue] - Similar to useState, but synced with URL + * + * @example + * const [search, setSearch] = useQueryParam("search"); + * // URL: /agents?search=hello + * // search = "hello" + */ +export function useQueryParam( + key: string, + options: UseQueryParamOptions = {} +): [string, (value: string) => void] { + const { defaultValue = "", shallow = true } = options; + const router = useRouter(); + + // Derive value directly from router.query (no local state needed) + const value = useMemo(() => { + if (!router.isReady) return defaultValue; + const urlValue = router.query[key]; + return typeof urlValue === "string" ? urlValue : defaultValue; + }, [router.isReady, router.query, key, defaultValue]); + + // Track if we're currently updating to prevent loops + const isUpdatingRef = useRef(false); + + // Update URL when value changes + const setValue = useCallback( + (newValue: string) => { + if (!router.isReady || isUpdatingRef.current) return; + + isUpdatingRef.current = true; + + const query = { ...router.query }; + if (newValue) { + query[key] = newValue; + } else { + delete query[key]; + } + + router.replace({ pathname: router.pathname, query }, undefined, { shallow }).finally(() => { + isUpdatingRef.current = false; + }); + }, + [router, key, shallow] + ); + + return [value, setValue]; +} diff --git a/ui/src/core/page-components/agent-detail/agent-detail.tsx b/ui/src/core/page-components/agent-detail/agent-detail.tsx index 0ad342ec..4ec0dedf 100644 --- a/ui/src/core/page-components/agent-detail/agent-detail.tsx +++ b/ui/src/core/page-components/agent-detail/agent-detail.tsx @@ -11,7 +11,6 @@ import { Stack, Tabs, Text, - TextInput, Title, } from "@mantine/core"; import { Button, Switch, Table } from "@rungalileo/jupiter-ds"; @@ -20,7 +19,6 @@ import { IconChartBar, IconInbox, IconPencil, - IconSearch, IconShield, } from "@tabler/icons-react"; import { type ColumnDef } from "@tanstack/react-table"; @@ -28,13 +26,15 @@ import { useMemo, useState } from "react"; import { ErrorBoundary } from "@/components/error-boundary"; import type { Control } from "@/core/api/types"; +import { SearchInput } from "@/core/components/search-input"; import { useAgent } from "@/core/hooks/query-hooks/use-agent"; import { useAgentControls } from "@/core/hooks/query-hooks/use-agent-controls"; import { useUpdateControl } from "@/core/hooks/query-hooks/use-update-control"; +import { useQueryParam } from "@/core/hooks/use-query-param"; import { AgentStats } from "./agent-stats"; -import { ControlStoreModal } from "./control-store-modal"; -import { EditControlContent } from "./edit-control"; +import { ControlStoreModal } from "./modals/control-store"; +import { EditControlContent } from "./modals/edit-control/edit-control-content"; interface AgentDetailPageProps { agentId: string; @@ -58,7 +58,8 @@ const AgentDetailPage = ({ agentId }: AgentDetailPageProps) => { const [editModalOpened, setEditModalOpened] = useState(false); const [controlStoreOpened, setControlStoreOpened] = useState(false); const [selectedControl, setSelectedControl] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); + // Get search value for filtering (SearchInput handles the UI and URL sync) + const [searchQuery] = useQueryParam("q"); // Fetch agent details and controls const { @@ -73,10 +74,9 @@ const AgentDetailPage = ({ agentId }: AgentDetailPageProps) => { } = useAgentControls(agentId); const updateControl = useUpdateControl(); - const allControls = controlsResponse?.controls || []; - // Filter controls based on search query const controls = useMemo(() => { + const allControls = controlsResponse?.controls || []; if (!searchQuery.trim()) return allControls; const query = searchQuery.toLowerCase(); return allControls.filter( @@ -84,7 +84,7 @@ const AgentDetailPage = ({ agentId }: AgentDetailPageProps) => { control.name.toLowerCase().includes(query) || control.control?.description?.toLowerCase().includes(query) ); - }, [allControls, searchQuery]); + }, [controlsResponse, searchQuery]); // Loading state if (agentLoading) { @@ -277,14 +277,12 @@ const AgentDetailPage = ({ agentId }: AgentDetailPageProps) => { - } - value={searchQuery} - onChange={(e) => setSearchQuery(e.currentTarget.value)} +