Compare commits

..

3 Commits

26 changed files with 945 additions and 319 deletions

View File

@@ -502,6 +502,9 @@ class GoogleDriveConnector(
files: list[RetrievedDriveFile],
seen_hierarchy_node_raw_ids: ThreadSafeSet[str],
fully_walked_hierarchy_node_raw_ids: ThreadSafeSet[str],
failed_folder_ids_by_email: (
ThreadSafeDict[str, ThreadSafeSet[str]] | None
) = None,
permission_sync_context: PermissionSyncContext | None = None,
add_prefix: bool = False,
) -> list[HierarchyNode]:
@@ -525,6 +528,9 @@ class GoogleDriveConnector(
seen_hierarchy_node_raw_ids: Set of already-yielded node IDs (modified in place)
fully_walked_hierarchy_node_raw_ids: Set of node IDs where the walk to root
succeeded (modified in place)
failed_folder_ids_by_email: Map of email → folder IDs where that email
previously confirmed no accessible parent. Skips the API call if the same
(folder, email) is encountered again (modified in place).
permission_sync_context: If provided, permissions will be fetched for hierarchy nodes.
Contains google_domain and primary_admin_email needed for permission syncing.
add_prefix: When True, prefix group IDs with source type (for indexing path).
@@ -569,7 +575,7 @@ class GoogleDriveConnector(
# Fetch folder metadata
folder = self._get_folder_metadata(
current_id, file.user_email, field_type
current_id, file.user_email, field_type, failed_folder_ids_by_email
)
if not folder:
# Can't access this folder - stop climbing
@@ -653,7 +659,13 @@ class GoogleDriveConnector(
return new_nodes
def _get_folder_metadata(
self, folder_id: str, retriever_email: str, field_type: DriveFileFieldType
self,
folder_id: str,
retriever_email: str,
field_type: DriveFileFieldType,
failed_folder_ids_by_email: (
ThreadSafeDict[str, ThreadSafeSet[str]] | None
) = None,
) -> GoogleDriveFileType | None:
"""
Fetch metadata for a folder by ID.
@@ -667,6 +679,17 @@ class GoogleDriveConnector(
# Use a set to deduplicate if retriever_email == primary_admin_email
for email in {retriever_email, self.primary_admin_email}:
failed_ids = (
failed_folder_ids_by_email.get(email)
if failed_folder_ids_by_email
else None
)
if failed_ids and folder_id in failed_ids:
logger.debug(
f"Skipping folder {folder_id} using {email} (previously confirmed no parents)"
)
continue
service = get_drive_service(self.creds, email)
folder = get_folder_metadata(service, folder_id, field_type)
@@ -682,6 +705,10 @@ class GoogleDriveConnector(
# Folder has no parents - could be a root OR user lacks access to parent
# Keep this as a fallback but try admin to see if they can see parents
if failed_folder_ids_by_email is not None:
failed_folder_ids_by_email.setdefault(email, ThreadSafeSet()).add(
folder_id
)
if best_folder is None:
best_folder = folder
logger.debug(
@@ -1090,6 +1117,13 @@ class GoogleDriveConnector(
]
yield from parallel_yield(user_retrieval_gens, max_workers=MAX_DRIVE_WORKERS)
# Free per-user cache entries now that this batch is done.
# Skip the admin email — it is shared across all user batches and must
# persist for the duration of the run.
for email in non_completed_org_emails:
if email != self.primary_admin_email:
checkpoint.failed_folder_ids_by_email.pop(email, None)
# if there are more emails to process, don't mark as complete
if not email_batch_takes_us_to_completion:
return
@@ -1546,6 +1580,7 @@ class GoogleDriveConnector(
files=files_batch,
seen_hierarchy_node_raw_ids=checkpoint.seen_hierarchy_node_raw_ids,
fully_walked_hierarchy_node_raw_ids=checkpoint.fully_walked_hierarchy_node_raw_ids,
failed_folder_ids_by_email=checkpoint.failed_folder_ids_by_email,
permission_sync_context=permission_sync_context,
add_prefix=True,
)
@@ -1782,6 +1817,7 @@ class GoogleDriveConnector(
files=files_batch,
seen_hierarchy_node_raw_ids=checkpoint.seen_hierarchy_node_raw_ids,
fully_walked_hierarchy_node_raw_ids=checkpoint.fully_walked_hierarchy_node_raw_ids,
failed_folder_ids_by_email=checkpoint.failed_folder_ids_by_email,
permission_sync_context=permission_sync_context,
)

View File

@@ -167,6 +167,13 @@ class GoogleDriveCheckpoint(ConnectorCheckpoint):
default_factory=ThreadSafeSet
)
# Maps email → set of IDs of folders where that email confirmed no accessible parent.
# Avoids redundant API calls when the same (folder, email) pair is
# encountered again within the same retrieval run.
failed_folder_ids_by_email: ThreadSafeDict[str, ThreadSafeSet[str]] = Field(
default_factory=ThreadSafeDict
)
@field_serializer("completion_map")
def serialize_completion_map(
self, completion_map: ThreadSafeDict[str, StageCompletion], _info: Any
@@ -211,3 +218,25 @@ class GoogleDriveCheckpoint(ConnectorCheckpoint):
if isinstance(v, list):
return ThreadSafeSet(set(v)) # ty: ignore[invalid-return-type]
return ThreadSafeSet()
@field_serializer("failed_folder_ids_by_email")
def serialize_failed_folder_ids_by_email(
self,
failed_folder_ids_by_email: ThreadSafeDict[str, ThreadSafeSet[str]],
_info: Any,
) -> dict[str, set[str]]:
return {
k: inner.copy() for k, inner in failed_folder_ids_by_email.copy().items()
}
@field_validator("failed_folder_ids_by_email", mode="before")
def validate_failed_folder_ids_by_email(
cls, v: Any
) -> ThreadSafeDict[str, ThreadSafeSet[str]]:
if isinstance(v, ThreadSafeDict):
return v
if isinstance(v, dict):
return ThreadSafeDict(
{k: ThreadSafeSet(set(vals)) for k, vals in v.items()}
)
return ThreadSafeDict()

View File

@@ -34,6 +34,7 @@ R = TypeVar("R")
KT = TypeVar("KT") # Key type
VT = TypeVar("VT") # Value type
_T = TypeVar("_T") # Default type
_MISSING: object = object()
class ThreadSafeDict(MutableMapping[KT, VT]):
@@ -117,10 +118,10 @@ class ThreadSafeDict(MutableMapping[KT, VT]):
with self.lock:
return self._dict.get(key, default)
def pop(self, key: KT, default: Any = None) -> Any:
def pop(self, key: KT, default: Any = _MISSING) -> Any:
"""Remove and return a value with optional default, atomically."""
with self.lock:
if default is None:
if default is _MISSING:
return self._dict.pop(key)
return self._dict.pop(key, default)

View File

@@ -12,12 +12,14 @@ from unittest.mock import patch
from onyx.background.celery.celery_utils import extract_ids_from_runnable_connector
from onyx.connectors.google_drive.connector import GoogleDriveConnector
from onyx.connectors.google_drive.file_retrieval import DriveFileFieldType
from onyx.connectors.google_drive.models import DriveRetrievalStage
from onyx.connectors.google_drive.models import GoogleDriveCheckpoint
from onyx.connectors.interfaces import SlimConnector
from onyx.connectors.interfaces import SlimConnectorWithPermSync
from onyx.connectors.models import SlimDocument
from onyx.utils.threadpool_concurrency import ThreadSafeDict
from onyx.utils.threadpool_concurrency import ThreadSafeSet
def _make_done_checkpoint() -> GoogleDriveCheckpoint:
@@ -198,3 +200,90 @@ class TestCeleryUtilsRouting:
mock_slim.assert_called_once()
mock_perm_sync.assert_not_called()
class TestFailedFolderIdsByEmail:
def _make_failed_map(
self, entries: dict[str, set[str]]
) -> ThreadSafeDict[str, ThreadSafeSet[str]]:
return ThreadSafeDict({k: ThreadSafeSet(v) for k, v in entries.items()})
def test_skips_api_call_for_known_failed_pair(self) -> None:
"""_get_folder_metadata must skip the API call for a (folder, email) pair
that previously confirmed no accessible parent."""
connector = _make_connector()
failed_map = self._make_failed_map(
{
"retriever@example.com": {"folder1"},
"admin@example.com": {"folder1"},
}
)
with patch(
"onyx.connectors.google_drive.connector.get_folder_metadata"
) as mock_api:
result = connector._get_folder_metadata(
folder_id="folder1",
retriever_email="retriever@example.com",
field_type=DriveFileFieldType.SLIM,
failed_folder_ids_by_email=failed_map,
)
mock_api.assert_not_called()
assert result is None
def test_records_failed_pair_when_no_parents(self) -> None:
"""_get_folder_metadata must record (email → folder_id) in the map
when the API returns a folder with no parents."""
connector = _make_connector()
failed_map: ThreadSafeDict[str, ThreadSafeSet[str]] = ThreadSafeDict()
folder_no_parents: dict = {"id": "folder1", "name": "Orphaned"}
with (
patch(
"onyx.connectors.google_drive.connector.get_drive_service",
return_value=MagicMock(),
),
patch(
"onyx.connectors.google_drive.connector.get_folder_metadata",
return_value=folder_no_parents,
),
):
connector._get_folder_metadata(
folder_id="folder1",
retriever_email="retriever@example.com",
field_type=DriveFileFieldType.SLIM,
failed_folder_ids_by_email=failed_map,
)
assert "folder1" in failed_map.get("retriever@example.com", ThreadSafeSet())
assert "folder1" in failed_map.get("admin@example.com", ThreadSafeSet())
def test_does_not_record_when_parents_found(self) -> None:
"""_get_folder_metadata must NOT record a pair when parents are found."""
connector = _make_connector()
failed_map: ThreadSafeDict[str, ThreadSafeSet[str]] = ThreadSafeDict()
folder_with_parents: dict = {
"id": "folder1",
"name": "Normal",
"parents": ["root"],
}
with (
patch(
"onyx.connectors.google_drive.connector.get_drive_service",
return_value=MagicMock(),
),
patch(
"onyx.connectors.google_drive.connector.get_folder_metadata",
return_value=folder_with_parents,
),
):
connector._get_folder_metadata(
folder_id="folder1",
retriever_email="retriever@example.com",
field_type=DriveFileFieldType.SLIM,
failed_folder_ids_by_email=failed_map,
)
assert len(failed_map) == 0

View File

@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import { EmptyMessageCard } from "@opal/components";
import { SvgSparkle, SvgUsers } from "@opal/icons";
import { SvgActions, SvgServer, SvgSparkle, SvgUsers } from "@opal/icons";
const PADDING_VARIANTS = ["fit", "2xs", "xs", "sm", "md", "lg"] as const;
@@ -26,6 +26,22 @@ export const WithCustomIcon: Story = {
},
};
export const MainUi: Story = {
args: {
sizePreset: "main-ui",
title: "No Actions Found",
icon: SvgActions,
description: "Provide OpenAPI schema to preview actions here.",
},
};
export const MainUiNoDescription: Story = {
args: {
sizePreset: "main-ui",
title: "No Knowledge",
},
};
export const PaddingVariants: Story = {
render: () => (
<div className="flex flex-col gap-4 w-96">
@@ -46,6 +62,12 @@ export const Multiple: Story = {
<EmptyMessageCard title="No models available." />
<EmptyMessageCard icon={SvgSparkle} title="No agents selected." />
<EmptyMessageCard icon={SvgUsers} title="No groups added." />
<EmptyMessageCard
sizePreset="main-ui"
icon={SvgServer}
title="No Discord servers configured yet"
description="Create a server configuration to get started."
/>
</div>
),
};

View File

@@ -6,25 +6,44 @@ A pre-configured Card for empty states. Renders a transparent card with a dashed
## Props
| Prop | Type | Default | Description |
| --------- | --------------------------- | ---------- | -------------------------------- |
| `icon` | `IconFunctionComponent` | `SvgEmpty` | Icon displayed alongside the title |
| `title` | `string` | — | Primary message text (required) |
| `padding` | `PaddingVariants` | `"sm"` | Padding preset for the card |
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
### Base props (all presets)
| Prop | Type | Default | Description |
| ------------ | ----------------------------- | ------------- | ---------------------------------- |
| `sizePreset` | `"secondary" \| "main-ui"` | `"secondary"` | Controls layout and text sizing |
| `icon` | `IconFunctionComponent` | `SvgEmpty` | Icon displayed alongside the title |
| `title` | `string \| RichStr` | — | Primary message text (required) |
| `padding` | `PaddingVariants` | `"md"` | Padding preset for the card |
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
### `sizePreset="main-ui"` only
| Prop | Type | Default | Description |
| ------------- | ------------------- | ------- | ------------------------ |
| `description` | `string \| RichStr` | — | Optional description text |
> `description` is **not accepted** when `sizePreset` is `"secondary"` (the default).
## Usage
```tsx
import { EmptyMessageCard } from "@opal/components";
import { SvgSparkle, SvgFileText } from "@opal/icons";
import { SvgSparkle, SvgFileText, SvgActions } from "@opal/icons";
// Default empty state
// Default empty state (secondary)
<EmptyMessageCard title="No items yet." />
// With custom icon
<EmptyMessageCard icon={SvgSparkle} title="No agents selected." />
// With custom padding
// main-ui with description
<EmptyMessageCard
sizePreset="main-ui"
icon={SvgActions}
title="No Actions Found"
description="Provide OpenAPI schema to preview actions here."
/>
// Custom padding
<EmptyMessageCard padding="xs" icon={SvgFileText} title="No documents available." />
```

View File

@@ -1,5 +1,5 @@
import { Card } from "@opal/components/cards/card/components";
import { Content, SizePreset } from "@opal/layouts";
import { Content } from "@opal/layouts";
import { SvgEmpty } from "@opal/icons";
import type {
IconFunctionComponent,
@@ -32,7 +32,7 @@ type EmptyMessageCardProps =
})
| (EmptyMessageCardBaseProps & {
sizePreset: "main-ui";
/** Description text. Only supported when `sizePreset` is `"main-ui"`. */
/** Optional description text. */
description?: string | RichStr;
});

View File

@@ -15,7 +15,7 @@ import { DeleteButton } from "@/components/DeleteButton";
import { Button } from "@opal/components";
import Switch from "@/refresh-components/inputs/Switch";
import { SvgEdit, SvgServer } from "@opal/icons";
import EmptyMessage from "@/refresh-components/EmptyMessage";
import { EmptyMessageCard } from "@opal/components";
import { DiscordGuildConfig } from "@/app/admin/discord-bot/types";
import {
deleteGuildConfig,
@@ -81,7 +81,8 @@ export function DiscordGuildsTable({ guilds, onRefresh }: Props) {
if (guilds.length === 0) {
return (
<EmptyMessage
<EmptyMessageCard
sizePreset="main-ui"
icon={SvgServer}
title="No Discord servers configured yet"
description="Create a server configuration to get started."

View File

@@ -10,7 +10,7 @@ import {
} from "@/components/ui/table";
import Switch from "@/refresh-components/inputs/Switch";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import EmptyMessage from "@/refresh-components/EmptyMessage";
import { EmptyMessageCard } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import { Section } from "@/layouts/general-layouts";
import {
@@ -61,7 +61,8 @@ export function DiscordChannelsTable({
}: Props) {
if (channels.length === 0) {
return (
<EmptyMessage
<EmptyMessageCard
sizePreset="main-ui"
title="No channels configured"
description="Run !sync-channels in Discord to add channels."
/>

View File

@@ -8,7 +8,7 @@ import {
} from "@/lib/search/interfaces";
import SearchCard from "@/ee/sections/SearchCard";
import { Divider, Pagination } from "@opal/components";
import EmptyMessage from "@/refresh-components/EmptyMessage";
import { EmptyMessageCard } from "@opal/components";
import { IllustrationContent } from "@opal/layouts";
import SvgNoResult from "@opal/illustrations/no-result";
import { getSourceMetadata } from "@/lib/sources";
@@ -334,7 +334,11 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
)}
>
{error ? (
<EmptyMessage title="Search failed" description={error} />
<EmptyMessageCard
sizePreset="main-ui"
title="Search failed"
description={error}
/>
) : paginatedResults.length > 0 ? (
<>
{paginatedResults.map((doc) => (

View File

@@ -0,0 +1,261 @@
/**
* Actions Layout Components
*
* A namespaced collection of components for building consistent action cards
* (MCP servers, OpenAPI tools, etc.). These components provide a standardized
* layout that separates presentation from business logic, making it easier to
* build and maintain action-related UIs.
*
* Built on top of ExpandableCard layouts for the underlying card structure.
*
* @example
* ```tsx
* import * as ActionsLayouts from "@/layouts/actions-layouts";
* import * as ExpandableCard from "@/layouts/expandable-card-layouts";
* import { SvgServer } from "@opal/icons";
* import Switch from "@/components/ui/switch";
*
* function MyActionCard() {
* return (
* <ExpandableCard.Root>
* <ActionsLayouts.Header
* title="My MCP Server"
* description="A powerful MCP server for automation"
* icon={SvgServer}
* rightChildren={
* <Button onClick={handleDisconnect}>Disconnect</Button>
* }
* />
* <ActionsLayouts.Content>
* <ActionsLayouts.Tool
* title="File Reader"
* description="Read files from the filesystem"
* icon={SvgFile}
* rightChildren={
* <Switch checked={enabled} onCheckedChange={setEnabled} />
* }
* />
* <ActionsLayouts.Tool
* title="Web Search"
* description="Search the web"
* icon={SvgGlobe}
* disabled={true}
* rightChildren={
* <Switch checked={false} disabled />
* }
* />
* </ActionsLayouts.Content>
* </ExpandableCard.Root>
* );
* }
* ```
*/
"use client";
import React, { HtmlHTMLAttributes } from "react";
import type { IconProps } from "@opal/types";
import { WithoutStyles } from "@/types";
import { ContentAction } from "@opal/layouts";
import * as ExpandableCard from "@/layouts/expandable-card-layouts";
import { Card } from "@/refresh-components/cards";
import { Label } from "@opal/layouts";
/**
* Actions Header Component
*
* The header section of an action card. Displays icon, title, description,
* and optional right-aligned actions.
*
* Features:
* - Icon, title, and description display
* - Custom right-aligned actions via rightChildren
* - Responsive layout with truncated text
*
* @example
* ```tsx
* // Basic header
* <ActionsLayouts.Header
* title="File Server"
* description="Manage local files"
* icon={SvgFolder}
* />
*
* // With actions
* <ActionsLayouts.Header
* title="API Server"
* description="RESTful API integration"
* icon={SvgCloud}
* rightChildren={
* <div className="flex gap-2">
* <Button onClick={handleEdit}>Edit</Button>
* <Button onClick={handleDelete}>Delete</Button>
* </div>
* }
* />
* ```
*/
export interface ActionsHeaderProps
extends WithoutStyles<HtmlHTMLAttributes<HTMLDivElement>> {
// Core content
name?: string;
title: string;
description?: string;
icon: React.FunctionComponent<IconProps>;
// Custom content
rightChildren?: React.ReactNode;
}
function ActionsHeader({
name,
title,
description,
icon: Icon,
rightChildren,
...props
}: ActionsHeaderProps) {
return (
<ExpandableCard.Header>
<div className="flex flex-col gap-2 pt-4 pb-2">
<div className="px-4">
<Label label={name}>
<ContentAction
icon={Icon}
title={title}
description={description}
sizePreset="section"
variant="section"
rightChildren={rightChildren}
paddingVariant="fit"
/>
</Label>
</div>
<div {...props} className="px-2" />
</div>
</ExpandableCard.Header>
);
}
/**
* Actions Content Component
*
* A container for the content area of an action card.
* Use this to wrap tools, settings, or other expandable content.
* Features a maximum height with scrollable overflow.
*
* IMPORTANT: Only ONE ActionsContent should be used within a single ExpandableCard.Root.
* This component self-registers with the ActionsLayout context to inform
* ActionsHeader whether content exists (for border-radius styling). Using
* multiple ActionsContent components will cause incorrect unmount behavior -
* when any one unmounts, it will incorrectly signal that no content exists,
* even if other ActionsContent components remain mounted.
*
* @example
* ```tsx
* <ActionsLayouts.Content>
* <ActionsLayouts.Tool {...} />
* <ActionsLayouts.Tool {...} />
* </ActionsLayouts.Content>
* ```
*/
function ActionsContent({
children,
...props
}: WithoutStyles<React.HTMLAttributes<HTMLDivElement>>) {
return (
<ExpandableCard.Content {...props}>
<div className="flex flex-col gap-2 p-2">{children}</div>
</ExpandableCard.Content>
);
}
/**
* Actions Tool Component
*
* Represents a single tool within an actions content area. Displays the tool's
* title, description, and icon. The component provides a label wrapper for
* custom right-aligned controls (like toggle switches).
*
* Features:
* - Tool title and description
* - Custom icon
* - Disabled state (applies strikethrough to title)
* - Custom right-aligned content via rightChildren
* - Responsive layout with truncated text
*
* @example
* ```tsx
* // Basic tool with switch
* <ActionsLayouts.Tool
* title="File Reader"
* description="Read files from the filesystem"
* icon={SvgFile}
* rightChildren={
* <Switch checked={enabled} onCheckedChange={setEnabled} />
* }
* />
*
* // Disabled tool
* <ActionsLayouts.Tool
* title="Premium Feature"
* description="This feature requires a premium subscription"
* icon={SvgLock}
* disabled={true}
* rightChildren={
* <Switch checked={false} disabled />
* }
* />
*
* // Tool with custom action
* <ActionsLayouts.Tool
* name="config_tool"
* title="Configuration"
* description="Configure system settings"
* icon={SvgSettings}
* rightChildren={
* <Button onClick={openSettings}>Configure</Button>
* }
* />
* ```
*/
export type ActionsToolProps = WithoutStyles<{
// Core content
name?: string;
title: string;
description: string;
icon?: React.FunctionComponent<IconProps>;
// State
disabled?: boolean;
rightChildren?: React.ReactNode;
}>;
function ActionsTool({
name,
title,
description,
icon,
disabled,
rightChildren,
}: ActionsToolProps) {
return (
<Card padding={0.75} variant={disabled ? "disabled" : undefined}>
<Label label={name} disabled={disabled}>
<ContentAction
icon={icon}
title={title}
description={description}
sizePreset="main-ui"
variant="section"
rightChildren={rightChildren}
paddingVariant="fit"
/>
</Label>
</Card>
);
}
export {
ActionsHeader as Header,
ActionsContent as Content,
ActionsTool as Tool,
};

View File

@@ -0,0 +1,291 @@
/**
* Expandable Card Layout Components
*
* A namespaced collection of components for building expandable cards with
* collapsible content sections. These provide the structural foundation
* without opinionated content styling - just pure containers.
*
* Use these components when you need:
* - A card with a header that can have expandable content below it
* - Automatic border-radius handling based on whether content exists/is folded
* - Controlled or uncontrolled folding state
*
* @example
* ```tsx
* import * as ExpandableCard from "@/layouts/expandable-card-layouts";
*
* // Uncontrolled — Root manages its own state
* function MyCard() {
* return (
* <ExpandableCard.Root>
* <ExpandableCard.Header>
* <div className="p-4">
* <h3>My Header</h3>
* </div>
* </ExpandableCard.Header>
* <ExpandableCard.Content>
* <div className="p-4">
* <p>Expandable content goes here</p>
* </div>
* </ExpandableCard.Content>
* </ExpandableCard.Root>
* );
* }
*
* // Controlled — consumer owns the state
* function MyControlledCard() {
* const [isFolded, setIsFolded] = useState(false);
*
* return (
* <ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
* <ExpandableCard.Header>
* <button onClick={() => setIsFolded(!isFolded)}>Toggle</button>
* </ExpandableCard.Header>
* <ExpandableCard.Content>
* <p>Content here</p>
* </ExpandableCard.Content>
* </ExpandableCard.Root>
* );
* }
* ```
*/
"use client";
import React, {
createContext,
useContext,
useState,
useMemo,
useLayoutEffect,
Dispatch,
SetStateAction,
} from "react";
import { cn } from "@/lib/utils";
import { WithoutStyles } from "@/types";
import ShadowDiv from "@/refresh-components/ShadowDiv";
import { Section, SectionProps } from "@/layouts/general-layouts";
import {
Collapsible,
CollapsibleContent,
} from "@/refresh-components/Collapsible";
/**
* Expandable Card Context
*
* Provides folding state management for expandable cards without prop drilling.
* Also tracks whether content is present via self-registration.
*/
interface ExpandableCardContextValue {
isFolded: boolean;
setIsFolded: Dispatch<SetStateAction<boolean>>;
hasContent: boolean;
registerContent: () => () => void;
}
const ExpandableCardContext = createContext<
ExpandableCardContextValue | undefined
>(undefined);
function useExpandableCardContext() {
const context = useContext(ExpandableCardContext);
if (!context) {
throw new Error(
"ExpandableCard components must be used within an ExpandableCard.Root"
);
}
return context;
}
/**
* Expandable Card Root Component
*
* The root container and context provider for an expandable card. Provides a
* flex column layout with no gap or padding by default.
*
* Supports both controlled and uncontrolled folding state:
* - **Uncontrolled**: Manages its own state. Use `defaultFolded` to set the
* initial folding state (defaults to `false`, i.e. expanded).
* - **Controlled**: Pass `isFolded` and `onFoldedChange` to manage folding
* state externally.
*
* @example
* ```tsx
* // Uncontrolled
* <ExpandableCard.Root>
* <ExpandableCard.Header>...</ExpandableCard.Header>
* <ExpandableCard.Content>...</ExpandableCard.Content>
* </ExpandableCard.Root>
*
* // Uncontrolled, starts folded
* <ExpandableCard.Root defaultFolded>
* ...
* </ExpandableCard.Root>
*
* // Controlled
* const [isFolded, setIsFolded] = useState(false);
* <ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
* ...
* </ExpandableCard.Root>
* ```
*/
export interface ExpandableCardRootProps extends SectionProps {
/** Controlled folding state. When provided, the component is controlled. */
isFolded?: boolean;
/** Callback when folding state changes. Required for controlled usage. */
onFoldedChange?: Dispatch<SetStateAction<boolean>>;
/** Initial folding state for uncontrolled usage. Defaults to `false`. */
defaultFolded?: boolean;
}
function ExpandableCardRoot({
isFolded: controlledFolded,
onFoldedChange,
defaultFolded = false,
...props
}: ExpandableCardRootProps) {
const [uncontrolledFolded, setUncontrolledFolded] = useState(defaultFolded);
const isControlled = controlledFolded !== undefined;
const isFolded = isControlled ? controlledFolded : uncontrolledFolded;
const setIsFolded = isControlled
? onFoldedChange ?? (() => {})
: setUncontrolledFolded;
const [hasContent, setHasContent] = useState(false);
// Registration function for Content to announce its presence
const registerContent = useMemo(
() => () => {
setHasContent(true);
return () => setHasContent(false);
},
[]
);
const contextValue = useMemo(
() => ({ isFolded, setIsFolded, hasContent, registerContent }),
[isFolded, setIsFolded, hasContent, registerContent]
);
return (
<ExpandableCardContext.Provider value={contextValue}>
<Section gap={0} padding={0} {...props} />
</ExpandableCardContext.Provider>
);
}
/**
* Expandable Card Header Component
*
* The header section of an expandable card. This is a pure container that:
* - Has a border and neutral background
* - Automatically handles border-radius based on content state:
* - Fully rounded when no content exists or when content is folded
* - Only top-rounded when content is visible
*
* You are responsible for adding your own padding, layout, and content inside.
*
* @example
* ```tsx
* <ExpandableCard.Header>
* <div className="flex items-center justify-between p-4">
* <h3>My Title</h3>
* <button>Action</button>
* </div>
* </ExpandableCard.Header>
* ```
*/
export interface ExpandableCardHeaderProps
extends WithoutStyles<React.HTMLAttributes<HTMLDivElement>> {
children?: React.ReactNode;
}
function ExpandableCardHeader({
children,
...props
}: ExpandableCardHeaderProps) {
const { isFolded, hasContent } = useExpandableCardContext();
// Round all corners if there's no content, or if content exists but is folded
const shouldFullyRound = !hasContent || isFolded;
return (
<div
{...props}
className={cn(
"border bg-background-neutral-00 w-full transition-[border-radius] duration-200 ease-out",
shouldFullyRound ? "rounded-16" : "rounded-t-16"
)}
>
{children}
</div>
);
}
/**
* Expandable Card Content Component
*
* The expandable content section of the card. This is a pure container that:
* - Self-registers with context to inform Header about its presence
* - Animates open/closed using Radix Collapsible (slide down/up)
* - Has side and bottom borders that connect to the header
* - Has a max-height with scrollable overflow via ShadowDiv
*
* You are responsible for adding your own content inside.
*
* IMPORTANT: Only ONE Content component should be used within a single Root.
* This component self-registers with the context to inform Header whether
* content exists (for border-radius styling). Using multiple Content components
* will cause incorrect unmount behavior.
*
* @example
* ```tsx
* <ExpandableCard.Content>
* <div className="p-4">
* <p>Your expandable content here</p>
* </div>
* </ExpandableCard.Content>
* ```
*/
export interface ExpandableCardContentProps
extends WithoutStyles<React.HTMLAttributes<HTMLDivElement>> {
children?: React.ReactNode;
}
function ExpandableCardContent({
children,
...props
}: ExpandableCardContentProps) {
const { isFolded, registerContent } = useExpandableCardContext();
// Self-register with context to inform Header that content exists
useLayoutEffect(() => {
return registerContent();
}, [registerContent]);
return (
<Collapsible open={!isFolded} className="w-full">
<CollapsibleContent>
<div
className={cn(
"border-x border-b rounded-b-16 overflow-hidden w-full transition-opacity duration-200 ease-out",
isFolded ? "opacity-0" : "opacity-100"
)}
>
<ShadowDiv
className="flex flex-col rounded-b-16 max-h-[20rem]"
{...props}
>
{children}
</ShadowDiv>
</div>
</CollapsibleContent>
</Collapsible>
);
}
export {
ExpandableCardRoot as Root,
ExpandableCardHeader as Header,
ExpandableCardContent as Content,
};

View File

@@ -1,42 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import EmptyMessage from "./EmptyMessage";
import { SvgFileText, SvgUsers } from "@opal/icons";
const meta: Meta<typeof EmptyMessage> = {
title: "refresh-components/messages/EmptyMessage",
component: EmptyMessage,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof EmptyMessage>;
export const Default: Story = {
args: {
title: "No items found",
},
};
export const WithDescription: Story = {
args: {
title: "No connectors configured",
description:
"Set up a connector to start indexing documents from your data sources.",
},
};
export const WithCustomIcon: Story = {
args: {
icon: SvgFileText,
title: "No documents available",
description: "Upload documents or connect a data source to get started.",
},
};
export const UsersEmpty: Story = {
args: {
icon: SvgUsers,
title: "No users in this group",
description: "Add users to this group to grant them access.",
},
};

View File

@@ -1,64 +0,0 @@
/**
* EmptyMessage - A component for displaying empty state messages
*
* Displays a translucent card with an icon and message text to indicate
* when no data or content is available.
*
* Features:
* - Translucent card background with dashed border
* - Horizontal layout with icon on left, text on right
* - 0.5rem gap between icon and text
* - Accepts string children for the message text
* - Customizable icon
*
* @example
* ```tsx
* import EmptyMessage from "@/refresh-components/EmptyMessage";
* import { SvgActivity } from "@opal/icons";
*
* // Basic usage
* <EmptyMessage icon={SvgActivity}>
* No connectors set up for your organization.
* </EmptyMessage>
*
* // With different icon
* <EmptyMessage icon={SvgFileText}>
* No documents available.
* </EmptyMessage>
* ```
*/
import { SvgEmpty } from "@opal/icons";
import Card from "@/refresh-components/cards/Card";
import Text from "@/refresh-components/texts/Text";
import { Content } from "@opal/layouts";
import { IconProps } from "@opal/types";
export interface EmptyMessageProps {
icon?: React.FunctionComponent<IconProps>;
title: string;
description?: string;
}
export default function EmptyMessage({
icon: Icon = SvgEmpty,
title,
description,
}: EmptyMessageProps) {
return (
<Card variant="tertiary">
<Content
icon={Icon}
title={title}
sizePreset="main-ui"
variant="body"
prominence="muted"
/>
{description && (
<Text secondaryBody text03>
{description}
</Text>
)}
</Card>
);
}

View File

@@ -82,6 +82,7 @@ export interface LineItemProps
selected?: boolean;
icon?: React.FunctionComponent<IconProps>;
strokeIcon?: boolean;
description?: string;
rightChildren?: React.ReactNode;
href?: string;
@@ -154,6 +155,7 @@ export default function LineItem({
skeleton,
emphasized,
icon: Icon,
strokeIcon = true,
description,
children,
rightChildren,
@@ -245,7 +247,12 @@ export default function LineItem({
!!(children && description) && "mt-0.5"
)}
>
<Icon className={cn("h-[1rem] w-[1rem]", iconClassNames[variant])} />
<Icon
className={cn(
"h-[1rem] w-[1rem]",
strokeIcon && iconClassNames[variant]
)}
/>
</div>
)}
<Section alignItems="start" gap={0}>

View File

@@ -99,6 +99,7 @@ export default function SwitchList({
item.leading) as React.FunctionComponent<IconProps>)
: undefined
}
strokeIcon={false}
rightChildren={
<Switch
checked={item.isEnabled}

View File

@@ -172,6 +172,7 @@ export default function ModelListContent({
<LineItem
muted
icon={group.Icon}
strokeIcon={false}
rightChildren={
open ? (
<SvgChevronDown className="h-4 w-4 stroke-text-04 shrink-0" />

View File

@@ -53,7 +53,7 @@ import CharacterCount from "@/refresh-components/CharacterCount";
import { InputPrompt } from "@/app/app/interfaces";
import usePromptShortcuts from "@/hooks/usePromptShortcuts";
import ColorSwatch from "@/refresh-components/ColorSwatch";
import EmptyMessage from "@/refresh-components/EmptyMessage";
import { EmptyMessageCard } from "@opal/components";
import Memories from "@/sections/settings/Memories";
import { FederatedConnectorOAuthStatus } from "@/components/chat/FederatedOAuthModal";
import {
@@ -1701,7 +1701,10 @@ function ConnectorsSettings() {
))}
</>
) : (
<EmptyMessage title="No connectors set up for your organization." />
<EmptyMessageCard
sizePreset="main-ui"
title="No connectors set up for your organization."
/>
)}
</Section>
</Section>

View File

@@ -26,19 +26,14 @@ import {
SvgRefreshCw,
} from "@opal/icons";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import {
Card as CardLayout,
Content,
InputHorizontal,
InputVertical,
} from "@opal/layouts";
import { Content, InputHorizontal, InputVertical } from "@opal/layouts";
import {
useSettingsContext,
useVectorDbEnabled,
} from "@/providers/SettingsProvider";
import useCCPairs from "@/hooks/useCCPairs";
import { getSourceMetadata } from "@/lib/sources";
import EmptyMessage from "@/refresh-components/EmptyMessage";
import { EmptyMessageCard } from "@opal/components";
import { Settings } from "@/interfaces/settings";
import { toast } from "@/hooks/useToast";
import { useAvailableTools } from "@/hooks/useAvailableTools";
@@ -54,6 +49,8 @@ import Modal from "@/refresh-components/Modal";
import Switch from "@/refresh-components/inputs/Switch";
import useMcpServersForAgentEditor from "@/hooks/useMcpServersForAgentEditor";
import useOpenApiTools from "@/hooks/useOpenApiTools";
import * as ExpandableCard from "@/layouts/expandable-card-layouts";
import * as ActionsLayouts from "@/layouts/actions-layouts";
import { getActionIcon } from "@/lib/tools/mcpUtils";
import { Disabled, Hoverable } from "@opal/core";
import IconButton from "@/refresh-components/buttons/IconButton";
@@ -106,60 +103,13 @@ function MCPServerCard({
? "Authenticate this MCP server before enabling its tools."
: undefined;
const expanded = !isFolded;
const hasContent = tools.length > 0 && filteredTools.length > 0;
return (
<OpalCard
expandable
expanded={expanded}
border="solid"
rounding="lg"
padding="sm"
expandedContent={
hasContent ? (
<div className="flex flex-col gap-2 p-2">
{filteredTools.map((tool) => (
<OpalCard key={tool.id} border="solid" rounding="lg" padding="sm">
<CardLayout.Header
headerChildren={
<Content
icon={tool.icon}
title={tool.name}
description={tool.description}
sizePreset="main-ui"
variant="section"
/>
}
topRightChildren={
<Tooltip tooltip={authTooltip} side="top">
<Switch
checked={isToolEnabled(tool.id)}
onCheckedChange={(checked) =>
onToggleTool(tool.id, checked)
}
disabled={needsAuth}
/>
</Tooltip>
}
/>
</OpalCard>
))}
</div>
) : undefined
}
>
<CardLayout.Header
headerChildren={
<Content
icon={getActionIcon(server.server_url, server.name)}
title={server.name}
description={server.description}
sizePreset="main-ui"
variant="section"
/>
}
topRightChildren={
<ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
<ActionsLayouts.Header
title={server.name}
description={server.description}
icon={getActionIcon(server.server_url, server.name)}
rightChildren={
<Tooltip tooltip={authTooltip} side="top">
<Switch
checked={serverEnabled}
@@ -168,29 +118,53 @@ function MCPServerCard({
/>
</Tooltip>
}
bottomChildren={
tools.length > 0 ? (
<Section flexDirection="row" gap={0.5}>
<InputTypeIn
placeholder="Search tools..."
variant="internal"
leftSearchIcon
value={query}
onChange={(e) => setQuery(e.target.value)}
>
{tools.length > 0 && (
<Section flexDirection="row" gap={0.5}>
<InputTypeIn
placeholder="Search tools..."
variant="internal"
leftSearchIcon
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<Button
rightIcon={isFolded ? SvgExpand : SvgFold}
onClick={() => setIsFolded((prev) => !prev)}
prominence="internal"
size="lg"
>
{isFolded ? "Expand" : "Fold"}
</Button>
</Section>
)}
</ActionsLayouts.Header>
{tools.length > 0 && filteredTools.length > 0 && (
<ActionsLayouts.Content>
<div className="flex flex-col gap-2">
{filteredTools.map((tool) => (
<ActionsLayouts.Tool
key={tool.id}
title={tool.name}
description={tool.description}
icon={tool.icon}
rightChildren={
<Tooltip tooltip={authTooltip} side="top">
<Switch
checked={isToolEnabled(tool.id)}
onCheckedChange={(checked) =>
onToggleTool(tool.id, checked)
}
disabled={needsAuth}
/>
</Tooltip>
}
/>
<Button
rightIcon={isFolded ? SvgExpand : SvgFold}
onClick={() => setIsFolded((prev) => !prev)}
prominence="internal"
size="lg"
>
{isFolded ? "Expand" : "Fold"}
</Button>
</Section>
) : undefined
}
/>
</OpalCard>
))}
</div>
</ActionsLayouts.Content>
)}
</ExpandableCard.Root>
);
}
@@ -698,7 +672,10 @@ function ChatPreferencesForm() {
gap={0.25}
>
{uniqueSources.length === 0 ? (
<EmptyMessage title="No connectors set up" />
<EmptyMessageCard
sizePreset="main-ui"
title="No connectors set up"
/>
) : (
<>
<Section
@@ -884,23 +861,12 @@ function ChatPreferencesForm() {
/>
))}
{openApiTools.map((tool) => (
<OpalCard
key={tool.id}
border="solid"
rounding="lg"
padding="sm"
>
<CardLayout.Header
headerChildren={
<Content
icon={SvgActions}
title={tool.display_name || tool.name}
description={tool.description}
sizePreset="main-ui"
variant="section"
/>
}
topRightChildren={
<ExpandableCard.Root key={tool.id} defaultFolded>
<ActionsLayouts.Header
title={tool.display_name || tool.name}
description={tool.description}
icon={SvgActions}
rightChildren={
<Switch
checked={isToolEnabled(tool.id)}
onCheckedChange={(checked) =>
@@ -909,7 +875,7 @@ function ChatPreferencesForm() {
/>
}
/>
</OpalCard>
</ExpandableCard.Root>
))}
</Section>
</SimpleCollapsible.Content>

View File

@@ -146,6 +146,7 @@ function SharedGroupResources({
interactive={!dimmed}
muted={dimmed}
icon={getSourceMetadata(p.connector.source).icon}
strokeIcon={false}
rightChildren={
p.groups.length > 0 || dimmed ? <SharedBadge /> : undefined
}

View File

@@ -186,6 +186,7 @@ export default function UserFilters({
<LineItem
key={role}
icon={isSelected ? SvgCheck : roleIcon}
strokeIcon={isSelected || role !== UserRole.SLACK_USER}
selected={isSelected}
emphasized={isSelected}
onClick={() => toggleRole(role)}

View File

@@ -33,7 +33,7 @@ import {
import InfoBlock from "@/refresh-components/messages/InfoBlock";
import { getActionIcon } from "@/lib/tools/mcpUtils";
import { Section } from "@/layouts/general-layouts";
import EmptyMessage from "@/refresh-components/EmptyMessage";
import { EmptyMessageCard } from "@opal/components";
interface AddOpenAPIActionModalProps {
skipOverlay?: boolean;
@@ -312,7 +312,8 @@ function FormContent({
</Section>
</>
) : (
<EmptyMessage
<EmptyMessageCard
sizePreset="main-ui"
title="No Actions Found"
icon={SvgActions}
description="Provide OpenAPI schema to preview actions here."

View File

@@ -129,6 +129,7 @@ function KnowledgeSidebar({
<LineItem
key={connectedSource.source}
icon={sourceMetadata.icon}
strokeIcon={false}
onClick={() => onNavigateToSource(connectedSource.source)}
selected={isActive}
emphasized={isActive || isSelected || selectionCount > 0}
@@ -718,6 +719,7 @@ const KnowledgeAddView = memo(function KnowledgeAddView({
<LineItem
key={connectedSource.source}
icon={sourceMetadata.icon}
strokeIcon={false}
onClick={() => onNavigateToSource(connectedSource.source)}
emphasized={isSelected || selectionCount > 0}
aria-label={`knowledge-add-source-${connectedSource.source}`}

View File

@@ -7,15 +7,10 @@ import { FullPersona } from "@/app/admin/agents/interfaces";
import { useModal } from "@/refresh-components/contexts/ModalContext";
import Modal from "@/refresh-components/Modal";
import { Section } from "@/layouts/general-layouts";
import {
Card as CardLayout,
Content,
ContentAction,
InputHorizontal,
} from "@opal/layouts";
import { Content, ContentAction, InputHorizontal } from "@opal/layouts";
import Text from "@/refresh-components/texts/Text";
import AgentAvatar from "@/refresh-components/avatars/AgentAvatar";
import { Card, Divider } from "@opal/components";
import { Divider } from "@opal/components";
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
import {
SvgActions,
@@ -26,10 +21,12 @@ import {
SvgStar,
SvgUser,
} from "@opal/icons";
import * as ExpandableCard from "@/layouts/expandable-card-layouts";
import * as ActionsLayouts from "@/layouts/actions-layouts";
import useMcpServersForAgentEditor from "@/hooks/useMcpServersForAgentEditor";
import { getActionIcon } from "@/lib/tools/mcpUtils";
import { MCPServer, ToolSnapshot } from "@/lib/tools/interfaces";
import EmptyMessage from "@/refresh-components/EmptyMessage";
import { EmptyMessageCard } from "@opal/components";
import Switch from "@/refresh-components/inputs/Switch";
import { Button } from "@opal/components";
import { SEARCH_PARAM_NAMES } from "@/app/app/services/searchParams";
@@ -53,55 +50,46 @@ interface ViewerMCPServerCardProps {
}
function ViewerMCPServerCard({ server, tools }: ViewerMCPServerCardProps) {
const [expanded, setExpanded] = useState(true);
const [folded, setFolded] = useState(false);
const serverIcon = getActionIcon(server.server_url, server.name);
return (
<Card
expandable
expanded={expanded}
border="solid"
rounding="lg"
padding="sm"
expandedContent={
tools.length > 0 ? (
<div className="flex flex-col gap-2 p-2">
{tools.map((tool) => (
<Section key={tool.id} padding={0.25}>
<Content
title={tool.display_name}
description={tool.description}
sizePreset="main-ui"
variant="section"
/>
</Section>
))}
</div>
) : undefined
}
>
<CardLayout.Header
headerChildren={
<ExpandableCard.Root isFolded={folded} onFoldedChange={setFolded}>
<ExpandableCard.Header>
<div className="p-2">
<ContentAction
icon={serverIcon}
title={server.name}
description={server.description}
sizePreset="main-ui"
variant="section"
paddingVariant="fit"
rightChildren={
<Button
prominence="internal"
rightIcon={folded ? SvgExpand : SvgFold}
onClick={() => setFolded((prev) => !prev)}
>
{folded ? "Expand" : "Fold"}
</Button>
}
/>
}
topRightChildren={
<Button
prominence="internal"
rightIcon={expanded ? SvgFold : SvgExpand}
onClick={() => setExpanded((prev) => !prev)}
>
{expanded ? "Fold" : "Expand"}
</Button>
}
/>
</Card>
</div>
</ExpandableCard.Header>
{tools.length > 0 && (
<ActionsLayouts.Content>
{tools.map((tool) => (
<Section key={tool.id} padding={0.25}>
<Content
title={tool.display_name}
description={tool.description}
sizePreset="main-ui"
variant="section"
/>
</Section>
))}
</ActionsLayouts.Content>
)}
</ExpandableCard.Root>
);
}
@@ -111,9 +99,9 @@ function ViewerMCPServerCard({ server, tools }: ViewerMCPServerCardProps) {
*/
function ViewerOpenApiToolCard({ tool }: { tool: ToolSnapshot }) {
return (
<Card border="solid" rounding="lg" padding="sm">
<CardLayout.Header
headerChildren={
<ExpandableCard.Root>
<ExpandableCard.Header>
<div className="p-2">
<Content
icon={SvgActions}
title={tool.display_name}
@@ -121,9 +109,9 @@ function ViewerOpenApiToolCard({ tool }: { tool: ToolSnapshot }) {
sizePreset="main-ui"
variant="section"
/>
}
/>
</Card>
</div>
</ExpandableCard.Header>
</ExpandableCard.Root>
);
}
@@ -319,7 +307,7 @@ export default function AgentViewerModal({ agent }: AgentViewerModalProps) {
})}
</Section>
) : (
<EmptyMessage title="No Knowledge" />
<EmptyMessageCard sizePreset="main-ui" title="No Knowledge" />
)}
</Section>
@@ -341,7 +329,7 @@ export default function AgentViewerModal({ agent }: AgentViewerModalProps) {
))}
</Section>
) : (
<EmptyMessage title="No Actions" />
<EmptyMessageCard sizePreset="main-ui" title="No Actions" />
)}
</SimpleCollapsible.Content>
</SimpleCollapsible>

View File

@@ -342,19 +342,22 @@ test.describe("Default Agent MCP Integration", () => {
// Scroll to the Actions & Tools section (open by default)
await scrollToBottom(page);
// Find the MCP server card by name text (expandable card)
const serverCard = page
.locator(".opal-card-expandable")
.filter({ hasText: serverName })
.first();
await expect(serverCard).toBeVisible({ timeout: 10000 });
// Find the MCP server card by name text
// The server name appears inside a label within the ActionsLayouts.Header
const serverLabel = page
.locator("label")
.filter({ has: page.getByText(serverName, { exact: true }) });
await expect(serverLabel.first()).toBeVisible({ timeout: 10000 });
console.log(`[test] MCP server card found for server: ${serverName}`);
// Scroll server card into view
await serverCard.scrollIntoViewIfNeeded();
await serverLabel.first().scrollIntoViewIfNeeded();
// The server-level Switch in the header toggles ALL tools
const serverSwitch = serverCard.getByRole("switch").first();
const serverSwitch = serverLabel
.first()
.locator('button[role="switch"]')
.first();
await expect(serverSwitch).toBeVisible({ timeout: 5000 });
// Enable all tools by toggling the server switch ON
@@ -640,13 +643,12 @@ test.describe("Default Agent MCP Integration", () => {
// Scroll to Actions & Tools section
await scrollToBottom(page);
// Find the MCP server card by name (expandable card)
const serverCard = page
.locator(".opal-card-expandable")
.filter({ hasText: serverName })
.first();
await expect(serverCard).toBeVisible({ timeout: 10000 });
await serverCard.scrollIntoViewIfNeeded();
// Find the MCP server card by name
const serverLabel = page
.locator("label")
.filter({ has: page.getByText(serverName, { exact: true }) });
await expect(serverLabel.first()).toBeVisible({ timeout: 10000 });
await serverLabel.first().scrollIntoViewIfNeeded();
// Click "Expand" to reveal individual tools
const expandButton = page.getByRole("button", { name: "Expand" }).first();
@@ -658,11 +660,14 @@ test.describe("Default Agent MCP Integration", () => {
}
// Find a specific tool by name inside the expanded card content
const toolCard = page
.locator(".opal-card")
.filter({ hasText: "tool_0" })
// Individual tools are rendered as ActionsLayouts.Tool with their own Card > Label
const toolLabel = page
.locator("label")
.filter({ has: page.getByText("tool_0", { exact: true }) });
const firstToolSwitch = toolLabel
.first()
.locator('button[role="switch"]')
.first();
const firstToolSwitch = toolCard.getByRole("switch").first();
await expect(firstToolSwitch).toBeVisible({ timeout: 5000 });
await firstToolSwitch.scrollIntoViewIfNeeded();
@@ -683,13 +688,12 @@ test.describe("Default Agent MCP Integration", () => {
await page.waitForURL("**/admin/configuration/chat-preferences**");
await scrollToBottom(page);
// Re-find the server card (expandable card)
const serverCardAfter = page
.locator(".opal-card-expandable")
.filter({ hasText: serverName })
.first();
await expect(serverCardAfter).toBeVisible({ timeout: 10000 });
await serverCardAfter.scrollIntoViewIfNeeded();
// Re-find the server card
const serverLabelAfter = page
.locator("label")
.filter({ has: page.getByText(serverName, { exact: true }) });
await expect(serverLabelAfter.first()).toBeVisible({ timeout: 10000 });
await serverLabelAfter.first().scrollIntoViewIfNeeded();
// Re-expand the card
const expandButtonAfter = page
@@ -704,11 +708,13 @@ test.describe("Default Agent MCP Integration", () => {
}
// Verify the tool state persisted
const toolCardAfter = page
.locator(".opal-card")
.filter({ hasText: "tool_0" })
const toolLabelAfter = page
.locator("label")
.filter({ has: page.getByText("tool_0", { exact: true }) });
const firstToolSwitchAfter = toolLabelAfter
.first()
.locator('button[role="switch"]')
.first();
const firstToolSwitchAfter = toolCardAfter.getByRole("switch").first();
await expect(firstToolSwitchAfter).toBeVisible({ timeout: 5000 });
const finalChecked =
await firstToolSwitchAfter.getAttribute("aria-checked");

View File

@@ -42,6 +42,7 @@ for (const theme of THEMES) {
page
.locator(".opal-content-md-header")
.filter({ hasText: expectedHeader })
.first()
).toBeVisible({ timeout: 10_000 });
} else {
await page.waitForLoadState("networkidle");