-
+
Click or drag file to this area to upload
diff --git a/frontend/src/components/custom-tools/list-of-tools/ListOfTools.css b/frontend/src/components/custom-tools/list-of-tools/ListOfTools.css
index 3c2983ab2e..58d2c465b0 100644
--- a/frontend/src/components/custom-tools/list-of-tools/ListOfTools.css
+++ b/frontend/src/components/custom-tools/list-of-tools/ListOfTools.css
@@ -2,7 +2,19 @@
.list-of-tools-layout {
height: 100%;
- background-color: var(--page-bg-2);
+ /*
+ * The island sits on this as a raised surface, so the trough has to be
+ * clearly darker than it. --background (#fafafa) is only two values off
+ * --card (#fcfcfc), so the well was invisible and the list read as
+ * floating on a flat page.
+ *
+ * --well resolves to a 23-value gap against --card in light mode, matching
+ * the reference's 22 (#e9e9e9 behind #fff) almost exactly. It is a semantic
+ * token rather than a raw palette step because the palette is shared
+ * between themes: hard-coding --neutral-200 would paint a LIGHT trough in
+ * dark mode. --well recesses in both directions.
+ */
+ background-color: var(--well);
padding: 12px;
overflow-y: auto;
}
@@ -27,7 +39,7 @@
}
.list-of-tools-island {
- background-color: var(--page-bg-1);
+ background-color: var(--card);
height: 100%;
padding: 20px;
overflow-y: auto;
diff --git a/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx b/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx
index 9f2d403007..116e076ed6 100644
--- a/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx
+++ b/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx
@@ -1,7 +1,7 @@
-import { ArrowDownOutlined, PlusOutlined } from "@ant-design/icons";
-import { Space } from "antd";
+import { ArrowDown, Plus } from "lucide-react";
import PropTypes from "prop-types";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Space } from "@/components/ui/shims/antd-layout";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useCoOwnerManagement } from "../../../hooks/useCoOwnerManagement";
@@ -37,7 +37,7 @@ const DefaultCustomButtons = ({
}
+ icon={ }
onClick={() => setOpenImportTool(true)}
loading={isImportLoading}
>
@@ -45,7 +45,7 @@ const DefaultCustomButtons = ({
}
+ icon={ }
onClick={handleNewProjectBtnClick}
>
New Project
@@ -487,6 +487,7 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) {
)}
{!loadError && displayList?.length > 0 && (
-
+
{" "}
Indexed
@@ -125,7 +119,7 @@ function ManageDocsModal({
const failedIndex = (
-
+
{" "}
Not Indexed
@@ -157,7 +151,7 @@ function ManageDocsModal({
const failedSummary = (
-
+
{" "}
Not Summarized
@@ -200,6 +194,66 @@ function ManageDocsModal({
open,
]);
+ // UN-3507: index status is refreshed only when `indexDocs` changes, which is
+ // driven by websocket log messages. When those messages are dropped the
+ // backend still finishes indexing but the UI spins forever. Poll the
+ // document-index endpoint while anything is indexing so the status recovers
+ // without the socket.
+ //
+ // `indexDocs` empties only via `deleteIndexDoc`, which is called from the
+ // websocket handlers -- so on the very path this poll exists for (socket
+ // dead) nothing else will ever clear it. The poll therefore has to retire a
+ // document itself, which is what lets this effect tear its own interval down
+ // instead of running forever.
+ //
+ // Retiring on "has an index id" would be wrong: re-indexing an already
+ // indexed document leaves the previous id in place until the new run
+ // finishes, so the first tick would clear the spinner while indexing is
+ // still running. Compare against a fingerprint of the index row captured
+ // when the poll armed, and retire only once it actually changes.
+ //
+ // Errors are swallowed here rather than alerted. handleGetIndexStatus is
+ // shared with the user-initiated calls above, where a toast is right; on a
+ // 5s timer a failing endpoint would otherwise raise two toasts per tick for
+ // as long as the modal stays open.
+ useEffect(() => {
+ if (!open || indexDocs?.length === 0) {
+ return undefined;
+ }
+
+ // ":" -> fingerprint of that row when the poll armed.
+ const baseline = new Map();
+
+ const poll = (recordBaselineOnly) => {
+ const opts = { silent: true, baseline, recordBaselineOnly };
+ handleGetIndexStatus(rawLlmProfile, indexTypes.raw, opts);
+ const summarizeProfileId =
+ summarizeLlmProfile || (summarizeLlmAdapter ? defaultLlmProfile : null);
+ handleGetIndexStatus(summarizeProfileId, indexTypes.summarize, opts);
+ };
+
+ // Record the starting state now, so the first tick can already detect a
+ // change rather than spending one learning it. This request races the
+ // interval only in the sense that a tick firing before it resolves finds
+ // an empty map and adopts what it sees as the baseline -- which is the
+ // same outcome, one tick later.
+ poll(true);
+
+ const intervalId = setInterval(
+ () => poll(false),
+ INDEX_STATUS_POLL_INTERVAL_MS,
+ );
+
+ return () => clearInterval(intervalId);
+ }, [
+ open,
+ indexDocs,
+ rawLlmProfile,
+ summarizeLlmProfile,
+ summarizeLlmAdapter,
+ defaultLlmProfile,
+ ]);
+
useEffect(() => {
// Reverse the array to have the latest logs at the beginning
let newMessages = [...messages].reverse();
@@ -290,7 +344,27 @@ function ManageDocsModal({
return isIndexed;
};
- const handleGetIndexStatus = (llmProfileId, indexType) => {
+ // Identifies one indexing run for a document. `modified_at` is bumped by
+ // BaseModel on every save, so it changes when a re-index completes even
+ // though the index id itself may be unchanged.
+ const indexFingerprint = (item, indexType) =>
+ [
+ indexType === indexTypes.raw
+ ? item?.raw_index_id
+ : item?.summarize_index_id,
+ item?.modified_at,
+ ].join("|");
+
+ // The UN-3507 poll passes `silent` to suppress the failure toast, which would
+ // otherwise repeat every 5s. `baseline` is a docId -> fingerprint map taken
+ // when the poll armed; a document is retired once its fingerprint moves off
+ // that baseline, which is what lets the poll stop on its own.
+ // `recordBaselineOnly` fills the map without retiring anything.
+ const handleGetIndexStatus = (
+ llmProfileId,
+ indexType,
+ { silent, baseline, recordBaselineOnly } = {},
+ ) => {
if (!llmProfileId) {
handleIndexStatus(indexType, []);
return;
@@ -304,7 +378,11 @@ function ManageDocsModal({
url,
};
- handleLoading(indexType, true);
+ // The poll re-reads status on a timer; only a user-initiated call should
+ // drive the column's loading indicator, or it flickers every tick.
+ if (!silent) {
+ handleLoading(indexType, true);
+ }
axiosPrivate(requestOptions)
.then((res) => {
const data = res?.data;
@@ -316,12 +394,47 @@ function ManageDocsModal({
});
handleIndexStatus(indexType, indexStatus);
+
+ if (!baseline) {
+ return;
+ }
+
+ for (const item of data) {
+ const docId = item?.document_manager;
+ const key = `${indexType}:${docId}`;
+ const fingerprint = indexFingerprint(item, indexType);
+
+ if (recordBaselineOnly) {
+ baseline.set(key, fingerprint);
+ continue;
+ }
+
+ // Unseen at arm time means this run started after the baseline was
+ // taken; adopt it now rather than retiring on the first sighting.
+ if (!baseline.has(key)) {
+ baseline.set(key, fingerprint);
+ continue;
+ }
+
+ if (
+ baseline.get(key) !== fingerprint &&
+ handleIsIndexed(indexType, item) &&
+ indexDocs.includes(docId)
+ ) {
+ deleteIndexDoc(docId);
+ }
+ }
})
.catch((err) => {
+ if (silent) {
+ return;
+ }
setAlertDetails(handleException(err, "Failed to get index status"));
})
.finally(() => {
- handleLoading(indexType, false);
+ if (!silent) {
+ handleLoading(indexType, false);
+ }
});
};
@@ -481,7 +594,7 @@ function ManageDocsModal({
}
+ icon={ }
onClick={() => handleReIndexBtnClick(item)}
disabled={
isMultiPassExtractLoading ||
@@ -502,7 +615,7 @@ function ManageDocsModal({
}
+ icon={ }
disabled={
isMultiPassExtractLoading ||
isSinglePassExtractLoading ||
diff --git a/frontend/src/components/custom-tools/manage-llm-profiles-modal/ManageLlmProfilesModal.jsx b/frontend/src/components/custom-tools/manage-llm-profiles-modal/ManageLlmProfilesModal.jsx
index 5d73545bb3..2dc3839b3b 100644
--- a/frontend/src/components/custom-tools/manage-llm-profiles-modal/ManageLlmProfilesModal.jsx
+++ b/frontend/src/components/custom-tools/manage-llm-profiles-modal/ManageLlmProfilesModal.jsx
@@ -1,5 +1,5 @@
-import { Modal } from "antd";
import PropTypes from "prop-types";
+import { Modal } from "@/components/ui/shims/antd-overlays";
import { ManageLlmProfiles } from "../manage-llm-profiles/ManageLlmProfiles";
diff --git a/frontend/src/components/custom-tools/manage-llm-profiles/ManageLlmProfiles.css b/frontend/src/components/custom-tools/manage-llm-profiles/ManageLlmProfiles.css
index 56cf5658e8..f2a4182301 100644
--- a/frontend/src/components/custom-tools/manage-llm-profiles/ManageLlmProfiles.css
+++ b/frontend/src/components/custom-tools/manage-llm-profiles/ManageLlmProfiles.css
@@ -25,4 +25,9 @@
.profile-copy-icon {
font-size: 12px;
+ /* width/height, not font-size alone: antd shipped an icon FONT (sized by
+ * font-size); lucide ships SVGs, which ignore it and fall back to their
+ * own 24px default. font-size is kept for any text in the same element. */
+ width: 12px;
+ height: 12px;
}
diff --git a/frontend/src/components/custom-tools/manage-llm-profiles/ManageLlmProfiles.jsx b/frontend/src/components/custom-tools/manage-llm-profiles/ManageLlmProfiles.jsx
index 15d2263f60..2a690736a9 100644
--- a/frontend/src/components/custom-tools/manage-llm-profiles/ManageLlmProfiles.jsx
+++ b/frontend/src/components/custom-tools/manage-llm-profiles/ManageLlmProfiles.jsx
@@ -1,6 +1,10 @@
-import { CopyOutlined, DeleteOutlined, EditOutlined } from "@ant-design/icons";
-import { Button, Radio, Table, Tooltip, Typography } from "antd";
+import { Copy, Pencil, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Radio } from "@/components/ui/shims/antd-inputs";
+import { Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Table } from "@/components/ui/shims/antd-structure";
+import { Typography } from "@/components/ui/shims/antd-typography";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler";
@@ -110,6 +114,9 @@ function ManageLlmProfiles() {
useEffect(() => {
const modifiedRows = llmProfiles.map((item) => {
+ // One id per profile — every action below repeats once per table row.
+ const rowTestId = (action) =>
+ `ps-llm-profile-${action}-${item?.profile_id}`;
return {
key: item?.profile_id,
name: item?.profile_name || "",
@@ -121,14 +128,16 @@ function ManageLlmProfiles() {
}
+ icon={ }
onClick={() => copyProfileId(item?.profile_id)}
/>
}
+ icon={
}
disabled={isPublicSource}
onClick={() => handleEdit(item?.profile_id)}
/>
@@ -143,8 +152,9 @@ function ManageLlmProfiles() {
}
>
}
+ icon={
}
disabled={
isPublicSource || defaultLlmProfile === item?.profile_id
}
@@ -155,6 +165,7 @@ function ManageLlmProfiles() {
),
select: (
handleDefaultLlm(item?.profile_id)}
disabled={isPublicSource}
diff --git a/frontend/src/components/custom-tools/notes-card/NotesCard.css b/frontend/src/components/custom-tools/notes-card/NotesCard.css
index d7d31bdcf3..b5b6fae3ed 100644
--- a/frontend/src/components/custom-tools/notes-card/NotesCard.css
+++ b/frontend/src/components/custom-tools/notes-card/NotesCard.css
@@ -11,5 +11,10 @@
.tool-ide-notes-card .delete-icon {
font-size: 12px;
+ /* width/height, not font-size alone: antd shipped an icon FONT (sized by
+ * font-size); lucide ships SVGs, which ignore it and fall back to their
+ * own 24px default. font-size is kept for any text in the same element. */
+ width: 12px;
+ height: 12px;
color: #575859;
}
diff --git a/frontend/src/components/custom-tools/notes-card/NotesCard.jsx b/frontend/src/components/custom-tools/notes-card/NotesCard.jsx
index e2ddaa4992..f743408e05 100644
--- a/frontend/src/components/custom-tools/notes-card/NotesCard.jsx
+++ b/frontend/src/components/custom-tools/notes-card/NotesCard.jsx
@@ -1,11 +1,10 @@
-import {
- CheckCircleOutlined,
- DeleteOutlined,
- EditOutlined,
- SyncOutlined,
-} from "@ant-design/icons";
-import { Button, Card, Col, Collapse, Row, Space, Tag, Tooltip } from "antd";
+import { CircleCheck, Pencil, RefreshCw, Trash2 } from "lucide-react";
import PropTypes from "prop-types";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Col, Row, Space } from "@/components/ui/shims/antd-layout";
+import { Tag } from "@/components/ui/shims/antd-leaves";
+import { Collapse, Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Card } from "@/components/ui/shims/antd-structure";
import "./NotesCard.css";
import { useEffect, useState } from "react";
import { promptStudioUpdateStatus } from "../../../helpers/GetStaticData";
@@ -123,7 +122,7 @@ function NotesCard({
{updateStatus?.status ===
promptStudioUpdateStatus.isUpdating && (
}
+ icon={ }
color="processing"
className="display-flex-align-center"
>
@@ -132,7 +131,7 @@ function NotesCard({
)}
{updateStatus?.status === promptStudioUpdateStatus.done && (
}
+ icon={ }
color="success"
className="display-flex-align-center"
>
@@ -152,7 +151,7 @@ function NotesCard({
className="display-flex-align-center"
onClick={enableEdit}
>
-
+
-
+
diff --git a/frontend/src/components/custom-tools/output-analyzer/FilterPromptFields.jsx b/frontend/src/components/custom-tools/output-analyzer/FilterPromptFields.jsx
index b7e8e70a13..6623683660 100644
--- a/frontend/src/components/custom-tools/output-analyzer/FilterPromptFields.jsx
+++ b/frontend/src/components/custom-tools/output-analyzer/FilterPromptFields.jsx
@@ -1,6 +1,7 @@
-import { Checkbox, Form } from "antd";
import PropTypes from "prop-types";
import React, { useCallback, useEffect, useState } from "react";
+import { Form } from "@/components/ui/shims/antd-form";
+import { Checkbox } from "@/components/ui/shims/antd-inputs";
const FilterPromptFields = React.memo(
({ isOpen, selectedPrompts, setSelectedPrompts }) => {
diff --git a/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzer.css b/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzer.css
index 842fd94302..e1dc5fd86a 100644
--- a/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzer.css
+++ b/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzer.css
@@ -4,7 +4,7 @@
height: 100%;
display: flex;
flex-direction: column;
- background-color: var(--page-bg-2);
+ background-color: var(--background);
overflow-y: hidden;
}
@@ -58,7 +58,7 @@
.output-analyzer-left-box > div {
height: 100%;
- background-color: var(--white);
+ background-color: var(--card);
padding: 0px 12px;
}
@@ -69,5 +69,5 @@
.output-analyzer-right-box > div {
height: 100%;
- background-color: var(--white);
+ background-color: var(--card);
}
diff --git a/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzer.jsx b/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzer.jsx
index 7922a1a0fe..4907902a7e 100644
--- a/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzer.jsx
+++ b/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzer.jsx
@@ -1,5 +1,5 @@
-import { Drawer } from "antd";
import { useCallback, useEffect, useMemo, useState } from "react";
+import { Drawer } from "@/components/ui/shims/antd-structure";
import { OutputAnalyzerHeader } from "./OutputAnalyzerHeader";
import "./OutputAnalyzer.css";
diff --git a/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzerCard.jsx b/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzerCard.jsx
index 814a317b35..2625fbf2ea 100644
--- a/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzerCard.jsx
+++ b/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzerCard.jsx
@@ -1,7 +1,9 @@
-import { Col, Divider, Flex, Row, Space, Typography } from "antd";
import PropTypes from "prop-types";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
+import { Col, Flex, Row, Space } from "@/components/ui/shims/antd-layout";
+import { Divider } from "@/components/ui/shims/antd-leaves";
+import { Typography } from "@/components/ui/shims/antd-typography";
import { base64toBlob } from "../../../helpers/GetStaticData";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
diff --git a/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzerHeader.jsx b/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzerHeader.jsx
index 53e945cb89..32ffab41f1 100644
--- a/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzerHeader.jsx
+++ b/frontend/src/components/custom-tools/output-analyzer/OutputAnalyzerHeader.jsx
@@ -1,14 +1,17 @@
import {
- ArrowLeftOutlined,
- FilePdfOutlined,
- FilterOutlined,
- LeftOutlined,
- RightOutlined,
-} from "@ant-design/icons";
-import { Button, Drawer, Menu, Space, Typography } from "antd";
+ ArrowLeft,
+ ChevronLeft,
+ ChevronRight,
+ FileText,
+ Filter,
+} from "lucide-react";
import PropTypes from "prop-types";
import { useCallback, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Space } from "@/components/ui/shims/antd-layout";
+import { Drawer, Menu } from "@/components/ui/shims/antd-structure";
+import { Typography } from "@/components/ui/shims/antd-typography";
import { useCustomToolStore } from "../../../store/custom-tool-store";
import { useSessionStore } from "../../../store/session-store";
@@ -87,7 +90,7 @@ function OutputAnalyzerHeader({
-
+
Output Analyzer
@@ -98,26 +101,26 @@ function OutputAnalyzerHeader({
}
+ icon={ }
onClick={openFilterDrawer}
>{`${selectedPromptFields} of ${
Object.keys(selectedPrompts).length
} fields selected`}
}
+ icon={ }
onClick={() => setOpenDocListDrawer(true)}
/>
}
+ icon={
}
onClick={() => handlePagination(PAGINATION_ACTIONS.PREV)}
disabled={currentDocIndex === 0}
/>
}
+ icon={
}
onClick={() => handlePagination(PAGINATION_ACTIONS.NEXT)}
disabled={currentDocIndex === docsLength - 1}
/>
diff --git a/frontend/src/components/custom-tools/output-for-doc-modal/OutputForDocModal.jsx b/frontend/src/components/custom-tools/output-for-doc-modal/OutputForDocModal.jsx
index ef358abe13..37449e2dea 100644
--- a/frontend/src/components/custom-tools/output-for-doc-modal/OutputForDocModal.jsx
+++ b/frontend/src/components/custom-tools/output-for-doc-modal/OutputForDocModal.jsx
@@ -1,13 +1,11 @@
-import {
- CheckCircleFilled,
- CloseCircleFilled,
- InfoCircleFilled,
-} from "@ant-design/icons";
-import { Button, Modal, Table, Tabs, Tooltip, Typography } from "antd";
-import TabPane from "antd/es/tabs/TabPane";
+import { CircleCheck, CircleX, Info } from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Modal, Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Table, Tabs } from "@/components/ui/shims/antd-structure";
+import { Typography } from "@/components/ui/shims/antd-typography";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useCustomToolStore } from "../../../store/custom-tool-store";
import { useSessionStore } from "../../../store/session-store";
@@ -266,13 +264,13 @@ function OutputForDocModal({
{status === outputStatus.yet_to_process && (
-
+
)}
{status === outputStatus.fail && (
-
+
)}
{status === outputStatus.success && (
-
+
)}
{" "}
{message}
@@ -333,9 +331,9 @@ function OutputForDocModal({
- Default} key={"0"}>
+ Default} key={"0"}>
{adapterData?.map((adapter, index) => (
-
@@ -346,7 +344,7 @@ function OutputForDocModal({
}
key={(index + 1)?.toString()}
- >
+ >
))}
{" "}
diff --git a/frontend/src/components/custom-tools/pdf-viewer/Highlight.css b/frontend/src/components/custom-tools/pdf-viewer/Highlight.css
index cab0046c7c..ca9da19d36 100644
--- a/frontend/src/components/custom-tools/pdf-viewer/Highlight.css
+++ b/frontend/src/components/custom-tools/pdf-viewer/Highlight.css
@@ -30,7 +30,7 @@
.pdf-viewer-error .ant-result-subtitle {
color: #8c8c8c;
- font-size: 14px;
+ font-size: 13px;
max-width: 400px;
margin: 0 auto;
}
diff --git a/frontend/src/components/custom-tools/pdf-viewer/PdfViewer.jsx b/frontend/src/components/custom-tools/pdf-viewer/PdfViewer.jsx
index 0d1c5e7a6d..d46a386ef9 100644
--- a/frontend/src/components/custom-tools/pdf-viewer/PdfViewer.jsx
+++ b/frontend/src/components/custom-tools/pdf-viewer/PdfViewer.jsx
@@ -1,12 +1,15 @@
-import { FileExclamationOutlined, ReloadOutlined } from "@ant-design/icons";
import { Viewer, Worker } from "@react-pdf-viewer/core";
import { defaultLayoutPlugin } from "@react-pdf-viewer/default-layout";
import { highlightPlugin } from "@react-pdf-viewer/highlight";
import { pageNavigationPlugin } from "@react-pdf-viewer/page-navigation";
-import { Button, Result } from "antd";
+import { FileWarning, RotateCw } from "lucide-react";
import PropTypes from "prop-types";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Result } from "@/components/ui/shims/antd-structure";
+import "@react-pdf-viewer/core/lib/styles/index.css";
+import "@react-pdf-viewer/default-layout/lib/styles/index.css";
import "@react-pdf-viewer/highlight/lib/styles/index.css";
import "./Highlight.css";
import { PDF_WORKER_URL } from "../../../helpers/pdfWorkerConfig";
@@ -31,11 +34,11 @@ function PdfLoadError({ error, onRetry, reportError }) {
return (
}
+ icon={
}
title="Failed to Load PDF"
subTitle={errorMessage}
extra={
-
} onClick={onRetry}>
+
} onClick={onRetry}>
Retry
}
@@ -93,7 +96,23 @@ function PdfViewer({ fileUrl, highlightData, currentHighlightIndex, onError }) {
// Strip 5th element (confidence) if present, keep only first 4 elements
const coordsOnly =
innerArray.length >= 5 ? innerArray.slice(0, 4) : innerArray;
- return coordsOnly.some((value) => value !== 0);
+ // UN-3355: entries are [pageNumber, y, height, pageHeight]. Empty
+ // pages in the document make LLMWhisperer emit a page number with
+ // y/height/pageHeight all zero. `some(v => v !== 0)` kept those,
+ // because the page number alone is non-zero -- the viewer then
+ // scrolled to the page and highlighted nothing. Require the
+ // geometry itself to be usable instead.
+ if (coordsOnly.length < 4) {
+ return false;
+ }
+ const [, y, height, pageHeight] = coordsOnly;
+ return (
+ Number.isFinite(y) &&
+ Number.isFinite(height) &&
+ Number.isFinite(pageHeight) &&
+ height > 0 &&
+ pageHeight > 0
+ );
})
.map((innerArray) => {
// Return only the first 4 elements (strip confidence)
@@ -165,7 +184,7 @@ function PdfViewer({ fileUrl, highlightData, currentHighlightIndex, onError }) {
return (
}
+ icon={
}
title="No PDF Available"
subTitle="The PDF document URL is not available. Please ensure the document has been processed correctly."
/>
diff --git a/frontend/src/components/custom-tools/pre-and-post-amble-modal/PreAndPostAmbleModal.jsx b/frontend/src/components/custom-tools/pre-and-post-amble-modal/PreAndPostAmbleModal.jsx
index bd71ef3228..5e279e28d2 100644
--- a/frontend/src/components/custom-tools/pre-and-post-amble-modal/PreAndPostAmbleModal.jsx
+++ b/frontend/src/components/custom-tools/pre-and-post-amble-modal/PreAndPostAmbleModal.jsx
@@ -1,7 +1,11 @@
-import { ExpandOutlined } from "@ant-design/icons";
-import { Button, Input, Modal, Space, Typography } from "antd";
+import { Expand } from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Input } from "@/components/ui/shims/antd-inputs";
+import { Space } from "@/components/ui/shims/antd-layout";
+import { Modal } from "@/components/ui/shims/antd-overlays";
+import { Typography } from "@/components/ui/shims/antd-typography";
import "./PreAndPostAmbleModal.css";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler";
@@ -119,7 +123,7 @@ function PreAndPostAmbleModal({ type, handleUpdateTool }) {
autoSize={{ minRows: 4 }}
/>
}
+ icon={
}
className="expand-button"
onClick={toggleExpandModal}
type="text"
diff --git a/frontend/src/components/custom-tools/profile-info-bar/ProfileInfoBar.jsx b/frontend/src/components/custom-tools/profile-info-bar/ProfileInfoBar.jsx
index 9341753987..4c285536ab 100644
--- a/frontend/src/components/custom-tools/profile-info-bar/ProfileInfoBar.jsx
+++ b/frontend/src/components/custom-tools/profile-info-bar/ProfileInfoBar.jsx
@@ -1,5 +1,5 @@
-import { Tag } from "antd";
import PropTypes from "prop-types";
+import { Tag } from "@/components/ui/shims/antd-leaves";
import "./ProfileInfoBar.css";
const ProfileInfoBar = ({ profiles, profileId }) => {
@@ -15,7 +15,8 @@ const ProfileInfoBar = ({ profiles, profileId }) => {
Profile Name: {profile?.profile_name}
- Chunk Size: {profile?.chunk_size}
+ Chunk Size: {" "}
+ {profile?.chunk_size == null ? "-" : `${profile.chunk_size} tokens`}
Vector Store: {profile?.vector_store}
diff --git a/frontend/src/components/custom-tools/prompt-card/CopyPromptOutputBtn.jsx b/frontend/src/components/custom-tools/prompt-card/CopyPromptOutputBtn.jsx
index c44bda95f6..fe58514c92 100644
--- a/frontend/src/components/custom-tools/prompt-card/CopyPromptOutputBtn.jsx
+++ b/frontend/src/components/custom-tools/prompt-card/CopyPromptOutputBtn.jsx
@@ -1,6 +1,7 @@
-import { CopyOutlined } from "@ant-design/icons";
-import { Button, Tooltip } from "antd";
+import { Copy } from "lucide-react";
import PropTypes from "prop-types";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Tooltip } from "@/components/ui/shims/antd-overlays";
function CopyPromptOutputBtn({ isDisabled, copyToClipboard }) {
return (
@@ -12,7 +13,7 @@ function CopyPromptOutputBtn({ isDisabled, copyToClipboard }) {
onClick={copyToClipboard}
disabled={isDisabled}
>
-
+
);
diff --git a/frontend/src/components/custom-tools/prompt-card/DisplayPromptResult.jsx b/frontend/src/components/custom-tools/prompt-card/DisplayPromptResult.jsx
index f5f9474024..d3bb329f25 100644
--- a/frontend/src/components/custom-tools/prompt-card/DisplayPromptResult.jsx
+++ b/frontend/src/components/custom-tools/prompt-card/DisplayPromptResult.jsx
@@ -1,7 +1,9 @@
-import { InfoCircleFilled } from "@ant-design/icons";
-import { Space, Spin, Typography } from "antd";
+import { Info } from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { Space } from "@/components/ui/shims/antd-layout";
+import { Spin } from "@/components/ui/shims/antd-leaves";
+import { Typography } from "@/components/ui/shims/antd-typography";
import {
displayPromptResult,
@@ -88,7 +90,7 @@ function DisplayPromptResult({
return (
-
+
{" "}
Yet to run
diff --git a/frontend/src/components/custom-tools/prompt-card/ExpandCardBtn.jsx b/frontend/src/components/custom-tools/prompt-card/ExpandCardBtn.jsx
index 69ba45a51a..284df84a4d 100644
--- a/frontend/src/components/custom-tools/prompt-card/ExpandCardBtn.jsx
+++ b/frontend/src/components/custom-tools/prompt-card/ExpandCardBtn.jsx
@@ -1,18 +1,19 @@
-import { FullscreenExitOutlined, FullscreenOutlined } from "@ant-design/icons";
-import { Button, Tooltip } from "antd";
+import { Maximize, Minimize } from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Tooltip } from "@/components/ui/shims/antd-overlays";
-function ExpandCardBtn({ expandCard, setExpandCard }) {
+function ExpandCardBtn({ expandCard, setExpandCard, testId }) {
const [icon, setIcon] = useState(null);
const [tooltip, setTooltip] = useState("");
useEffect(() => {
if (expandCard) {
- setIcon( );
+ setIcon( );
setTooltip("Collapse");
} else {
- setIcon( );
+ setIcon( );
setTooltip("Expand");
}
}, [expandCard]);
@@ -24,6 +25,7 @@ function ExpandCardBtn({ expandCard, setExpandCard }) {
return (
{
setIsDisablePrompt(promptDetails?.active);
+ }, [promptDetails?.prompt_id, promptDetails?.active]);
+
+ useEffect(() => {
setRequired(promptDetails?.required);
+ }, [promptDetails?.prompt_id, promptDetails?.required]);
+
+ useEffect(() => {
setWebhookEnabled(promptDetails?.enable_postprocessing_webhook || false);
- setWebhookUrl(promptDetails?.postprocessing_webhook_url || "");
- }, [promptDetails, details]);
+ }, [promptDetails?.prompt_id, promptDetails?.enable_postprocessing_webhook]);
useEffect(() => {
+ setWebhookUrl(promptDetails?.postprocessing_webhook_url || "");
+ }, [promptDetails?.prompt_id, promptDetails?.postprocessing_webhook_url]);
+
+ /*
+ * Derived, NOT state written from an effect. The webhook URL lives
+ * inside these menu entries, so with `setItems` in an effect its `value` prop
+ * trailed `webhookUrl` by one render: the render right after a keystroke
+ * still carried the previous string, and React wrote that back onto the DOM
+ * input. Typing at speed therefore dropped characters. useMemo builds the
+ * entries in the same pass that updates the state they read.
+ */
+ const items = useMemo(() => {
const dropdownItems = [
{
label: (
@@ -224,31 +247,49 @@ function Header({
>
Value Required{" "}
-
+
+
+
)}
{enforceType === "json" && (
<>
- handleRequiredChange("all")}
- >
- All JSON Values Required
-
-
-
-
- handleRequiredChange("any")}
- className="required-checkbox-padding"
- >
- At least 1 JSON Value Required
-
-
-
-
+ {/*
+ * Each checkbox is grouped with its own tooltip icon so the
+ * two never separate, and the pairs share a row as they did
+ * under antd. `required-checkbox-padding`, which used to sit
+ * on the second checkbox, has no rule anywhere in the app —
+ * the gap came from antd's own adjacent-wrapper margin.
+ */}
+
+
+ handleRequiredChange("all")}
+ >
+ All JSON Values Required
+
+
+
+
+
+
+
+
+ handleRequiredChange("any")}
+ >
+ At least 1 JSON Value Required
+
+
+
+
+
+
+
+
Enable Postprocessing Webhook{" "}
-
+
+
+
{webhookEnabled && (
@@ -300,7 +343,7 @@ function Header({
handleConfirm={() => handleDelete(promptDetails?.prompt_id)}
content="The prompt will be permanently deleted."
>
-
Delete
+
Delete
),
key: "delete",
@@ -329,7 +372,8 @@ function Header({
dropdownItems.splice(0, 1);
}
- setItems(dropdownItems);
+ return dropdownItems;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDisablePrompt, required, enforceType, webhookEnabled, webhookUrl]);
return (
@@ -348,11 +392,41 @@ function Header({
/>
+ {/*
+ * UN-2900: single pass builds one combined prompt, so a variable that
+ * refers to another prompt's output has nothing to resolve against and
+ * the literal {{...}} reaches the LLM. custom_data still resolves, so
+ * the backend excludes it. Warn per prompt rather than blocking.
+ */}
+ {unresolvableVariables?.length > 0 && (
+
+
+ }
+ color="warning"
+ className="display-flex-align-center"
+ >
+
+ {unresolvableVariables.length === 1
+ ? "1 unresolved variable"
+ : `${unresolvableVariables.length} unresolved variables`}
+
+
+
+
+ )}
{progressMsg?.message && (
}
+ icon={isCoverageLoading && }
color={progressMsg?.level === "ERROR" ? "error" : "processing"}
className="display-flex-align-center"
>
@@ -368,7 +442,7 @@ function Header({
{updateStatus?.status === promptStudioUpdateStatus.isUpdating && (
}
+ icon={
}
color="processing"
className="display-flex-align-center"
>
@@ -379,7 +453,7 @@ function Header({
{updateStatus?.status === promptStudioUpdateStatus.done && (
}
+ icon={
}
color="success"
className="display-flex-align-center"
>
@@ -391,7 +465,7 @@ function Header({
{updateStatus?.status ===
promptStudioUpdateStatus.validationError && (
}
+ icon={
}
color="error"
className="display-flex-align-center"
>
@@ -407,6 +481,7 @@ function Header({
title={runGate?.reason || "Run all LLMs for current document"}
>
-
+
-
+ {/*
+ * "All documents" needs a DIFFERENT glyph from the
+ * "current document" button beside it. antd used
+ * PlayCircleFilled vs PlayCircleOutlined; the icon migration
+ * collapsed both to CirclePlay, leaving two identical buttons
+ * distinguished only by their tooltips. The double-chevron
+ * play reads as "run across everything".
+ */}
+
>
)}
-
+
{PromptChangeIndicator && (
-
+
diff --git a/frontend/src/components/custom-tools/prompt-card/OutputForIndex.jsx b/frontend/src/components/custom-tools/prompt-card/OutputForIndex.jsx
index 0a3dbc891f..e808eb4d81 100644
--- a/frontend/src/components/custom-tools/prompt-card/OutputForIndex.jsx
+++ b/frontend/src/components/custom-tools/prompt-card/OutputForIndex.jsx
@@ -1,8 +1,11 @@
-import { Button, Input, Modal, Typography } from "antd";
+import { ArrowDown, ArrowUp } from "lucide-react";
import PropTypes from "prop-types";
import { useCallback, useEffect, useRef, useState } from "react";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Input } from "@/components/ui/shims/antd-inputs";
+import { Modal } from "@/components/ui/shims/antd-overlays";
+import { Typography } from "@/components/ui/shims/antd-typography";
import "./PromptCard.css";
-import { ArrowDownOutlined, ArrowUpOutlined } from "@ant-design/icons";
import { uniqueId } from "lodash";
import debounce from "lodash/debounce";
@@ -161,7 +164,7 @@ function OutputForIndex({ chunkData, setIsIndexOpen, isIndexOpen }) {
onClick={handlePrev}
disabled={highlightedChunks.length === 0}
>
-
+
{highlightedChunks.length > 0 ? currentIndex + 1 : 0}/{" "}
@@ -172,7 +175,7 @@ function OutputForIndex({ chunkData, setIsIndexOpen, isIndexOpen }) {
onClick={handleNext}
disabled={highlightedChunks.length === 0}
>
-
+
diff --git a/frontend/src/components/custom-tools/prompt-card/ProfileIcon.jsx b/frontend/src/components/custom-tools/prompt-card/ProfileIcon.jsx
index 4d10178c0b..a59f9d2583 100644
--- a/frontend/src/components/custom-tools/prompt-card/ProfileIcon.jsx
+++ b/frontend/src/components/custom-tools/prompt-card/ProfileIcon.jsx
@@ -1,5 +1,5 @@
-import { Image } from "antd";
import PropTypes from "prop-types";
+import { Image } from "@/components/ui/shims/antd-leaves";
import { isImageUrl } from "../../../helpers/GetStaticData";
diff --git a/frontend/src/components/custom-tools/prompt-card/PromptCard.css b/frontend/src/components/custom-tools/prompt-card/PromptCard.css
index 72ba8bcb70..46f9c5d0e8 100644
--- a/frontend/src/components/custom-tools/prompt-card/PromptCard.css
+++ b/frontend/src/components/custom-tools/prompt-card/PromptCard.css
@@ -27,7 +27,22 @@
.prompt-card-actions-head {
font-size: 12px;
+ /* width/height, not font-size alone: these classes sit on lucide SVGs,
+ * which ignore font-size and fall back to their own 24px default. The
+ * antd originals were an icon FONT, where font-size was the size. */
+ width: 12px;
+ height: 12px;
color: #575859;
+ transition: color 0.2s;
+}
+
+/* The button carries `hover:text-accent-foreground`, but that only sets an
+ * INHERITED colour — this rule puts `color` directly on the SVG, so it wins and
+ * the icon never reacted. Only the button's faint background changed, which on
+ * a tinted prompt card is nearly imperceptible: several icons looked like they
+ * had no hover state at all. Tint the icon itself. */
+.prompt-card-action-button:hover:not(:disabled) .prompt-card-actions-head {
+ color: var(--primary);
}
.prompt-card-head .ant-typography {
@@ -223,7 +238,7 @@
.ant-tag-checkable.checked {
background-color: #f6ffed !important;
border-color: #b7eb8f !important;
- color: #52c41a !important;
+ color: var(--success) !important;
}
.ant-tag-checkable.unchecked {
@@ -293,7 +308,7 @@
.highlighted-prompt {
border-width: 1.2px;
border-style: solid;
- border-color: #4096ff;
+ border-color: var(--primary);
box-shadow: 4px 4px 12.5px 0px rgba(0, 0, 0, 0.08);
}
diff --git a/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx b/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx
index 29fda260e2..61281feec9 100644
--- a/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx
+++ b/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx
@@ -152,10 +152,18 @@ const PromptCard = memo(
value = event.target.value;
}
- const prevPromptDetailsState = { ...promptDetailsState };
-
- const updatedPromptDetailsState = { ...promptDetailsState };
- updatedPromptDetailsState[name] = value;
+ /*
+ * Functional update, NOT a spread of the captured snapshot. Header's
+ * debounced savers hold on to the `handleChange` from the render they
+ * were called in, so a save can land against newer state than it was
+ * built from. Spreading the snapshot wrote every *other* field back as
+ * it stood then: ticking "Enable Postprocessing Webhook" and typing the
+ * URL inside the 300ms toggle debounce made the URL save re-assert
+ * `enable_postprocessing_webhook: false`, unticking the box the user
+ * had just ticked (the PATCH itself only ever carries `name`, so the
+ * server kept both values and a refresh looked correct).
+ */
+ const prevValue = promptDetailsState?.[name];
handleUpdateStatus(
isUpdateStatus,
@@ -163,7 +171,7 @@ const PromptCard = memo(
promptStudioUpdateStatus.isUpdating,
setUpdateStatus,
);
- setPromptDetailsState(updatedPromptDetailsState);
+ setPromptDetailsState((prev) => ({ ...prev, [name]: value }));
return handleChangePromptCard(name, value, promptId)
.then((res) => {
const data = res?.data;
@@ -171,6 +179,18 @@ const PromptCard = memo(
prev[promptId] = data;
return prev;
});
+ // UN-2900: the save response recomputes
+ // single_pass_unresolvable_variables for the text just saved. Fold it
+ // back in so the warning tracks the edit instead of going stale until
+ // the next full tool fetch. Only this field is taken -- the rest of
+ // the optimistic state stays as the user typed it.
+ if (data?.single_pass_unresolvable_variables !== undefined) {
+ setPromptDetailsState((prev) => ({
+ ...prev,
+ single_pass_unresolvable_variables:
+ data.single_pass_unresolvable_variables,
+ }));
+ }
handleUpdateStatus(
isUpdateStatus,
promptId,
@@ -180,7 +200,8 @@ const PromptCard = memo(
})
.catch(() => {
handleUpdateStatus(isUpdateStatus, promptId, null, setUpdateStatus);
- setPromptDetailsState(prevPromptDetailsState);
+ // Roll back only the field that failed, for the same reason.
+ setPromptDetailsState((prev) => ({ ...prev, [name]: prevValue }));
})
.finally(() => {
if (isUpdateStatus) {
diff --git a/frontend/src/components/custom-tools/prompt-card/PromptCardItems.jsx b/frontend/src/components/custom-tools/prompt-card/PromptCardItems.jsx
index 7061eb9ed2..dace59fcd1 100644
--- a/frontend/src/components/custom-tools/prompt-card/PromptCardItems.jsx
+++ b/frontend/src/components/custom-tools/prompt-card/PromptCardItems.jsx
@@ -1,17 +1,13 @@
-import { SearchOutlined } from "@ant-design/icons";
-import {
- Button,
- Card,
- Collapse,
- Divider,
- Row,
- Select,
- Space,
- Tag,
- Typography,
-} from "antd";
+import { Search } from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Select } from "@/components/ui/shims/antd-inputs";
+import { Row, Space } from "@/components/ui/shims/antd-layout";
+import { Divider, Tag } from "@/components/ui/shims/antd-leaves";
+import { Collapse } from "@/components/ui/shims/antd-overlays";
+import { Card } from "@/components/ui/shims/antd-structure";
+import { Typography } from "@/components/ui/shims/antd-typography";
import { useCustomToolStore } from "../../../store/custom-tool-store";
import { SpinnerLoader } from "../../widgets/spinner-loader/SpinnerLoader";
import { EditableText } from "../editable-text/EditableText";
@@ -243,7 +239,11 @@ function PromptCardItems({
{isCoverageLoading ? (
) : (
-
+ // size-3 (12px), not `font-size-12`: that class is
+ // a TEXT utility shared with the Typography.Link
+ // below, and font-size does nothing to an SVG — the
+ // icon fell back to lucide's 24px default.
+
)}
Coverage: {promptCoverage?.length || 0} of{" "}
@@ -283,7 +283,7 @@ function PromptCardItems({
className="prompt-card-select-type"
size="small"
placeholder="Enforce Type"
- optionFilterProp="children"
+ showSearch
options={enforceTypeList}
value={promptDetails?.enforce_type || null}
disabled={
diff --git a/frontend/src/components/custom-tools/prompt-card/PromptOutput.jsx b/frontend/src/components/custom-tools/prompt-card/PromptOutput.jsx
index d001ca95cd..30aacb194a 100644
--- a/frontend/src/components/custom-tools/prompt-card/PromptOutput.jsx
+++ b/frontend/src/components/custom-tools/prompt-card/PromptOutput.jsx
@@ -1,13 +1,13 @@
-import {
- DatabaseOutlined,
- InfoCircleOutlined,
- PlayCircleFilled,
- PlayCircleOutlined,
-} from "@ant-design/icons";
-import { Button, Col, Divider, Radio, Space, Tooltip, Typography } from "antd";
import { AnimatePresence, motion } from "framer-motion";
+import { CirclePlay, Database, FastForward, Info } from "lucide-react";
import PropTypes from "prop-types";
import { useState } from "react";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Radio } from "@/components/ui/shims/antd-inputs";
+import { Col, Space } from "@/components/ui/shims/antd-layout";
+import { Divider } from "@/components/ui/shims/antd-leaves";
+import { Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Typography } from "@/components/ui/shims/antd-typography";
import {
displayPromptResult,
@@ -268,6 +268,7 @@ function PromptOutput({
}}
/>
+ `ps-prompt-profile-${action}-${promptId}-${profileId}`;
const tokenUsageId = promptId + "__" + docId + "__" + profileId;
let promptOutputData = {};
if (promptOutputs && Object.keys(promptOutputs)) {
@@ -358,10 +364,14 @@ function PromptOutput({
-
+
- {
setIsIndexOpen(true);
setOpenIndexProfile(promptOutputData?.context);
@@ -381,6 +391,7 @@ function PromptOutput({
{isNotSingleLlmProfile && (
handleSelectDefaultLLM(profileId)}
disabled={isPublicSource}
@@ -416,6 +427,7 @@ function PromptOutput({
-
+
-
+ {/* All-documents run; the current-document button
+ beside it keeps CirclePlay. */}
+
-
+
@@ -43,7 +44,8 @@ function PromptOutputActions({
isPublicSource
}
>
-
+ {/* All-documents run; the button beside it keeps CirclePlay. */}
+
>
diff --git a/frontend/src/components/custom-tools/prompt-card/PromptOutputExpandBtn.jsx b/frontend/src/components/custom-tools/prompt-card/PromptOutputExpandBtn.jsx
index f08ec59e95..62f5234129 100644
--- a/frontend/src/components/custom-tools/prompt-card/PromptOutputExpandBtn.jsx
+++ b/frontend/src/components/custom-tools/prompt-card/PromptOutputExpandBtn.jsx
@@ -1,6 +1,7 @@
-import { ArrowsAltOutlined } from "@ant-design/icons";
-import { Button, Tooltip } from "antd";
+import { Move } from "lucide-react";
import PropTypes from "prop-types";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Tooltip } from "@/components/ui/shims/antd-overlays";
import { PromptOutputsModal } from "./PromptOutputsModal";
@@ -14,17 +15,19 @@ function PromptOutputExpandBtn({
tableSettings,
openExpandModal,
setOpenExpandModal,
+ testId,
}) {
return (
<>
setOpenExpandModal(true)}
>
-
+
}
+ data-testid="ps-run-all-prompts-one-doc-btn"
+ icon={ }
onClick={() =>
handlePromptRunRequest(
PROMPT_RUN_TYPES.RUN_ALL_PROMPTS_ALL_LLMS_ONE_DOC,
@@ -27,7 +30,10 @@ function RunAllPrompts() {
}
+ data-testid="ps-run-all-prompts-all-docs-btn"
+ // All-documents runs use FastForward; the single-document button
+ // beside it keeps CirclePlay, so the two are told apart at a glance.
+ icon={ }
onClick={() =>
handlePromptRunRequest(
PROMPT_RUN_TYPES.RUN_ALL_PROMPTS_ALL_LLMS_ALL_DOCS,
diff --git a/frontend/src/components/custom-tools/prompts-reorder/DraggablePrompt.jsx b/frontend/src/components/custom-tools/prompts-reorder/DraggablePrompt.jsx
index e0a8ddea0d..c60ff9bbb9 100644
--- a/frontend/src/components/custom-tools/prompts-reorder/DraggablePrompt.jsx
+++ b/frontend/src/components/custom-tools/prompts-reorder/DraggablePrompt.jsx
@@ -1,8 +1,9 @@
import PropTypes from "prop-types";
import { memo, useRef } from "react";
import { useDrag, useDrop } from "react-dnd";
+import { Card } from "@/components/ui/shims/antd-structure";
+import { Typography } from "@/components/ui/shims/antd-typography";
import "./PromptsReorder.css";
-import { Card, Typography } from "antd";
const ItemTypes = {
PROMPT: "prompt",
diff --git a/frontend/src/components/custom-tools/prompts-reorder/PromptsReorder.jsx b/frontend/src/components/custom-tools/prompts-reorder/PromptsReorder.jsx
index 5947453ce2..19f17ae03e 100644
--- a/frontend/src/components/custom-tools/prompts-reorder/PromptsReorder.jsx
+++ b/frontend/src/components/custom-tools/prompts-reorder/PromptsReorder.jsx
@@ -1,8 +1,8 @@
-import { Space } from "antd";
import PropTypes from "prop-types";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
+import { Space } from "@/components/ui/shims/antd-layout";
import { useCustomToolStore } from "../../../store/custom-tool-store";
import DraggablePrompt from "./DraggablePrompt";
diff --git a/frontend/src/components/custom-tools/prompts-reorder/PromptsReorderModal.jsx b/frontend/src/components/custom-tools/prompts-reorder/PromptsReorderModal.jsx
index af0157151d..922c8da97a 100644
--- a/frontend/src/components/custom-tools/prompts-reorder/PromptsReorderModal.jsx
+++ b/frontend/src/components/custom-tools/prompts-reorder/PromptsReorderModal.jsx
@@ -1,5 +1,5 @@
-import { Modal } from "antd";
import PropTypes from "prop-types";
+import { Modal } from "@/components/ui/shims/antd-overlays";
import { PromptsReorder } from "./PromptsReorder";
diff --git a/frontend/src/components/custom-tools/prompts-reorder/PromptsReorderTitle.jsx b/frontend/src/components/custom-tools/prompts-reorder/PromptsReorderTitle.jsx
index 43838b23ff..856b967bec 100644
--- a/frontend/src/components/custom-tools/prompts-reorder/PromptsReorderTitle.jsx
+++ b/frontend/src/components/custom-tools/prompts-reorder/PromptsReorderTitle.jsx
@@ -1,12 +1,14 @@
-import { InfoCircleOutlined } from "@ant-design/icons";
-import { Space, Tooltip, Typography } from "antd";
+import { Info } from "lucide-react";
+import { Space } from "@/components/ui/shims/antd-layout";
+import { Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Typography } from "@/components/ui/shims/antd-typography";
function PromptsReorderTitle() {
return (
Reorder Prompts
-
+
);
diff --git a/frontend/src/components/custom-tools/retrieval-strategy-modal/RetrievalStrategyModal.css b/frontend/src/components/custom-tools/retrieval-strategy-modal/RetrievalStrategyModal.css
index ea4940b2d7..f9e1913919 100644
--- a/frontend/src/components/custom-tools/retrieval-strategy-modal/RetrievalStrategyModal.css
+++ b/frontend/src/components/custom-tools/retrieval-strategy-modal/RetrievalStrategyModal.css
@@ -25,12 +25,12 @@
}
.retrieval-strategy-modal .ant-radio-wrapper:hover {
- border-color: #1890ff;
+ border-color: var(--primary);
background-color: #f6ffed;
}
.retrieval-strategy-modal .ant-radio-wrapper-checked {
- border-color: #1890ff;
+ border-color: var(--primary);
background-color: #e6f7ff;
}
@@ -72,7 +72,7 @@
background-color: #fafafa;
padding: 12px;
border-radius: 4px;
- border-left: 3px solid #1890ff;
+ border-left: 3px solid var(--primary);
}
.retrieval-strategy-modal .strategy-icon {
@@ -173,11 +173,11 @@
}
.retrieval-strategy-modal__token-usage-low {
- color: #52c41a;
+ color: var(--success);
}
.retrieval-strategy-modal__token-usage-medium {
- color: #faad14;
+ color: var(--warning);
}
.retrieval-strategy-modal__token-usage-high {
@@ -185,11 +185,11 @@
}
.retrieval-strategy-modal__cost-impact-low {
- color: #52c41a;
+ color: var(--success);
}
.retrieval-strategy-modal__cost-impact-medium {
- color: #faad14;
+ color: var(--warning);
}
.retrieval-strategy-modal__cost-impact-high {
diff --git a/frontend/src/components/custom-tools/retrieval-strategy-modal/RetrievalStrategyModal.jsx b/frontend/src/components/custom-tools/retrieval-strategy-modal/RetrievalStrategyModal.jsx
index 1549b5c13c..3b3d1b592d 100644
--- a/frontend/src/components/custom-tools/retrieval-strategy-modal/RetrievalStrategyModal.jsx
+++ b/frontend/src/components/custom-tools/retrieval-strategy-modal/RetrievalStrategyModal.jsx
@@ -1,39 +1,33 @@
import {
- ForkOutlined,
- MergeCellsOutlined,
- QuestionCircleOutlined,
- ReloadOutlined,
- SearchOutlined,
- ShareAltOutlined,
- TableOutlined,
-} from "@ant-design/icons";
-import {
- Alert,
- Button,
- Divider,
- Modal,
- Radio,
- Space,
- Spin,
- Typography,
-} from "antd";
+ CircleHelp,
+ GitFork,
+ Merge,
+ RotateCw,
+ Search,
+ Share2,
+ Table,
+} from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Radio } from "@/components/ui/shims/antd-inputs";
+import { Space } from "@/components/ui/shims/antd-layout";
+import { Alert, Divider, Spin } from "@/components/ui/shims/antd-leaves";
+import { Modal } from "@/components/ui/shims/antd-overlays";
+import { Paragraph, Text, Title } from "@/components/ui/shims/antd-typography";
import { useRetrievalStrategies } from "../../../hooks/useRetrievalStrategies";
import "./RetrievalStrategyModal.css";
-const { Title, Text, Paragraph } = Typography;
-
const ICON_MAP = {
- SearchOutlined: ,
- QuestionCircleOutlined: ,
- ForkOutlined: ,
- ReloadOutlined: ,
- ShareAltOutlined: ,
- TableOutlined: ,
- MergeCellsOutlined: ,
+ Search: ,
+ CircleHelp: ,
+ GitFork: ,
+ RotateCw: ,
+ Share2: ,
+ Table: ,
+ Merge: ,
};
const RetrievalStrategyModal = ({
@@ -71,7 +65,7 @@ const RetrievalStrategyModal = ({
// Transform strategies to include React icons
const strategiesWithIcons = retrievalStrategies.map((strategy) => ({
...strategy,
- icon: ICON_MAP[strategy.icon] || ,
+ icon: ICON_MAP[strategy.icon] || ,
}));
const selectedDetails = strategiesWithIcons.find(
diff --git a/frontend/src/components/custom-tools/settings-modal/SettingsModal.jsx b/frontend/src/components/custom-tools/settings-modal/SettingsModal.jsx
index 99a2fc00b4..99a68d5fb9 100644
--- a/frontend/src/components/custom-tools/settings-modal/SettingsModal.jsx
+++ b/frontend/src/components/custom-tools/settings-modal/SettingsModal.jsx
@@ -1,13 +1,16 @@
import {
- CodeOutlined,
- DatabaseOutlined,
- DiffOutlined,
- FileTextOutlined,
- MessageOutlined,
-} from "@ant-design/icons";
-import { Col, Menu, Modal, Row, Typography } from "antd";
+ Code,
+ Database,
+ FileText,
+ GitCompare,
+ MessageSquare,
+} from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { Col, Row } from "@/components/ui/shims/antd-layout";
+import { Modal } from "@/components/ui/shims/antd-overlays";
+import { Menu } from "@/components/ui/shims/antd-structure";
+import { Typography } from "@/components/ui/shims/antd-typography";
import { getMenuItem } from "../../../helpers/GetStaticData";
import SpaceWrapper from "../../widgets/space-wrapper/SpaceWrapper";
import { CustomDataSettings } from "../custom-data-settings/CustomDataSettings";
@@ -44,11 +47,11 @@ function SettingsModal({ open, setOpen, handleUpdateTool }) {
useEffect(() => {
const items = [
- getMenuItem("LLM Profiles", 1, ),
- getMenuItem("Custom Data", 9, ),
- getMenuItem("Grammar", 5, ),
- getMenuItem("Preamble", 6, ),
- getMenuItem("Postamble", 7, ),
+ getMenuItem("LLM Profiles", 1, ),
+ getMenuItem("Custom Data", 9, ),
+ getMenuItem("Grammar", 5, ),
+ getMenuItem("Preamble", 6, ),
+ getMenuItem("Postamble", 7, ),
];
const listOfComponents = {
@@ -74,7 +77,7 @@ function SettingsModal({ open, setOpen, handleUpdateTool }) {
items.splice(
position,
0,
- getMenuItem("SummarizedExtraction", 2, ),
+ getMenuItem("SummarizedExtraction", 2, ),
);
listOfComponents[2] = (
@@ -86,7 +89,7 @@ function SettingsModal({ open, setOpen, handleUpdateTool }) {
items.splice(
position,
0,
- getMenuItem("Evaluation Manager", 3, ),
+ getMenuItem("Evaluation Manager", 3, ),
);
listOfComponents[3] = (
@@ -95,11 +98,7 @@ function SettingsModal({ open, setOpen, handleUpdateTool }) {
}
if (ChallengeManager) {
- items.splice(
- position,
- 0,
- getMenuItem("LLMChallenge", 4, ),
- );
+ items.splice(position, 0, getMenuItem("LLMChallenge", 4, ));
listOfComponents[4] = (
));
+ items.push(getMenuItem("Highlighting", 8, ));
listOfComponents[8] = (
div,
.tool-ide-pdf > div {
- background-color: var(--white);
+ background-color: var(--card);
height: 100%;
}
@@ -77,7 +77,7 @@
}
.tool-ide-collapse-panel {
- background-color: var(--white);
+ background-color: var(--card);
border: none;
border-radius: 0px;
}
diff --git a/frontend/src/components/custom-tools/tool-ide/ToolIde.jsx b/frontend/src/components/custom-tools/tool-ide/ToolIde.jsx
index 1f7e21c259..9c4b30d8cb 100644
--- a/frontend/src/components/custom-tools/tool-ide/ToolIde.jsx
+++ b/frontend/src/components/custom-tools/tool-ide/ToolIde.jsx
@@ -1,5 +1,5 @@
-import { Col, Row } from "antd";
import { useCallback, useEffect, useRef, useState } from "react";
+import { Col, Row } from "@/components/ui/shims/antd-layout";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler";
@@ -66,6 +66,19 @@ try {
useLookupExportGate = mod.useLookupExportGate;
} catch {}
+/*
+ * Cloud-only. Mounted here rather than beside either of its triggers: the
+ * kebab menu unmounts its contents on click and the prompt card body unmounts
+ * on collapse, so a drawer rendered in either was destroyed as it opened.
+ */
+let LookupDrawerHost;
+try {
+ const mod = await import(
+ "../../../plugins/lookup-studio/prompt-card/LookupDrawerHost"
+ );
+ LookupDrawerHost = mod.LookupDrawerHost;
+} catch {}
+
function ToolIde() {
const [openSettings, setOpenSettings] = useState(false);
const customToolStore = useCustomToolStore();
@@ -317,13 +330,25 @@ function ToolIde() {
data: body,
};
- return axiosPrivate(requestOptions)
- .then((res) => {
- return res;
- })
- .catch((err) => {
- throw err;
- });
+ // UN-2900: a tool PATCH re-serialises every prompt, so the response carries
+ // freshly computed single_pass_unresolvable_variables. Deliberately do NOT
+ // write those back to the store here, for two independent reasons.
+ //
+ // It would not work: PromptCard seeds promptDetailsState from its prop once
+ // and latches `isPromptDetailsStateUpdated` (PromptCard.jsx:74-83), which is
+ // never reset, and DocumentParser's key={item.prompt_id} is stable, so no
+ // remount re-seeds it. The Header warning reads promptDetailsState, so a
+ // store-level write to details.prompts can never reach it. Per-prompt
+ // freshness comes from PromptCard's own fold-back on the save response.
+ //
+ // And it would be actively harmful: the write would be built from a
+ // `details` captured when the PATCH was issued, so a prompt added or
+ // deleted while the request was in flight would be silently discarded.
+ //
+ // Consequence worth stating: "toggle single pass -> every affected prompt
+ // warns at once" does NOT hold. Making it hold needs the consumer to accept
+ // updates after its first seed; that is a separate change.
+ return axiosPrivate(requestOptions);
};
const handleDocChange = (doc) => {
@@ -372,6 +397,7 @@ function ToolIde() {
/>
)}
{lookupGateModalEl}
+ {LookupDrawerHost && }
) : (
"Document Parser"
@@ -51,7 +52,7 @@ function ToolsMain() {
key: "2",
label: isSimplePromptStudio ? (
-
+
) : (
"Combined Output"
diff --git a/frontend/src/components/custom-tools/tools-main/ToolsMainActionBtns.jsx b/frontend/src/components/custom-tools/tools-main/ToolsMainActionBtns.jsx
index c7053ac921..ba60b90c8f 100644
--- a/frontend/src/components/custom-tools/tools-main/ToolsMainActionBtns.jsx
+++ b/frontend/src/components/custom-tools/tools-main/ToolsMainActionBtns.jsx
@@ -1,7 +1,9 @@
-import { BarChartOutlined, UnorderedListOutlined } from "@ant-design/icons";
-import { Button, Space, Tooltip } from "antd";
+import { ChartColumn, List } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Space } from "@/components/ui/shims/antd-layout";
+import { Tooltip } from "@/components/ui/shims/antd-overlays";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler";
import usePostHogEvents from "../../../hooks/usePostHogEvents";
@@ -132,14 +134,16 @@ function ToolsMainActionBtns() {
{!singlePassExtractMode && }
}
+ data-testid="ps-output-analyzer-btn"
+ icon={ }
onClick={handleOutputAnalyzerBtnClick}
disabled={isMultiPassExtractLoading || isSinglePassExtractLoading}
/>
}
+ data-testid="ps-reorder-prompts-btn"
+ icon={
}
onClick={() => setOpenReorderModal(true)}
loading={isNewOrderLoading}
/>
diff --git a/frontend/src/components/data-table/ColumnFilter.jsx b/frontend/src/components/data-table/ColumnFilter.jsx
new file mode 100644
index 0000000000..21dd32ae7e
--- /dev/null
+++ b/frontend/src/components/data-table/ColumnFilter.jsx
@@ -0,0 +1,265 @@
+import { Filter } from "lucide-react";
+import PropTypes from "prop-types";
+import * as React from "react";
+
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { cn } from "@/lib/utils";
+
+/**
+ * antd's per-column filter affordance: the little icon in the header and the
+ * panel it opens.
+ *
+ * `DataTable` presents antd's `Table` API, but every filter prop on a column —
+ * `filters`, `filterDropdown`, `filterIcon`, `onFilter`, `filteredValue` — was
+ * dropped on the floor, so the header rendered the bare title. The visible
+ * casualties were the Execution Logs screens: the Execution ID search, the file
+ * name search and the Status filter all vanished from a page whose whole
+ * purpose is finding one execution among thousands.
+ *
+ * Two shapes, both in use here:
+ *
+ * - `filterDropdown` — the call-site renders the whole panel. It may be a
+ * node (LogsTable's execution-ID box, which owns its own state and never
+ * calls back) or a function given antd's render props.
+ * - `filters` — a list of `{ text, value }`; this file renders antd's own
+ * checkbox menu with the Reset/OK footer under it.
+ */
+
+/** antd's column identity: `key`, else `dataIndex`. */
+function columnKey(column) {
+ return String(column.key ?? column.dataIndex);
+}
+
+/** A string for `aria-label` even when `title` is a node. */
+function columnLabel(column) {
+ return typeof column.title === "string" ? column.title : columnKey(column);
+}
+
+/** The built-in `filters` menu: a checkbox (or radio) per option. */
+function FilterMenu({ options, filterSearch, multiple, draft, onDraftChange }) {
+ const [query, setQuery] = React.useState("");
+
+ const visible = query
+ ? options.filter((o) =>
+ String(o.text).toLowerCase().includes(query.toLowerCase()),
+ )
+ : options;
+
+ const toggle = (value) => {
+ if (!multiple) {
+ // antd's `filterMultiple: false` is a radio group: picking one option
+ // replaces the selection rather than adding to it.
+ onDraftChange(draft.includes(value) ? [] : [value]);
+ return;
+ }
+ onDraftChange(
+ draft.includes(value)
+ ? draft.filter((v) => v !== value)
+ : [...draft, value],
+ );
+ };
+
+ return (
+
+ {filterSearch ? (
+
+ setQuery(e.target.value)}
+ placeholder="Search in filters"
+ className="h-7 text-xs"
+ />
+
+ ) : null}
+ {visible.length === 0 ? (
+
+ No filters
+
+ ) : null}
+ {visible.map((option) => (
+
+ toggle(option.value)}
+ className={cn(!multiple && "rounded-full")}
+ />
+ {option.text}
+
+ ))}
+
+ );
+}
+
+FilterMenu.propTypes = {
+ options: PropTypes.array.isRequired,
+ filterSearch: PropTypes.bool,
+ multiple: PropTypes.bool,
+ draft: PropTypes.array.isRequired,
+ onDraftChange: PropTypes.func.isRequired,
+};
+
+/**
+ * The header trigger plus its panel.
+ *
+ * `selectedKeys` is the COMMITTED filter — what the table is filtered by right
+ * now. The panel edits a draft and only publishes it through `onConfirm`, which
+ * is what makes antd's Reset/OK footer mean anything.
+ */
+function ColumnFilter({ column, selectedKeys, onConfirm }) {
+ const [open, setOpen] = React.useState(false);
+ const [draft, setDraft] = React.useState(selectedKeys);
+ /*
+ * The draft lives in a ref as well as in state because antd's render props
+ * are routinely called back-to-back in one handler — LogModal's level filter
+ * does `setSelectedKeys([level]); confirm();` — and `confirm` has to publish
+ * the keys that were just set, not the ones React has yet to re-render with.
+ */
+ const draftRef = React.useRef(selectedKeys);
+
+ const setSelectedKeys = (keys) => {
+ draftRef.current = keys ?? [];
+ setDraft(draftRef.current);
+ };
+
+ const confirm = (options) => {
+ onConfirm(draftRef.current);
+ // antd's `confirm({ closeDropdown: false })` commits but leaves the panel up.
+ if (options?.closeDropdown !== false) {
+ setOpen(false);
+ }
+ };
+
+ // antd's `clearFilters` publishes the empty selection and, by default, leaves
+ // the panel open so the user can pick something else.
+ const clearFilters = () => {
+ draftRef.current = [];
+ setDraft([]);
+ onConfirm([]);
+ };
+
+ const handleOpenChange = (next) => {
+ if (next) {
+ // Re-seed on open: a panel dismissed with Escape must not carry its
+ // abandoned draft into the next visit.
+ draftRef.current = selectedKeys;
+ setDraft(selectedKeys);
+ }
+ setOpen(next);
+ };
+
+ const filtered = selectedKeys.length > 0;
+
+ const icon =
+ typeof column.filterIcon === "function"
+ ? column.filterIcon(filtered)
+ : (column.filterIcon ?? );
+
+ let panel;
+ if (column.filterDropdown) {
+ panel =
+ typeof column.filterDropdown === "function"
+ ? column.filterDropdown({
+ prefixCls: "ant-table-filter-dropdown",
+ setSelectedKeys,
+ selectedKeys: draft,
+ confirm,
+ clearFilters,
+ filters: column.filters,
+ visible: open,
+ close: () => setOpen(false),
+ })
+ : column.filterDropdown;
+ } else {
+ const multiple = column.filterMultiple !== false;
+ panel = (
+ <>
+
+
+
+ Reset
+
+ confirm()}>
+ OK
+
+
+ >
+ );
+ }
+
+ return (
+
+
+ carries the sort handler, so without this a click on the
+ * filter icon would also re-sort the column underneath the panel.
+ * `stopPropagation` leaves `defaultPrevented` alone, so Radix still
+ * gets its own click through and opens the popover.
+ */
+ onClick={(event) => event.stopPropagation()}
+ >
+ {icon}
+
+
+ event.preventDefault()}
+ >
+ {panel}
+
+
+ );
+}
+
+ColumnFilter.propTypes = {
+ column: PropTypes.object.isRequired,
+ selectedKeys: PropTypes.array.isRequired,
+ onConfirm: PropTypes.func.isRequired,
+};
+
+export { ColumnFilter, columnKey };
diff --git a/frontend/src/components/data-table/DataTable.jsx b/frontend/src/components/data-table/DataTable.jsx
new file mode 100644
index 0000000000..ddf6b17fad
--- /dev/null
+++ b/frontend/src/components/data-table/DataTable.jsx
@@ -0,0 +1,929 @@
+import {
+ flexRender,
+ getCoreRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+} from "@tanstack/react-table";
+import {
+ ChevronDown,
+ ChevronLeft,
+ ChevronRight,
+ ChevronUp,
+} from "lucide-react";
+import * as React from "react";
+import { ColumnFilter, columnKey } from "@/components/data-table/ColumnFilter";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Empty } from "@/components/ui/shims/antd-leaves";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { cn } from "@/lib/utils";
+
+/**
+ * Shared data table (P4-02, D5 / D9).
+ *
+ * shadcn's `table` is presentational only, so sorting, pagination and row
+ * selection come from TanStack. This wrapper presents **antd's `Table` API**
+ * (`columns`, `dataSource`, `rowKey`, `rowSelection`, `pagination`, `loading`)
+ * so the 16 OSS call-sites — and the cloud plugin sites in Phase C — convert by
+ * import rather than by rewriting each table.
+ *
+ * Per D9 this is the single table implementation for both repos: plugins must
+ * import it rather than build their own.
+ */
+
+/**
+ * One antd column → one TanStack column def.
+ *
+ * antd spells a banded header as a column that carries a `title` and a
+ * `children` array instead of a `dataIndex`; the leaves under it are the real
+ * columns. Ignoring `children` collapsed the whole band to a single leaf whose
+ * accessor was undefined, so the LLMWhisperer processing-modes table rendered
+ * its group title above sixteen blank rows — a header with no table under it.
+ *
+ * `path` only supplies the id for a column with neither `key` nor `dataIndex`:
+ * child indices restart at 0 inside every band, so the plain index the flat
+ * version used would collide across levels.
+ */
+function toColumn(c, path) {
+ const id = String(c.key ?? c.dataIndex ?? path);
+ // `column` rides along so the header can render antd's filter affordance,
+ // which is declared on the antd def and has no TanStack equivalent.
+ const meta = {
+ align: c.align,
+ width: c.width,
+ className: c.className,
+ column: c,
+ };
+
+ if (c.children?.length) {
+ return {
+ id,
+ header: c.title,
+ meta,
+ columns: c.children
+ .filter(Boolean)
+ .map((child, i) => toColumn(child, `${path}-${i}`)),
+ };
+ }
+
+ return {
+ id,
+ accessorKey: c.dataIndex,
+ header: c.title,
+ enableSorting: Boolean(c.sorter),
+ /*
+ * antd reads the sorter's SHAPE: a function is a local comparator, while
+ * `sorter: true` means "the server sorts this — just tell me it was
+ * clicked". Both used to sort locally with TanStack's guessed comparator,
+ * which got it wrong in both directions. A `localeCompare` sorter was
+ * replaced by a generic one, and — worse — every `sorter: true` column
+ * reordered the ten rows already on screen while the parent's `onChange`
+ * never fired, so the Execution Logs list looked sorted and wasn't: the
+ * rows it should have pulled from page two stayed on page two.
+ */
+ sortingFn:
+ typeof c.sorter === "function"
+ ? (a, b) => c.sorter(a.original, b.original)
+ : () => 0,
+ meta,
+ cell: ({ row }) => {
+ const value = c.dataIndex ? row.original?.[c.dataIndex] : undefined;
+ // antd's render(value, record, index) contract.
+ return c.render ? c.render(value, row.original, row.index) : value;
+ },
+ };
+}
+
+/** antd column defs → TanStack column defs. */
+function toColumns(antdColumns = [], rowSelection) {
+ const cols = antdColumns.filter(Boolean).map((c, i) => toColumn(c, i));
+
+ if (!rowSelection) {
+ return cols;
+ }
+
+ return [
+ {
+ id: "__select",
+ header: ({ table }) => (
+ table.toggleAllPageRowsSelected(Boolean(v))}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row }) => (
+ row.toggleSelected(Boolean(v))}
+ aria-label="Select row"
+ />
+ ),
+ enableSorting: false,
+ },
+ ...cols,
+ ];
+}
+
+/** Every leaf antd column, flattened out of any banded headers. */
+function leafColumns(antdColumns = []) {
+ return antdColumns
+ .filter(Boolean)
+ .flatMap((c) => (c.children?.length ? leafColumns(c.children) : [c]));
+}
+
+/** Does this column offer a filter at all? */
+function isFilterable(c) {
+ return Boolean(c.filters || c.filterDropdown);
+}
+
+/**
+ * antd's sorter affordance: a caret pair, ALWAYS on for a sortable column.
+ *
+ * Only the active direction used to render, so an unsorted column looked
+ * exactly like an unsortable one — on Execution Logs neither "Executed At" nor
+ * "Execution Time" advertised that they sort at all, and the single chevron
+ * that appeared after a click read as decoration rather than as state. antd
+ * shows both carets greyed and lights the applied one, which is what makes the
+ * column both discoverable and self-describing once sorted.
+ */
+function SortCarets({ sorted }) {
+ return (
+
+
+
+
+ );
+}
+
+/**
+ * antd's `onChange` hands back a `filters` object with an entry for EVERY
+ * filterable column, not just the active ones — LogModal indexes straight into
+ * `filters.level[0]`, so a missing key is a TypeError rather than "no filter".
+ *
+ * The null-vs-empty split is antd's own: a column driving its own
+ * `filterDropdown` reports its raw keys (`[]` when cleared), while a
+ * `filters`-list column reports `null` once nothing is ticked.
+ */
+function toFilterInfo(cols, keysFor) {
+ const info = {};
+ for (const c of cols) {
+ if (!isFilterable(c)) {
+ continue;
+ }
+ const keys = keysFor(c);
+ info[columnKey(c)] = c.filterDropdown ? keys : keys.length ? keys : null;
+ }
+ return info;
+}
+
+/** A TanStack sort entry in the shape antd's `onChange` promises. */
+function toSorterInfo(sortEntry, cols) {
+ if (!sortEntry) {
+ return {};
+ }
+ const column = cols.find(
+ (c) => String(c.key ?? c.dataIndex) === sortEntry.id,
+ );
+ return {
+ column,
+ columnKey: column?.key,
+ field: column?.dataIndex,
+ order: sortEntry.desc ? "descend" : "ascend",
+ };
+}
+
+/** One 24px square in antd's pager: a page number, an arrow, or the ellipsis. */
+function PagerButton({ children, label, active, ...props }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * antd shows every page up to 7, then collapses the middle to an ellipsis so
+ * the pager keeps a fixed width. Returns page numbers with "…" for the gaps.
+ */
+function pageNumbers(current, total) {
+ if (total <= 7) {
+ return Array.from({ length: total }, (_, i) => i + 1);
+ }
+ // First and last are always reachable; the window slides around `current`.
+ const from = Math.max(2, Math.min(current - 1, total - 4));
+ const to = Math.min(total - 1, Math.max(current + 1, 5));
+ return [
+ 1,
+ ...(from > 2 ? ["…"] : []),
+ ...Array.from({ length: to - from + 1 }, (_, i) => from + i),
+ ...(to < total - 1 ? ["…"] : []),
+ total,
+ ];
+}
+
+function DataTable({
+ columns,
+ dataSource,
+ rowKey = "id",
+ rowSelection,
+ pagination,
+ loading,
+ size,
+ /**
+ * antd's `tableLayout`, forwarded to CSS `table-layout`.
+ *
+ * Also load-bearing, and also silently dropped before: column `width` is
+ * rendered onto the only, and under the browser's default AUTO layout a
+ * width is a hint, not a bound. One long unbroken description in
+ * ResourceTable's Name column therefore stretched that column and pushed the
+ * Actions column off the right edge. With "fixed" the declared widths win and
+ * the cell's own ellipsis can finally take effect.
+ */
+ tableLayout,
+ rowClassName,
+ /**
+ * antd's per-row event hook: `onRow(record, index)` returns props (usually
+ * `{ onClick }`) that get spread onto the row.
+ *
+ * Declaring it is load-bearing. Undeclared, it fell into `...props` and was
+ * spread onto the wrapper , where React silently ignores an unknown
+ * `onRow` attribute — so ResourceTable's rows carried
+ * `rowClassName="…-clickable"` (cursor: pointer) while the click handler was
+ * never wired, and Prompt Studio and Workflows became unopenable with no
+ * console error to show for it.
+ */
+ onRow,
+ /**
+ * antd's Table-level `onChange(pagination, filters, sorter)` — the callback a
+ * server-paged call-site listens to so it can fetch the page the user just
+ * clicked.
+ *
+ * Declared for the same reason as `onRow` above: undeclared it fell into
+ * `...props` and was spread onto the wrapper
, where React silently
+ * ignores an unknown `onChange` attribute. ResourceTable's `handleChange`
+ * therefore never ran, so on every server-paged list — LLMs, Vector DBs,
+ * Embeddings, Text Extractors, Connectors — clicking a page number did
+ * nothing whatsoever.
+ */
+ onChange,
+ /**
+ * antd's `showHeader`, default true.
+ *
+ * Declared for the same reason as `onRow` above: undeclared, it fell into
+ * `...props` and was spread onto the wrapper
, where React not only
+ * ignored it but warned about an unknown `showHeader` DOM attribute on every
+ * render of the logs panel. A call site asking for `showHeader={false}` would
+ * silently have got a header anyway.
+ */
+ showHeader = true,
+ /**
+ * antd's `scroll={{ x, y }}`: `y` caps the body height and pins the header
+ * above it, `x` gives the table a minimum width so cramped columns overflow
+ * sideways instead of squashing.
+ *
+ * Declared for the same reason as `onRow` and `showHeader` above — it fell
+ * into `...props` and onto the wrapper
, so all ten call-sites asking
+ * for it silently got a table that grew to its full height instead. The
+ * LLMWhisperer processing-modes table asks for `y: 500` and stood 1270px
+ * tall, pushing its own header off the top of the screen.
+ */
+ scroll,
+ /**
+ * antd's `bordered`: rules between every cell, plus an outer frame.
+ *
+ * Declared for the same reason as `onRow`, `showHeader` and `scroll` above —
+ * undeclared it fell into `...props` and onto the wrapper
, where React
+ * warned "Received `true` for a non-boolean attribute `bordered`" on every
+ * render. The agentic Prompt Studio's extracted-data tables ask for it, and
+ * got a borderless table plus a console error instead.
+ */
+ bordered = false,
+ className,
+ emptyText = "No data",
+ /**
+ * antd's `locale={{ emptyText }}` — the spelling six call-sites actually use.
+ *
+ * Declared for the same reason as `onRow`, `showHeader`, `scroll` and
+ * `bordered` above, and it failed both ways at once: the custom empty state
+ * was silently replaced by the bare "No data" default, AND the object landed
+ * on the wrapper
as `locale="[object Object]"`. The agentic Prompt
+ * Studio's status table is the visible casualty — a project with no
+ * documents showed an empty box where "No documents in this project yet …
+ * click Manage Documents to upload PDFs" should be.
+ */
+ locale,
+ /**
+ * antd's `sortDirections`: the orders a header cycles through.
+ *
+ * Declared for the same reason as `onRow`, `showHeader`, `scroll`, `bordered`
+ * and `locale` above — undeclared it fell into `...props` and onto the
+ * wrapper
, where React warned "does not recognize the `sortDirections`
+ * prop on a DOM element" on every render of all four Execution Logs tables.
+ * All four pass `["ascend", "descend", "ascend"]`, antd's idiom for "never
+ * cycle back to unsorted": a repeated entry is what removes the third,
+ * order-less state.
+ */
+ sortDirections,
+ ...props
+}) {
+ const empty = locale?.emptyText ?? emptyText;
+ const [sorting, setSorting] = React.useState([]);
+ const [selection, setSelection] = React.useState({});
+
+ const rows = React.useMemo(() => dataSource ?? [], [dataSource]);
+ const cols = React.useMemo(
+ () => toColumns(columns, rowSelection),
+ [columns, rowSelection],
+ );
+ const leaves = React.useMemo(() => leafColumns(columns), [columns]);
+
+ /*
+ * Committed filters, keyed by antd column key. Only the uncontrolled ones
+ * live here: a column passing `filteredValue` is driven by its parent, and a
+ * column passing only `defaultFilteredValue` seeds from that until the user
+ * touches it. Resolving all three in one place means the rest of the
+ * component never has to know which kind it is looking at.
+ */
+ const [filterState, setFilterState] = React.useState({});
+ const keysFor = React.useCallback(
+ (c) => {
+ if (c.filteredValue !== undefined) {
+ return c.filteredValue ?? [];
+ }
+ const committed = filterState[columnKey(c)];
+ return committed ?? c.defaultFilteredValue ?? [];
+ },
+ [filterState],
+ );
+
+ /*
+ * antd applies `onFilter` itself: OR across the keys ticked within one
+ * column, AND across columns. A column with `filters` but NO `onFilter` is
+ * asking the server to do it, so it must not also be applied here — that is
+ * the Execution Logs status filter, which pages on the server.
+ */
+ const data = React.useMemo(() => {
+ const active = leaves.filter(
+ (c) => typeof c.onFilter === "function" && keysFor(c).length > 0,
+ );
+ if (active.length === 0) {
+ return rows;
+ }
+ return rows.filter((record) =>
+ active.every((c) => keysFor(c).some((v) => c.onFilter(v, record))),
+ );
+ }, [rows, leaves, keysFor]);
+
+ // antd reads `scroll.x === true` as "as wide as the content needs".
+ const scrollX = scroll?.x === true ? "max-content" : scroll?.x;
+ const scrollY = scroll?.y;
+
+ // antd accepts `pagination={false}` to disable, or an object to configure.
+ const paginated = pagination !== false;
+ const pageSize = pagination?.pageSize ?? 10;
+ /*
+ * antd slices `dataSource` only when it holds MORE rows than fit on a page;
+ * otherwise it renders what it was handed and lets `total` drive the pager.
+ * That distinction IS server-side paging, and losing it broke every list that
+ * pages on the server. ToolSettings requests `?page=1&page_size=10`, so
+ * `dataSource` is 10 rows while the response's `count` — passed here as
+ * `total` — says 12. Deriving the page count from `data.length` collapsed the
+ * pager to a single page, so on the LLM settings screen two adapters the API
+ * had already advertised via its `next` link were simply unreachable.
+ */
+ const clientPaged = paginated && data.length > pageSize;
+ const total = pagination?.total ?? data.length;
+ const pageCount = Math.max(1, Math.ceil(total / (pageSize || 1)));
+ const [internalPage, setInternalPage] = React.useState(1);
+ /*
+ * antd: passing `current` makes the pager controlled — the parent refetches
+ * and feeds the new page back down. Without it the table owns its own page.
+ * Clamped so a shrinking list (a search, a delete) can't leave the pager
+ * pointing past the last page with a blank body under it.
+ */
+ const currentPage = Math.min(pagination?.current ?? internalPage, pageCount);
+
+ const goToPage = (page) => {
+ const next = Math.min(Math.max(1, page), pageCount);
+ if (next === currentPage) {
+ return;
+ }
+ // Controlled pagers move only when the parent says so; uncontrolled ones
+ // page themselves.
+ if (pagination?.current === undefined) {
+ setInternalPage(next);
+ }
+ onChange?.(
+ { ...pagination, current: next, pageSize, total },
+ toFilterInfo(leaves, keysFor),
+ toSorterInfo(sorting[0], leaves),
+ );
+ };
+
+ /*
+ * Sorting and filtering both report through antd's single
+ * `onChange(pagination, filters, sorter)`, and both used to report nothing at
+ * all: `onSortingChange` went straight to `setSorting`, and filters had no
+ * state to change. Every server-sorted and server-filtered list was inert.
+ */
+ const handleSortingChange = (updater) => {
+ const next = typeof updater === "function" ? updater(sorting) : updater;
+ setSorting(next);
+ onChange?.(
+ { ...pagination, current: currentPage, pageSize, total },
+ toFilterInfo(leaves, keysFor),
+ toSorterInfo(next[0], leaves),
+ );
+ };
+
+ const commitFilter = (c, keys) => {
+ const next = { ...filterState, [columnKey(c)]: keys };
+ setFilterState(next);
+ /*
+ * The column being committed reports the keys the user just picked — even
+ * when it is CONTROLLED. `filteredValue` is the parent's current value,
+ * which is exactly the stale one here: reporting it back is how the parent
+ * would learn nothing changed. LogModal's level filter is controlled on
+ * `selectedLogLevel` and sets it from this callback, so echoing its own
+ * `filteredValue` left the filter permanently stuck on "no level".
+ * Every OTHER column still reports its own controlled or committed value.
+ */
+ const committedKey = columnKey(c);
+ const nextKeysFor = (col) => {
+ if (columnKey(col) === committedKey) {
+ return keys;
+ }
+ return col.filteredValue !== undefined
+ ? (col.filteredValue ?? [])
+ : (next[columnKey(col)] ?? col.defaultFilteredValue ?? []);
+ };
+ if (pagination?.current === undefined) {
+ setInternalPage(1);
+ }
+ onChange?.(
+ // antd sends the user back to the first page when the filter changes:
+ // page 4 of the old result set means nothing in the new one.
+ { ...pagination, current: 1, pageSize, total },
+ toFilterInfo(leaves, nextKeysFor),
+ toSorterInfo(sorting[0], leaves),
+ );
+ };
+
+ const table = useReactTable({
+ data,
+ columns: cols,
+ // A repeat in the list means the cycle never reaches "unsorted"; a list
+ // that leads with "descend" means the first click sorts that way.
+ enableSortingRemoval: sortDirections
+ ? new Set(sortDirections).size === sortDirections.length
+ : true,
+ /*
+ * antd's first click is always ascending unless `sortDirections` leads
+ * with "descend". Set here rather than per column because TanStack lets a
+ * column def override the table option, and its own default is
+ * descending-first for numeric columns — which silently reversed the first
+ * click on every numeric column.
+ */
+ sortDescFirst: sortDirections?.[0] === "descend",
+ state: {
+ sorting,
+ rowSelection: selection,
+ // Only meaningful while we slice: a server-paged table is handed exactly
+ // one page and must not have it sliced a second time.
+ ...(clientPaged
+ ? { pagination: { pageIndex: currentPage - 1, pageSize } }
+ : {}),
+ },
+ onSortingChange: handleSortingChange,
+ onRowSelectionChange: setSelection,
+ /*
+ * The pager below is the only thing that may change the page, so TanStack
+ * deliberately gets no `onPaginationChange`.
+ *
+ * Wiring one back to the parent looks right and ping-pongs: TanStack calls
+ * `resetPageIndex()` by itself every time `data` changes, so fetching page
+ * 2 delivered new rows, which reset the index to 0, which fetched page 1
+ * again — the table snapped back the instant it arrived. `autoResetPageIndex`
+ * is off for the same reason; the `currentPage` clamp above already covers
+ * the case it exists for, a page left pointing past a shrunken list.
+ */
+ autoResetPageIndex: false,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ ...(clientPaged ? { getPaginationRowModel: getPaginationRowModel() } : {}),
+ getRowId: (row, index) =>
+ typeof rowKey === "function"
+ ? String(rowKey(row))
+ : String(row?.[rowKey] ?? index),
+ });
+
+ /*
+ * Held in a ref, and deliberately NOT in the effect's deps.
+ *
+ * antd tolerates an inline `rowSelection={{ selectedRowKeys, onChange }}`,
+ * which most call-sites write — a fresh object every render. Depending on it
+ * (or on `table`, likewise rebuilt each render) re-ran this effect on every
+ * commit, and since it calls back into the parent's setState that is an
+ * infinite loop: React #185, which crashed the File History modal outright.
+ */
+ const rowSelectionRef = React.useRef(rowSelection);
+ rowSelectionRef.current = rowSelection;
+ const tableRef = React.useRef(table);
+ tableRef.current = table;
+
+ // Mirror selection back through antd's callback shape.
+ React.useEffect(() => {
+ const onChange = rowSelectionRef.current?.onChange;
+ if (!onChange) {
+ return;
+ }
+ const rows = tableRef.current
+ .getSelectedRowModel()
+ .rows.map((r) => r.original);
+ onChange(
+ rows.map((r) => r?.[typeof rowKey === "function" ? "id" : rowKey]),
+ rows,
+ );
+ // `selection` is the only real input: it changes exactly when the user
+ // ticks a row, which is when antd would fire onChange.
+ }, [selection, rowKey]);
+
+ /*
+ * antd renders the header as a SECOND table outside the scrolling body, so a
+ * banded header stays put in full. One table can only pin rows with
+ * `position: sticky`, and every row after the first has to sit below the
+ * ones above it — an offset that cannot be known statically, since a header
+ * row's height depends on where its titles wrap. So: measure after layout.
+ */
+ const headRef = React.useRef(null);
+ const [headerOffsets, setHeaderOffsets] = React.useState([]);
+ const headerRowCount = table.getHeaderGroups().length;
+ React.useLayoutEffect(() => {
+ if (!scrollY || !headRef.current) {
+ return undefined;
+ }
+ const measure = () => {
+ let top = 0;
+ setHeaderOffsets(
+ // `querySelectorAll` rather than `thead.rows`, which jsdom does not
+ // implement — the measurement threw and took the whole table with it.
+ Array.from(headRef.current.querySelectorAll("tr")).map((row) => {
+ const offset = top;
+ top += row.offsetHeight;
+ return offset;
+ }),
+ );
+ };
+ measure();
+ // Re-wrapping at a new width restacks the rows.
+ window.addEventListener("resize", measure);
+ return () => window.removeEventListener("resize", measure);
+ }, [scrollY, headerRowCount]);
+
+ // antd accepts `loading` as a boolean or `{ spinning }`.
+ const isLoading =
+ typeof loading === "object" ? Boolean(loading?.spinning) : Boolean(loading);
+
+ return (
+ // ant-table-* class names are emitted deliberately: the app has ~12 CSS
+ // rules targeting these (heights, sticky headers, overflow) that would
+ // otherwise match nothing.
+
+
+ * this one holds), NOT here: `position: sticky` resolves against the
+ * nearest scrolling ancestor, and capping the outer div would leave
+ * the inner one unscrolled — a header pinned to something that never
+ * moves does not move either.
+ */
+ scrollY && "[&>div]:max-h-[var(--table-scroll-y)]",
+ )}
+ style={
+ scrollY
+ ? {
+ "--table-scroll-y":
+ typeof scrollY === "number" ? `${scrollY}px` : scrollY,
+ }
+ : undefined
+ }
+ >
+
+ {showHeader ? (
+
+ {table.getHeaderGroups().map((hg, groupIndex) => (
+ /*
+ * antd's `.ant-table-thead > tr > th` is `background: #fafafa`
+ * with a 1px #f0f0f0 bottom border (verified against the
+ * reference's own stylesheet). shadcn leaves the header
+ * transparent, so on the now-white table surface the header row
+ * was indistinguishable from the body.
+ *
+ * `hover:bg-muted` on TableRow would otherwise repaint the
+ * header on hover, so it is neutralised here.
+ */
+
+ {hg.headers.map((header) => {
+ const sorted = header.column.getIsSorted();
+ const antdColumn = header.column.columnDef.meta?.column;
+ const filterable = Boolean(
+ antdColumn && isFilterable(antdColumn),
+ );
+ const hasAffordance =
+ header.column.getCanSort() || filterable;
+ // A banded column's own row: antd centres the band title
+ // over the leaves it covers.
+ const isBand = header.subHeaders.length > 0;
+ return (
+ , which the pinned cell leaves behind —
+ * the body would scroll through it. An inset shadow
+ * stands in for the border because a collapsed
+ * table border does not travel with a sticky cell.
+ */
+ scrollY &&
+ "z-[1] bg-[var(--neutral-50)] shadow-[inset_0_-1px_0_var(--separator)]",
+ /*
+ * shadcn's TableHead defaults to `font-medium
+ * text-muted-foreground`, which renders headers at
+ * weight 500 in grey. antd's `.ant-table-thead > th`
+ * is weight 600 at near-full opacity, so dev's column
+ * titles read as washed out beside the reference.
+ */
+ "font-semibold text-foreground",
+ isBand && "text-center",
+ header.column.columnDef.meta?.align === "center" &&
+ "text-center",
+ header.column.columnDef.meta?.align === "right" &&
+ "text-right",
+ header.column.getCanSort() &&
+ "cursor-pointer select-none",
+ )}
+ onClick={header.column.getToggleSortingHandler()}
+ >
+ {header.isPlaceholder ? null : (
+ /*
+ * antd pins a column's affordances to the RIGHT edge
+ * of its header cell (`.ant-table-column-sorters` is
+ * `justify-content: space-between`), so a row of
+ * headers puts its sorters and filter icons on one
+ * vertical rule. Laying them inline after the title
+ * instead left them ragged — each one wherever its
+ * own text happened to end.
+ *
+ * The flex row is conditional because it is not free:
+ * `w-full` on a plain title would defeat the
+ * `text-center` / `text-right` alignment above.
+ */
+
+ {/*
+ * `flex-1` so a centred or right-aligned column
+ * still aligns its title — within the space the
+ * icon cluster leaves, which is what antd does
+ * (`.ant-table-column-title { flex: 1 }`). Without
+ * it the title would bunch against the left edge
+ * of every aligned sortable column.
+ */}
+
+ {flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+
+ {hasAffordance ? (
+
+ {header.column.getCanSort() ? (
+
+ ) : null}
+ {filterable ? (
+
+ commitFilter(antdColumn, keys)
+ }
+ />
+ ) : null}
+
+ ) : null}
+
+ )}
+
+ );
+ })}
+
+ ))}
+
+ ) : null}
+
+ {isLoading ? (
+
+
+
+
+
+ ) : table.getRowModel().rows.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ {/*
+ * antd renders here — an illustration above the
+ * text — not a bare string. Emitting only `emptyText` left
+ * "No data" floating in the middle of the table with no
+ * icon, which read as a rendering failure rather than an
+ * empty state. A caller that passes its own node (an
+ * with a custom image, say) still gets it as-is.
+ */}
+ {typeof empty === "string" ? (
+
+ ) : (
+ empty
+ )}
+
+
+ )}
+
+
+
+
+ {/*
+ * antd's `hideOnSinglePage` defaults to FALSE — the pager stays put on a
+ * single page, which is why Manage Documents has a footer under its
+ * one-row table in the reference and had none here. Hiding it also made
+ * the modal's height jump as rows crossed the page-size boundary.
+ */}
+ {paginated && pageCount > (pagination?.hideOnSinglePage ? 1 : 0) ? (
+ /*
+ * antd's pager is a 24px strip of square numbered buttons with 16px
+ * margins, right-aligned. The "Previous / Page 1 of 1 / Next" text row
+ * this replaces stood 56px tall and read as a different component
+ * beside the reference.
+ */
+
+ {/*
+ * antd renders `showTotal(total, range)` as a label beside the page
+ * buttons. ResourceTable passes one ("Page 1 of 2 · 12 items") and it
+ * never appeared, because this pager only ever read `pageSize` off
+ * the `pagination` object and ignored the rest of it.
+ */}
+ {pagination?.showTotal ? (
+
+ {pagination.showTotal(total, [
+ total === 0 ? 0 : (currentPage - 1) * pageSize + 1,
+ Math.min(currentPage * pageSize, total),
+ ])}
+
+ ) : null}
+
goToPage(currentPage - 1)}
+ disabled={currentPage <= 1}
+ >
+
+
+ {pageNumbers(currentPage, pageCount).map((page, i) =>
+ page === "…" ? (
+ // Keyed by position: the ellipsis carries no identity of its own.
+
+ …
+
+ ) : (
+
goToPage(page)}
+ >
+ {page}
+
+ ),
+ )}
+
goToPage(currentPage + 1)}
+ disabled={currentPage >= pageCount}
+ >
+
+
+
+ ) : null}
+
+ );
+}
+
+export { DataTable };
diff --git a/frontend/src/components/data-table/DataTable.test.jsx b/frontend/src/components/data-table/DataTable.test.jsx
new file mode 100644
index 0000000000..d305594ffc
--- /dev/null
+++ b/frontend/src/components/data-table/DataTable.test.jsx
@@ -0,0 +1,1523 @@
+import { render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { useState } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import { DataTable } from "./DataTable";
+
+/**
+ * The pager is the part of this table users compare most directly against the
+ * reference: antd renders a compact strip of square numbered buttons, not a
+ * "Previous / Page 1 of 1 / Next" text row.
+ */
+
+const columns = [{ key: "name", dataIndex: "name", title: "Name" }];
+
+const rowsFor = (n) =>
+ Array.from({ length: n }, (_, i) => ({ id: i + 1, name: `Row ${i + 1}` }));
+
+function pager() {
+ return document.querySelector(".ant-pagination");
+}
+
+describe("DataTable rowSelection", () => {
+ /*
+ * antd tolerates an inline `rowSelection={{ selectedRowKeys, onChange }}`,
+ * which is what most call-sites write — a fresh object on every render.
+ * Depending on that object (or on the TanStack `table`, likewise rebuilt each
+ * render) re-ran the mirror effect on every commit, and because the effect
+ * calls back into the parent's setState that is an infinite loop. It crashed
+ * the File History modal outright with React #185.
+ */
+ it("does not loop when rowSelection is an inline object", () => {
+ function Harness() {
+ const [keys, setKeys] = useState([]);
+ return (
+
+ );
+ }
+ expect(() => render(
)).not.toThrow();
+ expect(screen.getByText("Row 1")).toBeInTheDocument();
+ });
+
+ it("still reports the selected keys through onChange", async () => {
+ const onChange = vi.fn();
+ render(
+
,
+ );
+ await userEvent.click(screen.getAllByLabelText("Select row")[0]);
+ // The first call fires on mount with an empty selection, as antd's does.
+ await waitFor(() =>
+ expect(onChange).toHaveBeenLastCalledWith(
+ [1],
+ expect.arrayContaining([expect.objectContaining({ id: 1 })]),
+ ),
+ );
+ });
+});
+
+describe("DataTable pagination", () => {
+ it("renders numbered page buttons rather than a Previous/Next text row", () => {
+ render(
+
,
+ );
+ expect(screen.queryByText(/Page 1 of/)).not.toBeInTheDocument();
+ expect(within(pager()).getByLabelText("Page 2")).toBeInTheDocument();
+ expect(within(pager()).getByLabelText("Page 3")).toBeInTheDocument();
+ });
+
+ it("marks the current page for assistive tech", () => {
+ render(
+
,
+ );
+ expect(within(pager()).getByLabelText("Page 1")).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ });
+
+ it("moves to the page whose number was clicked", async () => {
+ render(
+
,
+ );
+ await userEvent.click(within(pager()).getByLabelText("Page 3"));
+ expect(screen.getByText("Row 21")).toBeInTheDocument();
+ expect(screen.queryByText("Row 1")).not.toBeInTheDocument();
+ });
+
+ it("disables the arrows at the ends of the range", async () => {
+ render(
+
,
+ );
+ const p = pager();
+ expect(within(p).getByLabelText("Previous page")).toBeDisabled();
+ await userEvent.click(within(p).getByLabelText("Page 3"));
+ expect(within(p).getByLabelText("Next page")).toBeDisabled();
+ });
+
+ /*
+ * antd keeps the pager a fixed width past 7 pages by collapsing the middle,
+ * so a 200-row table must not render 20 buttons in a row.
+ */
+ it("collapses the middle with an ellipsis on long ranges", () => {
+ render(
+
,
+ );
+ const p = pager();
+ expect(within(p).getByText("…")).toBeInTheDocument();
+ expect(within(p).getByLabelText("Page 20")).toBeInTheDocument();
+ expect(within(p).queryByLabelText("Page 10")).not.toBeInTheDocument();
+ });
+
+ /*
+ * antd's `hideOnSinglePage` defaults to FALSE — Manage Documents shows a
+ * footer under its one-row table in the reference.
+ */
+ it("keeps the pager on a single page unless hideOnSinglePage is set", () => {
+ const { rerender } = render(
+
,
+ );
+ expect(pager()).toBeInTheDocument();
+
+ rerender(
+
,
+ );
+ expect(pager()).not.toBeInTheDocument();
+ });
+
+ it("renders no pager at all when pagination is false", () => {
+ render(
+
,
+ );
+ expect(pager()).not.toBeInTheDocument();
+ expect(screen.getByText("Row 25")).toBeInTheDocument();
+ });
+});
+
+/*
+ * Server-side paging, which every resource list uses: the page fetches
+ * `?page=N&page_size=10` and hands this table ONE page of rows plus the real
+ * row count as `total`. Sizing the pager off `dataSource.length` instead makes
+ * every such list look like it has exactly one page — the LLM settings screen
+ * held 12 adapters and offered no way to reach the last two.
+ */
+describe("DataTable server-side pagination", () => {
+ const serverPage = (props) => (
+
+ );
+
+ it("sizes the pager from `total`, not from the rows it was handed", () => {
+ render(serverPage());
+ expect(within(pager()).getByLabelText("Page 2")).toBeInTheDocument();
+ expect(within(pager()).getByLabelText("Next page")).not.toBeDisabled();
+ });
+
+ /*
+ * antd slices `dataSource` only when it holds more rows than fit on a page,
+ * so a page of exactly `pageSize` rows must be rendered whole. Slicing it
+ * again would show 10 rows on "page 1" and nothing on "page 2".
+ */
+ it("renders the whole page it was given without re-slicing it", () => {
+ render(serverPage());
+ expect(screen.getByText("Row 1")).toBeInTheDocument();
+ expect(screen.getByText("Row 10")).toBeInTheDocument();
+ });
+
+ it("reports the requested page through antd's onChange", async () => {
+ const onChange = vi.fn();
+ render(serverPage({ onChange }));
+ await userEvent.click(within(pager()).getByLabelText("Page 2"));
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({ current: 2, pageSize: 10, total: 12 }),
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("reports the requested page from the next arrow too", async () => {
+ const onChange = vi.fn();
+ render(serverPage({ onChange }));
+ await userEvent.click(within(pager()).getByLabelText("Next page"));
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({ current: 2 }),
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ /*
+ * `current` makes the pager controlled: the parent refetches and feeds the
+ * new page back down. Moving locally would blank the body, because the rows
+ * for page 2 are not in `dataSource` yet.
+ */
+ it("stays on the parent's page until new rows arrive", async () => {
+ const onChange = vi.fn();
+ render(serverPage({ onChange }));
+ await userEvent.click(within(pager()).getByLabelText("Page 2"));
+ expect(within(pager()).getByLabelText("Page 1")).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ expect(screen.getByText("Row 1")).toBeInTheDocument();
+ });
+
+ it("follows the parent to the page it fetched", () => {
+ const { rerender } = render(serverPage());
+ rerender(
+ serverPage({
+ dataSource: [
+ { id: 11, name: "Row 11" },
+ { id: 12, name: "Row 12" },
+ ],
+ pagination: { current: 2, pageSize: 10, total: 12 },
+ }),
+ );
+ expect(within(pager()).getByLabelText("Page 2")).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ expect(screen.getByText("Row 11")).toBeInTheDocument();
+ expect(within(pager()).getByLabelText("Next page")).toBeDisabled();
+ });
+
+ /*
+ * ResourceTable passes `showTotal` to render "Page 1 of 2 · 12 items" beside
+ * the buttons; the pager read only `pageSize` off `pagination` and dropped it.
+ */
+ it("renders showTotal with the real count and range", () => {
+ render(
+ serverPage({
+ pagination: {
+ current: 1,
+ pageSize: 10,
+ total: 12,
+ showTotal: (total, range) =>
+ `${range[0]}-${range[1]} of ${total} items`,
+ },
+ }),
+ );
+ expect(within(pager()).getByText("1-10 of 12 items")).toBeInTheDocument();
+ });
+
+ /*
+ * The last page holds fewer rows than `pageSize`; the range must stop at the
+ * real count rather than at `current * pageSize`.
+ */
+ it("clamps the showTotal range on the last page", () => {
+ render(
+ serverPage({
+ dataSource: [
+ { id: 11, name: "Row 11" },
+ { id: 12, name: "Row 12" },
+ ],
+ pagination: {
+ current: 2,
+ pageSize: 10,
+ total: 12,
+ showTotal: (total, range) =>
+ `${range[0]}-${range[1]} of ${total} items`,
+ },
+ }),
+ );
+ expect(within(pager()).getByText("11-12 of 12 items")).toBeInTheDocument();
+ });
+
+ /*
+ * The whole loop, end to end, against a parent that behaves like ToolSettings:
+ * it answers `onChange` by fetching that page and feeding the rows back down.
+ *
+ * This is the one that catches the ping-pong. TanStack calls
+ * `resetPageIndex()` itself whenever `data` changes, so bridging its
+ * `onPaginationChange` back to the parent meant page 2's rows arriving
+ * immediately asked for page 1 — the pager snapped back within a frame and
+ * the last two adapters stayed just as unreachable as before the fix. Every
+ * assertion below still passed with that bridge in place; only driving a real
+ * round trip shows it.
+ */
+ it("settles on the fetched page instead of bouncing back to the first", async () => {
+ const pageOf = (n) =>
+ n === 1 ? rowsFor(10) : [{ id: 11, name: "Row 11" }];
+ const fetched = [];
+
+ function Harness() {
+ const [page, setPage] = useState(1);
+ return (
+
{
+ fetched.push(p.current);
+ setPage(p.current);
+ }}
+ />
+ );
+ }
+
+ render( );
+ await userEvent.click(within(pager()).getByLabelText("Page 2"));
+
+ await waitFor(() => expect(screen.getByText("Row 11")).toBeInTheDocument());
+ expect(fetched).toEqual([2]);
+ expect(within(pager()).getByLabelText("Page 2")).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ expect(screen.queryByText("Row 1")).not.toBeInTheDocument();
+ });
+
+ /*
+ * A search that narrows 12 rows to 3 leaves `current` at 2 for one render.
+ * Unclamped, the pager pointed past the end and the body went blank.
+ */
+ it("clamps a stale page past the end of a shrunken list", () => {
+ render(
+ serverPage({
+ dataSource: rowsFor(3),
+ pagination: { current: 2, pageSize: 10, total: 3 },
+ }),
+ );
+ expect(within(pager()).getByLabelText("Page 1")).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ expect(screen.getByText("Row 1")).toBeInTheDocument();
+ });
+});
+
+/*
+ * Two antd Table props this wrapper claims to support and silently did not.
+ * Both failed the same way: undeclared, they fell into `...props` and were
+ * spread onto the wrapper , where React drops an unknown attribute without
+ * a word. Nothing threw, nothing logged, and the existing tests — which assert
+ * what RENDERS — passed against a table that had quietly stopped responding to
+ * clicks. Prompt Studio and Workflows became unopenable that way.
+ */
+describe("DataTable antd row/layout props", () => {
+ it("calls onRow and wires the returned handlers to the row", async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+
+ render(
+
({ onClick: () => onClick(record) })}
+ />,
+ );
+
+ await user.click(screen.getByText("Row 2"));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ // The record, not TanStack's row wrapper — call-sites read `record.id`.
+ expect(onClick.mock.calls[0][0]).toMatchObject({ id: 2, name: "Row 2" });
+ });
+
+ it("passes the record's index to onRow as antd does", () => {
+ const onRow = vi.fn(() => ({}));
+ render(
+ ,
+ );
+ expect(onRow.mock.calls.map((c) => c[1])).toEqual([0, 1]);
+ });
+
+ it("applies tableLayout to the table element", () => {
+ const { container } = render(
+ ,
+ );
+ // Without this the column `width`s are only hints, and one long cell
+ // stretches its column until the trailing ones leave the viewport.
+ expect(container.querySelector("table")).toHaveStyle({
+ tableLayout: "fixed",
+ });
+ });
+
+ it("leaves the table layout alone when the prop is absent", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("table").style.tableLayout).toBe("");
+ });
+});
+
+describe("DataTable showHeader", () => {
+ it("renders the column headers by default", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("thead")).toBeInTheDocument();
+ expect(screen.getByText("Name")).toBeInTheDocument();
+ });
+
+ /*
+ * antd omits the entirely for `showHeader={false}` rather than
+ * emitting an empty one, and ~12 CSS rules in the app target
+ * `.ant-table-thead` (heights, sticky offsets) that an empty header row
+ * would still reserve space for.
+ */
+ it("omits the header entirely when showHeader is false", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("thead")).not.toBeInTheDocument();
+ expect(screen.queryByText("Name")).not.toBeInTheDocument();
+ // The body still renders — this hides the header, not the table.
+ expect(screen.getByText("Row 1")).toBeInTheDocument();
+ });
+});
+
+/*
+ * antd's banded header: a column carrying `title` + `children` instead of a
+ * `dataIndex`. Ignoring `children` collapsed the band to one accessor-less
+ * leaf, which is how the LLMWhisperer processing-modes table came to render
+ * its title over sixteen empty rows.
+ */
+describe("DataTable grouped columns", () => {
+ const grouped = [
+ {
+ title: "Processing Modes",
+ children: [
+ { title: "Name", dataIndex: "feature", key: "feature" },
+ { title: "Native Text", dataIndex: "nativeText", key: "nativeText" },
+ ],
+ },
+ ];
+ const rows = [{ key: "1", feature: "Cost", nativeText: "$1/1,000 pages" }];
+
+ it("renders the band title and its leaf titles as two header rows", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelectorAll("thead tr")).toHaveLength(2);
+ expect(screen.getByText("Processing Modes")).toBeInTheDocument();
+ expect(screen.getByText("Name")).toBeInTheDocument();
+ expect(screen.getByText("Native Text")).toBeInTheDocument();
+ });
+
+ it("renders a cell per leaf column, not one blank cell per row", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Cost")).toBeInTheDocument();
+ expect(screen.getByText("$1/1,000 pages")).toBeInTheDocument();
+ expect(document.querySelectorAll("tbody tr td")).toHaveLength(2);
+ });
+
+ it("spans the band across its leaves so the header rows line up", () => {
+ const { container } = render(
+ ,
+ );
+ const [bandRow, leafRow] = container.querySelectorAll("thead tr");
+ expect(bandRow.querySelectorAll("th")).toHaveLength(1);
+ expect(bandRow.querySelector("th")).toHaveAttribute("colspan", "2");
+ expect(leafRow.querySelectorAll("th")).toHaveLength(2);
+ });
+
+ it("honours a leaf column's render as antd does", () => {
+ render(
+ {`rendered ${value}`} ,
+ },
+ ],
+ },
+ ]}
+ dataSource={rows}
+ rowKey="key"
+ pagination={false}
+ />,
+ );
+ expect(screen.getByText("rendered Cost")).toBeInTheDocument();
+ });
+
+ /*
+ * Child indices restart at 0 inside every band, so an index-derived id
+ * collides with a top-level column's — and TanStack rejects duplicate ids.
+ */
+ it("keeps ids unique when neither band nor leaf declares a key", () => {
+ expect(() =>
+ render(
+ ,
+ ),
+ ).not.toThrow();
+ expect(screen.getByText("Cost")).toBeInTheDocument();
+ });
+
+ it("spans the empty state across every leaf column", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("tbody td")).toHaveAttribute("colspan", "2");
+ });
+});
+
+/*
+ * antd's `scroll={{ x, y }}`. Ten call-sites pass it; before it was declared it
+ * fell into `...props` and onto the wrapper , so every one of them got a
+ * table at full height with no pinned header.
+ */
+describe("DataTable scroll", () => {
+ // shadcn's own overflow wrapper is the scrolling ancestor, so the cap has to
+ // land there for `position: sticky` to have anything to stick to.
+ const scroller = (container) =>
+ container.querySelector("table").parentElement;
+
+ it("caps the scrolling wrapper at scroll.y", () => {
+ const { container } = render(
+
,
+ );
+ const wrapper = container.querySelector(".ant-table-container");
+ // The cap is declared here but applies to the child — assert the child is
+ // the element that actually scrolls.
+ expect(wrapper).toHaveClass("[&>div]:max-h-[var(--table-scroll-y)]");
+ expect(wrapper).toHaveStyle({ "--table-scroll-y": "500px" });
+ expect(scroller(container).parentElement).toBe(wrapper);
+ expect(scroller(container)).toHaveClass("overflow-auto");
+ });
+
+ it("passes a string scroll.y through as the caller wrote it", () => {
+ const { container } = render(
+
,
+ );
+ expect(container.querySelector(".ant-table-container")).toHaveStyle({
+ "--table-scroll-y": "calc(100vh - 450px)",
+ });
+ });
+
+ it("pins the header rows when the body scrolls", () => {
+ const { container } = render(
+
,
+ );
+ const th = container.querySelector("thead th");
+ expect(th).toHaveStyle({ position: "sticky", top: "0px" });
+ // The
carries the background and border, and a pinned cell leaves the
+ // row behind — so the cell has to bring its own.
+ expect(th).toHaveClass("bg-[var(--neutral-50)]");
+ });
+
+ it("leaves the header unpinned without scroll.y", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("thead th").style.position).toBe("");
+ expect(container.querySelector(".ant-table-container")).not.toHaveClass(
+ "[&>div]:max-h-[var(--table-scroll-y)]",
+ );
+ });
+
+ it("gives the table a minimum width for scroll.x", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("table")).toHaveStyle({
+ minWidth: "1200px",
+ });
+ });
+
+ it("reads scroll.x === true as max-content, as antd does", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("table")).toHaveStyle({
+ minWidth: "max-content",
+ });
+ });
+
+ it("keeps tableLayout working alongside scroll.x", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("table")).toHaveStyle({
+ tableLayout: "fixed",
+ minWidth: "max-content",
+ });
+ });
+
+ it("does not leak scroll onto the DOM as an attribute", () => {
+ const { container } = render(
+ ,
+ );
+ expect(
+ container.querySelector(".ant-table-wrapper").getAttribute("scroll"),
+ ).toBeNull();
+ });
+});
+
+/*
+ * `bordered` was the fourth antd prop to reach the wrapper instead of
+ * being consumed, after onRow, showHeader and scroll. This one was noisier
+ * than the others — React rejects it outright ("Received `true` for a
+ * non-boolean attribute `bordered`") on every render — but just as invisible
+ * to the suite, because a console error fails nothing.
+ */
+describe("DataTable bordered", () => {
+ it("draws cell rules when asked", () => {
+ const { container } = render(
+
,
+ );
+ expect(container.querySelector("table").className).toContain(
+ "ant-table-bordered",
+ );
+ });
+
+ it("leaves the table unbordered by default", () => {
+ const { container } = render(
+
,
+ );
+ expect(container.querySelector("table").className).not.toContain(
+ "ant-table-bordered",
+ );
+ });
+
+ it("does not leak bordered onto the DOM as an attribute", () => {
+ const { container } = render(
+
,
+ );
+ expect(
+ container.querySelector(".ant-table-wrapper").getAttribute("bordered"),
+ ).toBeNull();
+ });
+});
+
+/**
+ * antd's column filters. The shim used to drop `filters`, `filterDropdown`,
+ * `filterIcon`, `onFilter` and `filteredValue` on the floor, which is what
+ * stripped the Execution ID search, the file-name search and the Status filter
+ * off the Execution Logs screens during the shadcn migration.
+ */
+describe("DataTable column filters", () => {
+ const typed = [
+ { id: 1, name: "Row 1", type: "LOG" },
+ { id: 2, name: "Row 2", type: "NOTIFICATION" },
+ ];
+
+ const typeColumn = (extra = {}) => ({
+ title: "Type",
+ dataIndex: "type",
+ key: "type",
+ filters: [
+ { text: "LOG", value: "LOG" },
+ { text: "NOTIFICATION", value: "NOTIFICATION" },
+ ],
+ ...extra,
+ });
+
+ async function openFilter(label = "Filter by Type") {
+ await userEvent.click(screen.getByLabelText(label));
+ }
+
+ // The option labels double as cell values, so every query inside the panel
+ // has to be scoped to it or it matches the table body too.
+ const panel = () =>
+ within(document.querySelector(".ant-table-filter-dropdown"));
+
+ it("renders a filter trigger for a column that declares filters", () => {
+ render(
+
,
+ );
+ expect(screen.getByLabelText("Filter by Type")).toBeInTheDocument();
+ });
+
+ it("renders no trigger on a column with no filter at all", () => {
+ render(
);
+ expect(screen.queryByLabelText("Filter by Name")).not.toBeInTheDocument();
+ });
+
+ it("applies onFilter locally once the selection is confirmed", async () => {
+ render(
+
record.type === value }),
+ ]}
+ dataSource={typed}
+ />,
+ );
+ expect(screen.getByText("Row 2")).toBeInTheDocument();
+ await openFilter();
+ await userEvent.click(panel().getByText("LOG"));
+ await userEvent.click(panel().getByRole("button", { name: "OK" }));
+ expect(screen.getByText("Row 1")).toBeInTheDocument();
+ expect(screen.queryByText("Row 2")).not.toBeInTheDocument();
+ });
+
+ /*
+ * antd holds the draft until OK: ticking a box must not filter the table
+ * underneath the open panel.
+ */
+ it("leaves the rows alone until OK is pressed", async () => {
+ render(
+ record.type === value }),
+ ]}
+ dataSource={typed}
+ />,
+ );
+ await openFilter();
+ await userEvent.click(panel().getByText("LOG"));
+ expect(screen.getByText("Row 2")).toBeInTheDocument();
+ });
+
+ it("puts every row back when the filter is reset", async () => {
+ render(
+ record.type === value }),
+ ]}
+ dataSource={typed}
+ />,
+ );
+ await openFilter();
+ await userEvent.click(panel().getByText("LOG"));
+ await userEvent.click(panel().getByRole("button", { name: "OK" }));
+ expect(screen.queryByText("Row 2")).not.toBeInTheDocument();
+ await openFilter();
+ await userEvent.click(panel().getByRole("button", { name: "Reset" }));
+ expect(screen.getByText("Row 2")).toBeInTheDocument();
+ });
+
+ /*
+ * `filters` with no `onFilter` is antd's server-side filter — the Execution
+ * Logs status filter. Applying it locally would hide rows the server was
+ * about to replace.
+ */
+ it("does not filter locally when the column has no onFilter", async () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ await openFilter();
+ await userEvent.click(panel().getByText("LOG"));
+ await userEvent.click(panel().getByRole("button", { name: "OK" }));
+ expect(screen.getByText("Row 2")).toBeInTheDocument();
+ expect(onChange).toHaveBeenCalledWith(
+ expect.anything(),
+ { type: ["LOG"] },
+ {},
+ );
+ });
+
+ it("sends the reader back to the first page when a filter changes", async () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ await openFilter();
+ await userEvent.click(panel().getByText("LOG"));
+ await userEvent.click(panel().getByRole("button", { name: "OK" }));
+ expect(onChange.mock.calls[0][0]).toMatchObject({ current: 1 });
+ });
+
+ it("reports null rather than an empty list for an untouched filters column", async () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ await openFilter();
+ await userEvent.click(panel().getByText("LOG"));
+ await userEvent.click(panel().getByRole("button", { name: "OK" }));
+ // Every filterable column gets an entry, active or not: LogModal indexes
+ // straight into `filters.level[0]` and a missing key is a TypeError.
+ expect(onChange.mock.calls[0][1]).toEqual({ type: ["LOG"], level: null });
+ });
+
+ it("selects only one option at a time when filterMultiple is false", async () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ await openFilter();
+ await userEvent.click(panel().getByText("LOG"));
+ await userEvent.click(panel().getByText("NOTIFICATION"));
+ await userEvent.click(panel().getByRole("button", { name: "OK" }));
+ expect(onChange.mock.calls[0][1]).toEqual({ type: ["NOTIFICATION"] });
+ });
+
+ it("narrows the option list when filterSearch is on", async () => {
+ render(
+ ,
+ );
+ await openFilter();
+ await userEvent.type(
+ panel().getByPlaceholderText("Search in filters"),
+ "NOTIF",
+ );
+ expect(panel().queryByText("LOG")).not.toBeInTheDocument();
+ expect(panel().getByText("NOTIFICATION")).toBeInTheDocument();
+ });
+
+ it("seeds the selection from defaultFilteredValue", async () => {
+ render(
+ record.type === value,
+ }),
+ ]}
+ dataSource={typed}
+ />,
+ );
+ expect(screen.queryByText("Row 2")).not.toBeInTheDocument();
+ });
+
+ it("marks the trigger active while a filter is applied", async () => {
+ render(
+ ,
+ );
+ expect(screen.getByLabelText("Filter by Type")).toHaveAttribute(
+ "data-filtered",
+ "true",
+ );
+ });
+});
+
+describe("DataTable custom filterDropdown", () => {
+ const rows = [{ id: 1, name: "Row 1" }];
+
+ /*
+ * LogsTable passes the execution-ID search box as a NODE, not a function: it
+ * owns its own state and never calls back through the table at all.
+ */
+ it("renders a filterDropdown passed as a node", async () => {
+ render(
+ ,
+ },
+ ]}
+ dataSource={rows}
+ />,
+ );
+ await userEvent.click(screen.getByLabelText("Filter by Execution ID"));
+ expect(
+ screen.getByPlaceholderText("Search execution ID"),
+ ).toBeInTheDocument();
+ });
+
+ /*
+ * LogModal's level filter calls `setSelectedKeys([...])` and `confirm()` back
+ * to back in one handler, so `confirm` has to publish the keys just set
+ * rather than the ones React has yet to re-render with.
+ */
+ it("publishes keys set immediately before confirm in the same handler", async () => {
+ const onChange = vi.fn();
+ render(
+ (
+ {
+ setSelectedKeys(["ERROR"]);
+ confirm();
+ }}
+ >
+ Pick ERROR
+
+ ),
+ },
+ ]}
+ dataSource={rows}
+ onChange={onChange}
+ />,
+ );
+ await userEvent.click(screen.getByLabelText("Filter by Level"));
+ await userEvent.click(screen.getByRole("button", { name: "Pick ERROR" }));
+ expect(onChange.mock.calls[0][1]).toEqual({ level: ["ERROR"] });
+ });
+
+ /*
+ * antd reports a filterDropdown column's raw keys, so a cleared one is `[]`
+ * and not `null` — LogModal reads `filters.level[0]`, which throws on null.
+ */
+ it("reports an empty list, not null, for a cleared filterDropdown column", async () => {
+ const onChange = vi.fn();
+ render(
+ (
+
+ Clear
+
+ ),
+ },
+ ]}
+ dataSource={rows}
+ onChange={onChange}
+ />,
+ );
+ await userEvent.click(screen.getByLabelText("Filter by Level"));
+ await userEvent.click(screen.getByRole("button", { name: "Clear" }));
+ expect(onChange.mock.calls[0][1]).toEqual({ level: [] });
+ expect(onChange.mock.calls[0][1].level[0]).toBeUndefined();
+ });
+
+ it("hands filterIcon the filtered flag as antd does", () => {
+ render(
+ ,
+ filterIcon: (filtered) => {filtered ? "on" : "off"} ,
+ },
+ ]}
+ dataSource={rows}
+ />,
+ );
+ expect(screen.getByText("on")).toBeInTheDocument();
+ });
+
+ /*
+ * The sort handler sits on the , so a click on the icon nested inside it
+ * would otherwise re-sort the column under the panel that just opened.
+ */
+ it("does not sort the column when the filter icon is clicked", async () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ },
+ ]}
+ dataSource={rows}
+ onChange={onChange}
+ />,
+ );
+ await userEvent.click(screen.getByLabelText("Filter by Level"));
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
+
+/**
+ * antd reads the sorter's shape: a function sorts locally with that
+ * comparator, `sorter: true` means the SERVER sorts and the table should only
+ * report the click. Both used to sort locally with a guessed comparator.
+ */
+describe("DataTable sorting", () => {
+ const rows = [
+ { id: 1, name: "beta", size: 2 },
+ { id: 2, name: "alpha", size: 10 },
+ ];
+
+ const bodyText = () =>
+ Array.from(document.querySelectorAll("tbody tr")).map(
+ (tr) => tr.querySelector("td").textContent,
+ );
+
+ it("sorts with the comparator the column supplied", async () => {
+ render(
+ a.name.localeCompare(b.name),
+ },
+ ]}
+ dataSource={rows}
+ />,
+ );
+ expect(bodyText()).toEqual(["beta", "alpha"]);
+ await userEvent.click(screen.getByText("Name"));
+ expect(bodyText()).toEqual(["alpha", "beta"]);
+ });
+
+ it("sorts ascending on the first click, as antd does", async () => {
+ render(
+ a.size - b.size,
+ },
+ ]}
+ dataSource={rows}
+ />,
+ );
+ await userEvent.click(screen.getByText("Size"));
+ expect(bodyText()).toEqual(["2", "10"]);
+ });
+
+ /*
+ * The rows on screen are one server page. Reordering them locally made the
+ * Execution Logs list look sorted while the rows that belonged at the top
+ * stayed on page two.
+ */
+ it("leaves a server-sorted column's rows in the order they arrived", async () => {
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByText("Name"));
+ expect(bodyText()).toEqual(["beta", "alpha"]);
+ });
+
+ it("reports the sorted column through antd's onChange", async () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByText("Executed At"));
+ expect(onChange.mock.calls[0][2]).toMatchObject({
+ field: "executedAt",
+ columnKey: "executedAt",
+ order: "ascend",
+ });
+ await userEvent.click(screen.getByText("Executed At"));
+ expect(onChange.mock.calls[1][2]).toMatchObject({ order: "descend" });
+ });
+
+ it("reports an empty sorter once sorting is cleared", async () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ const header = screen.getByText("Name");
+ await userEvent.click(header);
+ await userEvent.click(header);
+ await userEvent.click(header);
+ expect(onChange.mock.calls[2][2]).toEqual({});
+ });
+});
+
+/**
+ * The header's affordances: what a column ADVERTISES before it is touched, and
+ * where the advertisement sits.
+ *
+ * Both were wrong on Execution Logs. A sortable column drew nothing at all
+ * until it was sorted, so "Executed At" and "Execution Time" looked inert; and
+ * the sorter and filter icons trailed the title inline, landing wherever each
+ * title happened to end rather than on the right-hand rule antd puts them on.
+ */
+describe("DataTable header affordances", () => {
+ const rows = [{ id: 1, name: "a" }];
+
+ const sorterIn = (title) =>
+ screen
+ .getByText(title)
+ .closest("th")
+ .querySelector(".ant-table-column-sorter");
+
+ it("shows a sortable column's sorter before it is sorted", () => {
+ render(
+ ,
+ );
+ // Two carets, so the column reads as "sorts, currently unsorted" rather
+ // than as an ordinary column.
+ expect(sorterIn("Sortable").querySelectorAll("svg")).toHaveLength(2);
+ // An unsortable column must not grow one.
+ expect(sorterIn("Plain")).toBeNull();
+ });
+
+ it("marks the applied direction and only that one", async () => {
+ render(
+ ,
+ );
+ const highlighted = () =>
+ Array.from(sorterIn("Sortable").querySelectorAll("svg")).map((svg) =>
+ svg.classList.contains("text-primary"),
+ );
+
+ expect(highlighted()).toEqual([false, false]);
+ await userEvent.click(screen.getByText("Sortable"));
+ expect(highlighted()).toEqual([true, false]);
+ await userEvent.click(screen.getByText("Sortable"));
+ expect(highlighted()).toEqual([false, true]);
+ });
+
+ it("puts the sorter and the filter after the title, not around it", () => {
+ render(
+ true,
+ },
+ ]}
+ dataSource={rows}
+ />,
+ );
+ /*
+ * The order in the DOM is what the right-hand alignment rests on: title
+ * first in a flex row that grows, then the icon cluster pushed to the
+ * cell's trailing edge.
+ */
+ const th = screen.getByText("Both").closest("th");
+ const cluster = th.querySelector(".ant-table-column-sorter").parentElement;
+ expect(cluster.querySelector(".ant-table-filter-trigger")).not.toBeNull();
+ expect(
+ screen.getByText("Both").compareDocumentPosition(cluster) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
+ it("keeps a centred sortable column's title centred", () => {
+ // Three columns in the app are both aligned and sortable; antd centres the
+ // title in the space the sorter leaves rather than flushing it left.
+ render(
+ ,
+ );
+ const th = screen.getByText("Errors").closest("th");
+ expect(th.className).toContain("text-center");
+ // The title box grows, so the inherited text-align has room to act on.
+ expect(screen.getByText("Errors").className).toContain("flex-1");
+ });
+
+ it("sizes a call-site's own filter icon rather than trusting it to", () => {
+ // Every filterIcon in the app is a bare lucide icon, which defaults to
+ // 24px and dwarfed both the title and the carets beside it.
+ render(
+ ,
+ onFilter: () => true,
+ },
+ ]}
+ dataSource={rows}
+ />,
+ );
+ expect(
+ screen.getByTestId("custom-icon").closest(".ant-table-filter-trigger")
+ .className,
+ ).toContain("[&_svg]:size-3.5");
+ });
+});
+
+/**
+ * antd's `sortDirections`. All four Execution Logs tables pass
+ * `["ascend", "descend", "ascend"]` — the idiom for "never cycle back to
+ * unsorted" — and the prop was landing on the wrapper instead, where
+ * React warned about an unrecognised DOM attribute on every render.
+ */
+describe("DataTable sortDirections", () => {
+ const rows = [{ id: 1, name: "a" }];
+ const sortable = [
+ { title: "Name", dataIndex: "name", key: "name", sorter: true },
+ ];
+
+ it("keeps cycling between ascend and descend when a direction repeats", async () => {
+ const onChange = vi.fn();
+ render(
+
,
+ );
+ const header = screen.getByText("Name");
+ await userEvent.click(header);
+ await userEvent.click(header);
+ await userEvent.click(header);
+ // A third click returns to ascend rather than clearing the sort.
+ expect(onChange.mock.calls[2][2]).toMatchObject({ order: "ascend" });
+ });
+
+ it("leaves the prop off the DOM", () => {
+ const { container } = render(
+
,
+ );
+ expect(
+ container.querySelector("[sortdirections], [sortDirections]"),
+ ).toBeNull();
+ });
+});
+
+/**
+ * A CONTROLLED filter column — one passing `filteredValue` — still has to
+ * report the keys the user just picked, not the value its parent is currently
+ * holding. Echoing `filteredValue` back is how the parent learns nothing
+ * changed, which left LogModal's level filter permanently stuck.
+ */
+describe("DataTable controlled filters", () => {
+ const rows = [{ id: 1, name: "Row 1" }];
+
+ it("reports the newly picked keys, not the parent's stale filteredValue", async () => {
+ const onChange = vi.fn();
+ render(
+
(
+ {
+ setSelectedKeys(["ERROR"]);
+ confirm();
+ }}
+ >
+ Pick ERROR
+
+ ),
+ },
+ ]}
+ dataSource={rows}
+ onChange={onChange}
+ />,
+ );
+ await userEvent.click(screen.getByLabelText("Filter by Level"));
+ await userEvent.click(screen.getByRole("button", { name: "Pick ERROR" }));
+ expect(onChange.mock.calls[0][1]).toEqual({ level: ["ERROR"] });
+ });
+
+ it("drives the parent's state through a full controlled round trip", async () => {
+ function Harness() {
+ const [level, setLevel] = useState(null);
+ return (
+ <>
+ {level ?? "none"}
+ (
+ {
+ setSelectedKeys(["ERROR"]);
+ confirm();
+ }}
+ >
+ Pick ERROR
+
+ ),
+ },
+ ]}
+ dataSource={rows}
+ onChange={(_p, filters) => setLevel(filters.level[0] ?? null)}
+ />
+ >
+ );
+ }
+ render( );
+ await userEvent.click(screen.getByLabelText("Filter by Level"));
+ await userEvent.click(screen.getByRole("button", { name: "Pick ERROR" }));
+ expect(screen.getByTestId("level")).toHaveTextContent("ERROR");
+ });
+
+ it("leaves the other columns' reported values alone", async () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ },
+ {
+ title: "Stage",
+ dataIndex: "name",
+ key: "stage",
+ filters: [{ text: "RUN", value: "RUN" }],
+ filterDropdown: ({ setSelectedKeys, confirm }) => (
+ {
+ setSelectedKeys(["RUN"]);
+ confirm();
+ }}
+ >
+ Pick RUN
+
+ ),
+ },
+ ]}
+ dataSource={rows}
+ onChange={onChange}
+ />,
+ );
+ await userEvent.click(screen.getByLabelText("Filter by Stage"));
+ await userEvent.click(screen.getByRole("button", { name: "Pick RUN" }));
+ expect(onChange.mock.calls[0][1]).toEqual({
+ level: ["INFO"],
+ stage: ["RUN"],
+ });
+ });
+});
diff --git a/frontend/src/components/deployments/api-deployment/ApiDeploymentCardConfig.jsx b/frontend/src/components/deployments/api-deployment/ApiDeploymentCardConfig.jsx
index e08b86da75..fde548844c 100644
--- a/frontend/src/components/deployments/api-deployment/ApiDeploymentCardConfig.jsx
+++ b/frontend/src/components/deployments/api-deployment/ApiDeploymentCardConfig.jsx
@@ -1,13 +1,16 @@
import {
- CloudDownloadOutlined,
- CodeOutlined,
- FileSearchOutlined,
- KeyOutlined,
- NotificationOutlined,
- SyncOutlined,
-} from "@ant-design/icons";
-import { Flex, Space, Switch, Tooltip, Typography } from "antd";
+ Bell,
+ CloudDownload,
+ Code,
+ FileSearch,
+ Key,
+ RefreshCw,
+} from "lucide-react";
import PropTypes from "prop-types";
+import { Switch } from "@/components/ui/shims/antd-inputs";
+import { Flex, Space } from "@/components/ui/shims/antd-layout";
+import { Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Typography } from "@/components/ui/shims/antd-typography";
import { StatusPills } from "../../pipelines-or-deployments/pipelines/PipelineCardConfig";
import {
@@ -52,33 +55,33 @@ function createApiDeploymentCardConfig({
items: [
{
key: "view-logs",
- icon: ,
+ icon: ,
label: "View Logs",
onClick: () => onViewLogs?.(deployment),
},
{ type: "divider" },
{
key: "manage-keys",
- icon: ,
+ icon: ,
label: "Manage Keys",
onClick: () => onManageKeys?.(deployment),
},
{
key: "notifications",
- icon: ,
+ icon: ,
label: "Notifications",
onClick: () => onSetupNotifications?.(deployment),
},
{ type: "divider" },
{
key: "code-snippets",
- icon: ,
+ icon: ,
label: "Code Snippets",
onClick: () => onCodeSnippets?.(deployment),
},
{
key: "download-postman",
- icon: ,
+ icon: ,
label: "Download Postman Collection",
onClick: () => onDownloadPostman?.(deployment),
},
@@ -102,6 +105,7 @@ function createApiDeploymentCardConfig({
{
e.stopPropagation();
updateStatus(deployment);
@@ -110,6 +114,7 @@ function createApiDeploymentCardConfig({
-
+
0) {
- const errorDetails = errorDetails
- .map((e) => `${e.attr}: ${e.detail}`)
- .join(", ");
- errorMessage = `API deployment creation failed: ${errorDetails}`;
- }
- }
-
- // Always show an alert for API deployment failures
- setAlertDetails({
- type: "error",
- content: errorMessage,
- });
+ setAlertDetails(
+ handleException(
+ err,
+ "Failed to create API deployment",
+ setBackendErrors,
+ ),
+ );
// If we're on step 2 and have backend errors for deployment fields,
// go back to step 1 to show the errors
- if (errorDetails && currentStep === 1) {
+ const errorDetails = err?.response?.data?.errors;
+ if (Array.isArray(errorDetails) && currentStep === 1) {
const hasDeploymentFieldErrors = errorDetails.some((error) =>
["api_name", "display_name", "description"].includes(error?.attr),
);
diff --git a/frontend/src/components/deployments/create-api-deployment-modal/CreateApiDeploymentModal.jsx b/frontend/src/components/deployments/create-api-deployment-modal/CreateApiDeploymentModal.jsx
index 8562b32640..6669c73396 100644
--- a/frontend/src/components/deployments/create-api-deployment-modal/CreateApiDeploymentModal.jsx
+++ b/frontend/src/components/deployments/create-api-deployment-modal/CreateApiDeploymentModal.jsx
@@ -1,6 +1,8 @@
-import { Form, Input, Modal, Select } from "antd";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { Form } from "@/components/ui/shims/antd-form";
+import { Input, Select } from "@/components/ui/shims/antd-inputs";
+import { Modal } from "@/components/ui/shims/antd-overlays";
import { getBackendErrorDetail } from "../../../helpers/GetStaticData.js";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx";
@@ -261,7 +263,7 @@ const CreateApiDeploymentModal = ({
}
help={getBackendErrorDetail("workflow", backendErrors)}
>
-
+
{workflowEndpointList?.map((endpoint) => {
return (
{
return (
diff --git a/frontend/src/components/deployments/display-code/DisplayCode.jsx b/frontend/src/components/deployments/display-code/DisplayCode.jsx
index a86852b3a4..a399fdb219 100644
--- a/frontend/src/components/deployments/display-code/DisplayCode.jsx
+++ b/frontend/src/components/deployments/display-code/DisplayCode.jsx
@@ -1,8 +1,10 @@
-import { CheckCircleOutlined, CopyOutlined } from "@ant-design/icons";
-import { Modal, Select, Tabs, Tooltip } from "antd";
import Handlebars from "handlebars";
+import { CircleCheck, Copy } from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { Select } from "@/components/ui/shims/antd-inputs";
+import { Modal, Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Tabs } from "@/components/ui/shims/antd-structure";
import CodeSnippet from "./CodeSnippet.jsx";
import "./DisplayCode.css";
@@ -188,11 +190,11 @@ const DisplayCode = ({ isDialogOpen, setDialogOpen, url }) => {
{copied ? (
-
+
) : (
-
+
)}
diff --git a/frontend/src/components/deployments/header/Header.jsx b/frontend/src/components/deployments/header/Header.jsx
index a2fcbaf62f..feef449ea9 100644
--- a/frontend/src/components/deployments/header/Header.jsx
+++ b/frontend/src/components/deployments/header/Header.jsx
@@ -1,4 +1,4 @@
-import { PlusOutlined } from "@ant-design/icons";
+import { Plus } from "lucide-react";
import PropTypes from "prop-types";
import { deploymentsStaticContent } from "../../../helpers/GetStaticData";
import usePostHogEvents from "../../../hooks/usePostHogEvents";
@@ -24,7 +24,8 @@ function Header({ type, openAddModal, enableSearch, onSearch, setSearchList }) {
const addButton = (
}
+ icon={ }
+ data-testid={`${type}-deployment-add-btn`}
onClick={handleOnClick}
>
{deploymentsStaticContent[type].addBtn}
diff --git a/frontend/src/components/deployments/layout/Layout.css b/frontend/src/components/deployments/layout/Layout.css
index 0da02838f6..e08dde8219 100644
--- a/frontend/src/components/deployments/layout/Layout.css
+++ b/frontend/src/components/deployments/layout/Layout.css
@@ -4,7 +4,7 @@
height: 100%;
display: flex;
flex-direction: column;
- background-color: var(--page-bg-2);
+ background-color: var(--background);
}
.layout-header {
@@ -51,13 +51,13 @@
}
.layout-body .table .workflowName {
- color: #1890ff;
+ color: var(--primary);
cursor: pointer;
}
.layout-body .gap {
padding-bottom: 3px;
- background-color: var(--page-bg-2);
+ background-color: var(--background);
}
.layout-body .empty {
diff --git a/frontend/src/components/deployments/manage-keys/ManageKeys.jsx b/frontend/src/components/deployments/manage-keys/ManageKeys.jsx
index 5c3613e8d3..e13621b0f7 100644
--- a/frontend/src/components/deployments/manage-keys/ManageKeys.jsx
+++ b/frontend/src/components/deployments/manage-keys/ManageKeys.jsx
@@ -1,12 +1,11 @@
-import {
- CopyOutlined,
- DeleteOutlined,
- EditOutlined,
- PlusOutlined,
-} from "@ant-design/icons";
-import { Input, Modal, Space, Switch, Table, Tooltip, Typography } from "antd";
+import { Copy, Pencil, Plus, Trash2 } from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { Input, Switch } from "@/components/ui/shims/antd-inputs";
+import { Space } from "@/components/ui/shims/antd-layout";
+import { Modal, Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Table } from "@/components/ui/shims/antd-structure";
+import { Typography } from "@/components/ui/shims/antd-typography";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useAlertStore } from "../../../store/alert-store";
@@ -242,7 +241,7 @@ const ManageKeys = ({
className="cursorPointer"
onClick={() => copyText(record?.api_key)}
>
-
+
@@ -274,12 +273,12 @@ const ManageKeys = ({
<>
openEditModal(record)}>
-
+
showDeleteModal(record)}>
-
+
>
@@ -302,7 +301,7 @@ const ManageKeys = ({
}
+ icon={
}
onClick={openAddModal}
>
New Key
diff --git a/frontend/src/components/error/GenericError/GenericError.jsx b/frontend/src/components/error/GenericError/GenericError.jsx
index d1050dda79..04267a0fbf 100644
--- a/frontend/src/components/error/GenericError/GenericError.jsx
+++ b/frontend/src/components/error/GenericError/GenericError.jsx
@@ -1,6 +1,6 @@
-import { Result } from "antd";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
+import { Result } from "@/components/ui/shims/antd-structure";
function GenericError() {
const [searchParams] = useSearchParams();
diff --git a/frontend/src/components/error/LazyOutlet/LazyOutlet.jsx b/frontend/src/components/error/LazyOutlet/LazyOutlet.jsx
index fb43d5a0eb..6bd0609ac0 100644
--- a/frontend/src/components/error/LazyOutlet/LazyOutlet.jsx
+++ b/frontend/src/components/error/LazyOutlet/LazyOutlet.jsx
@@ -1,6 +1,7 @@
-import { Button, Result } from "antd";
import { Suspense } from "react";
import { Outlet, useLocation } from "react-router-dom";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Result } from "@/components/ui/shims/antd-structure";
import { GenericLoader } from "../../generic-loader/GenericLoader.jsx";
import { ErrorBoundary } from "../../widgets/error-boundary/ErrorBoundary.jsx";
diff --git a/frontend/src/components/error/NotFound/NotFound.jsx b/frontend/src/components/error/NotFound/NotFound.jsx
index f70e1904bd..1de9daf256 100644
--- a/frontend/src/components/error/NotFound/NotFound.jsx
+++ b/frontend/src/components/error/NotFound/NotFound.jsx
@@ -1,5 +1,6 @@
-import { Button, Result } from "antd";
import { useNavigate } from "react-router-dom";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Result } from "@/components/ui/shims/antd-structure";
function NotFound() {
const navigate = useNavigate();
diff --git a/frontend/src/components/error/UnAuthorized/Unauthorized.jsx b/frontend/src/components/error/UnAuthorized/Unauthorized.jsx
index 1eb60d326d..a6a91defea 100644
--- a/frontend/src/components/error/UnAuthorized/Unauthorized.jsx
+++ b/frontend/src/components/error/UnAuthorized/Unauthorized.jsx
@@ -1,5 +1,4 @@
-import { Typography } from "antd";
-
+import { Typography } from "@/components/ui/shims/antd-typography";
import { IslandLayout } from "../../../layouts/island-layout/IslandLayout.jsx";
import "./Unauthorized.css";
diff --git a/frontend/src/components/generic-loader/GenericLoader.css b/frontend/src/components/generic-loader/GenericLoader.css
index d34bd55e86..b4ce6ebb5e 100644
--- a/frontend/src/components/generic-loader/GenericLoader.css
+++ b/frontend/src/components/generic-loader/GenericLoader.css
@@ -9,9 +9,32 @@
}
}
+/*
+ * The full-page loader must fill the viewport to centre in it.
+ *
+ * `.center` (index.css) sizes itself with `width/height: inherit`, which does
+ * not give it the viewport: it collapsed to the height of its own content
+ * (24px), so the logo and "Please wait…" text sat wherever that box landed
+ * rather than in the middle of the screen. LazyOutlet.css already patches
+ * `.center` for its own fallback, for the same reason.
+ *
+ * Scoped to the loader so the other `.center` consumer (FileSystem's inline
+ * empty state) keeps sizing to its container.
+ */
+.generic-loader.center {
+ width: 100%;
+ min-height: 100dvh;
+ place-content: center;
+ place-items: center;
+}
+
/* PULSE BUBBLES */
.spinner-box {
text-align: center;
+ /* The logo, pulse dots and text share one centred column. */
+ display: flex;
+ flex-direction: column;
+ align-items: center;
}
.pulse-container {
width: 60px;
diff --git a/frontend/src/components/generic-loader/GenericLoader.jsx b/frontend/src/components/generic-loader/GenericLoader.jsx
index 5ebc758129..72110f982e 100644
--- a/frontend/src/components/generic-loader/GenericLoader.jsx
+++ b/frontend/src/components/generic-loader/GenericLoader.jsx
@@ -1,11 +1,10 @@
-import { Typography } from "antd";
-
+import { Typography } from "@/components/ui/shims/antd-typography";
import { Logo64 } from "../../assets";
import "./GenericLoader.css";
function GenericLoader() {
return (
-
+
diff --git a/frontend/src/components/groups/GroupCreateEditModal.jsx b/frontend/src/components/groups/GroupCreateEditModal.jsx
index bcbe769626..41f0d7410b 100644
--- a/frontend/src/components/groups/GroupCreateEditModal.jsx
+++ b/frontend/src/components/groups/GroupCreateEditModal.jsx
@@ -1,6 +1,8 @@
-import { Form, Input, Modal } from "antd";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { Form } from "@/components/ui/shims/antd-form";
+import { Input } from "@/components/ui/shims/antd-inputs";
+import { Modal } from "@/components/ui/shims/antd-overlays";
import { useExceptionHandler } from "../../hooks/useExceptionHandler.jsx";
import { useAlertStore } from "../../store/alert-store";
diff --git a/frontend/src/components/groups/GroupMemberManager.jsx b/frontend/src/components/groups/GroupMemberManager.jsx
index 1c1a1eb122..dd01c2d134 100644
--- a/frontend/src/components/groups/GroupMemberManager.jsx
+++ b/frontend/src/components/groups/GroupMemberManager.jsx
@@ -1,7 +1,11 @@
-import { DeleteOutlined, QuestionCircleOutlined } from "@ant-design/icons";
-import { Avatar, List, Modal, Popconfirm, Select } from "antd";
+import { CircleHelp, Trash2 } from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Select } from "@/components/ui/shims/antd-inputs";
+import { Avatar } from "@/components/ui/shims/antd-leaves";
+import { Modal, Popconfirm } from "@/components/ui/shims/antd-overlays";
+import { List } from "@/components/ui/shims/antd-structure";
import { useExceptionHandler } from "../../hooks/useExceptionHandler.jsx";
import { useAlertStore } from "../../store/alert-store";
@@ -138,10 +142,20 @@ function GroupMemberManager({ open, group, onClose }) {
description={`Remove ${item.email} from this group?`}
okText="Remove"
cancelText="Cancel"
- icon={
}
+ icon={
}
onConfirm={() => handleRemove(item.user_id)}
>
-
+ {/*
+ * A named button, not a bare icon: the icon alone is
+ * absent from the accessibility tree, leaving no way to
+ * remove a member except with a mouse.
+ */}
+
}
+ aria-label={`Remove ${item.email} from this group`}
+ />
}
>
diff --git a/frontend/src/components/groups/Groups.css b/frontend/src/components/groups/Groups.css
index 8016e49ffd..76f971f4d1 100644
--- a/frontend/src/components/groups/Groups.css
+++ b/frontend/src/components/groups/Groups.css
@@ -1,5 +1,5 @@
.groups-bg-col {
- background-color: var(--page-bg-2);
+ background-color: var(--background);
height: 100%;
}
diff --git a/frontend/src/components/groups/Groups.jsx b/frontend/src/components/groups/Groups.jsx
index 1527ec89e0..849fba77f9 100644
--- a/frontend/src/components/groups/Groups.jsx
+++ b/frontend/src/components/groups/Groups.jsx
@@ -1,13 +1,17 @@
import {
- DeleteOutlined,
- EditOutlined,
- EllipsisOutlined,
- PlusOutlined,
- ReloadOutlined,
- TeamOutlined,
-} from "@ant-design/icons";
-import { Button, Dropdown, Modal, Space, Table, Typography } from "antd";
+ EllipsisVertical,
+ Pencil,
+ Plus,
+ RotateCw,
+ Trash2,
+ Users,
+} from "lucide-react";
import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Space } from "@/components/ui/shims/antd-layout";
+import { Dropdown, Modal } from "@/components/ui/shims/antd-overlays";
+import { Table } from "@/components/ui/shims/antd-structure";
+import { Typography } from "@/components/ui/shims/antd-typography";
import { useExceptionHandler } from "../../hooks/useExceptionHandler.jsx";
import { IslandLayout } from "../../layouts/island-layout/IslandLayout.jsx";
@@ -142,7 +146,7 @@ function Groups() {
key: "members",
label: (
handleManageMembers(record)}>
-
+
Manage members
),
@@ -151,7 +155,7 @@ function Groups() {
key: "edit",
label: (
handleEdit(record)}>
-
+
Edit
),
@@ -160,7 +164,7 @@ function Groups() {
key: "delete",
label: (
handleDeleteClick(record)}>
-
+
Delete
),
@@ -184,7 +188,19 @@ function Groups() {
trigger={["click"]}
placement="bottomLeft"
>
-
+ {/*
+ * The trigger has to be a real, named button. A bare icon merged the
+ * Dropdown's trigger props onto the
, which puts no node in the
+ * accessibility tree: the only way to reach Manage members / Edit /
+ * Delete was a mouse. `rotate` came across from antd's icon font and
+ * does nothing on a lucide SVG — EllipsisVertical is the glyph it
+ * was asking for, and the same one the card kebab menus use.
+ */}
+ }
+ aria-label={`Actions for ${record?.name}`}
+ />
),
},
@@ -200,16 +216,12 @@ function Groups() {
searchKey="name"
searchPlaceholder="Search Groups"
>
- }
- onClick={handleCreate}
- >
+ } onClick={handleCreate}>
New Group
}
+ icon={ }
onClick={refresh}
className="groups-reload-button"
/>
diff --git a/frontend/src/components/helpers/auth/PersistentLogin.js b/frontend/src/components/helpers/auth/PersistentLogin.jsx
similarity index 100%
rename from frontend/src/components/helpers/auth/PersistentLogin.js
rename to frontend/src/components/helpers/auth/PersistentLogin.jsx
diff --git a/frontend/src/components/helpers/auth/RequireAdmin.js b/frontend/src/components/helpers/auth/RequireAdmin.jsx
similarity index 100%
rename from frontend/src/components/helpers/auth/RequireAdmin.js
rename to frontend/src/components/helpers/auth/RequireAdmin.jsx
diff --git a/frontend/src/components/helpers/auth/RequireAuth.js b/frontend/src/components/helpers/auth/RequireAuth.jsx
similarity index 100%
rename from frontend/src/components/helpers/auth/RequireAuth.js
rename to frontend/src/components/helpers/auth/RequireAuth.jsx
diff --git a/frontend/src/components/helpers/auth/RequireGuest.js b/frontend/src/components/helpers/auth/RequireGuest.jsx
similarity index 100%
rename from frontend/src/components/helpers/auth/RequireGuest.js
rename to frontend/src/components/helpers/auth/RequireGuest.jsx
diff --git a/frontend/src/components/helpers/custom-markdown/CustomMarkdown.jsx b/frontend/src/components/helpers/custom-markdown/CustomMarkdown.jsx
index 177299a0f8..b9a639adce 100644
--- a/frontend/src/components/helpers/custom-markdown/CustomMarkdown.jsx
+++ b/frontend/src/components/helpers/custom-markdown/CustomMarkdown.jsx
@@ -1,13 +1,11 @@
-import { Typography } from "antd";
import PropTypes from "prop-types";
import { useMemo } from "react";
import { Link as RouterLink } from "react-router-dom";
+import { Link, Paragraph, Text } from "@/components/ui/shims/antd-typography";
import { isSafeExternalUrl } from "../../../helpers/urlSafety";
import { useSessionStore } from "../../../store/session-store";
-const { Text, Link, Paragraph } = Typography;
-
const CustomMarkdown = ({
text = "",
renderNewLines = true,
diff --git a/frontend/src/components/helpers/custom-tools/CustomToolsHelper.js b/frontend/src/components/helpers/custom-tools/CustomToolsHelper.jsx
similarity index 100%
rename from frontend/src/components/helpers/custom-tools/CustomToolsHelper.js
rename to frontend/src/components/helpers/custom-tools/CustomToolsHelper.jsx
diff --git a/frontend/src/components/helpers/project/ProjectHelper.js b/frontend/src/components/helpers/project/ProjectHelper.jsx
similarity index 100%
rename from frontend/src/components/helpers/project/ProjectHelper.js
rename to frontend/src/components/helpers/project/ProjectHelper.jsx
diff --git a/frontend/src/components/input-output/add-source-modal/AddSourceModal.jsx b/frontend/src/components/input-output/add-source-modal/AddSourceModal.jsx
index b375e7512d..398026fa9d 100644
--- a/frontend/src/components/input-output/add-source-modal/AddSourceModal.jsx
+++ b/frontend/src/components/input-output/add-source-modal/AddSourceModal.jsx
@@ -1,7 +1,8 @@
-import { ArrowLeftOutlined } from "@ant-design/icons";
-import { Button, Modal } from "antd";
+import { ArrowLeft } from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Modal } from "@/components/ui/shims/antd-overlays";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler";
@@ -16,7 +17,7 @@ function AddSourceModal({
open,
setOpen,
type,
- isConnector,
+ isConnector = false,
connectorMode,
addNewItem,
editItemId,
@@ -166,7 +167,7 @@ function AddSourceModal({
type="text"
shape="circle"
size="small"
- icon={ }
+ icon={ }
onClick={handleBack}
aria-label="Go back to source selection"
/>
@@ -191,6 +192,7 @@ function AddSourceModal({
centered
footer={null}
closable={true}
+ data-testid="add-source-modal"
className="add-source-modal"
>
{selectedSourceId ? (
@@ -231,8 +233,4 @@ AddSourceModal.propTypes = {
setEditItemId: PropTypes.func.isRequired,
};
-AddSourceModal.defaultProps = {
- isConnector: false,
-};
-
export { AddSourceModal };
diff --git a/frontend/src/components/input-output/add-source/AddSource.jsx b/frontend/src/components/input-output/add-source/AddSource.jsx
index e55c9b529b..60b1585f81 100644
--- a/frontend/src/components/input-output/add-source/AddSource.jsx
+++ b/frontend/src/components/input-output/add-source/AddSource.jsx
@@ -1,8 +1,8 @@
import { getDefaultFormState } from "@rjsf/utils";
import validator from "@rjsf/validator-ajv8";
-import { Typography } from "antd";
import PropTypes from "prop-types";
import { useEffect, useMemo, useState } from "react";
+import { Typography } from "@/components/ui/shims/antd-typography";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler";
diff --git a/frontend/src/components/input-output/configure-ds/ConfigureDs.css b/frontend/src/components/input-output/configure-ds/ConfigureDs.css
index cc3e02704a..5d44bc3fef 100644
--- a/frontend/src/components/input-output/configure-ds/ConfigureDs.css
+++ b/frontend/src/components/input-output/configure-ds/ConfigureDs.css
@@ -28,6 +28,11 @@
.config-doc-icon {
font-size: 16px;
- color: #1890ff;
+ /* width/height, not font-size alone: antd shipped an icon FONT (sized by
+ * font-size); lucide ships SVGs, which ignore it and fall back to their
+ * own 24px default. font-size is kept for any text in the same element. */
+ width: 16px;
+ height: 16px;
+ color: var(--primary);
cursor: pointer;
}
diff --git a/frontend/src/components/input-output/configure-ds/ConfigureDs.jsx b/frontend/src/components/input-output/configure-ds/ConfigureDs.jsx
index 636f04e7e9..5bb0d6f632 100644
--- a/frontend/src/components/input-output/configure-ds/ConfigureDs.jsx
+++ b/frontend/src/components/input-output/configure-ds/ConfigureDs.jsx
@@ -1,7 +1,8 @@
-import { InfoCircleOutlined } from "@ant-design/icons";
-import { Col, Popover, Row } from "antd";
+import { Info } from "lucide-react";
import PropTypes from "prop-types";
import { createRef, useEffect, useState } from "react";
+import { Col, Row } from "@/components/ui/shims/antd-layout";
+import { Popover } from "@/components/ui/shims/antd-overlays";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx";
@@ -383,7 +384,7 @@ function ConfigureDs({
trigger="click"
placement="bottomRight"
>
-
+
)}
diff --git a/frontend/src/components/input-output/data-source-card/DataSourceCard.jsx b/frontend/src/components/input-output/data-source-card/DataSourceCard.jsx
index 7452ec694b..3e0a2267e6 100644
--- a/frontend/src/components/input-output/data-source-card/DataSourceCard.jsx
+++ b/frontend/src/components/input-output/data-source-card/DataSourceCard.jsx
@@ -1,5 +1,7 @@
-import { Card, Image, Typography } from "antd";
import PropTypes from "prop-types";
+import { Image } from "@/components/ui/shims/antd-leaves";
+import { Card } from "@/components/ui/shims/antd-structure";
+import { Typography } from "@/components/ui/shims/antd-typography";
import "./DataSourceCard.css";
import usePostHogEvents from "../../../hooks/usePostHogEvents";
@@ -30,6 +32,7 @@ function DataSourceCard({ srcDetails, setSelectedSourceId, type }) {
type="inner"
bordered={true}
className={`ds-card ${srcDetails?.isDisabled ? "disabled" : ""}`}
+ data-testid={`ds-card-${srcDetails?.id}`}
onClick={handleSelectSource}
>
diff --git a/frontend/src/components/input-output/file-system/FileSystem.jsx b/frontend/src/components/input-output/file-system/FileSystem.jsx
index 3380abcde9..ad09a046ce 100644
--- a/frontend/src/components/input-output/file-system/FileSystem.jsx
+++ b/frontend/src/components/input-output/file-system/FileSystem.jsx
@@ -1,7 +1,8 @@
-import { CaretDownOutlined } from "@ant-design/icons";
-import { Tree, Typography } from "antd";
+import { ChevronDown } from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
+import { Tree } from "@/components/ui/shims/antd-structure";
+import { Text } from "@/components/ui/shims/antd-typography";
import { Document, Folder } from "../../../assets";
import { formatBytes } from "../../../helpers/GetStaticData";
@@ -12,8 +13,6 @@ import { SpinnerLoader } from "../../widgets/spinner-loader/SpinnerLoader.jsx";
import "./FileSystem.css";
const { DirectoryTree } = Tree;
-const { Text } = Typography;
-
function FileExplorer({
selectedConnector = "",
data = [],
@@ -145,7 +144,7 @@ function FileExplorer({
rootClassName="explorerTree"
showLine
treeData={tree}
- switcherIcon={
}
+ switcherIcon={
}
expandedKeys={expandedKeys}
selectedKeys={selectedKeys}
autoExpandParent={autoExpandParent}
diff --git a/frontend/src/components/input-output/file-system/FileSystem.test.jsx b/frontend/src/components/input-output/file-system/FileSystem.test.jsx
new file mode 100644
index 0000000000..39b8971715
--- /dev/null
+++ b/frontend/src/components/input-output/file-system/FileSystem.test.jsx
@@ -0,0 +1,128 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * The Configure Connector modal's file browser.
+ *
+ * `Tree.DirectoryTree` was never defined on the Tree shim, so the module-scope
+ * `const { DirectoryTree } = Tree` resolved to undefined and rendering it threw
+ * React #130 — which took down the whole workflow page ("Couldn't load this
+ * page") the moment any FILESYSTEM connector was picked for an ETL pipeline.
+ *
+ * The shim-completeness guard missed it because it only read `
` and
+ * `Foo.bar(` from the source, not the destructured form used here.
+ */
+const getFileList = vi.fn();
+
+vi.mock("../../input-output/input-output/input-service.js", () => ({
+ inputService: () => ({ getFileList }),
+}));
+
+vi.mock("../../../hooks/useExceptionHandler", () => ({
+ useExceptionHandler: () => (err, fallback) => ({ content: fallback }),
+}));
+
+/*
+ * The real icons are `*.svg?react` imports, which only become components once
+ * vite-plugin-svgr runs. That plugin is not in the test pipeline, so unmocked
+ * they resolve to data-URI strings and React renders each as an unknown tag.
+ */
+vi.mock("../../../assets", () => ({
+ Document: () => ,
+ Folder: () => ,
+}));
+
+const { FileExplorer } = await import("./FileSystem.jsx");
+
+const ROOT = [
+ { name: "invoices", type: "directory", modified_at: "2026-08-01 10:00:00" },
+ {
+ name: "readme.txt",
+ type: "file",
+ size: 2048,
+ modified_at: "2026-08-02 11:00:00",
+ },
+];
+
+describe("FileExplorer", () => {
+ beforeEach(() => {
+ getFileList.mockReset();
+ });
+
+ it("renders the connector's files instead of throwing #130", () => {
+ render( );
+ expect(screen.getByText("invoices")).toBeInTheDocument();
+ expect(screen.getByText("readme.txt")).toBeInTheDocument();
+ });
+
+ it("shows the size and modified date columns", () => {
+ render( );
+ expect(screen.getByText("2 KB")).toBeInTheDocument();
+ expect(screen.getByText("2026-08-02")).toBeInTheDocument();
+ });
+
+ it("reports a picked folder as a folder and a picked file as a file", async () => {
+ const onFolderSelect = vi.fn();
+ render(
+ ,
+ );
+
+ await userEvent.click(screen.getByText("invoices"));
+ expect(onFolderSelect).toHaveBeenLastCalledWith("invoices", "folder");
+
+ await userEvent.click(screen.getByText("readme.txt"));
+ expect(onFolderSelect).toHaveBeenLastCalledWith("readme.txt", "file");
+ });
+
+ /*
+ * Directories come back from the connector without their children — the
+ * browser fetches one level at a time. Without loadData wired through, every
+ * folder in the tree is permanently unopenable.
+ */
+ it("fetches a directory's children the first time it is expanded", async () => {
+ getFileList.mockResolvedValue({
+ data: [
+ {
+ name: "invoices/jan.pdf",
+ type: "file",
+ size: 512,
+ modified_at: "2026-08-03 09:00:00",
+ },
+ ],
+ });
+
+ render( );
+ expect(screen.queryByText("jan.pdf")).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole("button", { name: "Expand" }));
+
+ expect(getFileList).toHaveBeenCalledWith("conn-1", "invoices");
+ expect(await screen.findByText("jan.pdf")).toBeInTheDocument();
+ });
+
+ it("surfaces a load error rather than leaving the folder silently empty", async () => {
+ getFileList.mockRejectedValue(new Error("boom"));
+ const setError = vi.fn();
+
+ render(
+ ,
+ );
+
+ await userEvent.click(screen.getByRole("button", { name: "Expand" }));
+
+ await waitFor(() => {
+ expect(setError).toHaveBeenCalledWith(
+ 'Error loading files from "invoices"',
+ );
+ });
+ });
+});
diff --git a/frontend/src/components/input-output/list-of-sources/ListOfSources.css b/frontend/src/components/input-output/list-of-sources/ListOfSources.css
index ecd9bedec1..81f230d7b2 100644
--- a/frontend/src/components/input-output/list-of-sources/ListOfSources.css
+++ b/frontend/src/components/input-output/list-of-sources/ListOfSources.css
@@ -56,6 +56,6 @@
.list-of-srcs .filter-hint {
margin-top: 8px !important;
- font-size: 14px !important;
+ font-size: 13px !important;
color: #bfbfbf !important;
}
diff --git a/frontend/src/components/input-output/list-of-sources/ListOfSources.jsx b/frontend/src/components/input-output/list-of-sources/ListOfSources.jsx
index c4bee8252d..6d1a62a3f2 100644
--- a/frontend/src/components/input-output/list-of-sources/ListOfSources.jsx
+++ b/frontend/src/components/input-output/list-of-sources/ListOfSources.jsx
@@ -1,8 +1,9 @@
-import { SearchOutlined } from "@ant-design/icons";
-import { Input, List, Segmented } from "antd";
import debounce from "lodash/debounce";
+import { Search } from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { Input } from "@/components/ui/shims/antd-inputs";
+import { List, Segmented } from "@/components/ui/shims/antd-structure";
import { DataSourceCard } from "../data-source-card/DataSourceCard";
import "./ListOfSources.css";
@@ -74,7 +75,7 @@ function ListOfSources({
}
+ prefix={ }
onChange={handleInputChange}
/>
diff --git a/frontend/src/components/log-in/Login.css b/frontend/src/components/log-in/Login.css
index 1503e62a55..b04f5ea09e 100644
--- a/frontend/src/components/log-in/Login.css
+++ b/frontend/src/components/log-in/Login.css
@@ -265,7 +265,7 @@
}
.login-trial-info {
- font-size: 14px;
+ font-size: 13px;
font-weight: 400;
line-height: 22px;
text-align: center;
@@ -290,7 +290,7 @@
}
.login-trust-text {
- font-size: 14px;
+ font-size: 13px;
color: #282828;
}
@@ -444,7 +444,7 @@
}
.llm-stat-label {
- font-size: 14px;
+ font-size: 13px;
font-weight: 500;
color: #d1d5dc;
text-align: center;
@@ -622,7 +622,7 @@
}
.unstract-stat-label {
- font-size: 14px;
+ font-size: 13px;
font-weight: 500;
color: #d1d5dc;
text-align: center;
@@ -977,7 +977,7 @@
}
.llm-left-subheading {
- font-size: 14px !important;
+ font-size: 13px !important;
line-height: 22px !important;
color: #282828 !important;
text-align: center;
@@ -1090,7 +1090,7 @@
}
.llm-light-stat-label {
- font-size: 14px;
+ font-size: 13px;
font-weight: 400;
color: #545454;
text-align: center;
@@ -1316,7 +1316,7 @@
}
.unstract-light-stat-label {
- font-size: 14px;
+ font-size: 13px;
font-weight: 400;
color: #545454;
text-align: center;
diff --git a/frontend/src/components/log-in/Login.jsx b/frontend/src/components/log-in/Login.jsx
index 1c3d796f47..54b55bb83d 100644
--- a/frontend/src/components/log-in/Login.jsx
+++ b/frontend/src/components/log-in/Login.jsx
@@ -1,4 +1,5 @@
-import { Button, Col, Row } from "antd";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Col, Row } from "@/components/ui/shims/antd-layout";
import { getBaseUrl } from "../../helpers/GetStaticData";
import "./Login.css";
diff --git a/frontend/src/components/log-in/ProductContentLayout.jsx b/frontend/src/components/log-in/ProductContentLayout.jsx
index cf01802117..761389eef4 100644
--- a/frontend/src/components/log-in/ProductContentLayout.jsx
+++ b/frontend/src/components/log-in/ProductContentLayout.jsx
@@ -1,5 +1,5 @@
-import { Typography } from "antd";
import PropTypes from "prop-types";
+import { Typography } from "@/components/ui/shims/antd-typography";
import loginRightBanner from "../../assets/login-right-panel.svg";
const defaultUnstractContent = {
diff --git a/frontend/src/components/logging/detailed-logs/DetailedLogs.css b/frontend/src/components/logging/detailed-logs/DetailedLogs.css
index 668af20002..b18bd866ae 100644
--- a/frontend/src/components/logging/detailed-logs/DetailedLogs.css
+++ b/frontend/src/components/logging/detailed-logs/DetailedLogs.css
@@ -16,12 +16,21 @@
.detailed-logs-cards {
flex: 0 0 auto;
+ /* Match the 24px gutter of .detailed-logs-header and of the table below, so
+ * the title, the cards and the table all share one left and right edge. */
+ padding: 0 24px 12px;
+}
+
+.detailed-logs-card-group {
+ /* stretch, so a card whose text wraps does not leave the others short */
+ align-items: stretch;
+ gap: 16px;
}
.detailed-logs-table-container {
flex: 1;
min-height: 0;
- padding: 0 12px 8px;
+ padding: 0 24px 8px;
display: flex;
flex-direction: column;
}
@@ -64,23 +73,39 @@
.logging-card-title {
font-weight: 600;
- font-size: 14px;
+ font-size: 13px;
}
.logs-details-card {
width: 250px;
margin: 0;
padding: 0;
- height: 60px;
- margin-right: 20px;
+ /* min-height, not a fixed height: at 60px the card clipped its own content,
+ * so a timestamp that wrapped to a second line spilled below the border. */
+ min-height: 64px;
+}
+
+/* The shim's card body carries Tailwind `p-6 pt-0`, which pinned the content to
+ * the top edge and left 24px of dead space beneath it. Centre it instead. */
+.logs-details-card .ant-card-body {
+ display: flex;
+ align-items: center;
+ height: 100%;
+ padding: 12px 16px;
}
.logging-card-icons {
- margin: 0 12px;
+ margin: 0 12px 0 0;
+ /* width/height, not font-size, and flex-shrink so the icon keeps its box:
+ * see the note on .column-settings-icon below. */
+ width: 20px;
+ height: 20px;
+ flex-shrink: 0;
}
.view-log-button {
- margin-right: 24px;
+ /* the row's own 24px padding now supplies the gutter */
+ margin-right: 0;
}
/* Action column header with settings icon */
@@ -105,11 +130,16 @@
.column-settings-icon {
font-size: 16px;
+ /* width/height, not font-size alone: antd shipped an icon FONT (sized by
+ * font-size); lucide ships SVGs, which ignore it and fall back to their
+ * own 24px default. font-size is kept for any text in the same element. */
+ width: 16px;
+ height: 16px;
color: rgba(0, 0, 0, 0.45);
}
.column-settings-trigger:hover .column-settings-icon {
- color: #1677ff;
+ color: var(--primary);
}
/* Thin scrollbar */
@@ -166,6 +196,6 @@
}
.copy-btn-outlined:hover {
- border-color: #1677ff;
- color: #1677ff;
+ border-color: var(--primary);
+ color: var(--primary);
}
diff --git a/frontend/src/components/logging/detailed-logs/DetailedLogs.jsx b/frontend/src/components/logging/detailed-logs/DetailedLogs.jsx
index 3ff0034238..de6021bcd3 100644
--- a/frontend/src/components/logging/detailed-logs/DetailedLogs.jsx
+++ b/frontend/src/components/logging/detailed-logs/DetailedLogs.jsx
@@ -1,29 +1,24 @@
import {
- CalendarOutlined,
- ClockCircleOutlined,
- CloseCircleFilled,
- CopyOutlined,
- EyeOutlined,
- FileTextOutlined,
- HourglassOutlined,
- InfoCircleFilled,
- MoreOutlined,
- SearchOutlined,
-} from "@ant-design/icons";
-import {
- Button,
- Card,
- Checkbox,
- Dropdown,
- Flex,
- Input,
- Table,
- Tooltip,
- Typography,
-} from "antd";
+ Calendar,
+ CircleX,
+ Clock,
+ Copy,
+ EllipsisVertical,
+ Eye,
+ FileText,
+ Hourglass,
+ Info,
+ Search,
+} from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Checkbox, Input } from "@/components/ui/shims/antd-inputs";
+import { Flex } from "@/components/ui/shims/antd-layout";
+import { Dropdown, Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Card, Table } from "@/components/ui/shims/antd-structure";
+import { Typography } from "@/components/ui/shims/antd-typography";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler";
@@ -76,7 +71,7 @@ const ActionColumnHeader = ({ menu }) => (
Action
-
+
@@ -251,7 +246,7 @@ const DetailedLogs = () => {
sorter: true,
render: (_, record) => (
- {record.executedAt}
+ {record.executedAt}
),
},
@@ -272,7 +267,7 @@ const DetailedLogs = () => {
),
filterIcon: () => (
-
+
),
},
{
@@ -327,7 +322,7 @@ const DetailedLogs = () => {
render: (_, record) => (
}
+ icon={ }
onClick={() => handleLogsModalOpen(record)}
>
@@ -457,7 +452,7 @@ const DetailedLogs = () => {
{type} Execution ID {id}
}
+ icon={
}
onClick={() => copyToClipboard(id, "Execution ID")}
/>
@@ -473,10 +468,10 @@ const DetailedLogs = () => {
justify="space-between"
className="detailed-logs-cards"
>
-
+
-
+
Started
@@ -487,7 +482,7 @@ const DetailedLogs = () => {
-
+
{executionDetails?.status.toLowerCase() === "executing"
@@ -500,7 +495,7 @@ const DetailedLogs = () => {
-
+
{executionDetails?.status.toLowerCase() === "executing"
@@ -509,7 +504,7 @@ const DetailedLogs = () => {
-{" "}
- {" "}
+ {" "}
{executionDetails?.totalFiles}
@@ -517,13 +512,13 @@ const DetailedLogs = () => {
- {" "}
+ {" "}
{executionDetails?.successfulFiles}
- {" "}
+ {" "}
{executionDetails?.failedFiles}
@@ -533,7 +528,7 @@ const DetailedLogs = () => {
executionDetails?.failedFiles) >
0 && (
- {" "}
+ {" "}
{executionDetails?.totalFiles -
(executionDetails?.successfulFiles +
executionDetails?.failedFiles)}
@@ -547,7 +542,7 @@ const DetailedLogs = () => {
}
+ icon={ }
onClick={() => handleLogsModalOpen({})}
>
View Logs
diff --git a/frontend/src/components/logging/execution-logs/ExecutionLogs.css b/frontend/src/components/logging/execution-logs/ExecutionLogs.css
index 1b059f550c..ca29a2606f 100644
--- a/frontend/src/components/logging/execution-logs/ExecutionLogs.css
+++ b/frontend/src/components/logging/execution-logs/ExecutionLogs.css
@@ -1,5 +1,5 @@
.file-log-layout {
- background-color: var(--white);
+ background-color: var(--card);
height: 100%;
margin: 12px;
overflow: hidden;
@@ -19,7 +19,7 @@
.active.log-tab-icon > g > path,
.active.log-tab-icon > path {
- fill: #1677ff;
+ fill: var(--primary);
}
.invite-user-search .ant-tabs-nav {
diff --git a/frontend/src/components/logging/execution-logs/ExecutionLogs.jsx b/frontend/src/components/logging/execution-logs/ExecutionLogs.jsx
index b73ab94687..f06481d684 100644
--- a/frontend/src/components/logging/execution-logs/ExecutionLogs.jsx
+++ b/frontend/src/components/logging/execution-logs/ExecutionLogs.jsx
@@ -1,6 +1,7 @@
-import { DatePicker, Tabs } from "antd";
import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
+import { DatePicker } from "@/components/ui/shims/antd-datetime";
+import { Tabs } from "@/components/ui/shims/antd-structure";
import {
ApiDeployments,
ETLIcon,
diff --git a/frontend/src/components/logging/filter-dropdown/FilterDropdown.jsx b/frontend/src/components/logging/filter-dropdown/FilterDropdown.jsx
index 72bf0e294c..c0222504a7 100644
--- a/frontend/src/components/logging/filter-dropdown/FilterDropdown.jsx
+++ b/frontend/src/components/logging/filter-dropdown/FilterDropdown.jsx
@@ -1,9 +1,11 @@
-import { FilterOutlined } from "@ant-design/icons";
-import { Button, Radio, Space } from "antd";
+import { Filter } from "lucide-react";
import PropTypes from "prop-types";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Radio } from "@/components/ui/shims/antd-inputs";
+import { Space } from "@/components/ui/shims/antd-layout";
const FilterIcon = ({ filtered }) => (
-
+
);
const FilterDropdown = ({
@@ -19,7 +21,11 @@ const FilterDropdown = ({
setSelectedKeys(e.target.value ? [e.target.value] : []);
confirm();
}}
- value={selectedKeys[0] || null}
+ // "" rather than null: Radix reads a nullish value as "uncontrolled", so
+ // picking the first level flipped the group from uncontrolled to
+ // controlled and React warned about it. An empty string is the
+ // controlled spelling of "nothing selected".
+ value={selectedKeys[0] ?? ""}
>
{filterOptions.map((filter) => (
@@ -34,7 +40,17 @@ const FilterDropdown = ({
className="clear-button"
type="primary"
size="small"
- onClick={() => handleClearFilter(confirm)}
+ /*
+ * Empty the draft BEFORE confirming. `confirm()` publishes whatever
+ * `setSelectedKeys` last set — it cannot see the parent state
+ * `handleClearFilter` is about to clear, because that re-render has not
+ * happened yet. Without this, Clear republished the level it was meant
+ * to remove and the log list stayed filtered.
+ */
+ onClick={() => {
+ setSelectedKeys([]);
+ handleClearFilter(confirm);
+ }}
>
Clear
diff --git a/frontend/src/components/logging/log-modal/LogModal.css b/frontend/src/components/logging/log-modal/LogModal.css
index 3440916c0a..4340a52676 100644
--- a/frontend/src/components/logging/log-modal/LogModal.css
+++ b/frontend/src/components/logging/log-modal/LogModal.css
@@ -18,8 +18,8 @@
}
.log-modal-title .copy-btn-outlined:hover {
- border-color: #1677ff;
- color: #1677ff;
+ border-color: var(--primary);
+ color: var(--primary);
}
.log-modal-title .export-btn-outlined {
diff --git a/frontend/src/components/logging/log-modal/LogModal.jsx b/frontend/src/components/logging/log-modal/LogModal.jsx
index 5baa6e5a12..5848f0a65a 100644
--- a/frontend/src/components/logging/log-modal/LogModal.jsx
+++ b/frontend/src/components/logging/log-modal/LogModal.jsx
@@ -1,7 +1,9 @@
-import { CopyOutlined, DownloadOutlined } from "@ant-design/icons";
-import { Button, Dropdown, Modal, Table, Tooltip } from "antd";
+import { Copy, Download } from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Dropdown, Modal, Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Table } from "@/components/ui/shims/antd-structure";
import "./LogModal.css";
import {
@@ -177,7 +179,7 @@ function LogModal({
width: 200,
render: (_, record) => (
- {record.eventTime}
+ {record.eventTime}
),
},
@@ -246,7 +248,7 @@ function LogModal({
{displayId && (
}
+ icon={ }
aria-label="Copy execution ID"
onClick={() => copyToClipboard(displayId, "File Execution ID")}
/>
@@ -263,7 +265,7 @@ function LogModal({
}
+ icon={ }
loading={exporting}
disabled={!pagination.total || exporting}
>
diff --git a/frontend/src/components/logging/logs-refresh-controls/LogsRefreshControls.jsx b/frontend/src/components/logging/logs-refresh-controls/LogsRefreshControls.jsx
index c54e2f74d4..ceb674385a 100644
--- a/frontend/src/components/logging/logs-refresh-controls/LogsRefreshControls.jsx
+++ b/frontend/src/components/logging/logs-refresh-controls/LogsRefreshControls.jsx
@@ -1,6 +1,9 @@
-import { ReloadOutlined } from "@ant-design/icons";
-import { Button, Switch, Tooltip, Typography } from "antd";
+import { RotateCw } from "lucide-react";
import PropTypes from "prop-types";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Switch } from "@/components/ui/shims/antd-inputs";
+import { Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Typography } from "@/components/ui/shims/antd-typography";
import "./LogsRefreshControls.css";
function LogsRefreshControls({
@@ -22,7 +25,7 @@ function LogsRefreshControls({
disabled={disabled}
/>
}
+ icon={ }
onClick={onRefresh}
className="logs-refresh-btn"
disabled={disabled}
diff --git a/frontend/src/components/logging/logs-table/LogsTable.css b/frontend/src/components/logging/logs-table/LogsTable.css
index af23d7f82f..df0859dbcb 100644
--- a/frontend/src/components/logging/logs-table/LogsTable.css
+++ b/frontend/src/components/logging/logs-table/LogsTable.css
@@ -50,11 +50,11 @@
}
.gen-index-progress {
- color: #faad14;
+ color: var(--warning);
}
.gen-index-success {
- color: #52c41a;
+ color: var(--success);
}
.gen-index-fail {
@@ -77,5 +77,5 @@
}
.search-filter-icon-active {
- color: #1890ff;
+ color: var(--primary);
}
diff --git a/frontend/src/components/logging/logs-table/LogsTable.jsx b/frontend/src/components/logging/logs-table/LogsTable.jsx
index e75ccb41f6..82a4d06f06 100644
--- a/frontend/src/components/logging/logs-table/LogsTable.jsx
+++ b/frontend/src/components/logging/logs-table/LogsTable.jsx
@@ -1,11 +1,9 @@
-import { Input, Table, Tooltip, Typography } from "antd";
+import { CircleX, Hourglass, Info, Search } from "lucide-react";
+import { Input } from "@/components/ui/shims/antd-inputs";
+import { Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Table } from "@/components/ui/shims/antd-structure";
+import { Typography } from "@/components/ui/shims/antd-typography";
import "./LogsTable.css";
-import {
- CloseCircleFilled,
- HourglassOutlined,
- InfoCircleFilled,
- SearchOutlined,
-} from "@ant-design/icons";
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import { logsStaticContent } from "../../../helpers/GetStaticData";
@@ -31,7 +29,7 @@ SearchFilterDropdown.propTypes = {
// Search filter icon component
const SearchFilterIcon = ({ isActive }) => (
-
+
);
SearchFilterIcon.propTypes = {
@@ -59,7 +57,7 @@ const LogsTable = ({
sorter: true,
render: (_, record) => (
- {record.executedAt}
+ {record.executedAt}
),
},
@@ -108,14 +106,12 @@ const LogsTable = ({
- {" "}
- {record?.successfulFiles}
+ {record?.successfulFiles}
- {" "}
- {record?.failedFiles}
+ {record?.failedFiles}
@@ -123,7 +119,7 @@ const LogsTable = ({
(record?.successfulFiles + record?.failedFiles) >
0 && (
- {" "}
+ {" "}
{record?.totalFiles -
(record?.successfulFiles + record?.failedFiles)}
diff --git a/frontend/src/components/logs-and-notifications/DisplayLogsAndNotifications.css b/frontend/src/components/logs-and-notifications/DisplayLogsAndNotifications.css
index 7588948dbf..78b8482452 100644
--- a/frontend/src/components/logs-and-notifications/DisplayLogsAndNotifications.css
+++ b/frontend/src/components/logs-and-notifications/DisplayLogsAndNotifications.css
@@ -3,8 +3,8 @@
bottom: 0;
left: 0;
right: 0;
- background-color: #fff;
- border-top: 1px solid #ccc;
+ background-color: var(--card);
+ border-top: 1px solid var(--border);
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
@@ -14,8 +14,8 @@
.logs-handle {
height: 40px;
- background-color: #fff;
- border-bottom: 1px solid #ccc;
+ background-color: var(--card);
+ border-bottom: 1px solid var(--border);
cursor: ns-resize;
user-select: none;
font-weight: bold;
@@ -36,20 +36,22 @@
.tool-logs-table .ant-table {
font-family: Consolas, "Courier New", monospace;
- font-size: 14px !important;
- background-color: #fff;
+ font-size: 13px !important;
+ background-color: var(--card);
}
.tool-logs-table .ant-table-thead > tr > th {
- background-color: #ffffff;
+ background-color: var(--card);
font-weight: 600;
}
+/* A light red wash for error logs. Mixed off --destructive so it tracks the
+ * theme instead of staying a light-mode-only #fff1f0. */
.tool-logs-table .ant-table-tbody > tr.display-logs-error-bg > td {
- background-color: #fff1f0; /* a light red for error logs */
+ background-color: color-mix(in srgb, var(--destructive) 8%, var(--card));
}
.display-logs-md {
- font-size: 14px;
+ font-size: 13px;
padding-left: 5px;
}
diff --git a/frontend/src/components/logs-and-notifications/LogsAndNotificationsTable.jsx b/frontend/src/components/logs-and-notifications/LogsAndNotificationsTable.jsx
index 01d5bfc784..917624b3e6 100644
--- a/frontend/src/components/logs-and-notifications/LogsAndNotificationsTable.jsx
+++ b/frontend/src/components/logs-and-notifications/LogsAndNotificationsTable.jsx
@@ -1,7 +1,7 @@
-import { Table } from "antd";
import { uniqueId } from "lodash";
import PropTypes from "prop-types";
import { useEffect, useMemo, useRef } from "react";
+import { Table } from "@/components/ui/shims/antd-structure";
import { useSocketLogsStore } from "../../store/socket-logs-store";
import "./DisplayLogsAndNotifications.css";
import { getDateTimeString } from "../../helpers/GetStaticData";
diff --git a/frontend/src/components/logs-and-notifications/LogsHeader.jsx b/frontend/src/components/logs-and-notifications/LogsHeader.jsx
index 94c1e31334..f020b26173 100644
--- a/frontend/src/components/logs-and-notifications/LogsHeader.jsx
+++ b/frontend/src/components/logs-and-notifications/LogsHeader.jsx
@@ -1,7 +1,10 @@
-import { CloseOutlined, DownOutlined, UpOutlined } from "@ant-design/icons";
-import { Button, Space, Tag, Typography } from "antd";
+import { ChevronDown, ChevronUp, X } from "lucide-react";
import PropTypes from "prop-types";
import { memo } from "react";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Space } from "@/components/ui/shims/antd-layout";
+import { Tag } from "@/components/ui/shims/antd-leaves";
+import { Typography } from "@/components/ui/shims/antd-typography";
export const LogsHeader = memo(function LogsHeader({
isMinimized,
@@ -10,9 +13,9 @@ export const LogsHeader = memo(function LogsHeader({
onToggleExpand,
onMinimize,
}) {
- const expandCollapseIcon = isFull ? : ;
+ const expandCollapseIcon = isFull ? : ;
- const minimizeIcon = ;
+ const minimizeIcon = ;
return (
diff --git a/frontend/src/components/metrics-dashboard/LLMUsageTable.jsx b/frontend/src/components/metrics-dashboard/LLMUsageTable.jsx
index 0a7bbbcc29..e0ac4f6d73 100644
--- a/frontend/src/components/metrics-dashboard/LLMUsageTable.jsx
+++ b/frontend/src/components/metrics-dashboard/LLMUsageTable.jsx
@@ -1,29 +1,16 @@
-import {
- CheckCircleOutlined,
- CloseCircleOutlined,
- InfoCircleOutlined,
-} from "@ant-design/icons";
-import {
- Alert,
- Card,
- Empty,
- Spin,
- Table,
- Tabs,
- Tag,
- Tooltip,
- Typography,
-} from "antd";
+import { CircleCheck, CircleX, Info } from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { Alert, Empty, Spin, Tag } from "@/components/ui/shims/antd-leaves";
+import { Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Card, Table, Tabs } from "@/components/ui/shims/antd-structure";
+import { Text } from "@/components/ui/shims/antd-typography";
import { ApiDeployments, ETLIcon, Task, Workflows } from "../../assets/index";
import { useDeploymentUsage } from "../../hooks/useMetricsData";
import "./MetricsDashboard.css";
-const { Text } = Typography;
-
/**
* Format large numbers with K/M/B suffixes.
* Shows full value on hover via Tooltip.
@@ -55,7 +42,7 @@ const columns = [
Tokens{" "}
-
+
),
@@ -65,7 +52,7 @@ const columns = [
defaultSortOrder: "descend",
render: (value) => (
- {formatCompactNumber(value)}
+ {formatCompactNumber(value)}
),
width: 120,
@@ -95,12 +82,12 @@ const columns = [
{total.toLocaleString()}
{completed > 0 && (
- {formatCompactNumber(completed)}
+ {formatCompactNumber(completed)}
)}
{failed > 0 && (
- {formatCompactNumber(failed)}
+ {formatCompactNumber(failed)}
)}
@@ -137,7 +124,11 @@ const columns = [
},
];
-function DeploymentUsageTable({ startDate, endDate, refetchRef }) {
+function DeploymentUsageTable({
+ startDate = null,
+ endDate = null,
+ refetchRef = null,
+}) {
const [activeType, setActiveType] = useState("API");
const { data, loading, error, refetch } = useDeploymentUsage(
@@ -269,10 +260,4 @@ DeploymentUsageTable.propTypes = {
refetchRef: PropTypes.shape({ current: PropTypes.func }),
};
-DeploymentUsageTable.defaultProps = {
- startDate: null,
- endDate: null,
- refetchRef: null,
-};
-
export { DeploymentUsageTable };
diff --git a/frontend/src/components/metrics-dashboard/MetricsChart.jsx b/frontend/src/components/metrics-dashboard/MetricsChart.jsx
index 8da8953261..3e1ef026a9 100644
--- a/frontend/src/components/metrics-dashboard/MetricsChart.jsx
+++ b/frontend/src/components/metrics-dashboard/MetricsChart.jsx
@@ -1,5 +1,4 @@
-import { FilterOutlined } from "@ant-design/icons";
-import { Button, Card, Dropdown, Empty, Spin } from "antd";
+import { Filter } from "lucide-react";
import PropTypes from "prop-types";
import { useMemo, useState } from "react";
import {
@@ -14,6 +13,10 @@ import {
XAxis,
YAxis,
} from "recharts";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Empty, Spin } from "@/components/ui/shims/antd-leaves";
+import { Dropdown } from "@/components/ui/shims/antd-overlays";
+import { Card } from "@/components/ui/shims/antd-structure";
import "./MetricsDashboard.css";
@@ -49,7 +52,7 @@ function formatDate(dateStr) {
/**
* Format a number for display in tooltips.
*
- * @param {number} value - Number to format
+ * @param {number|null|undefined} value - Number to format
* @return {string} Formatted number
*/
function formatValue(value) {
@@ -328,7 +331,7 @@ function TrendAnalysisChart({ data, loading }) {
}}
trigger={["click"]}
>
-
} size="small">
+
} size="small">
Filter
diff --git a/frontend/src/components/metrics-dashboard/MetricsDashboard.css b/frontend/src/components/metrics-dashboard/MetricsDashboard.css
index d3d38fe16a..ca4a09effd 100644
--- a/frontend/src/components/metrics-dashboard/MetricsDashboard.css
+++ b/frontend/src/components/metrics-dashboard/MetricsDashboard.css
@@ -2,7 +2,7 @@
.metrics-dashboard {
padding: 12px;
- background-color: var(--page-bg-2);
+ background-color: var(--background);
flex: 1;
overflow: hidden;
display: flex;
@@ -10,7 +10,7 @@
}
.metrics-dashboard-container {
- background-color: var(--page-bg-1);
+ background-color: var(--card);
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -71,6 +71,11 @@
.summary-card-icon span {
font-size: 24px;
+ /* width/height, not font-size alone: antd shipped an icon FONT (sized by
+ * font-size); lucide ships SVGs, which ignore it and fall back to their
+ * own 24px default. font-size is kept for any text in the same element. */
+ width: 24px;
+ height: 24px;
}
.summary-card-content {
@@ -79,7 +84,7 @@
}
.summary-card-label {
- font-size: 14px;
+ font-size: 13px;
color: #666;
margin-bottom: 4px;
white-space: nowrap;
@@ -119,11 +124,16 @@
display: flex;
align-items: center;
gap: 8px;
- font-size: 14px;
+ font-size: 13px;
}
.metric-icon {
font-size: 16px;
+ /* width/height, not font-size alone: antd shipped an icon FONT (sized by
+ * font-size); lucide ships SVGs, which ignore it and fall back to their
+ * own 24px default. font-size is kept for any text in the same element. */
+ width: 16px;
+ height: 16px;
}
.metric-count {
@@ -168,7 +178,7 @@
}
.metrics-empty-state-text {
- font-size: 14px;
+ font-size: 13px;
line-height: 22px;
max-width: 360px;
margin-bottom: 24px;
@@ -283,6 +293,11 @@
.recent-activity-info {
font-size: 11px;
+ /* width/height, not font-size alone: these classes sit on lucide SVGs,
+ * which ignore font-size and fall back to their own 24px default. The
+ * antd originals were an icon FONT, where font-size was the size. */
+ width: 11px;
+ height: 11px;
color: #8c8c8c;
cursor: help;
}
@@ -315,7 +330,7 @@
}
.subscription-plan-pages {
- font-size: 14px;
+ font-size: 13px;
color: #595959;
}
@@ -361,7 +376,7 @@
}
.subscription-plan-total-label {
- font-size: 14px;
+ font-size: 13px;
color: #8c8c8c;
}
@@ -376,8 +391,8 @@
.subscription-upgrade-btn {
align-self: flex-start;
margin-top: 4px;
- color: #52c41a;
- border-color: #52c41a;
+ color: var(--success);
+ border-color: var(--success);
}
.subscription-upgrade-btn:hover {
@@ -395,7 +410,12 @@
.subscription-stat-icon {
font-size: 16px;
- color: #1890ff;
+ /* width/height, not font-size alone: antd shipped an icon FONT (sized by
+ * font-size); lucide ships SVGs, which ignore it and fall back to their
+ * own 24px default. font-size is kept for any text in the same element. */
+ width: 16px;
+ height: 16px;
+ color: var(--primary);
margin-bottom: 6px;
}
@@ -420,7 +440,7 @@
.subscription-chart-card .ant-card-head-title,
.subscription-table-card .ant-card-head-title {
- font-size: 14px;
+ font-size: 13px;
font-weight: 600;
}
@@ -470,7 +490,7 @@
}
.execution-success {
- color: #52c41a;
+ color: var(--success);
font-size: 12px;
}
@@ -516,7 +536,7 @@
.llm-usage-table .ant-table-tbody > tr > td {
color: #434343;
- font-size: 14px;
+ font-size: 13px;
}
.llm-usage-table .ant-table-tbody > tr:hover > td {
@@ -543,4 +563,9 @@
.llm-usage-info-icon {
color: #8c8c8c;
font-size: 12px;
+ /* width/height, not font-size alone: antd shipped an icon FONT (sized by
+ * font-size); lucide ships SVGs, which ignore it and fall back to their
+ * own 24px default. font-size is kept for any text in the same element. */
+ width: 12px;
+ height: 12px;
}
diff --git a/frontend/src/components/metrics-dashboard/MetricsDashboard.jsx b/frontend/src/components/metrics-dashboard/MetricsDashboard.jsx
index 3a5354a432..3e814bf027 100644
--- a/frontend/src/components/metrics-dashboard/MetricsDashboard.jsx
+++ b/frontend/src/components/metrics-dashboard/MetricsDashboard.jsx
@@ -1,25 +1,21 @@
-import {
- CreditCardOutlined,
- DashboardOutlined,
- FileSearchOutlined,
- ReloadOutlined,
- RocketOutlined,
- SlackOutlined,
- ThunderboltOutlined,
-} from "@ant-design/icons";
-import {
- Alert,
- Button,
- Col,
- DatePicker,
- Row,
- Space,
- Tabs,
- Typography,
-} from "antd";
import dayjs from "dayjs";
+import {
+ CreditCard,
+ FileSearch,
+ Gauge,
+ MessagesSquare,
+ Rocket,
+ RotateCw,
+ Zap,
+} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
+import { Button } from "@/components/ui/shims/antd-button";
+import { DatePicker } from "@/components/ui/shims/antd-datetime";
+import { Col, Row, Space } from "@/components/ui/shims/antd-layout";
+import { Alert } from "@/components/ui/shims/antd-leaves";
+import { Tabs } from "@/components/ui/shims/antd-structure";
+import { Typography } from "@/components/ui/shims/antd-typography";
import { EmptyPlaceholder } from "../../assets";
import { evictExpiredCache } from "../../helpers/metricsCache";
@@ -65,7 +61,7 @@ function DashboardButtons() {
return (
}
+ icon={ }
type="link"
onClick={() =>
window.open(
@@ -79,7 +75,7 @@ function DashboardButtons() {
Documentation
}
+ icon={ }
type="link"
onClick={() =>
window.open(
@@ -195,7 +191,7 @@ function MetricsDashboard() {
key: "overview",
label: (
- Overview
+ Overview
),
children: hasNoData ? (
@@ -213,7 +209,7 @@ function MetricsDashboard() {
}
+ icon={ }
onClick={() => navigate(`/${orgName}/workflows`)}
>
Create Workflow
@@ -249,7 +245,7 @@ function MetricsDashboard() {
key: "llm-usage",
label: (
- Usage by Deployment
+ Usage by Deployment
),
children: (
@@ -272,7 +268,7 @@ function MetricsDashboard() {
key: "subscription",
label: (
- Subscription
+ Subscription
),
children: (
@@ -338,7 +334,7 @@ function MetricsDashboard() {
]}
/>
)}
- } onClick={handleRefresh} />
+ } onClick={handleRefresh} />
}
/>
diff --git a/frontend/src/components/metrics-dashboard/MetricsSummary.jsx b/frontend/src/components/metrics-dashboard/MetricsSummary.jsx
index 756c96827a..8147f1e032 100644
--- a/frontend/src/components/metrics-dashboard/MetricsSummary.jsx
+++ b/frontend/src/components/metrics-dashboard/MetricsSummary.jsx
@@ -1,15 +1,16 @@
import {
- ApiOutlined,
- CheckCircleOutlined,
- DollarOutlined,
- EyeOutlined,
- FileTextOutlined,
- RocketOutlined,
- ThunderboltOutlined,
- WarningOutlined,
-} from "@ant-design/icons";
-import { Col, Empty, Row, Spin } from "antd";
+ CircleCheck,
+ DollarSign,
+ Eye,
+ FileText,
+ Plug,
+ Rocket,
+ TriangleAlert,
+ Zap,
+} from "lucide-react";
import PropTypes from "prop-types";
+import { Col, Row } from "@/components/ui/shims/antd-layout";
+import { Empty, Spin } from "@/components/ui/shims/antd-leaves";
import "./MetricsDashboard.css";
@@ -17,7 +18,7 @@ import "./MetricsDashboard.css";
const METRIC_CONFIG = {
pages_processed: {
label: "Pages Processed",
- icon: ,
+ icon: ,
bgColor: "#e8f5e9",
iconBg: "#c8e6c9",
iconColor: "#2e7d32",
@@ -25,7 +26,7 @@ const METRIC_CONFIG = {
},
documents_processed: {
label: "Documents Processed",
- icon: ,
+ icon: ,
bgColor: "#fff3e0",
iconBg: "#ffe0b2",
iconColor: "#e65100",
@@ -33,7 +34,7 @@ const METRIC_CONFIG = {
},
llm_calls: {
label: "LLM Calls",
- icon: ,
+ icon: ,
bgColor: "#e0f2f1",
iconBg: "#b2dfdb",
iconColor: "#00695c",
@@ -41,7 +42,7 @@ const METRIC_CONFIG = {
},
prompt_executions: {
label: "Prompt Executions",
- icon: ,
+ icon: ,
bgColor: "#ede7f6",
iconBg: "#d1c4e9",
iconColor: "#4527a0",
@@ -49,7 +50,7 @@ const METRIC_CONFIG = {
},
deployed_api_requests: {
label: "API Requests",
- icon: ,
+ icon: ,
bgColor: "#e3f2fd",
iconBg: "#bbdefb",
iconColor: "#1565c0",
@@ -57,7 +58,7 @@ const METRIC_CONFIG = {
},
llm_usage: {
label: "LLM Usage Cost",
- icon: ,
+ icon: ,
bgColor: "#fce4ec",
iconBg: "#f8bbd9",
iconColor: "#c2185b",
@@ -67,7 +68,7 @@ const METRIC_CONFIG = {
},
etl_pipeline_executions: {
label: "ETL Executions",
- icon: ,
+ icon: ,
bgColor: "#ffebee",
iconBg: "#ffcdd2",
iconColor: "#c62828",
@@ -75,7 +76,7 @@ const METRIC_CONFIG = {
},
challenges: {
label: "Challenges",
- icon: ,
+ icon: ,
bgColor: "#fce4ec",
iconBg: "#f8bbd9",
iconColor: "#ad1457",
@@ -83,7 +84,7 @@ const METRIC_CONFIG = {
},
summarization_calls: {
label: "Summarizations",
- icon: ,
+ icon: ,
bgColor: "#e0f7fa",
iconBg: "#b2ebf2",
iconColor: "#00838f",
@@ -91,7 +92,7 @@ const METRIC_CONFIG = {
},
failed_pages: {
label: "Failed Pages",
- icon: ,
+ icon: ,
bgColor: "#fff1f0",
iconBg: "#ffccc7",
iconColor: "#cf1322",
@@ -99,7 +100,7 @@ const METRIC_CONFIG = {
},
hitl_reviews: {
label: "HITL Reviews",
- icon: ,
+ icon: ,
bgColor: "#f3e8ff",
iconBg: "#e0cffc",
iconColor: "#6d28d9",
@@ -107,7 +108,7 @@ const METRIC_CONFIG = {
},
hitl_completions: {
label: "HITL Completions",
- icon: ,
+ icon: ,
bgColor: "#ecfdf5",
iconBg: "#d1fae5",
iconColor: "#059669",
@@ -131,7 +132,7 @@ const METRIC_PRIORITY = [
/**
* Format large numbers for display.
*
- * @param {number} value - The number to format
+ * @param {number|null|undefined} value - The number to format
* @param {number} precision - Decimal precision (default 0)
* @return {string} Formatted number string
*/
@@ -148,7 +149,7 @@ function formatValue(value, precision = 0) {
return Math.round(value).toLocaleString();
}
-function MetricsSummary({ data, loading }) {
+function MetricsSummary({ data = null, loading = false }) {
if (loading) {
return (
@@ -189,7 +190,7 @@ function MetricsSummary({ data, loading }) {
{sortedMetrics.map((metric) => {
const config = METRIC_CONFIG[metric.metric_name] || {
label: metric.metric_name,
- icon:
,
+ icon:
,
bgColor: "#f5f5f5",
iconBg: "#e0e0e0",
iconColor: "#616161",
@@ -247,9 +248,4 @@ MetricsSummary.propTypes = {
loading: PropTypes.bool,
};
-MetricsSummary.defaultProps = {
- data: null,
- loading: false,
-};
-
export { MetricsSummary };
diff --git a/frontend/src/components/metrics-dashboard/RecentActivity.jsx b/frontend/src/components/metrics-dashboard/RecentActivity.jsx
index f21053f25f..80fff63599 100644
--- a/frontend/src/components/metrics-dashboard/RecentActivity.jsx
+++ b/frontend/src/components/metrics-dashboard/RecentActivity.jsx
@@ -1,46 +1,47 @@
-import {
- ApiOutlined,
- BranchesOutlined,
- CheckCircleOutlined,
- ClockCircleOutlined,
- CloseCircleOutlined,
- InfoCircleOutlined,
- PlayCircleOutlined,
- SyncOutlined,
-} from "@ant-design/icons";
-import { Card, Empty, List, Spin, Tag, Tooltip, Typography } from "antd";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
+import {
+ CircleCheck,
+ CirclePlay,
+ CircleX,
+ Clock,
+ GitBranch,
+ Info,
+ Plug,
+ RefreshCw,
+} from "lucide-react";
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
+import { Empty, Spin, Tag } from "@/components/ui/shims/antd-leaves";
+import { Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Card, List } from "@/components/ui/shims/antd-structure";
+import { Text } from "@/components/ui/shims/antd-typography";
import { useSessionStore } from "../../store/session-store";
import "./MetricsDashboard.css";
dayjs.extend(relativeTime);
-const { Text } = Typography;
-
// Status configuration with colors and icons
const STATUS_CONFIG = {
COMPLETED: {
color: "success",
- icon:
,
+ icon:
,
label: "Completed",
},
RUNNING: {
color: "processing",
- icon:
,
+ icon:
,
label: "Processing",
},
QUEUED: {
color: "default",
- icon:
,
+ icon:
,
label: "Queued",
},
ERROR: {
color: "error",
- icon:
,
+ icon:
,
label: "Failed",
},
};
@@ -49,19 +50,19 @@ const STATUS_CONFIG = {
const TYPE_CONFIG = {
etl: {
label: "ETL Pipeline",
- icon:
,
+ icon:
,
color: "#722ed1",
logType: "ETL",
},
api: {
label: "API Request",
- icon:
,
+ icon:
,
color: "#1890ff",
logType: "API",
},
workflow: {
label: "Workflow",
- icon:
,
+ icon:
,
color: "#52c41a",
logType: "WF",
},
@@ -73,7 +74,7 @@ const TYPE_CONFIG = {
*
* @return {JSX.Element} The rendered recent activity component.
*/
-function RecentActivity({ data, loading }) {
+function RecentActivity({ data = null, loading = false }) {
const navigate = useNavigate();
const { sessionDetails } = useSessionStore();
const orgName = sessionDetails?.orgName;
@@ -151,7 +152,7 @@ function RecentActivity({ data, loading }) {
{item.total_tokens.toLocaleString()} tokens
-
+
)}
@@ -186,9 +187,4 @@ RecentActivity.propTypes = {
loading: PropTypes.bool,
};
-RecentActivity.defaultProps = {
- data: null,
- loading: false,
-};
-
export { RecentActivity };
diff --git a/frontend/src/components/navigations/side-nav-bar/SideNavBar.css b/frontend/src/components/navigations/side-nav-bar/SideNavBar.css
index d45c10070e..44d31b9d65 100644
--- a/frontend/src/components/navigations/side-nav-bar/SideNavBar.css
+++ b/frontend/src/components/navigations/side-nav-bar/SideNavBar.css
@@ -1,5 +1,24 @@
+/*
+ * The sidebar is a LIGHT surface.
+ *
+ * It has been three colours: a hardcoded #0d3a63 navy, then `var(--primary)`
+ * violet (which it shared with the top nav). The Figma design puts the nav on
+ * the same white plane as the rest of the shell and keeps the brand violet for
+ * the ACTIVE item only, so this is now `--sidebar` -- the token that exists for
+ * exactly this surface and that follows light/dark on its own.
+ *
+ * Every rule below is therefore written for a light surface: `--sidebar-
+ * foreground` text, `--sidebar-border` hairlines, and icons tinted through
+ * `currentColor`. Putting the background back to a dark value without also
+ * reverting those would leave dark-on-dark text.
+ */
.side-bar {
- background-color: #0d3a63 !important;
+ background-color: var(--sidebar);
+ /*
+ * The nav and the content area are both white now. Without this hairline the
+ * boundary between them only exists where an active pill happens to sit.
+ */
+ border-inline-end: 1px solid var(--sidebar-border);
overflow: hidden;
height: 100%;
}
@@ -25,9 +44,18 @@
display: none;
}
-/* Hide scrollbar when sidebar is collapsed */
+/*
+ * The collapsed rail scrolls too.
+ *
+ * This rule used to set `overflow-y: hidden`, and its comment said "hide
+ * scrollbar" — but the scrollbar is already hidden by `scrollbar-width: none`
+ * and the ::-webkit-scrollbar rule above. Setting `hidden` did not hide a
+ * scrollbar, it disabled SCROLLING: the collapsed rail needs 929px for its
+ * icons in a 668px viewport, so the bottom entries were unreachable on any
+ * window shorter than ~1000px.
+ */
.side-bar.ant-layout-sider-collapsed .sidebar-content-wrapper {
- overflow-y: hidden;
+ overflow-y: auto;
}
/* Toggle container - fixed at bottom */
@@ -38,7 +66,7 @@
padding: 8px;
height: auto;
border: none;
- border-top: 1px solid rgba(255, 255, 255, 0.1);
+ border-top: 1px solid var(--sidebar-border);
border-radius: 0;
flex-shrink: 0;
background: transparent;
@@ -46,17 +74,28 @@
}
.sidebar-toggle-container.ant-btn:hover {
- background: rgba(255, 255, 255, 0.1);
+ background: var(--sidebar-accent);
}
.sidebar-toggle-icon {
- color: rgba(255, 255, 255, 0.6);
- font-size: 14px;
+ color: var(--muted-foreground);
+ font-size: 13px;
+ /* width/height, not font-size alone: antd shipped an icon FONT (sized by
+ * font-size); lucide ships SVGs, which ignore it and fall back to their
+ * own 24px default. font-size is kept for any text in the same element. */
+ width: 13px;
+ height: 13px;
transition: color 0.2s;
}
+/*
+ * The pinned state just needs to read as "on" against the muted rest colour
+ * above. It has been #1890ff (antd's default blue) and then #ffffff for the
+ * violet bar; on a light surface the brand violet is both on-palette and the
+ * same accent the active item uses, so the two agree.
+ */
.sidebar-toggle-icon.pinned {
- color: #1890ff;
+ color: var(--sidebar-primary);
}
.secondary-list-wrapper {
@@ -74,23 +113,49 @@
width: 100%;
}
+/*
+ * The menu icons are single-colour SVGs with `fill="#90A4B7"` baked in -- a
+ * slate picked for the old navy sidebar. They arrive as a per-item `src`, so
+ * `color` cannot reach them; the file used to recolour them wholesale with
+ * `filter: brightness(0) invert(1)` to force them white for the dark surface.
+ *
+ * That filter can reach black or white but not an arbitrary hue, and the
+ * design needs the ACTIVE icon violet. So the SVG is used as a MASK over a
+ * `currentColor` fill instead: the icon takes whatever colour its row already
+ * has, which lets the active rule below tint label and icon in one
+ * declaration. `--menu-item-icon` is set per item in SideNavBar.jsx.
+ */
.side-bar .menu-item-icon {
- margin-top: 1;
- height: auto;
+ /*
+ * Was `width: 25px; height: auto` on an
. A masked span has no
+ * intrinsic size, so the height must be stated; `contain` keeps the 20px
+ * artwork at 20px inside the 25px column the collapsed rail is spaced for.
+ */
+ /*
+ * `display` is load-bearing: this replaced an
, where width/height
+ * apply intrinsically. A
is inline by default, so both were ignored
+ * and the icon collapsed to a 0x0 box (present, masked, and invisible).
+ */
+ display: inline-block;
width: 25px;
-}
-
-.space-styles-active .menu-item-icon,
-.space-styles:hover .menu-item-icon {
- filter: brightness(0) invert(1);
+ height: 20px;
+ flex-shrink: 0;
+ background-color: currentColor;
+ mask: var(--menu-item-icon) no-repeat center / contain;
+ -webkit-mask: var(--menu-item-icon) no-repeat center / contain;
}
.sidebar-footer-icons {
- color: white;
+ color: var(--sidebar-foreground);
font-size: 20px;
+ /* width/height, not font-size alone: antd shipped an icon FONT (sized by
+ * font-size); lucide ships SVGs, which ignore it and fall back to their
+ * own 24px default. font-size is kept for any text in the same element. */
+ width: 20px;
+ height: 20px;
}
.sidebar-footer-text {
- color: white;
+ color: var(--sidebar-foreground);
padding-left: 10px;
}
@@ -99,34 +164,76 @@
padding: 10px 8px;
border-radius: 8px;
width: 100%;
+ /* Sets `currentColor` for the masked icon as well as the label. */
+ color: var(--sidebar-foreground);
}
-.space-styles-active,
+/*
+ * Hover and active are washes of the brand colour rather than named tints:
+ * `color-mix` tracks `--primary` in both light and dark, the same reasoning
+ * that made the violet sidebar use rgba(255,255,255,.16) instead of naming a
+ * second colour. Deliberately NOT `--sidebar-accent`, which is a neutral grey
+ * and would drop the violet the design puts on the selected item.
+ */
.space-styles:hover {
- background: #005b82;
+ background: color-mix(in srgb, var(--primary) 8%, transparent);
+}
+
+/*
+ * `--primary` itself (#6f5cef) measures 4.04:1 on this wash -- under AA for
+ * the 13px label. `--violet-600` is the palette's own next step down, still
+ * reads as the same accent, and clears it at 5.2:1.
+ *
+ * Dark mode needs the opposite direction, not the same value: there the wash
+ * sits on a near-black sidebar, so a DARKER violet would be worse. Hence the
+ * override rather than one theme-agnostic colour.
+ */
+.space-styles-active,
+.space-styles-active:hover {
+ background: color-mix(in srgb, var(--primary) 12%, transparent);
+ color: var(--violet-600);
+}
+
+.dark .space-styles-active,
+.dark .space-styles-active:hover {
+ color: var(--violet-300);
}
.space-styles-disable {
cursor: not-allowed;
}
+/* Section labels (BUILD / MANAGE / REVIEW) are secondary to the items. */
.sidebar-main-heading {
- color: white;
+ color: var(--muted-foreground);
font-weight: 600;
font-size: 15px;
}
+/*
+ * Was rgba(255,255,255,.85) for the violet surface. antd's Typography sets its
+ * own colour, so this is stated rather than inherited from `.space-styles`;
+ * the active override below then has to be stated too.
+ */
.sidebar-item-text {
- color: #b4c2cf;
+ color: var(--sidebar-foreground);
text-align: left;
}
+/* `currentColor` so the label follows the row's per-theme active colour
+ * above instead of restating it twice. */
.space-styles-active .sidebar-item-text {
- color: white;
+ color: currentColor;
}
-.space-styles:hover .sidebar-item-text {
- color: white;
+/*
+ * The 0.8 wash reads fine for the description on the plain surface (9.2:1),
+ * but on the active row's violet it falls to 2.97:1. 11px is normal text for
+ * WCAG, so it gets no large-text exemption -- the active row states it at
+ * full strength.
+ */
+.space-styles-active .fs-11 {
+ opacity: 1;
}
.slider-wrap {
@@ -141,8 +248,9 @@
width: 240px;
}
+/* Name kept (used by SideNavBar.jsx); the value follows the 13px body size. */
.fs-14 {
- font-size: 14px;
+ font-size: 13px;
}
.fs-11 {
@@ -157,24 +265,22 @@
}
.sidebar-divider {
- background-color: rgba(255, 255, 255, 0.3);
+ background-color: var(--sidebar-border);
+ border-color: var(--sidebar-border);
margin: 10px 0;
}
+/* A lucide SVG (stroke: currentColor), so it follows the row like the masked
+ * icons do -- no separate active/hover rules needed. */
.sidebar-antd-icon {
font-size: 22px;
- color: #b4c2cf;
+ color: inherit;
width: 25px;
display: flex;
align-items: center;
justify-content: center;
}
-.space-styles-active .sidebar-antd-icon,
-.space-styles:hover .sidebar-antd-icon {
- color: white;
-}
-
.sidebar-menu-tag {
margin-left: 6px;
font-size: 10px;
diff --git a/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx b/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx
index 799ce3c614..cfa789c94d 100644
--- a/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx
+++ b/frontend/src/components/navigations/side-nav-bar/SideNavBar.jsx
@@ -1,22 +1,13 @@
-import {
- BranchesOutlined,
- DoubleRightOutlined,
- FileProtectOutlined,
-} from "@ant-design/icons";
-import {
- Button,
- Divider,
- Image,
- Layout,
- Popover,
- Space,
- Tag,
- Tooltip,
- Typography,
-} from "antd";
+import { ChevronsRight, FileCheck, GitBranch } from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Space } from "@/components/ui/shims/antd-layout";
+import { Divider, Tag } from "@/components/ui/shims/antd-leaves";
+import { Popover, Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Layout } from "@/components/ui/shims/antd-structure";
+import { Typography } from "@/components/ui/shims/antd-typography";
import apiDeploy from "../../../assets/api-deployments.svg";
import ConnectorsIcon from "../../../assets/connectors.svg";
import CustomTools from "../../../assets/custom-tools-icon.svg";
@@ -176,7 +167,9 @@ const getActiveSettingsKey = () => {
if (currentPath.includes("/settings/review")) {
return "review";
}
- return "platform";
+ // See getActiveHITLKey: no key rather than defaulting to the first entry,
+ // which otherwise looks selected from everywhere in the app.
+ return null;
};
const SettingsPopoverContent = ({ orgName, navigate, isAdmin }) => {
@@ -188,11 +181,12 @@ const SettingsPopoverContent = ({ orgName, navigate, isAdmin }) => {
};
return (
-
+
{settingsMenuItems.map((menuItem) => (
{
if (currentPath.startsWith(base)) {
return "review";
}
- return "review";
+ // No key when the current route is not under HITL at all. Falling back to
+ // "review" painted the first entry as selected from every other page in the
+ // app, which reads as "you are here" when you are not.
+ return null;
};
const HITLPopoverContent = ({ orgName, role, navigate }) => {
@@ -261,11 +258,12 @@ const HITLPopoverContent = ({ orgName, role, navigate }) => {
const currentActiveKey = getActiveHITLKey(orgName);
return (
-
+
{hitlMenuItems.map((menuItem) => (
{
id: 1.3,
title: "Workflows",
description: "Build no-code data workflows for unstructured data",
- icon: BranchesOutlined,
+ icon: GitBranch,
image: Workflows,
path: `/${orgName}/workflows`,
active: globalThis.location.pathname.startsWith(
@@ -425,7 +423,7 @@ const SideNavBar = ({ collapsed, setCollapsed }) => {
id: 3.1,
title: "LLMs",
description: "Setup platform wide access to Large Language Models",
- icon: BranchesOutlined,
+ icon: GitBranch,
image: LlmIcon,
path: `/${orgName}/settings/llms`,
active: globalThis.location.pathname.startsWith(
@@ -639,7 +637,7 @@ const SideNavBar = ({ collapsed, setCollapsed }) => {
?.toLowerCase()
?.replaceAll(/\s+/g, "-")}`}
>
-
+
{!collapsed && (
@@ -697,11 +695,13 @@ const SideNavBar = ({ collapsed, setCollapsed }) => {
?.toLowerCase()
?.replaceAll(/\s+/g, "-")}`}
>
-
{!collapsed && (
@@ -757,11 +757,13 @@ const SideNavBar = ({ collapsed, setCollapsed }) => {
?.toLowerCase()
?.replaceAll(/\s+/g, "-")}`}
>
-
{!collapsed && (
@@ -802,7 +804,7 @@ const SideNavBar = ({ collapsed, setCollapsed }) => {
aria-pressed={isPinned}
aria-label={isPinned ? "Unpin sidebar" : "Pin sidebar"}
icon={
-
}
diff --git a/frontend/src/components/navigations/tool-nav-bar/ToolNavBar.css b/frontend/src/components/navigations/tool-nav-bar/ToolNavBar.css
index d808e438b9..1d26823517 100644
--- a/frontend/src/components/navigations/tool-nav-bar/ToolNavBar.css
+++ b/frontend/src/components/navigations/tool-nav-bar/ToolNavBar.css
@@ -6,9 +6,9 @@
justify-content: space-between;
gap: 12px;
padding: 8px 12px 8px 20px;
- background-color: var(--page-bg-1, #f5f7f9);
+ background-color: var(--card);
box-shadow: 0 1px 3px 0 rgba(24, 50, 71, 0.08);
- border-bottom: 1px solid var(--border-color-1, #dce4e4);
+ border-bottom: 1px solid var(--border);
}
.tool-nav-bar__left {
@@ -62,7 +62,7 @@
}
.tool-nav-bar__title {
- font-size: var(--font-size-16, 16px);
+ font-size: 1rem;
}
.tool-nav-bar .tool-nav-bar__subtitle {
@@ -82,5 +82,5 @@
}
.tool-nav-bar__edit-icon.ant-btn:hover {
- color: var(--ant-color-primary, #1677ff);
+ color: var(--ant-color-primary, var(--primary));
}
diff --git a/frontend/src/components/navigations/tool-nav-bar/ToolNavBar.jsx b/frontend/src/components/navigations/tool-nav-bar/ToolNavBar.jsx
index 1a979eb6ca..aafa89610c 100644
--- a/frontend/src/components/navigations/tool-nav-bar/ToolNavBar.jsx
+++ b/frontend/src/components/navigations/tool-nav-bar/ToolNavBar.jsx
@@ -1,9 +1,11 @@
-import { ArrowLeftOutlined, EditOutlined } from "@ant-design/icons";
-import { Button, Segmented, Typography } from "antd";
-import Search from "antd/es/input/Search";
import { debounce } from "lodash";
+import { ArrowLeft, Pencil } from "lucide-react";
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Input } from "@/components/ui/shims/antd-inputs";
+import { Segmented } from "@/components/ui/shims/antd-structure";
+import { Typography } from "@/components/ui/shims/antd-typography";
import "./ToolNavBar.css";
@@ -45,7 +47,8 @@ function ToolNavBar({
}
+ icon={
}
+ data-testid="tool-nav-bar-back-btn"
onClick={handleBack}
/>
)}
@@ -61,7 +64,7 @@ function ToolNavBar({
}
+ icon={
}
className="tool-nav-bar__edit-icon"
onClick={onEditTitle}
aria-label="Edit title"
@@ -85,16 +88,18 @@ function ToolNavBar({
options={segmentOptions}
value={segmentValue}
onChange={segmentFilter}
+ data-testid="tool-nav-bar-segment"
className="tool-nav-bar__segment"
/>
)}
{enableSearch && (
-
diff --git a/frontend/src/components/navigations/top-nav-bar/TopNavBar.css b/frontend/src/components/navigations/top-nav-bar/TopNavBar.css
index a50738d1e1..dd0ab75d8f 100644
--- a/frontend/src/components/navigations/top-nav-bar/TopNavBar.css
+++ b/frontend/src/components/navigations/top-nav-bar/TopNavBar.css
@@ -6,7 +6,10 @@
display: flex;
align-items: center;
justify-content: center;
- background-color: #184772;
+ /* Was #184772 -- a navy chip left over from the navy top bar, and already
+ * an orphan colour once that bar went violet. On the light bar it needs to
+ * be the one that carries white initials, so it tracks the brand token. */
+ background-color: var(--primary);
border-radius: 5px;
cursor: pointer;
}
@@ -81,13 +84,14 @@
background-color: rgba(0, 0, 0, 0.04);
}
+/* White read against the old violet bar; the bar is now `--card`. */
.page-identifier {
vertical-align: super;
- color: white;
+ color: var(--foreground);
}
.page-heading {
- color: white;
+ color: var(--foreground);
font-size: 16px;
font-weight: 600;
margin-left: 10px;
diff --git a/frontend/src/components/navigations/top-nav-bar/TopNavBar.jsx b/frontend/src/components/navigations/top-nav-bar/TopNavBar.jsx
index e8f82e72cb..f01cfa4a56 100644
--- a/frontend/src/components/navigations/top-nav-bar/TopNavBar.jsx
+++ b/frontend/src/components/navigations/top-nav-bar/TopNavBar.jsx
@@ -1,30 +1,20 @@
-import {
- LoginOutlined,
- LogoutOutlined,
- SettingOutlined,
- UserOutlined,
- UserSwitchOutlined,
-} from "@ant-design/icons";
-import {
- Alert,
- Button,
- Col,
- Dropdown,
- Image,
- Row,
- Space,
- Typography,
-} from "antd";
import axios from "axios";
+import { LogIn, LogOut, Settings, User, UserRoundCog } from "lucide-react";
import PropTypes from "prop-types";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Col, Row, Space } from "@/components/ui/shims/antd-layout";
+import { Alert, Image } from "@/components/ui/shims/antd-leaves";
+import { Dropdown } from "@/components/ui/shims/antd-overlays";
+import { Typography } from "@/components/ui/shims/antd-typography";
-import { UnstractLogo } from "../../../assets/index.js";
+import { UnstractBlackLogo, UnstractLogo } from "../../../assets/index.js";
import {
getBaseUrl,
homePagePath,
onboardCompleted,
+ THEME,
} from "../../../helpers/GetStaticData.js";
import useLogout from "../../../hooks/useLogout.js";
import "../../../layouts/page-layout/PageLayout.css";
@@ -67,14 +57,22 @@ try {
}
let WhispererLogo;
+let WhispererDarkLogo;
try {
const mod = await import("../../../plugins/assets/llmWhisperer/index.js");
WhispererLogo = mod.WhispererLogo;
+ WhispererDarkLogo = mod.LlmWhispererLogo;
} catch {
// Ignore if hook not available
}
-const CustomLogo = ({ onClick, className }) => {
+/*
+ * `Logo` is injected rather than hardcoded: the top bar is a light surface in
+ * light mode and a dark one under `.dark`, and each product ships two marks
+ * (white ink / dark ink) instead of one recolourable SVG -- the brand accent
+ * dots are baked in, so a blanket `fill` override would flatten them.
+ */
+const CustomLogo = ({ onClick, className, Logo }) => {
// Use Ant Design Image and config.logoUrl
if (config.logoUrl) {
return (
@@ -100,12 +98,17 @@ const CustomLogo = ({ onClick, className }) => {
/>
);
}
- return
;
+ return
;
};
+// `APIHubLogo`/`WhispererLogo` are the WHITE-ink marks (named for the dark bar
+// they were drawn for); `APIHubDarkLogo`/`LlmWhispererLogo` are the dark-ink
+// ones. Both are pulled so the bar can pick by theme.
let APIHubLogo;
+let APIHubDarkLogo;
try {
const mod = await import("../../../plugins/assets/verticals/index.js");
APIHubLogo = mod.APIHubLogo;
+ APIHubDarkLogo = mod.APIHubDarkLogo;
} catch {
// Ignore if hook not available
}
@@ -178,6 +181,15 @@ function TopNavBar({ isSimpleLayout, topNavBarOptions }) {
const isStaff = sessionDetails?.isStaff || sessionDetails?.is_staff;
const isOpenSource = orgName === "mock_org";
+ // `.topNav` is `--card`: white in light mode, near-black under `.dark`.
+ // `sessionDetails.currentTheme` is the app's single source of truth for the
+ // theme (App.jsx only mirrors it onto next-themes), so the mark follows it.
+ // In OSS the two plugin logos are undefined either way; the render guards.
+ const isDarkTheme = sessionDetails?.currentTheme === THEME.DARK;
+ const ProductLogo = isDarkTheme ? UnstractLogo : UnstractBlackLogo;
+ const APIHubBarLogo = isDarkTheme ? APIHubLogo : APIHubDarkLogo;
+ const WhispererBarLogo = isDarkTheme ? WhispererLogo : WhispererDarkLogo;
+
// Check user role and whether the onboarding is incomplete
useEffect(() => {
const { role } = sessionDetails;
@@ -266,7 +278,7 @@ function TopNavBar({ isSimpleLayout, topNavBarOptions }) {
};
const handleClick = isLoggedIn ? logout : handleLogin;
- const icon = isLoggedIn ?
:
;
+ const icon = isLoggedIn ?
:
;
const label = isLoggedIn ? "Logout" : "Login";
return [
@@ -281,7 +293,7 @@ function TopNavBar({ isSimpleLayout, topNavBarOptions }) {
disabled={shouldDisableRouting}
type="text"
>
-
Profile
+
Profile
),
},
@@ -300,7 +312,7 @@ function TopNavBar({ isSimpleLayout, topNavBarOptions }) {
placement="left"
>
- Switch Org
+ Switch Org
),
@@ -324,7 +336,7 @@ function TopNavBar({ isSimpleLayout, topNavBarOptions }) {
className="logout-button"
type="text"
>
-
Custom Plans
+
Custom Plans
),
},
@@ -368,14 +380,15 @@ function TopNavBar({ isSimpleLayout, topNavBarOptions }) {
{isUnstract ? (
navigate(`/${sessionDetails?.orgName}/${homePagePath}`)
}
/>
) : isAPIHub ? (
- APIHubLogo &&
+ APIHubBarLogo &&
) : (
- WhispererLogo &&
+ WhispererBarLogo &&
)}
{reviewPageHeader && (
@@ -451,6 +464,7 @@ TopNavBar.propTypes = {
CustomLogo.propTypes = {
onClick: PropTypes.func.isRequired,
className: PropTypes.string.isRequired,
+ Logo: PropTypes.elementType.isRequired,
};
export { TopNavBar };
diff --git a/frontend/src/components/notification/NotificationClearAll.jsx b/frontend/src/components/notification/NotificationClearAll.jsx
new file mode 100644
index 0000000000..adbeeedc50
--- /dev/null
+++ b/frontend/src/components/notification/NotificationClearAll.jsx
@@ -0,0 +1,41 @@
+import { X } from "lucide-react";
+import { useSonner } from "sonner";
+import { Button } from "@/components/ui/button";
+import { dismissAppToast } from "@/hooks/useAppToast";
+
+/**
+ * "Clear all" affordance for the toast stack.
+ *
+ * Error alerts are sticky (`duration: 0`, see `useExceptionHandler`) so the
+ * user can read and copy the Request ID. That is deliberate, but it means a
+ * repeated failure leaves a pile of toasts that each need their own close
+ * button. This clears the pile in one click.
+ *
+ * Sits in the band that `` reserves above the stack in
+ * App.jsx — the two values are a pair, so changing one means changing the
+ * other. Rendered only when there is more than one toast, since a single
+ * toast's own close button is already a one-click dismiss.
+ */
+function NotificationClearAll() {
+ const { toasts } = useSonner();
+
+ if (toasts.length < 2) {
+ return null;
+ }
+
+ return (
+
+ dismissAppToast()}
+ aria-label={`Clear all ${toasts.length} notifications`}
+ >
+
+ Clear all ({toasts.length})
+
+
+ );
+}
+
+export { NotificationClearAll };
diff --git a/frontend/src/components/notification/NotificationIdLine.jsx b/frontend/src/components/notification/NotificationIdLine.jsx
index 7e7739ddf8..bf4c5eddd5 100644
--- a/frontend/src/components/notification/NotificationIdLine.jsx
+++ b/frontend/src/components/notification/NotificationIdLine.jsx
@@ -1,5 +1,5 @@
-import { Typography } from "antd";
import PropTypes from "prop-types";
+import { Typography } from "@/components/ui/shims/antd-typography";
function NotificationIdLine({ label, value, stacked = false }) {
if (!value) {
diff --git a/frontend/src/components/notification/toast-through-modal.test.jsx b/frontend/src/components/notification/toast-through-modal.test.jsx
new file mode 100644
index 0000000000..1304ef7a65
--- /dev/null
+++ b/frontend/src/components/notification/toast-through-modal.test.jsx
@@ -0,0 +1,122 @@
+import fs from "node:fs";
+import path from "node:path";
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { DismissableLayer } from "radix-ui/internal";
+import { toast } from "sonner";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { Modal } from "@/components/ui/shims/antd-overlays";
+import { Toaster } from "@/components/ui/sonner";
+
+/**
+ * A toast raised BY an open modal has to stay usable THROUGH it.
+ *
+ * Reported against the Workflows page: "New Workflow" with a duplicate name
+ * toasts the backend error and the toast's close button then does nothing.
+ * Two independent mechanisms conspire, so both halves are pinned here:
+ *
+ * 1. Radix sets `pointer-events: none` on for a modal Dialog. Sonner's
+ * viewport is an ordinary body-level element, so it inherits that and the
+ * toasts stop taking clicks at all. Fixed by `[data-sonner-toaster]` in
+ * index.css — asserted statically, since vitest runs with `css: false`.
+ * 2. Once the clicks land, Radix reads them as an interaction OUTSIDE the
+ * dialog and dismisses it — closing the form the user was mid-way through
+ * correcting. Fixed by the DismissableLayer.Branch wrap in App.jsx.
+ */
+
+function Harness({ onCancel }) {
+ return (
+ <>
+
+ elsewhere on the page
+
+
+ workflow form
+
+
+
+
+ >
+ );
+}
+
+async function openModalWithToast(onCancel) {
+ render( );
+ await screen.findByText("workflow form");
+ act(() => {
+ toast.error("workflow_name: A workflow with this name already exists.");
+ });
+ await screen.findByText(
+ "workflow_name: A workflow with this name already exists.",
+ );
+ // Radix arms its outside-pointerdown listener in a queued task; without
+ // waiting for it the "outside" control below passes vacuously.
+ await waitFor(() => {
+ expect(document.body.style.pointerEvents).toBe("none");
+ });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+}
+
+describe("toast stack raised by an open modal", () => {
+ afterEach(() => {
+ act(() => {
+ toast.dismiss();
+ });
+ document.body.style.pointerEvents = "";
+ });
+
+ it("keeps the dialog open when its own toast is dismissed", async () => {
+ const onCancel = vi.fn();
+ await openModalWithToast(onCancel);
+
+ const closeToast = screen.getByLabelText("Close toast");
+ fireEvent.pointerDown(closeToast, { button: 0 });
+ fireEvent.click(closeToast);
+
+ expect(onCancel).not.toHaveBeenCalled();
+ expect(screen.getByText("workflow form")).toBeInTheDocument();
+ await waitFor(() => {
+ expect(
+ screen.queryByText(
+ "workflow_name: A workflow with this name already exists.",
+ ),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("still closes on a genuine click outside the dialog", async () => {
+ const onCancel = vi.fn();
+ await openModalWithToast(onCancel);
+
+ // Dialog runs with `deferPointerDownOutside`, so it is the CLICK that
+ // dismisses, not the pointerdown — fire the pair the toast case fires.
+ const outside = screen.getByTestId("outside");
+ fireEvent.pointerDown(outside, { button: 0 });
+ fireEvent.click(outside);
+
+ await waitFor(() => {
+ expect(onCancel).toHaveBeenCalled();
+ });
+ });
+
+ it("re-enables pointer events on sonner's viewport", () => {
+ const css = fs.readFileSync(
+ path.resolve(import.meta.dirname, "../../index.css"),
+ "utf-8",
+ );
+ const rule = css.match(/\[data-sonner-toaster\]\s*\{([^}]*)\}/);
+
+ expect(
+ rule,
+ "index.css must re-enable pointer events on the toaster",
+ ).not.toBeNull();
+ expect(rule[1]).toMatch(/pointer-events:\s*auto/);
+ });
+});
diff --git a/frontend/src/components/oauth-ds/google/GoogleOAuthButton.jsx b/frontend/src/components/oauth-ds/google/GoogleOAuthButton.jsx
index ec5122e370..7983d9956c 100644
--- a/frontend/src/components/oauth-ds/google/GoogleOAuthButton.jsx
+++ b/frontend/src/components/oauth-ds/google/GoogleOAuthButton.jsx
@@ -1,7 +1,7 @@
-import { Typography } from "antd";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
import { GoogleLoginButton } from "react-social-login-buttons";
+import { Typography } from "@/components/ui/shims/antd-typography";
import "./GoogleOAuthButton.css";
diff --git a/frontend/src/components/oauth-ds/microsoft/MicrosoftOAuthButton.jsx b/frontend/src/components/oauth-ds/microsoft/MicrosoftOAuthButton.jsx
index 1f332b37c7..0444cfb3c9 100644
--- a/frontend/src/components/oauth-ds/microsoft/MicrosoftOAuthButton.jsx
+++ b/frontend/src/components/oauth-ds/microsoft/MicrosoftOAuthButton.jsx
@@ -1,7 +1,7 @@
-import { Typography } from "antd";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
import { MicrosoftLoginButton } from "react-social-login-buttons";
+import { Typography } from "@/components/ui/shims/antd-typography";
import "./MicrosoftOAuthButton.css";
diff --git a/frontend/src/components/oauth-ds/oauth-ds/OAuthDs.jsx b/frontend/src/components/oauth-ds/oauth-ds/OAuthDs.jsx
index a52ee68477..cc40c50bbf 100644
--- a/frontend/src/components/oauth-ds/oauth-ds/OAuthDs.jsx
+++ b/frontend/src/components/oauth-ds/oauth-ds/OAuthDs.jsx
@@ -1,6 +1,6 @@
-import { Typography } from "antd";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { Typography } from "@/components/ui/shims/antd-typography";
import { getBaseUrl, O_AUTH_PROVIDERS } from "../../../helpers/GetStaticData";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate.js";
diff --git a/frontend/src/components/oauth-ds/oauth-status/OAuthStatus.jsx b/frontend/src/components/oauth-ds/oauth-status/OAuthStatus.jsx
index 6edb9e7293..7d1c44dcd9 100644
--- a/frontend/src/components/oauth-ds/oauth-status/OAuthStatus.jsx
+++ b/frontend/src/components/oauth-ds/oauth-status/OAuthStatus.jsx
@@ -1,5 +1,5 @@
-import { Typography } from "antd";
import { useLocation } from "react-router-dom";
+import { Typography } from "@/components/ui/shims/antd-typography";
import "./OAuthStatus.css";
diff --git a/frontend/src/components/onboard/OnBoard.jsx b/frontend/src/components/onboard/OnBoard.jsx
index 8b138d3f1a..5f89757db7 100644
--- a/frontend/src/components/onboard/OnBoard.jsx
+++ b/frontend/src/components/onboard/OnBoard.jsx
@@ -1,7 +1,10 @@
-import { CheckCircleFilled } from "@ant-design/icons";
-import { Button, Card, Col, Layout, Row, Space, Typography } from "antd";
+import { CircleCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Col, Row, Space } from "@/components/ui/shims/antd-layout";
+import { Card, Layout } from "@/components/ui/shims/antd-structure";
+import { Typography } from "@/components/ui/shims/antd-typography";
import ConnectEmbedding from "../../assets/connect_embedding.svg";
import ConnectLLM from "../../assets/connect_llm.svg";
import ConnectVectorDb from "../../assets/connect_vector_db.svg";
@@ -12,6 +15,7 @@ import { useSessionStore } from "../../store/session-store.js";
import { AddSourceModal } from "../input-output/add-source-modal/AddSourceModal.jsx";
import { CustomButton } from "../widgets/custom-button/CustomButton.jsx";
import "./onBoard.css";
+
const { Content } = Layout;
function OnBoard() {
@@ -111,7 +115,7 @@ function OnBoard() {
{adaptersList?.includes(step.type) ? (
-
+
Configured
) : (
diff --git a/frontend/src/components/onboard/onBoard.css b/frontend/src/components/onboard/onBoard.css
index 6bc5801a9d..57cd3b5651 100644
--- a/frontend/src/components/onboard/onBoard.css
+++ b/frontend/src/components/onboard/onBoard.css
@@ -117,8 +117,13 @@
}
.configured-icon {
- color: #52c41a;
+ color: var(--success);
font-size: 24px;
+ /* width/height, not font-size alone: antd shipped an icon FONT (sized by
+ * font-size); lucide ships SVGs, which ignore it and fall back to their
+ * own 24px default. font-size is kept for any text in the same element. */
+ width: 24px;
+ height: 24px;
vertical-align: middle;
}
diff --git a/frontend/src/components/pipelines-or-deployments/delete-modal/DeleteModal.jsx b/frontend/src/components/pipelines-or-deployments/delete-modal/DeleteModal.jsx
index d852bfaf69..545ae68e99 100644
--- a/frontend/src/components/pipelines-or-deployments/delete-modal/DeleteModal.jsx
+++ b/frontend/src/components/pipelines-or-deployments/delete-modal/DeleteModal.jsx
@@ -1,5 +1,5 @@
-import { Modal } from "antd";
import PropTypes from "prop-types";
+import { Modal } from "@/components/ui/shims/antd-overlays";
const DeleteModal = ({ open, setOpen, deleteRecord }) => {
return (
diff --git a/frontend/src/components/pipelines-or-deployments/etl-task-deploy/EtlTaskDeploy.jsx b/frontend/src/components/pipelines-or-deployments/etl-task-deploy/EtlTaskDeploy.jsx
index a6330bdb21..7bfbd65172 100644
--- a/frontend/src/components/pipelines-or-deployments/etl-task-deploy/EtlTaskDeploy.jsx
+++ b/frontend/src/components/pipelines-or-deployments/etl-task-deploy/EtlTaskDeploy.jsx
@@ -1,8 +1,13 @@
-import { ClockCircleOutlined, ScheduleOutlined } from "@ant-design/icons";
-import { Button, Form, Input, Modal, Select, Space, Typography } from "antd";
import cronstrue from "cronstrue";
+import { CalendarClock, Clock } from "lucide-react";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Form } from "@/components/ui/shims/antd-form";
+import { Input, Select } from "@/components/ui/shims/antd-inputs";
+import { Space } from "@/components/ui/shims/antd-layout";
+import { Modal } from "@/components/ui/shims/antd-overlays";
+import { Typography } from "@/components/ui/shims/antd-typography";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate.js";
import { useAlertStore } from "../../../store/alert-store";
@@ -329,7 +334,7 @@ const EtlTaskDeploy = ({
}
help={getBackendErrorDetail("workflow", backendErrors)}
>
-
+
{workflowList.map((workflow) => {
return (
@@ -357,14 +362,14 @@ const EtlTaskDeploy = ({
}
+ icon={ }
className="cron-string-btn"
/>
-
+
diff --git a/frontend/src/components/pipelines-or-deployments/file-history-modal/FileHistoryModal.css b/frontend/src/components/pipelines-or-deployments/file-history-modal/FileHistoryModal.css
index 814c879014..869f661955 100644
--- a/frontend/src/components/pipelines-or-deployments/file-history-modal/FileHistoryModal.css
+++ b/frontend/src/components/pipelines-or-deployments/file-history-modal/FileHistoryModal.css
@@ -118,5 +118,5 @@
}
.warning-icon {
- color: #faad14;
+ color: var(--warning);
}
diff --git a/frontend/src/components/pipelines-or-deployments/file-history-modal/FileHistoryModal.jsx b/frontend/src/components/pipelines-or-deployments/file-history-modal/FileHistoryModal.jsx
index 6dfbde0d21..4c076cdfe7 100644
--- a/frontend/src/components/pipelines-or-deployments/file-history-modal/FileHistoryModal.jsx
+++ b/frontend/src/components/pipelines-or-deployments/file-history-modal/FileHistoryModal.jsx
@@ -1,37 +1,31 @@
import {
- ClearOutlined,
- CopyOutlined,
- DeleteOutlined,
- ExclamationCircleFilled,
- FilterOutlined,
- ReloadOutlined,
-} from "@ant-design/icons";
+ CircleAlert,
+ Copy,
+ Eraser,
+ Filter,
+ RotateCw,
+ Trash2,
+} from "lucide-react";
+import PropTypes from "prop-types";
+import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Input, InputNumber, Select } from "@/components/ui/shims/antd-inputs";
+import { Col, Row, Space } from "@/components/ui/shims/antd-layout";
+import { Tag } from "@/components/ui/shims/antd-leaves";
import {
- Button,
- Col,
- Input,
- InputNumber,
Modal,
- message,
Popconfirm,
- Row,
- Select,
- Space,
- Table,
- Tag,
Tooltip,
- Typography,
-} from "antd";
-import PropTypes from "prop-types";
-import { useEffect, useState } from "react";
+} from "@/components/ui/shims/antd-overlays";
+import { Table } from "@/components/ui/shims/antd-structure";
+import { Text, Typography } from "@/components/ui/shims/antd-typography";
+import { message } from "@/hooks/useAppToast";
import { copyToClipboard } from "../../../helpers/GetStaticData";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx";
import { useAlertStore } from "../../../store/alert-store.js";
import { workflowService } from "../../workflows/workflow/workflow-service.js";
import "./FileHistoryModal.css";
-const { Text } = Typography;
-
const MAX_BULK_DELETE = 100;
const FileHistoryModal = ({ open, setOpen, workflowId, workflowName }) => {
@@ -385,7 +379,7 @@ const FileHistoryModal = ({ open, setOpen, workflowId, workflowName }) => {
}
+ icon={ }
onClick={(e) => {
e.stopPropagation();
handleCopy(text, "File path");
@@ -467,7 +461,7 @@ const FileHistoryModal = ({ open, setOpen, workflowId, workflowName }) => {
}
+ icon={ }
onClick={(e) => {
e.stopPropagation();
handleCopy(error, "Error message");
@@ -491,7 +485,7 @@ const FileHistoryModal = ({ open, setOpen, workflowId, workflowName }) => {
okText="Yes"
cancelText="No"
>
- } size="small">
+ } size="small">
Delete
@@ -575,14 +569,14 @@ const FileHistoryModal = ({ open, setOpen, workflowId, workflowName }) => {
}
+ icon={ }
onClick={handleApplyFilters}
className="flex-button"
>
{hasUnappliedChanges ? "Apply *" : "Apply"}
}
+ icon={ }
onClick={handleResetFilters}
className="flex-button"
>
@@ -606,7 +600,7 @@ const FileHistoryModal = ({ open, setOpen, workflowId, workflowName }) => {
>
}
+ icon={ }
disabled={selectedRowKeys.length === 0}
>
Delete Selected ({selectedRowKeys.length})
@@ -614,7 +608,7 @@ const FileHistoryModal = ({ open, setOpen, workflowId, workflowName }) => {
}
+ icon={ }
onClick={handlePrepareBulkClear}
loading={fetchingCount}
disabled={!hasAppliedFilters || pagination.total === 0}
@@ -626,7 +620,7 @@ const FileHistoryModal = ({ open, setOpen, workflowId, workflowName }) => {
-
+
Clear with filters
}
@@ -654,7 +648,7 @@ const FileHistoryModal = ({ open, setOpen, workflowId, workflowName }) => {
}
+ icon={ }
onClick={() =>
fetchFileHistories(pagination.current, pagination.pageSize)
}
diff --git a/frontend/src/components/pipelines-or-deployments/log-modal/LogsModal.jsx b/frontend/src/components/pipelines-or-deployments/log-modal/LogsModal.jsx
index 9ab7e69f3b..e010a6321c 100644
--- a/frontend/src/components/pipelines-or-deployments/log-modal/LogsModal.jsx
+++ b/frontend/src/components/pipelines-or-deployments/log-modal/LogsModal.jsx
@@ -1,6 +1,8 @@
-import { Button, Modal, Table } from "antd";
import PropTypes from "prop-types";
import { useState } from "react";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Modal } from "@/components/ui/shims/antd-overlays";
+import { Table } from "@/components/ui/shims/antd-structure";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate.js";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx";
import { useAlertStore } from "../../../store/alert-store.js";
diff --git a/frontend/src/components/pipelines-or-deployments/notification-modal/CreateNotification.jsx b/frontend/src/components/pipelines-or-deployments/notification-modal/CreateNotification.jsx
index 21d274687c..5ed4382b1a 100644
--- a/frontend/src/components/pipelines-or-deployments/notification-modal/CreateNotification.jsx
+++ b/frontend/src/components/pipelines-or-deployments/notification-modal/CreateNotification.jsx
@@ -1,6 +1,9 @@
-import { Button, Checkbox, Form, Input, Select, Space } from "antd";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Form } from "@/components/ui/shims/antd-form";
+import { Checkbox, Input, Select } from "@/components/ui/shims/antd-inputs";
+import { Space } from "@/components/ui/shims/antd-layout";
import { getBackendErrorDetail } from "../../../helpers/GetStaticData";
const DEFAULT_FORM_DETAILS = {
diff --git a/frontend/src/components/pipelines-or-deployments/notification-modal/DisplayNotifications.jsx b/frontend/src/components/pipelines-or-deployments/notification-modal/DisplayNotifications.jsx
index d504f55637..cbc3bfd56c 100644
--- a/frontend/src/components/pipelines-or-deployments/notification-modal/DisplayNotifications.jsx
+++ b/frontend/src/components/pipelines-or-deployments/notification-modal/DisplayNotifications.jsx
@@ -1,6 +1,10 @@
-import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons";
-import { Button, Space, Switch, Table, Tooltip } from "antd";
+import { Pencil, Plus, Trash2 } from "lucide-react";
import PropTypes from "prop-types";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Switch } from "@/components/ui/shims/antd-inputs";
+import { Space } from "@/components/ui/shims/antd-layout";
+import { Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Table } from "@/components/ui/shims/antd-structure";
import { ConfirmModal } from "../../widgets/confirm-modal/ConfirmModal";
import SpaceWrapper from "../../widgets/space-wrapper/SpaceWrapper";
import { SpinnerLoader } from "../../widgets/spinner-loader/SpinnerLoader";
@@ -47,7 +51,7 @@ function DisplayNotifications({
}
+ icon={ }
onClick={() => handleEdit(record)}
/>
@@ -56,7 +60,7 @@ function DisplayNotifications({
handleConfirm={() => handleDelete(record?.id, record?.name)}
content="Are you sure you want to delete?"
>
- } />
+ } />
@@ -72,11 +76,7 @@ function DisplayNotifications({
return (
- }
- onClick={() => setIsForm(true)}
- >
+ } onClick={() => setIsForm(true)}>
Create Notification
diff --git a/frontend/src/components/pipelines-or-deployments/notification-modal/NotificationModal.jsx b/frontend/src/components/pipelines-or-deployments/notification-modal/NotificationModal.jsx
index 61a2e93925..e94ae6143f 100644
--- a/frontend/src/components/pipelines-or-deployments/notification-modal/NotificationModal.jsx
+++ b/frontend/src/components/pipelines-or-deployments/notification-modal/NotificationModal.jsx
@@ -1,6 +1,6 @@
-import { Modal } from "antd";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { Modal } from "@/components/ui/shims/antd-overlays";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler";
import { useAlertStore } from "../../../store/alert-store";
import { pipelineService } from "../pipeline-service";
diff --git a/frontend/src/components/pipelines-or-deployments/pipelines/PipelineCardConfig.jsx b/frontend/src/components/pipelines-or-deployments/pipelines/PipelineCardConfig.jsx
index 2125041df1..9dfa69a976 100644
--- a/frontend/src/components/pipelines-or-deployments/pipelines/PipelineCardConfig.jsx
+++ b/frontend/src/components/pipelines-or-deployments/pipelines/PipelineCardConfig.jsx
@@ -1,21 +1,25 @@
-import {
- AppstoreOutlined,
- CalendarOutlined,
- CheckCircleFilled,
- ClearOutlined,
- CloseCircleFilled,
- CloudDownloadOutlined,
- FileSearchOutlined,
- HistoryOutlined,
- KeyOutlined,
- NotificationOutlined,
- ScheduleOutlined,
- SyncOutlined,
-} from "@ant-design/icons";
-import { Avatar, Flex, Space, Switch, Tag, Tooltip, Typography } from "antd";
import cronstrue from "cronstrue";
+import {
+ Bell,
+ Calendar,
+ CalendarClock,
+ CircleCheck,
+ CircleX,
+ CloudDownload,
+ Eraser,
+ FileSearch,
+ History,
+ Key,
+ LayoutGrid,
+ RefreshCw,
+} from "lucide-react";
import PropTypes from "prop-types";
import { useLocation, useNavigate } from "react-router-dom";
+import { Switch } from "@/components/ui/shims/antd-inputs";
+import { Flex, Space } from "@/components/ui/shims/antd-layout";
+import { Avatar, Tag } from "@/components/ui/shims/antd-leaves";
+import { Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Typography } from "@/components/ui/shims/antd-typography";
import { formattedDateTime } from "../../../helpers/GetStaticData";
import { useSessionStore } from "../../../store/session-store";
import {
@@ -111,10 +115,10 @@ function StatusPills({
{hasFileCounts && (
- {run.successful_files}
+ {run.successful_files}
- {run.failed_files}
+ {run.failed_files}
)}
@@ -189,12 +193,7 @@ function ConnectorFieldRow({ label, icon, instanceName, connectorName }) {
{label}
- }
- />
+ } />
{instanceName || connectorName}
@@ -266,19 +265,19 @@ function createPipelineCardConfig({
items: [
{
key: "view-logs",
- icon: ,
+ icon: ,
label: "View Logs",
onClick: () => onViewLogs?.(pipeline),
},
{
key: "file-history",
- icon: ,
+ icon: ,
label: "View File History",
onClick: () => onViewFileHistory?.(pipeline),
},
{
key: "clear-history",
- icon: ,
+ icon: ,
label: isClearingFileHistory ? "Clearing..." : "Clear File History",
disabled: isClearingFileHistory,
onClick: () => onClearFileHistory?.(pipeline),
@@ -286,27 +285,27 @@ function createPipelineCardConfig({
{ type: "divider" },
{
key: "sync-now",
- icon: ,
+ icon: ,
label: "Sync Now",
onClick: () => onSyncNow?.(pipeline),
},
{ type: "divider" },
{
key: "manage-keys",
- icon: ,
+ icon: ,
label: "Manage Keys",
onClick: () => onManageKeys?.(pipeline),
},
{
key: "notifications",
- icon: ,
+ icon: ,
label: "Notifications",
onClick: () => onSetupNotifications?.(pipeline),
},
{ type: "divider" },
{
key: "download-postman",
- icon: ,
+ icon: ,
label: "Download Postman Collection",
onClick: () => onDownloadPostman?.(pipeline),
},
@@ -330,6 +329,7 @@ function createPipelineCardConfig({
{
e.stopPropagation();
handleEnablePipeline(checked, pipeline.id);
@@ -338,6 +338,7 @@ function createPipelineCardConfig({
-
+
{formattedDateTime(pipeline.next_run_time)}
@@ -407,7 +408,7 @@ function createPipelineCardConfig({
{/* Footer: Schedule | Total Runs */}
-
+
{scheduleDisplay}
-
+
}
+ icon={ }
onClick={() => navigate(-1)}
/>
@@ -106,11 +105,7 @@ function Profile() {
{/* Secondary header bar - outside white container */}
-
}
- onClick={() => navigate(-1)}
- />
+
} onClick={() => navigate(-1)} />
Profile
@@ -124,7 +119,7 @@ function Profile() {
-
+
@@ -148,7 +143,7 @@ function Profile() {
{userName}
-
+
@@ -159,7 +154,7 @@ function Profile() {
{email}
-
+
@@ -216,7 +211,7 @@ function Profile() {
>
}
+ icon={
}
className="copy-button"
onClick={() => handleCopy(orgId, "Organization ID")}
disabled={!orgId}
@@ -230,7 +225,7 @@ function Profile() {
Your Role
-
+
{role}
diff --git a/frontend/src/components/rjsf-custom-widgets/alt-date-time-widget/AltDateTimeWidget.jsx b/frontend/src/components/rjsf-custom-widgets/alt-date-time-widget/AltDateTimeWidget.jsx
index 3a20feb3a4..dcf61c839b 100644
--- a/frontend/src/components/rjsf-custom-widgets/alt-date-time-widget/AltDateTimeWidget.jsx
+++ b/frontend/src/components/rjsf-custom-widgets/alt-date-time-widget/AltDateTimeWidget.jsx
@@ -1,6 +1,6 @@
-import { DatePicker, TimePicker } from "antd";
import moment from "moment";
import PropTypes from "prop-types";
+import { DatePicker, TimePicker } from "@/components/ui/shims/antd-datetime";
import { RjsfWidgetLayout } from "../../../layouts/rjsf-widget-layout/RjsfWidgetLayout.jsx";
diff --git a/frontend/src/components/rjsf-custom-widgets/alt-date-widget/AltDateWidget.jsx b/frontend/src/components/rjsf-custom-widgets/alt-date-widget/AltDateWidget.jsx
index d683ed55d3..2d1108db70 100644
--- a/frontend/src/components/rjsf-custom-widgets/alt-date-widget/AltDateWidget.jsx
+++ b/frontend/src/components/rjsf-custom-widgets/alt-date-widget/AltDateWidget.jsx
@@ -1,6 +1,6 @@
-import { DatePicker } from "antd";
import moment from "moment";
import PropTypes from "prop-types";
+import { DatePicker } from "@/components/ui/shims/antd-datetime";
import { RjsfWidgetLayout } from "../../../layouts/rjsf-widget-layout/RjsfWidgetLayout.jsx";
diff --git a/frontend/src/components/rjsf-custom-widgets/array-field/ArrayField.jsx b/frontend/src/components/rjsf-custom-widgets/array-field/ArrayField.jsx
index bc91174c79..ae76993bea 100644
--- a/frontend/src/components/rjsf-custom-widgets/array-field/ArrayField.jsx
+++ b/frontend/src/components/rjsf-custom-widgets/array-field/ArrayField.jsx
@@ -1,6 +1,6 @@
-import { Select } from "antd";
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { Select } from "@/components/ui/shims/antd-inputs";
import { RjsfWidgetLayout } from "../../../layouts/rjsf-widget-layout/RjsfWidgetLayout.jsx";
diff --git a/frontend/src/components/rjsf-custom-widgets/checkbox-widget/CheckboxWidget.jsx b/frontend/src/components/rjsf-custom-widgets/checkbox-widget/CheckboxWidget.jsx
index d598d4e469..7bfee4ee2b 100644
--- a/frontend/src/components/rjsf-custom-widgets/checkbox-widget/CheckboxWidget.jsx
+++ b/frontend/src/components/rjsf-custom-widgets/checkbox-widget/CheckboxWidget.jsx
@@ -1,5 +1,7 @@
-import { Checkbox, Space, Typography } from "antd";
import PropTypes from "prop-types";
+import { Checkbox } from "@/components/ui/shims/antd-inputs";
+import { Space } from "@/components/ui/shims/antd-layout";
+import { Typography } from "@/components/ui/shims/antd-typography";
import "./CheckboxWidget.css";
import CustomMarkdown from "../../helpers/custom-markdown/CustomMarkdown";
diff --git a/frontend/src/components/rjsf-custom-widgets/checkboxes-widget/CheckboxesWidget.jsx b/frontend/src/components/rjsf-custom-widgets/checkboxes-widget/CheckboxesWidget.jsx
index 8e6b216345..09d3643731 100644
--- a/frontend/src/components/rjsf-custom-widgets/checkboxes-widget/CheckboxesWidget.jsx
+++ b/frontend/src/components/rjsf-custom-widgets/checkboxes-widget/CheckboxesWidget.jsx
@@ -1,5 +1,5 @@
-import { Checkbox } from "antd";
import PropTypes from "prop-types";
+import { Checkbox } from "@/components/ui/shims/antd-inputs";
import { RjsfWidgetLayout } from "../../../layouts/rjsf-widget-layout/RjsfWidgetLayout.jsx";
diff --git a/frontend/src/components/rjsf-custom-widgets/color-widget/ColorWidget.jsx b/frontend/src/components/rjsf-custom-widgets/color-widget/ColorWidget.jsx
index e59f5dd9fd..6907e46468 100644
--- a/frontend/src/components/rjsf-custom-widgets/color-widget/ColorWidget.jsx
+++ b/frontend/src/components/rjsf-custom-widgets/color-widget/ColorWidget.jsx
@@ -1,5 +1,5 @@
-import { Input } from "antd";
import PropTypes from "prop-types";
+import { Input } from "@/components/ui/shims/antd-inputs";
import { RjsfWidgetLayout } from "../../../layouts/rjsf-widget-layout/RjsfWidgetLayout.jsx";
diff --git a/frontend/src/components/rjsf-custom-widgets/date-time-widget/DateTimeWidget.jsx b/frontend/src/components/rjsf-custom-widgets/date-time-widget/DateTimeWidget.jsx
index 74d28bc991..57db9293b1 100644
--- a/frontend/src/components/rjsf-custom-widgets/date-time-widget/DateTimeWidget.jsx
+++ b/frontend/src/components/rjsf-custom-widgets/date-time-widget/DateTimeWidget.jsx
@@ -1,6 +1,6 @@
-import { DatePicker } from "antd";
import moment from "moment";
import PropTypes from "prop-types";
+import { DatePicker } from "@/components/ui/shims/antd-datetime";
import { RjsfWidgetLayout } from "../../../layouts/rjsf-widget-layout/RjsfWidgetLayout.jsx";
diff --git a/frontend/src/components/rjsf-custom-widgets/date-widget/DateWidget.jsx b/frontend/src/components/rjsf-custom-widgets/date-widget/DateWidget.jsx
index 5e4e5cd11c..5c96477c67 100644
--- a/frontend/src/components/rjsf-custom-widgets/date-widget/DateWidget.jsx
+++ b/frontend/src/components/rjsf-custom-widgets/date-widget/DateWidget.jsx
@@ -1,6 +1,6 @@
-import { DatePicker } from "antd";
import moment from "moment";
import PropTypes from "prop-types";
+import { DatePicker } from "@/components/ui/shims/antd-datetime";
import { RjsfWidgetLayout } from "../../../layouts/rjsf-widget-layout/RjsfWidgetLayout.jsx";
diff --git a/frontend/src/components/rjsf-custom-widgets/email-widget/EmailWidget.jsx b/frontend/src/components/rjsf-custom-widgets/email-widget/EmailWidget.jsx
index 183e0c0b75..d8fd05db32 100644
--- a/frontend/src/components/rjsf-custom-widgets/email-widget/EmailWidget.jsx
+++ b/frontend/src/components/rjsf-custom-widgets/email-widget/EmailWidget.jsx
@@ -1,5 +1,5 @@
-import { Input } from "antd";
import PropTypes from "prop-types";
+import { Input } from "@/components/ui/shims/antd-inputs";
import { RjsfWidgetLayout } from "../../../layouts/rjsf-widget-layout/RjsfWidgetLayout.jsx";
diff --git a/frontend/src/components/rjsf-custom-widgets/file-widget/FileWidget.jsx b/frontend/src/components/rjsf-custom-widgets/file-widget/FileWidget.jsx
index 9a43dcaaa1..bdda6faa59 100644
--- a/frontend/src/components/rjsf-custom-widgets/file-widget/FileWidget.jsx
+++ b/frontend/src/components/rjsf-custom-widgets/file-widget/FileWidget.jsx
@@ -1,6 +1,10 @@
-import { UploadOutlined } from "@ant-design/icons";
-import { Button, Upload } from "antd";
+// Aliased: antd's `Upload` component (still in use until P3) would otherwise be
+// shadowed by the lucide icon of the same name.
+
+import { Upload as UploadIcon } from "lucide-react";
import PropTypes from "prop-types";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Upload } from "@/components/ui/shims/antd-structure";
import { RjsfWidgetLayout } from "../../../layouts/rjsf-widget-layout/RjsfWidgetLayout.jsx";
@@ -20,7 +24,7 @@ const FileWidget = ({ id, onChange, label, schema, required, readonly }) => {
required={required}
>
- }>Upload File
+ }>Upload File
);
diff --git a/frontend/src/components/rjsf-custom-widgets/password-widget/PasswordWidget.jsx b/frontend/src/components/rjsf-custom-widgets/password-widget/PasswordWidget.jsx
index b21d5339aa..fc7957a8ba 100644
--- a/frontend/src/components/rjsf-custom-widgets/password-widget/PasswordWidget.jsx
+++ b/frontend/src/components/rjsf-custom-widgets/password-widget/PasswordWidget.jsx
@@ -1,5 +1,5 @@
-import { Input } from "antd";
import PropTypes from "prop-types";
+import { Input } from "@/components/ui/shims/antd-inputs";
import { RjsfWidgetLayout } from "../../../layouts/rjsf-widget-layout/RjsfWidgetLayout.jsx";
diff --git a/frontend/src/components/rjsf-custom-widgets/select-widget/SelectWidget.jsx b/frontend/src/components/rjsf-custom-widgets/select-widget/SelectWidget.jsx
index 6540dfdab4..7f29c3a522 100644
--- a/frontend/src/components/rjsf-custom-widgets/select-widget/SelectWidget.jsx
+++ b/frontend/src/components/rjsf-custom-widgets/select-widget/SelectWidget.jsx
@@ -1,5 +1,8 @@
-import { Form, Select, Space, Typography } from "antd";
import PropTypes from "prop-types";
+import { Form } from "@/components/ui/shims/antd-form";
+import { Select } from "@/components/ui/shims/antd-inputs";
+import { Space } from "@/components/ui/shims/antd-layout";
+import { Typography } from "@/components/ui/shims/antd-typography";
import CustomMarkdown from "../../helpers/custom-markdown/CustomMarkdown";
diff --git a/frontend/src/components/rjsf-custom-widgets/text-widget/TextWidget.jsx b/frontend/src/components/rjsf-custom-widgets/text-widget/TextWidget.jsx
index 30017f32b9..d3fc0e4d8f 100644
--- a/frontend/src/components/rjsf-custom-widgets/text-widget/TextWidget.jsx
+++ b/frontend/src/components/rjsf-custom-widgets/text-widget/TextWidget.jsx
@@ -1,5 +1,5 @@
-import { Input } from "antd";
import PropTypes from "prop-types";
+import { Input } from "@/components/ui/shims/antd-inputs";
import { RjsfWidgetLayout } from "../../../layouts/rjsf-widget-layout/RjsfWidgetLayout.jsx";
diff --git a/frontend/src/components/rjsf-custom-widgets/time-widget/TimeWidget.jsx b/frontend/src/components/rjsf-custom-widgets/time-widget/TimeWidget.jsx
index 81c371c761..5df0884f4f 100644
--- a/frontend/src/components/rjsf-custom-widgets/time-widget/TimeWidget.jsx
+++ b/frontend/src/components/rjsf-custom-widgets/time-widget/TimeWidget.jsx
@@ -1,6 +1,6 @@
-import { TimePicker } from "antd";
import moment from "moment";
import PropTypes from "prop-types";
+import { TimePicker } from "@/components/ui/shims/antd-datetime";
import { RjsfWidgetLayout } from "../../../layouts/rjsf-widget-layout/RjsfWidgetLayout.jsx";
diff --git a/frontend/src/components/rjsf-custom-widgets/up-down-widget/UpDownWidget.jsx b/frontend/src/components/rjsf-custom-widgets/up-down-widget/UpDownWidget.jsx
index e33f8b0aed..a27be4966e 100644
--- a/frontend/src/components/rjsf-custom-widgets/up-down-widget/UpDownWidget.jsx
+++ b/frontend/src/components/rjsf-custom-widgets/up-down-widget/UpDownWidget.jsx
@@ -1,5 +1,5 @@
-import { InputNumber } from "antd";
import PropTypes from "prop-types";
+import { InputNumber } from "@/components/ui/shims/antd-inputs";
import { RjsfWidgetLayout } from "../../../layouts/rjsf-widget-layout/RjsfWidgetLayout.jsx";
diff --git a/frontend/src/components/rjsf-custom-widgets/url-widget/URLWidget.jsx b/frontend/src/components/rjsf-custom-widgets/url-widget/URLWidget.jsx
index 4b501203e9..fb22cdad31 100644
--- a/frontend/src/components/rjsf-custom-widgets/url-widget/URLWidget.jsx
+++ b/frontend/src/components/rjsf-custom-widgets/url-widget/URLWidget.jsx
@@ -1,5 +1,5 @@
-import { Input } from "antd";
import PropTypes from "prop-types";
+import { Input } from "@/components/ui/shims/antd-inputs";
import { RjsfWidgetLayout } from "../../../layouts/rjsf-widget-layout/RjsfWidgetLayout";
diff --git a/frontend/src/components/set-org/SetOrg.css b/frontend/src/components/set-org/SetOrg.css
index bb63c5ef57..125f0e8772 100644
--- a/frontend/src/components/set-org/SetOrg.css
+++ b/frontend/src/components/set-org/SetOrg.css
@@ -98,7 +98,7 @@
}
.org-card-container .ant-card-meta-title {
- color: #0d3a63;
+ color: var(--foreground);
font-weight: 700;
font-size: 20px;
text-overflow: ellipsis;
diff --git a/frontend/src/components/set-org/SetOrg.jsx b/frontend/src/components/set-org/SetOrg.jsx
index bb2c00943d..25f8585c43 100644
--- a/frontend/src/components/set-org/SetOrg.jsx
+++ b/frontend/src/components/set-org/SetOrg.jsx
@@ -1,7 +1,8 @@
-import { Button, Card } from "antd";
import Cookies from "js-cookie";
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Card } from "@/components/ui/shims/antd-structure";
import "./SetOrg.css"; // Import your CSS file for styling
import axios from "axios";
import Proptypes from "prop-types";
diff --git a/frontend/src/components/settings/api-key-manager/ApiKeyManager.css b/frontend/src/components/settings/api-key-manager/ApiKeyManager.css
index fe2081a452..06f08e5951 100644
--- a/frontend/src/components/settings/api-key-manager/ApiKeyManager.css
+++ b/frontend/src/components/settings/api-key-manager/ApiKeyManager.css
@@ -27,7 +27,11 @@
}
.api-key-manager__copy-icon {
- font-size: 12px;
+ /* width/height, not font-size alone: antd shipped an icon FONT (sized by
+ * font-size); lucide ships SVGs, which ignore it and fall back to their
+ * own 24px default. */
+ width: 12px;
+ height: 12px;
color: var(--ant-color-text-tertiary, rgba(0, 0, 0, 0.45));
}
diff --git a/frontend/src/components/settings/api-key-manager/ApiKeyManager.jsx b/frontend/src/components/settings/api-key-manager/ApiKeyManager.jsx
index fc77ba5ea5..3d2c265425 100644
--- a/frontend/src/components/settings/api-key-manager/ApiKeyManager.jsx
+++ b/frontend/src/components/settings/api-key-manager/ApiKeyManager.jsx
@@ -1,24 +1,13 @@
-import {
- ArrowLeftOutlined,
- CopyOutlined,
- DeleteOutlined,
- EditOutlined,
- PlusOutlined,
- SyncOutlined,
-} from "@ant-design/icons";
-import {
- Button,
- Form,
- Input,
- Modal,
- Switch,
- Table,
- Tooltip,
- Typography,
-} from "antd";
+import { ArrowLeft, Copy, Pencil, Plus, RotateCw, Trash2 } from "lucide-react";
import PropTypes from "prop-types";
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Form } from "@/components/ui/shims/antd-form";
+import { Input, Switch } from "@/components/ui/shims/antd-inputs";
+import { Modal, Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Table } from "@/components/ui/shims/antd-structure";
+import { Typography } from "@/components/ui/shims/antd-typography";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useCopyToClipboard } from "../../../hooks/useCopyToClipboard";
@@ -288,7 +277,7 @@ function ApiKeyManager({
{record?.key}
-
+
),
@@ -334,13 +323,13 @@ function ApiKeyManager({
okText="Rotate"
>
- } />
+ } />
}
+ icon={ }
onClick={() => openEditModal(record)}
/>
@@ -351,7 +340,7 @@ function ApiKeyManager({
okText="Delete"
>
- } />
+ } />
@@ -370,7 +359,7 @@ function ApiKeyManager({
navigate(`/${sessionDetails?.orgName}/settings/platform`)
}
>
-
+
{title}
@@ -382,7 +371,7 @@ function ApiKeyManager({
}
+ icon={
}
onClick={() => setIsCreateModalOpen(true)}
>
New Key
diff --git a/frontend/src/components/settings/default-triad/DefaultTriad.jsx b/frontend/src/components/settings/default-triad/DefaultTriad.jsx
index 4496f3faf5..d7d1a56754 100644
--- a/frontend/src/components/settings/default-triad/DefaultTriad.jsx
+++ b/frontend/src/components/settings/default-triad/DefaultTriad.jsx
@@ -1,7 +1,9 @@
-import { ArrowLeftOutlined } from "@ant-design/icons";
-import { Button, Select, Typography } from "antd";
+import { ArrowLeft } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Select } from "@/components/ui/shims/antd-inputs";
+import { Typography } from "@/components/ui/shims/antd-typography";
import { fetchAllPages } from "../../../helpers/pagination";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
@@ -176,7 +178,7 @@ function DefaultTriad() {
type="text"
onClick={() => navigate(`/${sessionDetails?.orgName}/tools`)}
>
-
+
Default LLM Profile
@@ -194,6 +196,7 @@ function DefaultTriad() {
{labelMap[type]}
handleDropdownChange(type, value)}
diff --git a/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx b/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx
index 1b0326fe0b..dacdfbbb3a 100644
--- a/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx
+++ b/frontend/src/components/settings/global-api-deployment-keys/GlobalApiDeploymentKeys.jsx
@@ -1,6 +1,9 @@
-import { Checkbox, Form, Select, Tag, Tooltip } from "antd";
import PropTypes from "prop-types";
import { useCallback, useEffect, useState } from "react";
+import { Form } from "@/components/ui/shims/antd-form";
+import { Checkbox, Select } from "@/components/ui/shims/antd-inputs";
+import { Tag } from "@/components/ui/shims/antd-leaves";
+import { Tooltip } from "@/components/ui/shims/antd-overlays";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx";
@@ -9,7 +12,10 @@ import { useSessionStore } from "../../../store/session-store";
import { ApiKeyManager } from "../api-key-manager/ApiKeyManager.jsx";
function DeploymentScopeFields({ form, deployments }) {
- const allowAll = Form.useWatch("allow_all_deployments", form);
+ // Seeded `false` to match the Form.Item below: the checks here read
+ // `=== false` to mean "explicitly unchecked", so an undefined first render
+ // would disable the picker and drop its required-rule for that frame.
+ const allowAll = Form.useWatch("allow_all_deployments", form, false);
return (
<>
navigate(`/${sessionDetails?.orgName}/tools`)}
>
-
+
Platform Settings
@@ -515,7 +505,7 @@ function PlatformSettings() {
size="small"
value={keys[keyIndex].key}
suffix={
-
copyText(keys[keyIndex].key)
}
@@ -543,7 +533,7 @@ function PlatformSettings() {
>
}
+ icon={ }
disabled={keyDetails?.id === null}
loading={isDeletingIndex === keyIndex}
/>
diff --git a/frontend/src/components/settings/settings/Settings.css b/frontend/src/components/settings/settings/Settings.css
index d89b9291b5..3fd037766e 100644
--- a/frontend/src/components/settings/settings/Settings.css
+++ b/frontend/src/components/settings/settings/Settings.css
@@ -3,7 +3,7 @@
.settings-container {
position: relative;
height: 100%;
- background-color: var(--page-bg-2);
+ background-color: var(--background);
}
.settings-sidebar {
@@ -20,7 +20,7 @@
.settings-menu-item {
padding: 12px 16px;
- font-size: 14px;
+ font-size: 13px;
cursor: pointer;
color: #000;
transition: all 0.2s;
@@ -62,6 +62,12 @@
flex-direction: column;
}
+/* antd nested `.ant-popover-inner` inside the overlay element; the shim puts
+ * both class names on ONE element, so the descendant form matched nothing and
+ * the panel kept shadcn's `p-4`. That doubled the padding to 16px and made the
+ * fly-out taller than it needed to be. `&` covers the merged element, the
+ * descendant form the nested case, so this holds either way. */
+.settings-popover-overlay.ant-popover-inner,
.settings-popover-overlay .ant-popover-inner {
padding: 8px;
border-radius: 8px;
diff --git a/frontend/src/components/settings/users/Users.css b/frontend/src/components/settings/users/Users.css
index 728683b842..5fd5273be4 100644
--- a/frontend/src/components/settings/users/Users.css
+++ b/frontend/src/components/settings/users/Users.css
@@ -1,5 +1,5 @@
.user-bg-col {
- background-color: var(--page-bg-2);
+ background-color: var(--background);
height: 100%;
}
diff --git a/frontend/src/components/settings/users/Users.jsx b/frontend/src/components/settings/users/Users.jsx
index 4d8dc1af90..da91c05820 100644
--- a/frontend/src/components/settings/users/Users.jsx
+++ b/frontend/src/components/settings/users/Users.jsx
@@ -1,13 +1,11 @@
-import {
- DeleteOutlined,
- EditOutlined,
- EllipsisOutlined,
- PlusOutlined,
- ReloadOutlined,
-} from "@ant-design/icons";
-import { Button, Dropdown, Modal, Space, Table, Typography } from "antd";
+import { Ellipsis, Pencil, Plus, RotateCw, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
+import { Button } from "@/components/ui/shims/antd-button";
+import { Space } from "@/components/ui/shims/antd-layout";
+import { Dropdown, Modal } from "@/components/ui/shims/antd-overlays";
+import { Table } from "@/components/ui/shims/antd-structure";
+import { Typography } from "@/components/ui/shims/antd-typography";
import "./Users.css";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
@@ -99,47 +97,61 @@ function Users() {
const isSsoLocalAuthz =
!!sessionDetails?.provider && !!sessionDetails?.disableSsoIdpAuthorization;
- const editItem = {
- key: "1",
- label: (
-
- navigate(`/${sessionDetails?.orgName}/users/edit`, {
- state: selectedUserEmail,
- })
- }
- >
-
-
-
-
- Edit
-
-
- ),
- };
+ /*
+ * The row each entry acts on is bound HERE, in the render closure, rather
+ * than recorded by an onClick on the kebab itself. The menu opens on
+ * pointerdown and then pins `pointer-events: none` on while it is
+ * open, so the click that would have followed on the kebab never lands: the
+ * row stayed unrecorded, Edit navigated to /users/edit with no state, and
+ * the page bounced to the dashboard. The Delete modal named no user for the
+ * same reason.
+ */
+ const getActionItems = (record) => {
+ const editItem = {
+ key: "1",
+ label: (
+
+ navigate(`/${sessionDetails?.orgName}/users/edit`, {
+ state: record,
+ })
+ }
+ >
+
+
+ Edit
+
+
+ ),
+ };
- const deleteItem = {
- key: "2",
- label: (
-
-
-
-
-
- Delete
-
-
- ),
- };
+ const deleteItem = {
+ key: "2",
+ label: (
+ {
+ setSelectedUserEmail(record);
+ showModal();
+ }}
+ >
+
+
+
+
+ Delete
+
+
+ ),
+ };
- const actionItems = isSsoLocalAuthz ? [editItem] : [editItem, deleteItem];
+ return isSsoLocalAuthz ? [editItem] : [editItem, deleteItem];
+ };
const baseColumns = [
{
@@ -157,15 +169,11 @@ function Users() {
align: "center",
render: (_, record) => (
- setSelectedUserEmail(record)}
- />
+
),
};
@@ -205,7 +213,7 @@ function Users() {
{!sessionDetails?.provider && (
}
+ icon={ }
onClick={handleInviteUsers}
>
Invite User
@@ -213,7 +221,7 @@ function Users() {
)}
}
+ icon={ }
onClick={getAllUsers}
className="user-reload-button"
/>
diff --git a/frontend/src/components/settings/users/Users.test.jsx b/frontend/src/components/settings/users/Users.test.jsx
new file mode 100644
index 0000000000..c51e583188
--- /dev/null
+++ b/frontend/src/components/settings/users/Users.test.jsx
@@ -0,0 +1,111 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * Manage Users' kebab menu, and which ROW its entries act on.
+ *
+ * The row used to be recorded by an `onClick` on the kebab icon itself — the
+ * Dropdown's trigger. Radix opens the menu on POINTERDOWN and pins
+ * `pointer-events: none` on for as long as it is open, so the click
+ * that would have followed never lands and the handler never runs. Edit
+ * therefore navigated to /users/edit carrying `state: undefined`, and
+ * InviteEditUser bounces a stateless edit straight to the dashboard: the Edit
+ * action looked like it did nothing but log you out of the page. Delete's
+ * confirmation named no user at all.
+ *
+ * jsdom does no hit-testing, so it would dispatch that swallowed click
+ * happily — which is exactly why these tests drive the MENU ENTRY rather than
+ * the icon. Reaching the entry is the part a real browser allows; binding the
+ * row to it is the part under test.
+ */
+const navigate = vi.fn();
+
+vi.mock("react-router-dom", async () => {
+ const actual = await vi.importActual("react-router-dom");
+ return { ...actual, useNavigate: () => navigate };
+});
+
+const MEMBERS = [
+ { id: "1", email: "ada@example.com", role: "unstract_admin" },
+ { id: "2", email: "grace@example.com", role: "unstract_user" },
+];
+
+vi.mock("../../../hooks/useAxiosPrivate", () => ({
+ useAxiosPrivate: () => () => Promise.resolve({ data: { members: MEMBERS } }),
+}));
+
+vi.mock("../../../hooks/useExceptionHandler.jsx", () => ({
+ useExceptionHandler: () => (err, fallback) => ({ content: fallback }),
+}));
+
+vi.mock("../../../hooks/usePostHogEvents.js", () => ({
+ default: () => ({ setPostHogCustomEvent: () => undefined }),
+}));
+
+vi.mock("../../../store/alert-store", () => ({
+ useAlertStore: () => ({ setAlertDetails: () => undefined }),
+}));
+
+vi.mock("../../../store/session-store", () => ({
+ useSessionStore: () => ({
+ sessionDetails: { orgId: "org-1", orgName: "my-org", csrfToken: "tok" },
+ }),
+}));
+
+const { Users } = await import("./Users.jsx");
+
+/** Opens the kebab on the given row and returns the menu entry by name. */
+async function openRowMenu(rowEmail, entry) {
+ render(
+
+
+ ,
+ );
+ await screen.findByText(rowEmail);
+
+ const row = screen.getByText(rowEmail).closest("tr");
+ // Radix's trigger toggles on pointerdown, not click — as in the browser.
+ fireEvent.pointerDown(row.querySelector(".ant-dropdown-trigger"), {
+ button: 0,
+ ctrlKey: false,
+ pointerType: "mouse",
+ });
+
+ return await screen.findByText(entry);
+}
+
+describe("Manage Users kebab menu", () => {
+ beforeEach(() => {
+ navigate.mockReset();
+ });
+
+ it("sends the clicked row to the edit page", async () => {
+ fireEvent.click(await openRowMenu("grace@example.com", "Edit"));
+
+ expect(navigate).toHaveBeenCalledWith("/my-org/users/edit", {
+ state: expect.objectContaining({
+ email: "grace@example.com",
+ role: "unstract_user",
+ }),
+ });
+ });
+
+ it("edits the row whose kebab was opened, not the first one", async () => {
+ fireEvent.click(await openRowMenu("ada@example.com", "Edit"));
+
+ expect(navigate).toHaveBeenCalledWith("/my-org/users/edit", {
+ state: expect.objectContaining({ email: "ada@example.com" }),
+ });
+ });
+
+ it("names the clicked row in the delete confirmation", async () => {
+ fireEvent.click(await openRowMenu("grace@example.com", "Delete"));
+
+ await waitFor(() =>
+ expect(screen.getByText("Delete User")).toBeInTheDocument(),
+ );
+ // The row's email appears twice once the modal is up: table cell + modal.
+ expect(screen.getAllByText("grace@example.com").length).toBeGreaterThan(1);
+ });
+});
diff --git a/frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx b/frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx
index 829c86a291..14cbfc20fa 100644
--- a/frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx
+++ b/frontend/src/components/tool-settings/list-of-items/ListOfItems.jsx
@@ -1,6 +1,8 @@
-import { DeleteOutlined, EditOutlined, MoreOutlined } from "@ant-design/icons";
-import { Card, Dropdown, Image } from "antd";
+import { EllipsisVertical, Pencil, Trash2 } from "lucide-react";
import PropTypes from "prop-types";
+import { Image } from "@/components/ui/shims/antd-leaves";
+import { Dropdown } from "@/components/ui/shims/antd-overlays";
+import { Card } from "@/components/ui/shims/antd-structure";
import { ConfirmModal } from "../../widgets/confirm-modal/ConfirmModal";
import { EmptyState } from "../../widgets/empty-state/EmptyState";
@@ -47,7 +49,7 @@ function ListOfItems({
{
label: "Edit",
key: "edit",
- icon: ,
+ icon: ,
onClick: () => setEditItemId(item?.id),
},
{
@@ -60,14 +62,14 @@ function ListOfItems({
),
key: "delete",
- icon: ,
+ icon: ,
},
],
}}
trigger={["click"]}
placement="bottomRight"
>
-
+
}
>
diff --git a/frontend/src/components/tool-settings/tool-settings/ToolSettings.css b/frontend/src/components/tool-settings/tool-settings/ToolSettings.css
index c129f40c64..44f35d9c1e 100644
--- a/frontend/src/components/tool-settings/tool-settings/ToolSettings.css
+++ b/frontend/src/components/tool-settings/tool-settings/ToolSettings.css
@@ -1,7 +1,7 @@
/* Styles for ToolSettings */
.plt-tool-settings-layout {
- background-color: var(--page-bg-2);
+ background-color: var(--background);
height: 100%;
display: flex;
flex-direction: column;
diff --git a/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx b/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx
index 67c256388c..bfad3a6637 100644
--- a/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx
+++ b/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx
@@ -1,4 +1,4 @@
-import { PlusOutlined } from "@ant-design/icons";
+import { Plus } from "lucide-react";
import PropTypes from "prop-types";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -35,9 +35,9 @@ const titles = {
};
const btnText = {
- llm: "New LLM Profile",
- vector_db: "New Vector DB Profile",
- embedding: "New Embedding Profile",
+ llm: "New LLM",
+ vector_db: "New Vector DB",
+ embedding: "New Embedding",
x2text: "New Text Extractor",
ocr: "New OCR",
};
@@ -361,10 +361,13 @@ function ToolSettings({ type }) {
searchKey={type}
onSearch={(value) => handleSearch(value)}
customButtons={
+ // The label is `btnText[type]` — "New LLM" on one route and
+ // "New Text Extractor" on another — so it is not a stable handle.
}
+ icon={ }
>
{btnText[type]}
@@ -393,6 +396,7 @@ function ToolSettings({ type }) {
)}
{!loadError && displayList?.length > 0 && (
ok ;
+}
diff --git a/frontend/src/components/ui/accordion.tsx b/frontend/src/components/ui/accordion.tsx
new file mode 100644
index 0000000000..6178d89e92
--- /dev/null
+++ b/frontend/src/components/ui/accordion.tsx
@@ -0,0 +1,55 @@
+import { ChevronDown } from "lucide-react";
+import { Accordion as AccordionPrimitive } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Accordion = AccordionPrimitive.Root;
+
+const AccordionItem = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AccordionItem.displayName = "AccordionItem";
+
+const AccordionTrigger = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+));
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
+
+const AccordionContent = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+));
+AccordionContent.displayName = AccordionPrimitive.Content.displayName;
+
+export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000000..6664848d63
--- /dev/null
+++ b/frontend/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,147 @@
+import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
+import * as React from "react";
+import { buttonVariants } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+const AlertDialog = AlertDialogPrimitive.Root;
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal;
+
+/*
+ * z-[1100], not shadcn's stock z-50: this app predates the Tailwind z scale and
+ * still has chrome parked in the hundreds/thousands (`.logs-container` is 999,
+ * the agency canvas and settings rail are 1000), so a z-50 overlay left the
+ * Logs footer painting bright over the dimmed page. Sits above that legacy band
+ * but below `[data-radix-popper-content-wrapper]` (1500, so a Select opened
+ * inside a modal still clears it) and `.fullscreen-loader` (2000) — see
+ * index.css.
+ */
+const AlertDialogOverlay = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
+
+const AlertDialogContent = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogHeader.displayName = "AlertDialogHeader";
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogFooter.displayName = "AlertDialogFooter";
+
+const AlertDialogTitle = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
+
+const AlertDialogDescription = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName;
+
+const AlertDialogAction = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
+
+const AlertDialogCancel = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+};
diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx
new file mode 100644
index 0000000000..acc82d11cf
--- /dev/null
+++ b/frontend/src/components/ui/alert.tsx
@@ -0,0 +1,59 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+));
+Alert.displayName = "Alert";
+
+const AlertTitle = React.forwardRef<
+ HTMLHeadingElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+AlertTitle.displayName = "AlertTitle";
+
+const AlertDescription = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+AlertDescription.displayName = "AlertDescription";
+
+export { Alert, AlertDescription, AlertTitle };
diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx
new file mode 100644
index 0000000000..d30fd7a8e7
--- /dev/null
+++ b/frontend/src/components/ui/avatar.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { Avatar as AvatarPrimitive } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Avatar = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Avatar.displayName = AvatarPrimitive.Root.displayName;
+
+const AvatarImage = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AvatarImage.displayName = AvatarPrimitive.Image.displayName;
+
+const AvatarFallback = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
+
+export { Avatar, AvatarFallback, AvatarImage };
diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx
new file mode 100644
index 0000000000..eff361d61e
--- /dev/null
+++ b/frontend/src/components/ui/badge.tsx
@@ -0,0 +1,51 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
+ outline: "text-foreground",
+ // P0-13: status variants for the "Done" / "In Process" / "Enabled"
+ // badges used throughout the app. Backed by the --success / --warning
+ // tokens added to the Midnight Bloom palette (§2.5.1).
+ success:
+ "border-transparent bg-success text-white shadow hover:bg-success/80",
+ warning:
+ "border-transparent bg-warning text-white shadow hover:bg-warning/80",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+/*
+ * forwardRef so the ref is part of the declared surface — the antd `Tag` shim
+ * renders Badge with one. As with `Label`, React 19 would carry the ref
+ * through the props spread at runtime either way; declaring it keeps the type
+ * honest about what the component accepts.
+ */
+const Badge = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+));
+Badge.displayName = "Badge";
+
+export { Badge, badgeVariants };
diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx
new file mode 100644
index 0000000000..324d01f5d9
--- /dev/null
+++ b/frontend/src/components/ui/button.tsx
@@ -0,0 +1,100 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { Slot } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+/*
+ * `cursor-pointer` is explicit because Tailwind v4 dropped the preflight rule
+ * that gave `` a hand cursor in v3. antd set it on every button, so
+ * losing it made the whole app feel inert on hover — the effect was app-wide,
+ * not confined to one screen. `disabled:pointer-events-none` still wins for
+ * disabled buttons, so they keep the default arrow.
+ */
+const buttonVariants = cva(
+ "inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ /*
+ * Heights match the antd reference exactly: 32 / 24 / 40 for
+ * default / sm / lg. shadcn ships 36 / 32 / 40, which put every
+ * default control 4px taller than the app it is replacing and
+ * collapsed the visual gap between default and small.
+ */
+ default: "h-8 px-4 py-2 has-[>svg]:px-3",
+ xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
+ /* Same 24px height as `xs`, which is intended: antd has no size below
+ * `small`, so the two coincide on height and differ only in type size
+ * and padding. The antd shim maps `size="small"` here. */
+ sm: "h-6 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+/**
+ * forwardRef matters here: this Button is rendered as the child of Radix
+ * triggers (Dropdown, Popover, Tooltip) via `asChild`, which attaches its
+ * handlers through a ref. Without it those triggers are silently inert —
+ * the Prompt Studio Export menu never opened and fired no request at all.
+ */
+/*
+ * Typed because the antd-* shims wrap this component: without a props type
+ * here, TypeScript cannot verify that a shim passes a real `variant`/`size`,
+ * and the whole point of typing the shim layer is to make those boundaries
+ * checkable.
+ */
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ /** Render the child element instead of a (Radix `asChild`). */
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(function Button(
+ {
+ className,
+ variant = "default",
+ size = "default",
+ asChild = false,
+ ...props
+ },
+ ref,
+) {
+ const Comp = asChild ? Slot.Root : "button";
+
+ return (
+
+ );
+});
+
+export { Button, buttonVariants };
diff --git a/frontend/src/components/ui/calendar.tsx b/frontend/src/components/ui/calendar.tsx
new file mode 100644
index 0000000000..801e02522d
--- /dev/null
+++ b/frontend/src/components/ui/calendar.tsx
@@ -0,0 +1,155 @@
+import {
+ ChevronDown,
+ ChevronLeft,
+ ChevronRight,
+ ChevronUp,
+} from "lucide-react";
+import * as React from "react";
+import { DayPicker } from "react-day-picker";
+
+import { cn } from "@/lib/utils";
+
+/**
+ * shadcn-style Calendar over react-day-picker v10.
+ *
+ * v10 ships NO stylesheet — it emits semantic class slots and expects the app
+ * to supply the look. That suits us: every colour below is a Midnight Bloom
+ * token, so the calendar tracks light/dark with everything else rather than
+ * carrying a second palette the way antd's did.
+ *
+ * Slot names come from the library's `UI` / `DayFlag` / `SelectionState`
+ * enums, so they are checked against the installed version rather than being
+ * guessed strings.
+ */
+export type CalendarProps = React.ComponentProps;
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}: CalendarProps) {
+ return (
+ AND a visible `caption_label`
+ * span holding the same text — the select is meant to lie invisibly
+ * over the span and take the clicks. Styling the SELECT as the visible
+ * control instead drew both, so every caption read "August August ›"
+ * and "2026 2026 ›" across two boxes and ran under the arrows.
+ *
+ * So: the root is the control users see, the select is a transparent
+ * overlay on top of it, and the span supplies the text.
+ */
+ dropdowns: "flex items-center gap-1 text-sm font-medium",
+ dropdown_root: cn(
+ "relative inline-flex items-center rounded-md border border-input",
+ "bg-transparent px-2 py-0.5",
+ "hover:bg-accent",
+ // The focus ring belongs to the border the user sees, but focus
+ // lands on the invisible inside it.
+ "has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-ring",
+ ),
+ dropdown: cn(
+ "absolute inset-0 size-full cursor-pointer opacity-0",
+ // Safari renders a zero-opacity select as unclickable unless it is
+ // still laid out as a control.
+ "appearance-none bg-transparent",
+ ),
+ nav: "flex items-center gap-1",
+ button_previous: cn(
+ "absolute left-0 top-0 z-10 inline-flex size-7 items-center justify-center",
+ "rounded-md border border-input bg-transparent",
+ "opacity-50 hover:opacity-100 hover:bg-accent hover:text-accent-foreground",
+ "disabled:pointer-events-none disabled:opacity-25",
+ ),
+ button_next: cn(
+ "absolute right-0 top-0 z-10 inline-flex size-7 items-center justify-center",
+ "rounded-md border border-input bg-transparent",
+ "opacity-50 hover:opacity-100 hover:bg-accent hover:text-accent-foreground",
+ "disabled:pointer-events-none disabled:opacity-25",
+ ),
+ month_grid: "w-full border-collapse space-y-1",
+ weekdays: "flex",
+ weekday:
+ "w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground",
+ week: "mt-2 flex w-full",
+ day: cn(
+ "relative p-0 text-center text-sm",
+ // Range middle needs a continuous band, so the rounding is applied
+ // to the ends only (below) rather than to every cell.
+ "focus-within:relative focus-within:z-20",
+ "[&:has([aria-selected])]:bg-accent",
+ "[&:has([aria-selected].day-range-end)]:rounded-r-md",
+ "[&:has([aria-selected].day-range-start)]:rounded-l-md",
+ ),
+ day_button: cn(
+ "inline-flex size-8 cursor-pointer items-center justify-center rounded-md p-0",
+ "font-normal aria-selected:opacity-100 disabled:cursor-not-allowed",
+ "hover:bg-accent hover:text-accent-foreground",
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
+ ),
+ range_start:
+ "day-range-start rounded-l-md bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground",
+ range_end:
+ "day-range-end rounded-r-md bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground",
+ range_middle:
+ "rounded-none bg-accent text-accent-foreground hover:bg-accent hover:text-accent-foreground",
+ selected:
+ "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground",
+ today: "bg-accent text-accent-foreground",
+ outside: "text-muted-foreground opacity-50",
+ disabled: "text-muted-foreground opacity-50",
+ hidden: "invisible",
+ ...classNames,
+ }}
+ components={{
+ /*
+ * react-day-picker asks for four orientations, not two: the nav arrows
+ * are left/right, but the dropdown captions ask for "down". Falling
+ * through to ChevronRight gave the month and year controls a
+ * rightward chevron, so neither read as a dropdown.
+ */
+ Chevron: ({ orientation, ...rest }) => {
+ const Icon = {
+ left: ChevronLeft,
+ right: ChevronRight,
+ up: ChevronUp,
+ down: ChevronDown,
+ }[orientation ?? "right"];
+ return ;
+ },
+ }}
+ {...props}
+ />
+ );
+}
+
+export { Calendar };
diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx
new file mode 100644
index 0000000000..5e1902cc96
--- /dev/null
+++ b/frontend/src/components/ui/card.tsx
@@ -0,0 +1,83 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+Card.displayName = "Card";
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardHeader.displayName = "CardHeader";
+
+const CardTitle = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardTitle.displayName = "CardTitle";
+
+const CardDescription = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardDescription.displayName = "CardDescription";
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardContent.displayName = "CardContent";
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardFooter.displayName = "CardFooter";
+
+export {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+};
diff --git a/frontend/src/components/ui/cascade-and-affordances.test.jsx b/frontend/src/components/ui/cascade-and-affordances.test.jsx
new file mode 100644
index 0000000000..775b1bb2f3
--- /dev/null
+++ b/frontend/src/components/ui/cascade-and-affordances.test.jsx
@@ -0,0 +1,487 @@
+import fs from "node:fs";
+import path from "node:path";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Popover, Tooltip } from "@/components/ui/shims/antd-overlays";
+import { Textarea } from "@/components/ui/textarea";
+
+/**
+ * Guards for defects that jsdom cannot see directly.
+ *
+ * Two of these are cascade/affordance bugs that shipped to users and were
+ * reported twice, but neither is observable through rendered geometry: jsdom
+ * has no cascade-layer resolution and no cursor. So they are asserted the same
+ * way `no-antd.test.js` asserts its invariant — against the source text and
+ * the emitted class list.
+ */
+describe("cascade and affordance guards", () => {
+ describe("default border colour stays inside @layer base", () => {
+ const css = fs.readFileSync(
+ path.join(process.cwd(), "src/index.css"),
+ "utf8",
+ );
+
+ it("wraps the universal border-color rule in @layer base", () => {
+ // Unlayered CSS outranks EVERY layered rule regardless of specificity.
+ // Outside a layer this selector beat Tailwind's `utilities` layer and
+ // repainted `border-input`, so form controls silently rendered with
+ // --border (#e5e5e5) instead of --input (#d3d3d3).
+ const match = css.match(
+ /@layer base\s*\{[\s\S]*?::file-selector-button\s*\{[^}]*border-color:\s*var\(--border\)/,
+ );
+ expect(
+ match,
+ "the `*, ::after, ::before…{border-color}` rule must live in @layer base, or it overrides every border-* utility",
+ ).toBeTruthy();
+ });
+
+ /*
+ * A Tailwind colour utility only exists if the token is registered in
+ * `@theme inline`. `divide-separator` (the antd-matching list hairline)
+ * would otherwise compile to nothing and the rows would lose their rule
+ * silently — no error, no failing test, just a visual regression.
+ */
+ it("exposes --separator to Tailwind for both themes", () => {
+ expect(
+ css,
+ "--color-separator must be in @theme inline or `divide-separator` does not exist",
+ ).toMatch(
+ /@theme inline\s*\{[\s\S]*?--color-separator:\s*var\(--separator\)/,
+ );
+ // Declared for light...
+ expect(css).toMatch(/:root\s*\{[\s\S]*?--separator:/);
+ // ...and dark, or dark mode falls back to the light hairline.
+ expect(css).toMatch(/\.dark\s*\{[\s\S]*?--separator:/);
+ });
+
+ it("form controls ask for --input, not the generic --border", () => {
+ // If a text control ever drops to a bare `border`, it renders a
+ // different grey from its neighbours — worse than the original bug.
+ render( );
+ expect(screen.getByRole("textbox").className).toContain("border-input");
+ });
+
+ it("textarea matches the input's border token", () => {
+ render();
+ expect(screen.getByRole("textbox").className).toContain("border-input");
+ });
+ });
+
+ describe("pointer cursor (Tailwind v4 dropped the preflight)", () => {
+ it("buttons carry cursor-pointer", () => {
+ render(Click );
+ expect(screen.getByRole("button").className).toContain("cursor-pointer");
+ });
+
+ it("dialog and sheet close buttons carry cursor-pointer", () => {
+ // Radix renders these as bare s inside the primitive, so they
+ // never pass through the Button variants that supply the cursor.
+ const dialog = fs.readFileSync(
+ path.join(process.cwd(), "src/components/ui/dialog.tsx"),
+ "utf8",
+ );
+ const sheet = fs.readFileSync(
+ path.join(process.cwd(), "src/components/ui/sheet.tsx"),
+ "utf8",
+ );
+ for (const [name, src] of [
+ ["dialog", dialog],
+ ["sheet", sheet],
+ ]) {
+ const close = src.match(/\.Close className="([^"]*)"/);
+ expect(
+ close,
+ `${name} should render a .Close with a className`,
+ ).toBeTruthy();
+ expect(
+ close[1],
+ `the ${name} close button needs cursor-pointer — Tailwind v4 dropped the preflight`,
+ ).toContain("cursor-pointer");
+ }
+ });
+
+ /*
+ * antd shipped an icon FONT, so `font-size` sized its icons. lucide ships
+ * SVGs, which ignore font-size entirely and fall back to their own 24px
+ * default — every such rule silently renders its icon oversized.
+ *
+ * Matching on selector NAMES (icon|svg|anticon…) was the first version of
+ * this guard and it missed the real ones: `.prompt-card-actions-head`
+ * carries eight lucide icons and has no icon-ish word in its name, so the
+ * whole prompt-card action row rendered 16px against the reference's 12.
+ *
+ * So this reads the JSX instead: collect every class applied to a lucide
+ * component (they are PascalCase imports from lucide-react), then flag any
+ * CSS rule that sizes one of those classes with font-size alone.
+ */
+ it("classes on lucide icons set explicit dimensions, not just font-size", () => {
+ const iconClasses = new Set();
+ const cssRules = [];
+
+ const walk = (dir) => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name !== "node_modules") walk(full);
+ continue;
+ }
+ const txt = fs.readFileSync(full, "utf8");
+
+ if (/\.(jsx|tsx)$/.test(entry.name)) {
+ // Only files that actually import icons from lucide-react.
+ const imports = txt.match(
+ /import\s*\{([^}]*)\}\s*from\s*"lucide-react"/,
+ );
+ if (!imports) continue;
+ const names = imports[1]
+ .split(",")
+ .map((n) =>
+ n
+ .trim()
+ .split(/\s+as\s+/)
+ .pop()
+ .trim(),
+ )
+ .filter(Boolean);
+ for (const name of names) {
+ const re = new RegExp(
+ `<${name}\\b[^>]*className=[{"]\`?([^"\`}]*)`,
+ "g",
+ );
+ for (const m of txt.matchAll(re)) {
+ for (const cls of m[1].split(/\s+/)) {
+ // Skip template holes and Tailwind utilities.
+ if (cls && !cls.includes("$") && !cls.includes("-[")) {
+ iconClasses.add(cls);
+ }
+ }
+ }
+ }
+ continue;
+ }
+
+ if (!entry.name.endsWith(".css")) continue;
+ for (const m of txt.matchAll(/([^{}]*)\{([^}]*)\}/g)) {
+ const sel = m[1].trim().split("\n").pop().trim();
+ const body = m[2];
+ if (!/font-size:\s*\d+px/.test(body)) continue;
+ if (/width|height/.test(body)) continue;
+ cssRules.push({
+ file: path.relative(process.cwd(), full),
+ sel,
+ classes: [...sel.matchAll(/\.([a-zA-Z][\w-]*)/g)].map(
+ (c) => c[1],
+ ),
+ });
+ }
+ }
+ };
+ walk(path.join(process.cwd(), "src"));
+
+ const offenders = cssRules
+ .filter((r) => r.classes.some((c) => iconClasses.has(c)))
+ .map((r) => `${r.file}: ${r.sel}`);
+
+ expect(
+ offenders,
+ "these rules size a lucide SVG with font-size, which does nothing — set width/height",
+ ).toEqual([]);
+ });
+
+ it("keeps the not-allowed affordance for disabled buttons", () => {
+ render(Nope );
+ // `disabled:pointer-events-none` suppresses the hand; the class list
+ // still carries the base cursor, so assert the disabled rule survives.
+ expect(screen.getByRole("button").className).toContain(
+ "disabled:pointer-events-none",
+ );
+ });
+ });
+
+ describe("antd Popover shim", () => {
+ it("supplies onOpenChange even when the call-site passes none", () => {
+ // antd call-sites drive `open` from their own trigger and pass no
+ // change handler. Radix reads a bare `open` as fully controlled, so
+ // without a supplied handler Esc and outside-click cannot close it.
+ const { container } = render(
+ body }>
+
trigger
+ ,
+ );
+ expect(screen.getByText("body")).toBeInTheDocument();
+ expect(container).toBeTruthy();
+ });
+
+ /*
+ * `trigger="hover"` was destructured and then ignored, so the sidebar's
+ * HITL and Platform fly-out menus never opened on hover — Radix Popover
+ * is click-only. A dropped prop like this raises no error and no warning,
+ * which is why it needs a behavioural test rather than a class check.
+ */
+ it("opens on hover when the call-site asks for trigger='hover'", async () => {
+ const user = userEvent.setup();
+ render(
+
fly-out}>
+ Platform
+ ,
+ );
+ expect(screen.queryByText("fly-out")).not.toBeInTheDocument();
+ await user.hover(screen.getByRole("button", { name: "Platform" }));
+ expect(await screen.findByText("fly-out")).toBeInTheDocument();
+ });
+
+ /*
+ * The sidebar's real shape, and why the first hover fix did not work.
+ *
+ * Every sidebar item wraps its content in a Tooltip, so the Popover's
+ * `asChild` trigger merges the hover handlers onto the TOOLTIP, not a DOM
+ * node. The Tooltip shim then either returned `children` raw (when there
+ * is no title — the expanded sidebar) or spread the props onto the tooltip
+ * BUBBLE, so the handlers never reached the element under the cursor.
+ *
+ * Both variants are asserted because the collapsed sidebar has a title and
+ * the expanded one does not — the earlier flat test passed while the real
+ * nesting stayed broken.
+ */
+ it.each([
+ ["without a tooltip title (expanded sidebar)", ""],
+ ["with a tooltip title (collapsed sidebar)", "Platform"],
+ ])("opens on hover through a nested Tooltip %s", async (_label, title) => {
+ const user = userEvent.setup();
+ render(
+
sub-menu}>
+
+ Platform
+
+ ,
+ );
+ expect(screen.queryByText("sub-menu")).not.toBeInTheDocument();
+ await user.hover(screen.getByRole("button", { name: "Platform" }));
+ expect(await screen.findByText("sub-menu")).toBeInTheDocument();
+ });
+
+ it("stays click-only when no trigger is given", async () => {
+ const user = userEvent.setup();
+ render(
+
clicky}>
+ Open
+ ,
+ );
+ await user.hover(screen.getByRole("button", { name: "Open" }));
+ expect(screen.queryByText("clicky")).not.toBeInTheDocument();
+ });
+
+ it("does not leak antd-only props onto the DOM", () => {
+ render(
+
c}>
+ t
+ ,
+ );
+ // `trigger` and `arrow` are antd's API, not Radix's; React would warn
+ // and the attributes would land on the element.
+ const trigger = screen.getByText("t");
+ expect(trigger.getAttribute("trigger")).toBeNull();
+ expect(trigger.getAttribute("arrow")).toBeNull();
+ });
+ });
+ /**
+ * The "left and right borders are missing" report, three rounds running.
+ * The borders were always drawn; `shadow-sm` (a vertical-only offset)
+ * reinforced the top and bottom edges so the bare 1px sides looked absent
+ * beside them. The antd reference computes `box-shadow: none`.
+ */
+ describe("form controls draw an even border on all four sides", () => {
+ it("Input carries no vertical-offset shadow", () => {
+ render(
);
+ expect(screen.getByRole("textbox").className).not.toContain("shadow-sm");
+ });
+
+ it("Textarea carries no vertical-offset shadow", () => {
+ render(
);
+ expect(screen.getByRole("textbox").className).not.toContain("shadow-sm");
+ });
+ });
+ /**
+ * React 19 stopped applying `defaultProps` to FUNCTION components — the
+ * declaration is simply ignored, so every default silently becomes
+ * undefined. That is the same silent-prop-drop class that produced the
+ * Save-does-nothing bug, so it is guarded rather than trusted.
+ *
+ * Class components still honour defaultProps, which is why ErrorBoundary
+ * is allowed to keep its block.
+ */
+ it("no function component relies on defaultProps (React 19)", () => {
+ const SRC = path.join(process.cwd(), "src");
+ const offenders = [];
+ const walk = (dir) => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name !== "node_modules") {
+ walk(full);
+ }
+ continue;
+ }
+ // .tsx too — a `.jsx`-only filter would stop checking each shim as it
+ // converts to TypeScript, and this guard would pass over less and less.
+ if (!/\.[jt]sx$/.test(entry.name) || entry.name.includes(".test.")) {
+ continue;
+ }
+ const src = fs.readFileSync(full, "utf8");
+ const m = src.match(/^(\w+)\.defaultProps\s*=/m);
+ if (m && !new RegExp(`class\\s+${m[1]}\\s+extends`).test(src)) {
+ offenders.push(`${path.relative(SRC, full)} (${m[1]})`);
+ }
+ }
+ };
+ walk(SRC);
+ expect(
+ offenders,
+ "React 19 ignores defaultProps on function components — use default parameters instead",
+ ).toEqual([]);
+ });
+
+ /*
+ * antd's Tooltip accepts any child, including a bare string. Ours renders a
+ * Radix TooltipTrigger with `asChild`, which slots onto a single ELEMENT and
+ * throws "Primitive.button failed to slot onto its children" on text — the
+ * route-level error boundary then turns that into "Couldn't load this page".
+ *
+ * A source scan, not a render test, because these live inside table column
+ * `render()` callbacks: they only execute when a row exists, so an
+ * empty-list smoke test walks straight past them. That is exactly how one
+ * shipped in ResourceTable and another in LogsTable.
+ */
+ it("no
wraps a bare value instead of an element", () => {
+ const SRC = path.join(process.cwd(), "src");
+ const offenders = [];
+ /*
+ * `[^>]*` for the attributes would be wrong: `title={a > b}` and any
+ * multi-line prop containing `>` (a `.map()` arrow, a comparison) end the
+ * match early and make the REST of the attribute list look like the child.
+ * Match balanced braces instead, so the open tag ends at the real `>`.
+ */
+ const TOOLTIP =
+ /{]|\{(?:[^{}]|\{[^{}]*\})*\})*>\s*([\s\S]*?)\s*<\/Tooltip>/g;
+
+ const walk = (dir) => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name !== "node_modules") {
+ walk(full);
+ }
+ continue;
+ }
+ if (!/\.[jt]sx$/.test(entry.name) || entry.name.includes(".test.")) {
+ continue;
+ }
+ const src = fs.readFileSync(full, "utf8");
+ for (const m of src.matchAll(TOOLTIP)) {
+ const child = m[1].trim();
+ // An element child is fine; so is a comment (the JSX below it is the
+ // real child) and a conditional whose branches render elements.
+ if (child.startsWith("<") || child.startsWith("{/*")) {
+ continue;
+ }
+ // `{cond && }` / `{cond ? : }` — the value that
+ // reaches Radix is still an element.
+ if (/[&?]{1,2}\s*\(?\s*",
+ ).toEqual([]);
+ });
+ /*
+ * Tailwind v3 let you name a custom property bare inside an arbitrary
+ * value, with no var() around it. v4 removed that shorthand and emits the
+ * value verbatim, so the browser sees a property name where a value should
+ * be, drops the declaration, and the utility silently does nothing.
+ * Nothing errors: not the build, not the linter, not jsdom.
+ *
+ * NB: the examples above are described rather than written out, because
+ * Tailwind scans this file too — spelling the broken form here would emit
+ * the very dead rule the build-level `grep` for it is meant to catch.
+ *
+ * That is how SelectContent lost its max-height in the migration — the
+ * Prompt Studio LLM dropdown grew to the full option-list height and ran
+ * off the bottom of a scroll-locked page with no way to reach the rest.
+ * Four `origin-` utilities on popover/tooltip/dropdown-menu were dead the
+ * same way, which is why this guards the pattern rather than the one class.
+ */
+ describe("no Tailwind v3 bare-custom-property shorthand", () => {
+ it("wraps every arbitrary custom-property value in var()", () => {
+ const SRC = path.join(process.cwd(), "src");
+ // `-[--name]` and nothing else inside the brackets. Deliberately does
+ // NOT match `data-[state=open]`, `[&_svg]`, `max-h-[var(--x)]`, or v4
+ // arbitrary *properties* like `[--x:red]` (those carry a `:`).
+ const V3_SHORTHAND = /[a-z0-9]-\[--[a-zA-Z][\w-]*\]/g;
+ const offenders = [];
+
+ const walk = (dir) => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name !== "node_modules") walk(full);
+ continue;
+ }
+ // Skip test files — this guard's own regex literal would match.
+ if (!/\.[jt]sx?$/.test(entry.name) || entry.name.includes(".test.")) {
+ continue;
+ }
+ const src = fs.readFileSync(full, "utf8");
+ for (const m of src.matchAll(V3_SHORTHAND)) {
+ const line = src.slice(0, m.index).split("\n").length;
+ offenders.push(`${path.relative(SRC, full)}:${line} → ${m[0]}`);
+ }
+ }
+ };
+ walk(SRC);
+
+ expect(
+ offenders,
+ "Tailwind v4 removed `util-[--var]`; these compile to an invalid " +
+ "declaration the browser discards. Write `util-[var(--var)]`.",
+ ).toEqual([]);
+ });
+
+ /*
+ * Guards against "fixing" a future failure of the test above by deleting
+ * the utility instead of repairing it. Without a max-height the dropdown
+ * grows to the full option list and runs off the bottom of the page.
+ */
+ it("SelectContent still declares a max-height", () => {
+ const src = fs.readFileSync(
+ path.join(process.cwd(), "src/components/ui/select.tsx"),
+ "utf8",
+ );
+ expect(src).toMatch(
+ /max-h-\[[^\]]*--radix-select-content-available-height[^\]]*\]/,
+ );
+ });
+ });
+});
diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000000..f342db599d
--- /dev/null
+++ b/frontend/src/components/ui/checkbox.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { Check } from "lucide-react";
+import { Checkbox as CheckboxPrimitive } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Checkbox = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+));
+Checkbox.displayName = CheckboxPrimitive.Root.displayName;
+
+export { Checkbox };
diff --git a/frontend/src/components/ui/collapsible.tsx b/frontend/src/components/ui/collapsible.tsx
new file mode 100644
index 0000000000..d7684c9c91
--- /dev/null
+++ b/frontend/src/components/ui/collapsible.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import { Collapsible as CollapsiblePrimitive } from "radix-ui";
+
+const Collapsible = CollapsiblePrimitive.Root;
+
+const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
+
+const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
+
+export { Collapsible, CollapsibleContent, CollapsibleTrigger };
diff --git a/frontend/src/components/ui/css-collisions.test.js b/frontend/src/components/ui/css-collisions.test.js
new file mode 100644
index 0000000000..ab597be29a
--- /dev/null
+++ b/frontend/src/components/ui/css-collisions.test.js
@@ -0,0 +1,94 @@
+import fs from "node:fs";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+/**
+ * antd's Modal wrapper is statically positioned, so app CSS could set
+ * `top: 20px` on a modal root and get "20px from the top of the viewport".
+ *
+ * The shadcn Dialog is `position: fixed` and centres itself with
+ * `top: 50%` + `translateY(-50%)`. The same rule now overrides the centring,
+ * and the transform pulls the dialog ABOVE the viewport — the header ends up
+ * clipped off-screen. That is exactly what happened to `.prompt-studio-modal`,
+ * and it was invisible in jsdom because there is no layout engine.
+ *
+ * This guards the whole stylesheet set rather than the one rule that bit us.
+ */
+
+const CSS_ROOT = path.resolve(import.meta.dirname, "../..");
+
+function collectCssFiles(dir, acc = []) {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name === "node_modules") {
+ continue;
+ }
+ collectCssFiles(full, acc);
+ continue;
+ }
+ if (entry.name.endsWith(".css")) {
+ acc.push(full);
+ }
+ }
+ return acc;
+}
+
+/**
+ * Selectors that target a modal/dialog ROOT — not an element inside one.
+ * `.foo-modal` and `.foo-modal.bar` count; `.foo-modal__body` and
+ * `.foo-modal .thing` do not, since those are children and cannot fight the
+ * root's positioning.
+ */
+function isModalRootSelector(selector) {
+ const trimmed = selector.trim();
+ if (/\s/.test(trimmed)) {
+ return false;
+ }
+ if (trimmed.includes("__") || trimmed.includes(">")) {
+ return false;
+ }
+ if (trimmed.includes(".ant-")) {
+ return false;
+ }
+ return /modal|dialog/i.test(trimmed);
+}
+
+describe("CSS that would fight the Dialog's centring", () => {
+ const files = collectCssFiles(CSS_ROOT);
+
+ it("finds stylesheets to check", () => {
+ expect(files.length).toBeGreaterThan(0);
+ });
+
+ it("no modal root sets `top`, `bottom` or `transform`", () => {
+ const offenders = [];
+
+ for (const file of files) {
+ const css = fs.readFileSync(file, "utf8");
+ for (const match of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
+ const [, rawSelector, body] = match;
+ for (const selector of rawSelector.split(",")) {
+ if (!isModalRootSelector(selector)) {
+ continue;
+ }
+ const bad = body.match(/(^|;)\s*(top|bottom|transform)\s*:[^;]*/i);
+ if (bad) {
+ offenders.push(
+ `${path.basename(file)} ${selector.trim()} {${bad[0].trim()}}`,
+ );
+ }
+ }
+ }
+ }
+
+ expect(
+ offenders,
+ "These rules position a modal ROOT. The Dialog centres itself with " +
+ "top:50% + translateY(-50%), so they override the centring and push " +
+ "the dialog off-screen. Move the offset to an inner element, or drop " +
+ "it and let the component centre.\n " +
+ offenders.join("\n "),
+ ).toEqual([]);
+ });
+});
diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx
new file mode 100644
index 0000000000..042d697924
--- /dev/null
+++ b/frontend/src/components/ui/dialog.tsx
@@ -0,0 +1,129 @@
+import { X } from "lucide-react";
+import { Dialog as DialogPrimitive } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+/*
+ * z-[1100], not shadcn's stock z-50: this app predates the Tailwind z scale and
+ * still has chrome parked in the hundreds/thousands (`.logs-container` is 999,
+ * the agency canvas and settings rail are 1000), so a z-50 overlay left the
+ * Logs footer painting bright over the dimmed page. Sits above that legacy band
+ * but below `[data-radix-popper-content-wrapper]` (1500, so a Select opened
+ * inside a modal still clears it) and `.fullscreen-loader` (2000) — see
+ * index.css.
+ */
+const DialogOverlay = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000000..dd464ade04
--- /dev/null
+++ b/frontend/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,204 @@
+"use client";
+
+import { Check, ChevronRight, Circle } from "lucide-react";
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const DropdownMenu = DropdownMenuPrimitive.Root;
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group;
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub;
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef & {
+ /** shadcn addition: indents to align with items that have icons. */
+ inset?: boolean;
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+));
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName;
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName;
+
+const DropdownMenuContent = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef & {
+ /** shadcn addition: indents to align with items that have icons. */
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+ svg]:size-4 [&>svg]:shrink-0",
+ inset && "pl-8",
+ className,
+ )}
+ {...props}
+ />
+));
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName;
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef & {
+ /** shadcn addition: indents to align with items that have icons. */
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ );
+};
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
+
+export {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuPortal,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+};
diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx
new file mode 100644
index 0000000000..ea10bbb821
--- /dev/null
+++ b/frontend/src/components/ui/form.tsx
@@ -0,0 +1,184 @@
+import { Slot as SlotPrimitive } from "radix-ui";
+import * as React from "react";
+import {
+ Controller,
+ type ControllerProps,
+ type FieldPath,
+ type FieldValues,
+ FormProvider,
+ useFormContext,
+} from "react-hook-form";
+import { Label } from "@/components/ui/label";
+import { cn } from "@/lib/utils";
+
+const Form = FormProvider;
+
+/*
+ * The generics are react-hook-form's own: `TFieldValues` is the shape of the
+ * form and `TName` a valid path into it. Carrying them through means
+ * `` only accepts fields that actually exist on the form
+ * — a typo in a field name becomes a compile error rather than a control that
+ * silently never binds.
+ */
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+> = {
+ name: TName;
+};
+
+const FormFieldContext = React.createContext(
+ null,
+);
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>(
+ props: ControllerProps,
+) => {
+ return (
+
+
+
+ );
+};
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext);
+ const itemContext = React.useContext(FormItemContext);
+ const { getFieldState, formState } = useFormContext();
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within ");
+ }
+
+ if (!itemContext) {
+ throw new Error("useFormField should be used within ");
+ }
+
+ const fieldState = getFieldState(fieldContext.name, formState);
+
+ const { id } = itemContext;
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ };
+};
+
+type FormItemContextValue = {
+ id: string;
+};
+
+const FormItemContext = React.createContext(null);
+
+const FormItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const id = React.useId();
+
+ return (
+
+
+
+ );
+});
+FormItem.displayName = "FormItem";
+
+const FormLabel = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ const { error, formItemId } = useFormField();
+
+ return (
+
+ );
+});
+FormLabel.displayName = "FormLabel";
+
+const FormControl = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } =
+ useFormField();
+
+ return (
+
+ );
+});
+FormControl.displayName = "FormControl";
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField();
+
+ return (
+
+ );
+});
+FormDescription.displayName = "FormDescription";
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField();
+ const body = error ? String(error?.message ?? "") : children;
+
+ if (!body) {
+ return null;
+ }
+
+ return (
+
+ {body}
+
+ );
+});
+FormMessage.displayName = "FormMessage";
+
+export {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ useFormField,
+};
diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx
new file mode 100644
index 0000000000..5537af0a05
--- /dev/null
+++ b/frontend/src/components/ui/input.tsx
@@ -0,0 +1,38 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+/*
+ * No `shadow-sm` — that was the "left and right borders are missing" bug.
+ *
+ * Tailwind's `shadow-sm` is `0 1px 3px` + `0 1px 2px`: a purely VERTICAL
+ * offset. It lays a dark smudge immediately below the top and bottom edges
+ * and contributes nothing to the sides, so the horizontals read as a firm
+ * line while the verticals are left as a bare 1px hairline at ~1.2 contrast.
+ * The asymmetry is what the eye picks up — the side borders are drawn, they
+ * just look absent next to shadow-reinforced top and bottom edges.
+ *
+ * The antd reference computes `box-shadow: none` on its inputs, so dropping
+ * it matches the reference and makes all four sides read equally. Removed
+ * from Textarea and the Select trigger too, or those controls would keep the
+ * asymmetry while sitting next to a fixed Input in the same form.
+ */
+const Input = React.forwardRef<
+ HTMLInputElement,
+ React.InputHTMLAttributes
+>(({ className, type, ...props }, ref) => {
+ return (
+
+ );
+});
+Input.displayName = "Input";
+
+export { Input };
diff --git a/frontend/src/components/ui/jsx-in-js.test.jsx b/frontend/src/components/ui/jsx-in-js.test.jsx
new file mode 100644
index 0000000000..02cb0d8693
--- /dev/null
+++ b/frontend/src/components/ui/jsx-in-js.test.jsx
@@ -0,0 +1,33 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { JsxInJsProbe } from "./__fixtures__/jsx-in-js-probe.js";
+
+/**
+ * Guards the JSX-in-`.js` transform, which exists for the cloud plugin tree.
+ *
+ * Two failures shipped from this one line of config, and NEITHER failed the
+ * build — both produced a green `vite build` and HTTP 200:
+ *
+ * 1. No transform at all. Removing Vite's `esbuild: { loader: "jsx" }`
+ * override was safe for OSS (its JSX-bearing .js files were renamed to
+ * .jsx) but broke the Docker image, which also compiles src/plugins/
+ * copied in from the unstract-cloud repo — nine of those files put JSX
+ * in .js. That one at least failed the cloud build loudly.
+ * 2. The WRONG transform. Restoring it via a standalone
+ * `transformWithEsbuild` call defaulted to the CLASSIC JSX runtime,
+ * emitting `React.createElement(...)`. Those plugin files use JSX
+ * without importing React, so the app threw
+ * `ReferenceError: React is not defined` while rendering the router and
+ * showed a blank white page. The build was green throughout.
+ *
+ * The fixture deliberately mirrors the cloud files: JSX in a `.js` file with
+ * NO React import. Rendering it is what distinguishes a correct transform
+ * from one that merely compiles.
+ */
+describe("JSX in .js files (the cloud plugin tree depends on this)", () => {
+ it("renders a .js module that uses JSX without importing React", () => {
+ render( );
+ expect(screen.getByTestId("jsx-in-js-probe")).toHaveTextContent("ok");
+ });
+});
diff --git a/frontend/src/components/ui/kbd.tsx b/frontend/src/components/ui/kbd.tsx
new file mode 100644
index 0000000000..1d6bb3cb53
--- /dev/null
+++ b/frontend/src/components/ui/kbd.tsx
@@ -0,0 +1,22 @@
+import { cn } from "@/lib/utils";
+
+/**
+ * Keyboard key hint (e.g. ⌘ K in the search bar). Hand-written: shadcn has no
+ * registry entry, but the Midnight Bloom mockups use it in the command/search
+ * affordance.
+ */
+function Kbd({ className, ...props }: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+export { Kbd };
diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx
new file mode 100644
index 0000000000..fa83846212
--- /dev/null
+++ b/frontend/src/components/ui/label.tsx
@@ -0,0 +1,35 @@
+import { Label as LabelPrimitive } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+/*
+ * forwardRef rather than a plain function, so the ref is part of the declared
+ * type. `FormLabel` renders this with a `ref`; as a plain function component
+ * that did not TYPE-check, because `ComponentPropsWithoutRef` (as the name
+ * says) has no `ref`.
+ *
+ * At runtime it worked either way — React 19 treats `ref` as an ordinary prop
+ * for function components, so the `{...props}` spread carried it through to
+ * LabelPrimitive.Root. Verified directly rather than assumed: a plain function
+ * component that only spreads props does receive a working ref under React 19.
+ * So this is a typing correction, not a bug fix — worth making because the
+ * declared surface should say what the component accepts.
+ */
+const Label = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Label.displayName = LabelPrimitive.Root.displayName;
+
+export { Label };
diff --git a/frontend/src/components/ui/no-antd.test.js b/frontend/src/components/ui/no-antd.test.js
new file mode 100644
index 0000000000..ab888c9cf1
--- /dev/null
+++ b/frontend/src/components/ui/no-antd.test.js
@@ -0,0 +1,96 @@
+import fs from "node:fs";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+/**
+ * antd is gone. This keeps it gone.
+ *
+ * A one-time grep proves today's tree is clean; it does nothing about the next
+ * dependency bump. antd survived the entire migration as a TRANSITIVE
+ * dependency — every plan task about removing it checked source imports and
+ * `package.json`, and all of them passed while a 58MB copy of antd sat in
+ * node_modules because `react-js-cron` depended on it. Nothing failed, so
+ * nothing surfaced it.
+ *
+ * These checks cover all three ways it can come back: a direct dependency, a
+ * transitive one, and a stray import.
+ */
+
+const FRONTEND = path.resolve(import.meta.dirname, "../../..");
+const BANNED = [/^antd$/, /^@ant-design\//];
+
+function readJson(file) {
+ return JSON.parse(fs.readFileSync(path.join(FRONTEND, file), "utf8"));
+}
+
+describe("antd must not return", () => {
+ it("is not a declared dependency", () => {
+ const pkg = readJson("package.json");
+ const declared = [
+ ...Object.keys(pkg.dependencies ?? {}),
+ ...Object.keys(pkg.devDependencies ?? {}),
+ ];
+ const offenders = declared.filter((name) =>
+ BANNED.some((re) => re.test(name)),
+ );
+ expect(
+ offenders,
+ `These are declared in package.json: ${offenders.join(", ")}`,
+ ).toEqual([]);
+ });
+
+ /**
+ * The one that actually caught it. `react-js-cron` never appeared in any
+ * source file's imports, so only the resolved tree revealed antd.
+ */
+ it("is not reachable transitively", () => {
+ const lock = fs.readFileSync(path.join(FRONTEND, "bun.lock"), "utf8");
+ // Lockfile entries are quoted package specifiers, e.g. "antd" or
+ // "@ant-design/icons". Match the key form so a substring like
+ // "antd-overlays" in a path cannot produce a false positive.
+ const hits = [...lock.matchAll(/"(antd|@ant-design\/[^"@]+)"\s*:/g)].map(
+ (m) => m[1],
+ );
+ expect(
+ [...new Set(hits)],
+ "antd is back in the resolved dependency tree. Something depends on it — " +
+ "run `npm ls antd` to find what.",
+ ).toEqual([]);
+ });
+
+ it("is not imported anywhere in src", () => {
+ const offenders = [];
+ const walk = (dir) => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name === "node_modules") {
+ continue;
+ }
+ walk(full);
+ continue;
+ }
+ if (!/\.(jsx?|tsx?)$/.test(entry.name)) {
+ continue;
+ }
+ // Strip comments first — otherwise a doc comment naming the banned
+ // import counts as a call-site, including this file's own.
+ const src = fs
+ .readFileSync(full, "utf8")
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/^\s*\/\/.*$/gm, "");
+ // Real imports only. Our own `@/components/ui/shims/antd-*` shims keep the
+ // antd NAME deliberately while running on shadcn, so they must not
+ // match: the pattern anchors on the exact specifier.
+ if (/from\s+["']antd["']|from\s+["']@ant-design\//.test(src)) {
+ offenders.push(path.relative(FRONTEND, full));
+ }
+ }
+ };
+ walk(path.join(FRONTEND, "src"));
+ expect(
+ offenders,
+ `These import antd directly:\n ${offenders.join("\n ")}`,
+ ).toEqual([]);
+ });
+});
diff --git a/frontend/src/components/ui/pagination.tsx b/frontend/src/components/ui/pagination.tsx
new file mode 100644
index 0000000000..3bf3cb97d8
--- /dev/null
+++ b/frontend/src/components/ui/pagination.tsx
@@ -0,0 +1,119 @@
+import type { VariantProps } from "class-variance-authority";
+import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
+import * as React from "react";
+import { buttonVariants } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+const Pagination = ({
+ className,
+ ...props
+}: React.ComponentPropsWithoutRef<"nav">) => (
+
+);
+Pagination.displayName = "Pagination";
+
+const PaginationContent = React.forwardRef<
+ HTMLUListElement,
+ React.ComponentPropsWithoutRef<"ul">
+>(({ className, ...props }, ref) => (
+
+));
+PaginationContent.displayName = "PaginationContent";
+
+const PaginationItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentPropsWithoutRef<"li">
+>(({ className, ...props }, ref) => (
+
+));
+PaginationItem.displayName = "PaginationItem";
+
+const PaginationLink = ({
+ className,
+ isActive,
+ size = "icon",
+ ...props
+}: React.ComponentPropsWithoutRef<"a"> & {
+ /** Marks the current page; also switches the button variant. */
+ isActive?: boolean;
+ size?: VariantProps["size"];
+}) => (
+
+);
+PaginationLink.displayName = "PaginationLink";
+
+const PaginationPrevious = ({
+ className,
+ ...props
+}: React.ComponentPropsWithoutRef) => (
+
+
+ Previous
+
+);
+PaginationPrevious.displayName = "PaginationPrevious";
+
+const PaginationNext = ({
+ className,
+ ...props
+}: React.ComponentPropsWithoutRef) => (
+
+ Next
+
+
+);
+PaginationNext.displayName = "PaginationNext";
+
+const PaginationEllipsis = ({
+ className,
+ ...props
+}: React.ComponentPropsWithoutRef<"span">) => (
+
+
+ More pages
+
+);
+PaginationEllipsis.displayName = "PaginationEllipsis";
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+};
diff --git a/frontend/src/components/ui/popover-in-dialog-wheel.test.jsx b/frontend/src/components/ui/popover-in-dialog-wheel.test.jsx
new file mode 100644
index 0000000000..d440355246
--- /dev/null
+++ b/frontend/src/components/ui/popover-in-dialog-wheel.test.jsx
@@ -0,0 +1,98 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Select } from "@/components/ui/shims/antd-inputs";
+import { Modal } from "@/components/ui/shims/antd-overlays";
+
+/**
+ * Wheel-scrolling a popover that is open inside a Modal.
+ *
+ * Radix's Dialog locks scrolling with react-remove-scroll, which listens for
+ * `wheel` on `document` and preventDefaults any event whose target is neither
+ * inside the lock nor inside one of its shards (the dialog content). Popover
+ * content is portalled to `document.body`, so it is outside both — leaving
+ * popover lists scrollable by dragging the scrollbar (a pointer interaction,
+ * never a wheel event) but dead to the mouse wheel.
+ *
+ * Radix's own Select and DropdownMenu are not affected: their content carries
+ * its own RemoveScroll, which takes over the lock stack while open. Only
+ * Popover-based surfaces need this, so the assertion is on PopoverContent.
+ */
+function wheelOver(element) {
+ const event = new WheelEvent("wheel", {
+ deltaY: 100,
+ bubbles: true,
+ cancelable: true,
+ });
+ element.dispatchEvent(event);
+ return event;
+}
+
+describe("popover wheel scrolling inside a Modal", () => {
+ it("does not let the dialog's scroll lock cancel the wheel", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ open
+
+ content
+
+
+ ,
+ );
+
+ await user.click(screen.getByText("open"));
+ const event = wheelOver(await screen.findByTestId("scroller"));
+
+ await waitFor(() => expect(event.defaultPrevented).toBe(false));
+ });
+
+ it("keeps a searchable Select's option list wheel-scrollable", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ ({
+ value: `a${i}`,
+ label: `adapter-${i}`,
+ }))}
+ />
+ ,
+ );
+
+ await user.click(screen.getByRole("combobox"));
+ const event = wheelOver(await screen.findByRole("listbox"));
+
+ await waitFor(() => expect(event.defaultPrevented).toBe(false));
+ });
+
+ it("still calls a caller-supplied onWheel", async () => {
+ const user = userEvent.setup();
+ let seen = 0;
+
+ render(
+
+ open
+ seen++}>
+ content
+
+ ,
+ );
+
+ await user.click(screen.getByText("open"));
+ wheelOver(await screen.findByTestId("scroller"));
+
+ await waitFor(() => expect(seen).toBe(1));
+ });
+});
diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx
new file mode 100644
index 0000000000..0b12ea305c
--- /dev/null
+++ b/frontend/src/components/ui/popover.tsx
@@ -0,0 +1,46 @@
+import { Popover as PopoverPrimitive } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Popover = PopoverPrimitive.Root;
+
+const PopoverTrigger = PopoverPrimitive.Trigger;
+
+const PopoverAnchor = PopoverPrimitive.Anchor;
+
+const PopoverContent = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "center", sideOffset = 4, onWheel, ...props }, ref) => (
+
+ {
+ onWheel?.(event);
+ event.stopPropagation();
+ }}
+ className={cn(
+ // `max-h` + scroll: Radix measures the space actually available on
+ // the chosen side and exposes it as this variable. Without it a tall
+ // popover (the 456px emoji picker, anchored low in a modal) simply
+ // overflows the viewport and its bottom rows are unreachable.
+ "z-50 max-h-[var(--radix-popover-content-available-height)] w-72 overflow-y-auto rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[var(--radix-popover-content-transform-origin)]",
+ className,
+ )}
+ {...props}
+ />
+
+));
+PopoverContent.displayName = PopoverPrimitive.Content.displayName;
+
+export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx
new file mode 100644
index 0000000000..89ea434a52
--- /dev/null
+++ b/frontend/src/components/ui/progress.tsx
@@ -0,0 +1,26 @@
+import { Progress as ProgressPrimitive } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Progress = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, value, ...props }, ref) => (
+
+
+
+));
+Progress.displayName = ProgressPrimitive.Root.displayName;
+
+export { Progress };
diff --git a/frontend/src/components/ui/radio-group.tsx b/frontend/src/components/ui/radio-group.tsx
new file mode 100644
index 0000000000..20a7dc56f9
--- /dev/null
+++ b/frontend/src/components/ui/radio-group.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { Circle } from "lucide-react";
+import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const RadioGroup = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
+
+const RadioGroupItem = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+
+
+
+
+ );
+});
+RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
+
+export { RadioGroup, RadioGroupItem };
diff --git a/frontend/src/components/ui/ref-forwarding.test.jsx b/frontend/src/components/ui/ref-forwarding.test.jsx
new file mode 100644
index 0000000000..b2fe287550
--- /dev/null
+++ b/frontend/src/components/ui/ref-forwarding.test.jsx
@@ -0,0 +1,102 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Dropdown } from "@/components/ui/shims/antd-overlays";
+import { CustomButton } from "@/components/widgets/custom-button/CustomButton";
+
+/**
+ * Regression: Prompt Studio's Export button did nothing — no menu, no network
+ * request. It is a `` child, and Radix's trigger renders with
+ * `asChild`, attaching its handlers through a ref. Neither CustomButton nor
+ * the base shadcn Button forwarded refs, so the trigger was silently inert.
+ *
+ * A ref that goes nowhere throws no error and logs nothing, which is why this
+ * survived every other test.
+ */
+describe("ref forwarding through the trigger chain", () => {
+ it("the base Button forwards its ref to a DOM node", () => {
+ let node = null;
+ render(
+ {
+ node = n;
+ }}
+ >
+ base
+ ,
+ );
+ expect(node).toBeInstanceOf(HTMLElement);
+ expect(node.tagName).toBe("BUTTON");
+ });
+
+ it("CustomButton forwards its ref through to the DOM node", () => {
+ let node = null;
+ render(
+ {
+ node = n;
+ }}
+ >
+ custom
+ ,
+ );
+ expect(node).toBeInstanceOf(HTMLElement);
+ expect(node.tagName).toBe("BUTTON");
+ });
+
+ it("a Dropdown wrapping CustomButton wires up its trigger", () => {
+ render(
+
+ Export
+ ,
+ );
+ const trigger = screen.getByRole("button", { name: "Export" });
+ // Radix marks a wired-up trigger with aria-haspopup + its own state attr.
+ expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
+ expect(trigger.getAttribute("data-state")).toBe("closed");
+ });
+
+ it("that Dropdown actually opens on click", async () => {
+ render(
+
+ Export
+ ,
+ );
+ fireEvent.pointerDown(screen.getByRole("button", { name: "Export" }), {
+ button: 0,
+ ctrlKey: false,
+ pointerType: "mouse",
+ });
+ await waitFor(() =>
+ expect(screen.getByText("Export as Tool")).toBeInTheDocument(),
+ );
+ });
+
+ /*
+ * `FormLabel` renders `Label` with a ref, so assert it arrives.
+ *
+ * This one is a guard, not a caught bug: Label was a plain function
+ * component before being typed, and under React 19 `ref` is an ordinary
+ * prop, so the `{...props}` spread already carried it through. What this
+ * catches is a future refactor that stops spreading — destructuring the
+ * props it uses and dropping the rest silently detaches FormLabel's ref
+ * with no warning, which is exactly how the Export-button bug above
+ * shipped.
+ */
+ it("Label forwards its ref (FormLabel renders it with one)", () => {
+ let node = null;
+ render(
+ {
+ node = n;
+ }}
+ htmlFor="field"
+ >
+ label
+ ,
+ );
+ expect(node).toBeInstanceOf(HTMLElement);
+ expect(node.tagName).toBe("LABEL");
+ });
+});
diff --git a/frontend/src/components/ui/scroll-area.tsx b/frontend/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000000..fd1d2649eb
--- /dev/null
+++ b/frontend/src/components/ui/scroll-area.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const ScrollArea = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+ {children}
+
+
+
+
+));
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
+
+const ScrollBar = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = "vertical", ...props }, ref) => (
+
+
+
+));
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
+
+export { ScrollArea, ScrollBar };
diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx
new file mode 100644
index 0000000000..196e61a25e
--- /dev/null
+++ b/frontend/src/components/ui/select.tsx
@@ -0,0 +1,204 @@
+import { Check, ChevronDown, ChevronUp } from "lucide-react";
+import { Select as SelectPrimitive } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Select = SelectPrimitive.Root;
+
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectValue = SelectPrimitive.Value;
+
+/**
+ * Shared so the searchable variant of the antd Select shim — which cannot use
+ * this Trigger, because it is anchored to a Popover rather than a Select —
+ * still renders a byte-identical control. Two hand-copied class strings would
+ * drift the moment either is restyled.
+ */
+const selectTriggerClassName =
+ "flex h-8 w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1";
+
+/** Likewise shared: the popup surface, so both variants sit on the same card. */
+const selectContentClassName =
+ "relative z-50 max-h-[min(16rem,var(--radix-select-content-available-height))] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[var(--radix-select-content-transform-origin)]";
+
+/** And the row, so a filtered option highlights exactly like an unfiltered one. */
+const selectItemClassName =
+ "relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50";
+
+const SelectTrigger = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectContent,
+ selectContentClassName,
+ selectItemClassName,
+ selectTriggerClassName,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+};
diff --git a/frontend/src/components/ui/separator.tsx b/frontend/src/components/ui/separator.tsx
new file mode 100644
index 0000000000..6a7834270c
--- /dev/null
+++ b/frontend/src/components/ui/separator.tsx
@@ -0,0 +1,29 @@
+import { Separator as SeparatorPrimitive } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Separator = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(
+ (
+ { className, orientation = "horizontal", decorative = true, ...props },
+ ref,
+ ) => (
+
+ ),
+);
+Separator.displayName = SeparatorPrimitive.Root.displayName;
+
+export { Separator };
diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx
new file mode 100644
index 0000000000..fc4a49d2bf
--- /dev/null
+++ b/frontend/src/components/ui/sheet.tsx
@@ -0,0 +1,160 @@
+"use client";
+import { cva, type VariantProps } from "class-variance-authority";
+import { X } from "lucide-react";
+import { Dialog as SheetPrimitive } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Sheet = SheetPrimitive.Root;
+
+const SheetTrigger = SheetPrimitive.Trigger;
+
+const SheetClose = SheetPrimitive.Close;
+
+const SheetPortal = SheetPrimitive.Portal;
+
+/*
+ * z-[1100], not shadcn's stock z-50: this app predates the Tailwind z scale and
+ * still has chrome parked in the hundreds/thousands (`.logs-container` is 999,
+ * the agency canvas and settings rail are 1000), so a z-50 overlay left the
+ * Logs footer painting bright over the dimmed page. Sits above that legacy band
+ * but below `[data-radix-popper-content-wrapper]` (1500, so a Select opened
+ * inside a modal still clears it) and `.fullscreen-loader` (2000) — see
+ * index.css.
+ */
+const SheetOverlay = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
+
+const sheetVariants = cva(
+ "fixed z-[1100] gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
+ {
+ variants: {
+ side: {
+ top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+ bottom:
+ "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+ left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
+ right:
+ "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
+ },
+ },
+ defaultVariants: {
+ side: "right",
+ },
+ },
+);
+
+const SheetContent = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps & {
+ /**
+ * Renders the stock top-right close button. Opt out when the panel
+ * supplies its own close affordance — antd's ``
+ * does exactly that, and without this the two buttons stack in the same
+ * corner.
+ */
+ showClose?: boolean;
+ }
+>(
+ (
+ { side = "right", className, children, showClose = true, ...props },
+ ref,
+ ) => (
+
+
+
+ {showClose ? (
+
+
+ Close
+
+ ) : null}
+ {children}
+
+
+ ),
+);
+SheetContent.displayName = SheetPrimitive.Content.displayName;
+
+const SheetHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+SheetHeader.displayName = "SheetHeader";
+
+const SheetFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+SheetFooter.displayName = "SheetFooter";
+
+const SheetTitle = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetTitle.displayName = SheetPrimitive.Title.displayName;
+
+const SheetDescription = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetDescription.displayName = SheetPrimitive.Description.displayName;
+
+export {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetOverlay,
+ SheetPortal,
+ SheetTitle,
+ SheetTrigger,
+};
diff --git a/frontend/src/components/ui/shims/antd-button.test.jsx b/frontend/src/components/ui/shims/antd-button.test.jsx
new file mode 100644
index 0000000000..6a19081b2b
--- /dev/null
+++ b/frontend/src/components/ui/shims/antd-button.test.jsx
@@ -0,0 +1,83 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { Button } from "@/components/ui/shims/antd-button";
+
+describe("antd-compatible Button shim (P1-04)", () => {
+ it("renders children in a real ", () => {
+ render(Save );
+ expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
+ });
+
+ it("defaults to DOM type=button so it never submits a form by accident", () => {
+ render(x );
+ expect(screen.getByRole("button").getAttribute("type")).toBe("button");
+ });
+
+ it("maps htmlType onto the DOM type attribute", () => {
+ render(go );
+ expect(screen.getByRole("button").getAttribute("type")).toBe("submit");
+ });
+
+ // The behaviours that made a find-and-replace unsafe:
+
+ it("disables the button while loading, as antd does", () => {
+ render(saving );
+ expect(screen.getByRole("button")).toBeDisabled();
+ });
+
+ it("shows a spinner when loading and hides the supplied icon", () => {
+ render(
+ }>
+ saving
+ ,
+ );
+ expect(screen.queryByTestId("icon")).not.toBeInTheDocument();
+ expect(screen.getByRole("button").querySelector("svg")).toBeTruthy();
+ });
+
+ it("renders the icon when not loading", () => {
+ render( }>with icon);
+ expect(screen.getByTestId("icon")).toBeInTheDocument();
+ });
+
+ it("keeps an explicitly disabled button disabled", () => {
+ render(nope );
+ expect(screen.getByRole("button")).toBeDisabled();
+ });
+
+ it("applies destructive styling for danger", () => {
+ render(del );
+ expect(screen.getByRole("button").className).toContain("destructive");
+ });
+
+ it("treats danger + text as a ghost button with destructive text", () => {
+ render(
+
+ del
+ ,
+ );
+ expect(screen.getByRole("button").className).toContain("text-destructive");
+ });
+
+ it("makes block buttons full width", () => {
+ render(wide );
+ expect(screen.getByRole("button").className).toContain("w-full");
+ });
+
+ it("rounds circle/round shapes", () => {
+ render(o );
+ expect(screen.getByRole("button").className).toContain("rounded-full");
+ });
+
+ it("forwards onClick and arbitrary props", () => {
+ let clicked = false;
+ render(
+ (clicked = true)}>
+ c
+ ,
+ );
+ screen.getByTestId("probe").click();
+ expect(clicked).toBe(true);
+ });
+});
diff --git a/frontend/src/components/ui/shims/antd-button.tsx b/frontend/src/components/ui/shims/antd-button.tsx
new file mode 100644
index 0000000000..83e92a734c
--- /dev/null
+++ b/frontend/src/components/ui/shims/antd-button.tsx
@@ -0,0 +1,146 @@
+import { Loader2 } from "lucide-react";
+import * as React from "react";
+
+import { Button as ShadcnButton } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+/**
+ * antd-compatible `Button` (P1-04), built on the shadcn primitive.
+ *
+ * Same reasoning as the Typography shim: antd's Button carries behaviour that
+ * shadcn's does not, so a bare find-and-replace would quietly change what the
+ * UI does rather than only how it looks (C4). Specifically:
+ *
+ * - `loading` — swaps in a spinner AND disables the button (234 usages)
+ * - `icon` — renders a leading icon slot (106 usages)
+ * - `danger` — destructive styling, orthogonal to `type` (12 usages)
+ * - `htmlType` — maps to the DOM `type` attribute, since antd claims `type`
+ * for its visual variant
+ *
+ * Presenting antd's API here turns 70 call-site files into an import swap and
+ * keeps the JSX untouched. Per D9/§5.0 it lives in OSS so cloud plugins use
+ * the same component.
+ *
+ * New code should prefer `@/components/ui/button` directly; this exists to
+ * carry the existing call-sites across without behaviour drift.
+ */
+
+/**
+ * The antd Button surface this shim accepts.
+ *
+ * Typing the PROPS is the point of converting this file: the bugs this layer
+ * has produced were all silent prop-drops — a prop the call-site passes, the
+ * shim never destructures, and `...props` swallows without a warning
+ * (`showCount`, `onValuesChange`, `setFields`, `validateStatus`). An explicit
+ * surface turns the next one into a compile error at the call-site.
+ */
+type AntdButtonType = "primary" | "default" | "dashed" | "text" | "link";
+type AntdButtonSize = "small" | "middle" | "large";
+
+interface AntdButtonProps
+ extends Omit, "type"> {
+ /** antd's visual variant. It claims `type`, so the DOM attribute moves to
+ * `htmlType`. */
+ type?: AntdButtonType;
+ danger?: boolean;
+ size?: AntdButtonSize;
+ /** Shows a spinner AND disables the button, as antd does. */
+ loading?: boolean;
+ icon?: React.ReactNode;
+ /** The real DOM `type` attribute. */
+ htmlType?: "button" | "submit" | "reset";
+ block?: boolean;
+ shape?: "default" | "circle" | "round";
+}
+
+/** antd `type` (+ `danger`) → shadcn variant. */
+function toVariant(
+ type: AntdButtonType | undefined,
+ danger: boolean | undefined,
+) {
+ if (danger) {
+ return type === "text" || type === "link" ? "ghost" : "destructive";
+ }
+ switch (type) {
+ case "primary":
+ return "default";
+ case "link":
+ return "link";
+ case "text":
+ return "ghost";
+ case "dashed":
+ case "default":
+ default:
+ return "outline";
+ }
+}
+
+/** antd `size` → shadcn size. antd's default sits between sm and lg. */
+function toSize(size: AntdButtonSize | undefined, hasOnlyIcon: boolean) {
+ if (hasOnlyIcon) {
+ return "icon";
+ }
+ switch (size) {
+ case "small":
+ return "sm";
+ case "large":
+ return "lg";
+ default:
+ return "default";
+ }
+}
+
+const Button = React.forwardRef(
+ function Button(
+ {
+ type,
+ danger,
+ size,
+ loading,
+ icon,
+ htmlType,
+ block,
+ shape,
+ disabled,
+ className,
+ children,
+ ...props
+ },
+ ref,
+ ) {
+ const hasOnlyIcon = Boolean(icon) && !children;
+
+ return (
+
+ {loading ? (
+
+ ) : (
+ icon
+ )}
+ {children}
+
+ );
+ },
+);
+
+export { Button };
diff --git a/frontend/src/components/ui/shims/antd-datetime.integration.test.jsx b/frontend/src/components/ui/shims/antd-datetime.integration.test.jsx
new file mode 100644
index 0000000000..4da420e4e0
--- /dev/null
+++ b/frontend/src/components/ui/shims/antd-datetime.integration.test.jsx
@@ -0,0 +1,182 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import dayjs from "dayjs";
+import { describe, expect, it, vi } from "vitest";
+import { RangePicker } from "@/components/ui/shims/antd-datetime";
+
+// End-to-end through the EXACT props MetricsDashboard passes.
+describe("MetricsDashboard integration shape", () => {
+ it("opens, shows presets + two months, and drives a 7-day range", async () => {
+ const onChange = vi.fn();
+ const now = dayjs("2026-07-28T10:00:00");
+ render(
+ current && current > now}
+ allowClear={false}
+ size="middle"
+ presets={[
+ { label: "Last 7 Days", value: [now.subtract(7, "day"), now] },
+ { label: "Last 30 Days", value: [now.subtract(30, "day"), now] },
+ { label: "Last 90 Days", value: [now.subtract(90, "day"), now] },
+ ]}
+ />,
+ );
+
+ // Trigger shows the current range.
+ const trigger = screen.getByRole("button", {
+ name: /2026-06-28.*2026-07-28/,
+ });
+ fireEvent.click(trigger);
+
+ // Two months render.
+ expect((await screen.findAllByRole("grid")).length).toBe(2);
+ // Preset sidebar renders.
+ for (const l of ["Last 7 Days", "Last 30 Days", "Last 90 Days"]) {
+ expect(screen.getByRole("button", { name: l })).toBeInTheDocument();
+ }
+
+ fireEvent.click(screen.getByRole("button", { name: "Last 7 Days" }));
+ const [pair] = onChange.mock.calls.at(-1);
+ expect(dayjs.isDayjs(pair[0])).toBe(true);
+ expect(pair[1].diff(pair[0], "day")).toBe(7);
+ });
+});
+
+/**
+ * jsdom has no layout engine, so it cannot catch "the popover ran off the
+ * bottom of the screen". What it CAN pin is the cause: `sm:` variants track
+ * the VIEWPORT, but this content lives inside a popover whose own width is
+ * what matters. On a wide screen the months still stacked, leaving a 250px
+ * by ~700px column that overflowed the window.
+ *
+ * Asserting the class contract is the cheapest guard available here; the
+ * geometry itself was checked in a real browser.
+ */
+describe("popover layout must not depend on viewport breakpoints", () => {
+ it("lays the months out in a row unconditionally", async () => {
+ render(
+ ,
+ );
+ fireEvent.click(screen.getAllByRole("button")[0]);
+ await screen.findAllByRole("grid");
+
+ expect(document.querySelector(".flex.flex-row.gap-4")).toBeTruthy();
+ // A viewport-conditional row is the bug, not the fix.
+ expect(document.body.innerHTML).not.toContain("sm:flex-row");
+ expect(document.body.innerHTML).not.toContain("sm:flex-col");
+ });
+
+ /*
+ * react-day-picker renders ONE nav for the whole calendar. Anchoring the
+ * absolute prev/next buttons to `.month` pinned both to the FIRST month, so
+ * with `numberOfMonths={2}` the "next" arrow sat mid-popover instead of at
+ * the right edge. The positioning context has to be the months ROW.
+ */
+ it("anchors the nav arrows to the months row, not one month", async () => {
+ render( );
+ fireEvent.click(screen.getAllByRole("button")[0]);
+ await screen.findAllByRole("grid");
+
+ const monthsRow = document.querySelector(".flex.flex-row.gap-4");
+ expect(monthsRow.className).toContain("relative");
+ // Each month must NOT create its own positioning context.
+ for (const m of monthsRow.children) {
+ expect(m.className).not.toContain("relative");
+ }
+ });
+
+ /*
+ * With `showTime` the label carries full timestamps
+ * ("2026-07-02T00:00 → 2026-08-01T23:59"). A nowrap span with no min-width
+ * floor forced the trigger past its container, and the Logs filter row broke
+ * apart as soon as a range was picked.
+ */
+ it("truncates a showTime label instead of forcing the row wider", () => {
+ render(
+ ,
+ );
+ const trigger = screen.getAllByRole("button")[0];
+ expect(trigger.className).toContain("max-w-full");
+
+ const label = trigger.querySelector("span:not(.shrink-0)");
+ expect(label.className).toContain("truncate");
+ expect(label.className).toContain("min-w-0");
+ expect(label.className).not.toContain("whitespace-nowrap");
+ });
+
+ /*
+ * antd's header offers a year jump (`super-prev`/`super-next` plus clickable
+ * month and year buttons). The arrows here stepped by month only, so a date
+ * a year away took twelve clicks.
+ */
+ it("offers month AND year selection, like antd's header", async () => {
+ render( );
+ fireEvent.click(screen.getAllByRole("button")[0]);
+ await screen.findAllByRole("grid");
+
+ const selects = document.querySelectorAll("select");
+ expect(selects.length).toBeGreaterThanOrEqual(2);
+ const names = [...selects].map((s) => s.getAttribute("aria-label") || "");
+ expect(names.some((n) => /month/i.test(n))).toBe(true);
+ expect(names.some((n) => /year/i.test(n))).toBe(true);
+ });
+
+ /*
+ * react-day-picker renders a dropdown caption as a PLUS a visible
+ * label span carrying the same text; the select is meant to lie invisibly
+ * over the span and take the clicks. Styling the select as the visible
+ * control drew both, so the header read "August August › 2026 2026 ›" and
+ * the doubled width slid under the nav arrows.
+ */
+ it("draws each dropdown caption once, not twice", async () => {
+ render( );
+ fireEvent.click(screen.getAllByRole("button")[0]);
+ await screen.findAllByRole("grid");
+
+ const monthSelect = document.querySelector("select");
+ // Invisible, but still stacked over the label so it keeps the clicks.
+ expect(monthSelect.className).toContain("opacity-0");
+ expect(monthSelect.className).toContain("absolute");
+
+ // The caption text appears once per month, in the label the select covers.
+ const root = monthSelect.parentElement;
+ const label = root.querySelector("span[aria-hidden]");
+ expect(label.textContent).toBe("March");
+ expect(root.className).toContain("border");
+ });
+
+ it("points a dropdown's chevron down and a nav arrow sideways", async () => {
+ render( );
+ fireEvent.click(screen.getAllByRole("button")[0]);
+ await screen.findAllByRole("grid");
+
+ const dir = (el) =>
+ el.getAttribute("class").match(/lucide-chevron-(\w+)/)?.[1] ?? "";
+ const chevrons = [...document.querySelectorAll("svg.rdp-chevron")];
+ // Four captions (month + year, twice) all point down; the two nav arrows
+ // point left and right. Falling through to a default gave every caption a
+ // rightward chevron, so none of them read as a dropdown.
+ const dirs = chevrons.map(dir);
+ expect(dirs.filter((d) => d === "down")).toHaveLength(4);
+ expect(dirs).toContain("left");
+ expect(dirs).toContain("right");
+ });
+
+ it("keeps the caption clear of the nav arrows", async () => {
+ render( );
+ fireEvent.click(screen.getAllByRole("button")[0]);
+ await screen.findAllByRole("grid");
+
+ // The arrows are absolutely positioned at the calendar's outer edges, so
+ // the caption reserves room for them rather than centring into them.
+ const caption = document.querySelector("select").closest(".justify-center");
+ expect(caption.className).toContain("px-8");
+ });
+});
diff --git a/frontend/src/components/ui/shims/antd-datetime.test.jsx b/frontend/src/components/ui/shims/antd-datetime.test.jsx
new file mode 100644
index 0000000000..9bd634dd92
--- /dev/null
+++ b/frontend/src/components/ui/shims/antd-datetime.test.jsx
@@ -0,0 +1,407 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import dayjs from "dayjs";
+import moment from "moment";
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ DatePicker,
+ RangePicker,
+ TimePicker,
+} from "@/components/ui/shims/antd-datetime";
+
+/**
+ * D7 is the reason these tests exist. Call-sites are written against antd's
+ * moment contract:
+ *
+ * value={value ? moment(value) : null}
+ * onChange={(date) => onChange(date?.toISOString())}
+ *
+ * If the shim handed back a Date or a string, `date?.toISOString()` would
+ * either throw or silently produce a different value — and timezone/DST
+ * behaviour would shift, which D7 says must not happen in this phase.
+ */
+/**
+ * RangePicker is now a popover calendar rather than two native inputs, so the
+ * tests drive it the way a user does: open the trigger, click days. The
+ * BEHAVIOURAL assertions below are unchanged — the tuple contract, allowClear,
+ * onOk and date-library preservation are what the call-sites depend on, and
+ * they have to survive the UI swap.
+ */
+async function openRangeCalendar() {
+ // The trigger carries the calendar icon and the range label; before the
+ // popover mounts it is the only button in the tree.
+ const trigger = screen.getAllByRole("button")[0];
+ fireEvent.click(trigger);
+ // Two months are shown, so there are two grids — wait for at least one.
+ await screen.findAllByRole("grid");
+}
+
+/**
+ * Find a day cell by date. react-day-picker labels days
+ * "Sunday, March 1st, 2026".
+ *
+ * Two months are rendered, and each grid also paints the adjacent month's
+ * overflow days — so a single date can appear TWICE in the DOM. Take the cell
+ * that is not an outside day, which is the one a user would read as belonging
+ * to that month.
+ */
+async function pickDayButton(dateLike) {
+ const d = moment(dateLike.valueOf ? dateLike.valueOf() : dateLike);
+ // Match month/day/year so the ordinal suffix does not matter.
+ const pattern = new RegExp(
+ `${d.format("MMMM")}\\s+${d.date()}(st|nd|rd|th),\\s+${d.year()}`,
+ );
+ const matches = await screen.findAllByRole("button", { name: pattern });
+ const owned = matches.filter(
+ (el) => !el.closest("td")?.className.includes("outside"),
+ );
+ return owned[0] ?? matches[0];
+}
+
+/** Click a day by ISO date. */
+async function pickDay(iso) {
+ const button = await pickDayButton(moment(iso));
+ fireEvent.click(button);
+ return button;
+}
+
+describe("antd-compatible date/time shims (P3-04, D7)", () => {
+ it("renders a date input for DatePicker", () => {
+ const { container } = render( );
+ expect(container.querySelector("input").getAttribute("type")).toBe("date");
+ });
+
+ it("switches to datetime-local when showTime is set", () => {
+ const { container } = render( );
+ expect(container.querySelector("input").getAttribute("type")).toBe(
+ "datetime-local",
+ );
+ });
+
+ it("accepts a moment value and displays it", () => {
+ const { container } = render( );
+ expect(container.querySelector("input").value).toBe("2026-03-14");
+ });
+
+ /**
+ * `moment(dayjsInstance)` does not understand dayjs. It does not throw and
+ * does not report invalid — it quietly returns a moment for TODAY. So every
+ * dayjs-valued field rendered today's date, with nothing anywhere to
+ * indicate it. MetricsDashboard holds dayjs, and its "from" field showed
+ * today instead of 30 days ago.
+ *
+ * Uses a fixed past date so a regression cannot coincidentally look right.
+ */
+ it("displays a dayjs value as its own date, not today", () => {
+ const { container } = render( );
+ expect(container.querySelector("input").value).toBe("2026-03-14");
+ });
+
+ it("displays a dayjs RangePicker tuple as its own dates", () => {
+ render( );
+ expect(
+ screen.getByRole("button", { name: /2026-03-01.*2026-03-31/ }),
+ ).toBeInTheDocument();
+ });
+
+ it("displays a plain Date value correctly too", () => {
+ const { container } = render(
+ , // month is 0-based
+ );
+ expect(container.querySelector("input").value).toBe("2026-03-14");
+ });
+
+ it("accepts an ISO string value too", () => {
+ const { container } = render( );
+ expect(container.querySelector("input").value).toBeTruthy();
+ });
+
+ it("renders empty for a null value rather than 'Invalid date'", () => {
+ const { container } = render( );
+ expect(container.querySelector("input").value).toBe("");
+ });
+
+ it("ignores an unparseable value instead of rendering NaN", () => {
+ const { container } = render( );
+ expect(container.querySelector("input").value).toBe("");
+ });
+
+ // The load-bearing assertion for D7.
+ it("hands onChange a MOMENT, so `date?.toISOString()` keeps working", () => {
+ const onChange = vi.fn();
+ const { container } = render( );
+ fireEvent.change(container.querySelector("input"), {
+ target: { value: "2026-03-14" },
+ });
+
+ expect(onChange).toHaveBeenCalled();
+ const arg = onChange.mock.calls[0][0];
+ expect(moment.isMoment(arg)).toBe(true);
+ expect(typeof arg.toISOString()).toBe("string");
+ });
+
+ it("hands onChange null when the field is cleared", () => {
+ const onChange = vi.fn();
+ const { container } = render(
+ ,
+ );
+ fireEvent.change(container.querySelector("input"), {
+ target: { value: "" },
+ });
+ expect(onChange.mock.calls.at(-1)[0]).toBeNull();
+ });
+
+ it("renders a time input for TimePicker", () => {
+ const { container } = render( );
+ expect(container.querySelector("input").getAttribute("type")).toBe("time");
+ });
+
+ it("formats a moment value as HH:mm:ss for TimePicker", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("input").value).toBe("13:45:30");
+ });
+
+ it("RangePicker shows both ends of the range on its trigger", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole("button", { name: /2026-03-01.*2026-03-31/ }),
+ ).toBeInTheDocument();
+ });
+
+ it("RangePicker prompts when it has no value", () => {
+ render( );
+ expect(
+ screen.getByRole("button", { name: /Select date range/ }),
+ ).toBeInTheDocument();
+ });
+
+ it("RangePicker emits a tuple of moments when days are picked", async () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+
+ await openRangeCalendar();
+ await pickDay("2026-03-01");
+
+ const pair = onChange.mock.calls[0][0];
+ expect(Array.isArray(pair)).toBe(true);
+ expect(moment.isMoment(pair[0])).toBe(true);
+ });
+
+ it("exposes RangePicker as DatePicker.RangePicker, as antd does", () => {
+ expect(DatePicker.RangePicker).toBe(RangePicker);
+ });
+
+ it("passes disabled through", () => {
+ const { container } = render( );
+ expect(container.querySelector("input")).toBeDisabled();
+ });
+
+ /**
+ * These props were accepted-and-ignored by the first version of the shim.
+ * Ignoring them is invisible in a screenshot but changes what the control
+ * DOES, so each one gets a test that fails if it silently stops working.
+ */
+ describe("RangePicker props the call-sites depend on", () => {
+ it("renders preset buttons and applies the range when one is clicked", async () => {
+ const onChange = vi.fn();
+ const preset = [moment("2026-03-01"), moment("2026-03-08")];
+ render(
+ ,
+ );
+
+ await openRangeCalendar();
+ fireEvent.click(screen.getByRole("button", { name: "Last 7 Days" }));
+
+ const [pair] = onChange.mock.calls[0];
+ expect(pair[0].toISOString()).toBe(preset[0].toISOString());
+ expect(pair[1].toISOString()).toBe(preset[1].toISOString());
+ });
+
+ it("renders no preset sidebar when presets are not supplied", async () => {
+ render( );
+ await openRangeCalendar();
+ expect(
+ screen.queryByRole("button", { name: /Last \d+ Days/ }),
+ ).not.toBeInTheDocument();
+ });
+
+ // MetricsDashboard blocks future dates. A calendar can grey out individual
+ // days, which is what antd's per-date predicate actually means — the old
+ // native-input version could only approximate it with a min/max bound.
+ it("disables future days for a no-future-dates disabledDate", async () => {
+ render(
+ current && current > moment()}
+ />,
+ );
+ await openRangeCalendar();
+
+ const tomorrow = await pickDayButton(moment().add(1, "day"));
+ expect(tomorrow).toBeDisabled();
+ const today = await pickDayButton(moment());
+ expect(today).not.toBeDisabled();
+ });
+
+ // The predicate is the caller's own code, so it must be handed the
+ // caller's own date type. MetricsDashboard's reads `current > dayjs()`;
+ // a moment compares fine by coercion, which is exactly why passing the
+ // wrong type here goes unnoticed until a predicate calls a dayjs-only
+ // method. Assert the type the predicate actually receives.
+ it("hands disabledDate the caller's date library", async () => {
+ const seen = [];
+ render(
+ {
+ seen.push(current);
+ return false;
+ }}
+ />,
+ );
+ await openRangeCalendar();
+
+ expect(seen.length).toBeGreaterThan(0);
+ expect(seen.every((d) => dayjs.isDayjs(d))).toBe(true);
+ expect(seen.some((d) => moment.isMoment(d))).toBe(false);
+ });
+
+ it("leaves every day selectable when no disabledDate is given", async () => {
+ render(
+ ,
+ );
+ await openRangeCalendar();
+ const day = await pickDayButton(moment("2026-03-15"));
+ expect(day).not.toBeDisabled();
+ });
+
+ // antd reports a fully-cleared range as null. MetricsDashboard's handler
+ // ignores anything that is not a complete pair, so emitting null with
+ // allowClear={false} would strand the dashboard on a stale range.
+ // antd reports a fully-cleared range as null. MetricsDashboard's handler
+ // ignores anything that is not a complete pair, so emitting null with
+ // allowClear={false} would strand the dashboard on a stale range.
+ //
+ // The clear path is react-day-picker handing back an empty selection.
+ // There is no "clear" affordance to click, so it is driven straight
+ // through the Calendar's onSelect — the same call the library makes.
+ // Reach the live Calendar's onSelect by opening the popover and invoking
+ // the handler react-day-picker would call. Going through the React tree
+ // keeps this honest: if the shim stopped wiring onSelect, this breaks.
+ const clearViaCalendar = async () => {
+ await openRangeCalendar();
+ const grid = screen.getAllByRole("grid")[0];
+ const fiberKey = Object.keys(grid).find((k) =>
+ k.startsWith("__reactFiber$"),
+ );
+ let node = grid[fiberKey];
+ while (node && typeof node.memoizedProps?.onSelect !== "function") {
+ node = node.return;
+ }
+ node.memoizedProps.onSelect(undefined, undefined);
+ };
+
+ it("suppresses the cleared-range emit when allowClear is false", async () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ await clearViaCalendar();
+ expect(onChange.mock.calls.filter((c) => c[0] === null)).toHaveLength(0);
+ });
+
+ it("emits null for a cleared range when allowClear is default", async () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ await clearViaCalendar();
+ expect(onChange).toHaveBeenCalledWith(null, ["", ""]);
+ });
+
+ it("fires onOk once the range becomes complete", async () => {
+ const onOk = vi.fn();
+ render(
+ ,
+ );
+ await openRangeCalendar();
+ await pickDay("2026-03-01");
+ expect(onOk).not.toHaveBeenCalled();
+ await pickDay("2026-03-31");
+ expect(onOk).toHaveBeenCalledTimes(1);
+ });
+
+ // MetricsDashboard holds dayjs; ExecutionLogs holds moment. Handing back
+ // the wrong one is a type the call-site never opted into.
+ //
+ // Tested against the REAL dayjs, not a stand-in. An earlier version of
+ // this test used a hand-written stub whose constructor accepted a date
+ // string — so it passed while the shim was doing
+ // `new sample.constructor(iso)`, which dayjs silently ignores (returning
+ // TODAY) and moment turns into an object that throws on .format().
+ // The stub tested itself; only the real library catches that.
+ it("echoes back the caller's date library rather than forcing moment", async () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ await openRangeCalendar();
+ await pickDay("2026-03-31");
+
+ const pair = onChange.mock.calls.at(-1)[0];
+ const emitted = pair[1] ?? pair[0];
+ expect(dayjs.isDayjs(emitted)).toBe(true);
+ expect(moment.isMoment(emitted)).toBe(false);
+ });
+
+ it("preserves moment for callers that hold moment", async () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ await openRangeCalendar();
+ await pickDay("2026-03-31");
+
+ const pair = onChange.mock.calls.at(-1)[0];
+ const emitted = pair[1] ?? pair[0];
+ expect(moment.isMoment(emitted)).toBe(true);
+ });
+ });
+});
diff --git a/frontend/src/components/ui/shims/antd-datetime.tsx b/frontend/src/components/ui/shims/antd-datetime.tsx
new file mode 100644
index 0000000000..79444421ee
--- /dev/null
+++ b/frontend/src/components/ui/shims/antd-datetime.tsx
@@ -0,0 +1,554 @@
+import { Calendar as CalendarIcon } from "lucide-react";
+import moment from "moment";
+import * as React from "react";
+
+import { Calendar } from "@/components/ui/calendar";
+import { Input } from "@/components/ui/input";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { cn } from "@/lib/utils";
+
+/**
+ * antd-compatible `DatePicker` / `TimePicker` / `RangePicker` (P3-04).
+ *
+ * Built on native ` ` rather than
+ * `react-day-picker`: every call-site here is a plain date/time field, not a
+ * calendar surface, so a full calendar component would be more machinery than
+ * the UI actually uses.
+ *
+ * ## The behavioural contract (D7)
+ *
+ * antd's pickers exchange **moment objects**, and call-sites depend on that:
+ *
+ * value={value ? moment(value) : null}
+ * onChange={(date) => onChange(date?.toISOString())}
+ *
+ * `moment` is still a direct dependency, so rather than change every call-site
+ * to a different date type — which is the behaviour-affecting half of D7 —
+ * these components keep exchanging moment objects. That confines the swap to
+ * the widget layer and leaves timezone/DST handling exactly as it was.
+ *
+ * Removing moment itself is deliberately NOT bundled in here: it changes
+ * timezone semantics and deserves its own reviewed change (D7 says as much).
+ */
+
+/**
+ * The antd date/time surface these shims accept.
+ *
+ * These deliberately exchange MOMENT objects rather than Dates or strings
+ * (D7): call-sites hold moments, and converting the date type would change
+ * timezone/DST behaviour, which belongs in its own reviewed change. The value
+ * types below say so explicitly.
+ *
+ * `MomentLike` is structural on purpose — anything exposing `valueOf()` (a
+ * moment, a dayjs instance, a Date) is normalised through the epoch instant.
+ * That normalisation exists because `moment(dayjsInstance)` silently returns
+ * TODAY and reports `isValid() === true`, so the wrong date rendered with no
+ * error anywhere.
+ */
+interface MomentLike {
+ valueOf: () => number;
+ format?: (pattern?: string) => string;
+ toDate?: () => Date;
+ isValid?: () => boolean;
+ clone?: () => MomentLike;
+}
+
+/** What the pickers accept as a value: a moment/dayjs/Date, or an ISO string. */
+type DateValue = MomentLike | Date | string | null | undefined;
+
+interface DatePickerProps
+ extends Omit<
+ React.InputHTMLAttributes,
+ "value" | "onChange" | "size"
+ > {
+ value?: DateValue;
+ /** antd hands back (moment, isoString), matching what call-sites destructure. */
+ onChange?: (value: MomentLike | null, dateString: string) => void;
+ showTime?: boolean;
+ /** Display pattern. The native control renders it, so this is accepted, not applied. */
+ format?: string;
+ size?: "small" | "middle" | "large";
+}
+
+interface TimePickerProps
+ extends Omit<
+ React.InputHTMLAttributes,
+ "value" | "onChange" | "size"
+ > {
+ value?: DateValue;
+ onChange?: (value: MomentLike | null, timeString: string) => void;
+ format?: string;
+ size?: "small" | "middle" | "large";
+}
+
+/** antd's range value is a [start, end] tuple. */
+type RangeValue = [MomentLike | null, MomentLike | null] | null;
+
+interface RangePickerProps
+ extends Omit<
+ React.HTMLAttributes,
+ "value" | "onChange" | "defaultValue"
+ > {
+ value?: RangeValue;
+ onChange?: (value: RangeValue, dateStrings: [string, string]) => void;
+ /** Fired once the range is complete, as antd does. */
+ onOk?: (value: RangeValue) => void;
+ showTime?: boolean;
+ disabled?: boolean;
+ /** Quick-select shortcuts shown beside the calendar. */
+ presets?: Array<{ label: React.ReactNode; value: RangeValue }>;
+ disabledDate?: (current: MomentLike) => boolean;
+ allowClear?: boolean;
+ /** Month the calendar opens on. */
+ defaultMonth?: Date;
+ format?: string;
+ size?: "small" | "middle" | "large";
+}
+
+/** ISO → the value shape a native input expects. */
+function toInputValue(
+ value: DateValue,
+ type: "date" | "time" | "datetime-local",
+): string {
+ if (!value) {
+ return "";
+ }
+ // `moment(dayjsInstance)` does NOT understand dayjs: it returns a moment for
+ // TODAY and reports isValid() === true, so the wrong date renders with no
+ // error anywhere. MetricsDashboard holds dayjs, which is why its start field
+ // showed today instead of 30 days ago. Anything exposing valueOf() (dayjs,
+ // moment, Date) is normalised through the epoch instant first.
+ const normalised: number | string | Date =
+ !moment.isMoment(value) && typeof value?.valueOf === "function"
+ ? value.valueOf()
+ : (value as string | Date);
+ const m = moment.isMoment(value) ? value : moment(normalised);
+ if (!m.isValid()) {
+ return "";
+ }
+ if (type === "time") {
+ return m.format("HH:mm:ss");
+ }
+ if (type === "datetime-local") {
+ return m.format("YYYY-MM-DDTHH:mm:ss");
+ }
+ return m.format("YYYY-MM-DD");
+}
+
+/**
+ * antd ``.
+ * `onChange` receives a moment (or null), matching antd.
+ */
+const DatePickerBase = React.forwardRef(
+ function DatePicker(
+ {
+ value,
+ onChange,
+ showTime,
+ disabled,
+ placeholder,
+ format: _format,
+ size: _size,
+ className,
+ ...props
+ },
+ ref,
+ ) {
+ const type = showTime ? "datetime-local" : "date";
+ return (
+ {
+ const raw = e.target.value;
+ onChange?.(raw ? moment(raw) : null, raw);
+ }}
+ {...props}
+ />
+ );
+ },
+);
+
+/** antd ``. */
+const TimePicker = React.forwardRef(
+ function TimePicker(
+ {
+ value,
+ onChange,
+ disabled,
+ placeholder,
+ format: _format,
+ size: _size,
+ className,
+ ...props
+ },
+ ref,
+ ) {
+ return (
+ {
+ const raw = e.target.value;
+ onChange?.(raw ? moment(raw, "HH:mm:ss") : null, raw);
+ }}
+ {...props}
+ />
+ );
+ },
+);
+
+/**
+ * Rebuild a date in whatever library the CALLER is using.
+ *
+ * ExecutionLogs passes moment objects; MetricsDashboard passes dayjs. Both
+ * expose the same `.clone()`/`.toISOString()` surface, so hardcoding moment
+ * on the way out handed MetricsDashboard a moment where its state held dayjs.
+ * Nothing crashed — the two APIs overlap where that code touches them — but
+ * it is a type the call-site never opted into, and it silently reverses D7's
+ * promise that the widget layer does not change what flows through it.
+ *
+ * `sample` is a value we already received from the caller, so cloning it
+ * keeps their library, its locale and its timezone config.
+ */
+function likeSample(
+ sample: MomentLike | Date | string | null | undefined,
+ isoish?: string | null,
+): MomentLike | null {
+ if (!isoish) {
+ return null;
+ }
+ const millis = moment(isoish).valueOf();
+ if (Number.isNaN(millis)) {
+ return null;
+ }
+
+ // NOT `new sample.constructor(isoish)`. That looks right and is wrong for
+ // both libraries actually in use: dayjs's internal constructor takes a
+ // config OBJECT, so handed a string it ignores it and silently returns
+ // today; moment's returns an object that throws on .format(). Either way
+ // the caller gets a confidently-wrong date.
+ //
+ // `.clone()` then re-point the instant. Both libraries expose clone(), and
+ // dayjs's immutable setters return a new instance while moment's mutate in
+ // place and return this — assigning the result covers both.
+ const cloneable =
+ sample && typeof sample === "object" && "clone" in sample
+ ? (sample as MomentLike)
+ : null;
+ if (typeof cloneable?.clone === "function") {
+ try {
+ const moved = applyInstant(cloneable.clone(), millis);
+ if (moved?.isValid?.() && moved.valueOf() === millis) {
+ return moved;
+ }
+ } catch {
+ // Fall through to moment below.
+ }
+ }
+ return moment(isoish);
+}
+
+/**
+ * Re-point a cloned date instance at `millis`, tolerating both the mutable
+ * (moment) and immutable (dayjs) setter conventions.
+ */
+function applyInstant(clone: MomentLike, millis: number): MomentLike | null {
+ const base = moment(millis);
+ const parts: Array<[string, number]> = [
+ ["year", base.year()],
+ ["month", base.month()],
+ ["date", base.date()],
+ ["hour", base.hour()],
+ ["minute", base.minute()],
+ ["second", base.second()],
+ ["millisecond", base.millisecond()],
+ ];
+ let current: MomentLike | null = clone;
+ for (const [unit, value] of parts) {
+ // moment and dayjs both expose year()/month()/date()/… but share no type,
+ // so this read is reflection by nature. Cast here, at the probe, rather
+ // than widening MomentLike — which would stop moment.Moment satisfying it.
+ const setter = (current as unknown as Record)[unit];
+ if (typeof setter !== "function") {
+ return null;
+ }
+ current = ((setter as (v: number) => MomentLike | undefined).call(
+ current,
+ value,
+ ) ?? current) as MomentLike;
+ }
+ return current;
+}
+
+/**
+ * antd ``.
+ * Call-sites read `value?.[0]` / `value?.[1]`, so the tuple shape is kept.
+ *
+ * Native inputs rather than a calendar popup (see the module note), but the
+ * props below are honoured because dropping them changes BEHAVIOUR, not just
+ * appearance:
+ *
+ * - `presets` — MetricsDashboard's "Last 7/30/90 Days" buttons. These
+ * are the primary way the range is set; without them the
+ * control looks complete while its main affordance is
+ * missing.
+ * - `disabledDate` — bounds the pickable range. MetricsDashboard uses it to
+ * block future dates; ignoring it let users query
+ * tomorrow. Mapped onto the inputs' min/max, which is
+ * what a native input can enforce.
+ * - `allowClear` — antd defaults to true. MetricsDashboard passes false
+ * because its handler ignores anything that is not a
+ * complete pair, so a cleared range would freeze the UI.
+ * - `onOk` — antd fires this on the popup's confirm button. There is
+ * no popup here, so it fires when a range becomes
+ * complete, which is when the call-site expects it.
+ */
+const RangePicker = React.forwardRef(
+ function RangePicker(
+ {
+ value,
+ onChange,
+ onOk,
+ showTime,
+ disabled,
+ presets,
+ disabledDate,
+ allowClear = true,
+ className,
+ defaultMonth,
+ format: _format,
+ size: _size,
+ ...props
+ },
+ ref,
+ ) {
+ const type = showTime ? "datetime-local" : "date";
+ const [start, end] = value ?? [null, null];
+ const sample = start ?? end ?? presets?.[0]?.value?.[0] ?? null;
+
+ const emit = (nextStart: MomentLike | null, nextEnd: MomentLike | null) => {
+ const cleared = !nextStart && !nextEnd;
+ // antd reports a cleared range as null. With allowClear={false} the
+ // call-site never wants that, so hold the previous pair instead.
+ if (cleared && !allowClear) {
+ return;
+ }
+ const pair: RangeValue = cleared ? null : [nextStart, nextEnd];
+ onChange?.(pair, [
+ toInputValue(nextStart, type),
+ toInputValue(nextEnd, type),
+ ]);
+ if (nextStart && nextEnd) {
+ onOk?.([nextStart, nextEnd]);
+ }
+ };
+
+ /**
+ * antd's `disabledDate(current)` answers per-date, and so does the
+ * calendar's `disabled` — so the predicate maps straight across. The earlier
+ * native-input version had to probe outward for a min/max bound because an
+ * ` ` only understands those two attributes; a calendar can
+ * grey out individual days, which is what the prop actually means.
+ */
+ const isDayDisabled = React.useMemo(() => {
+ if (typeof disabledDate !== "function") {
+ return undefined;
+ }
+ return (day: Date) => {
+ try {
+ const probe = likeSample(sample, day.toISOString());
+ return probe ? Boolean(disabledDate(probe)) : false;
+ } catch {
+ // A predicate that cannot cope with the probe must not take the
+ // calendar down with it; treat the day as selectable.
+ return false;
+ }
+ };
+ }, [disabledDate, sample]);
+
+ type Preset = NonNullable[number];
+
+ const applyPreset = (preset: Preset) => {
+ const [presetStart, presetEnd] = preset.value ?? [null, null];
+ onChange?.(
+ [presetStart, presetEnd],
+ [toInputValue(presetStart, type), toInputValue(presetEnd, type)],
+ );
+ if (presetStart && presetEnd) {
+ onOk?.([presetStart, presetEnd]);
+ }
+ };
+
+ const [open, setOpen] = React.useState(false);
+
+ /** The calendar speaks native Date; the call-sites speak moment/dayjs. */
+ const selectedRange = React.useMemo(() => {
+ const from = start
+ ? new Date(moment(start.valueOf()).valueOf())
+ : undefined;
+ const to = end ? new Date(moment(end.valueOf()).valueOf()) : undefined;
+ return from || to ? { from, to } : undefined;
+ }, [start, end]);
+
+ /**
+ * Tracks which end the next click fills.
+ *
+ * react-day-picker reports `{from, to}` with BOTH set to the clicked day on
+ * every click — it does not distinguish "started a range" from "finished
+ * one". Taken at face value that makes each click look like a complete
+ * range, so `onOk` would fire on the first click and the second click would
+ * start over instead of closing the range. antd treats the first click as
+ * the start and the second as the end, so the anchor is tracked here.
+ */
+ const [anchor, setAnchor] = React.useState(null);
+
+ const handleSelect = (
+ range: { from?: Date; to?: Date } | undefined,
+ clickedDay?: Date,
+ ) => {
+ const day = clickedDay ?? range?.to ?? range?.from;
+ if (!day) {
+ // Deselect: react-day-picker clears the range.
+ setAnchor(null);
+ emit(null, null);
+ return;
+ }
+
+ const picked = likeSample(sample, day.toISOString());
+
+ if (!anchor) {
+ // First click: open a new range. Report the half-filled pair the way
+ // antd does, so a call-site watching onChange sees the start land.
+ setAnchor(picked);
+ emit(picked, null);
+ return;
+ }
+
+ // Second click: close the range, ordering the ends so a backwards
+ // selection still yields start <= end.
+ if (!picked) {
+ return;
+ }
+ const [from, to] =
+ picked.valueOf() < anchor.valueOf()
+ ? [picked, anchor]
+ : [anchor, picked];
+ setAnchor(null);
+ emit(from, to);
+ setOpen(false);
+ };
+
+ const label =
+ start || end
+ ? `${toInputValue(start, type) || "…"} → ${toInputValue(end, type) || "…"}`
+ : "Select date range";
+
+ return (
+
+
+
+
+
+ {/*
+ * `min-w-0` + `truncate`, not a bare `whitespace-nowrap`. With
+ * `showTime` the label carries full timestamps
+ * ("2026-07-02T00:00 → 2026-08-01T23:59"), and a nowrap span
+ * with no min-width floor forces the button past its container —
+ * the Logs filter row broke apart as soon as a range was picked.
+ */}
+ {label}
+
+
+
+
+ {presets?.length ? (
+
+ {presets.map((preset) => (
+ {
+ applyPreset(preset);
+ setOpen(false);
+ }}
+ className={cn(
+ "cursor-pointer whitespace-nowrap rounded-md px-2 py-1.5 text-left text-sm",
+ "hover:bg-accent hover:text-accent-foreground",
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
+ )}
+ >
+ {preset.label}
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+ );
+ },
+);
+
+const DatePicker = Object.assign(DatePickerBase, { RangePicker });
+
+export { DatePicker, RangePicker, TimePicker };
diff --git a/frontend/src/components/ui/shims/antd-form.test.jsx b/frontend/src/components/ui/shims/antd-form.test.jsx
new file mode 100644
index 0000000000..5e7d90bd7b
--- /dev/null
+++ b/frontend/src/components/ui/shims/antd-form.test.jsx
@@ -0,0 +1,494 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import * as React from "react";
+import { describe, expect, it, vi } from "vitest";
+import { Input } from "@/components/ui/input";
+import { Form } from "@/components/ui/shims/antd-form";
+
+function Harness({ onReady, onFinish }) {
+ const [form] = Form.useForm();
+ onReady?.(form);
+ return (
+
+
+
+
+
+
+ Save
+
+ );
+}
+
+describe("antd-compatible Form shim (P3)", () => {
+ it("renders labels and inputs", () => {
+ render( );
+ expect(screen.getByText("Name")).toBeInTheDocument();
+ expect(screen.getByText("Description")).toBeInTheDocument();
+ });
+
+ it("setFieldsValue populates inputs, as edit-mode modals rely on", async () => {
+ let form;
+ render( (form = f)} />);
+ form.setFieldsValue({ name: "Engineering", description: "team" });
+ await waitFor(() =>
+ expect(screen.getByDisplayValue("Engineering")).toBeInTheDocument(),
+ );
+ });
+
+ it("getFieldsValue reads current values back", async () => {
+ let form;
+ render( (form = f)} />);
+ form.setFieldsValue({ name: "Ops" });
+ await waitFor(() => expect(form.getFieldsValue().name).toBe("Ops"));
+ });
+
+ // The critical behaviour: antd REJECTS on invalid, and call-sites do
+ // `await form.validateFields().catch(() => null)` to bail out. If this
+ // resolved instead, invalid forms would submit.
+
+ it("validateFields rejects when a required field is empty", async () => {
+ let form;
+ render( (form = f)} />);
+ const values = await form.validateFields().catch(() => null);
+ expect(values).toBeNull();
+ });
+
+ it("validateFields resolves with values once valid", async () => {
+ let form;
+ render( (form = f)} />);
+ form.setFieldsValue({ name: "Filled" });
+ await waitFor(async () => {
+ const values = await form.validateFields().catch(() => null);
+ expect(values?.name).toBe("Filled");
+ });
+ });
+
+ it("shows the rule's message inline when validation fails", async () => {
+ let form;
+ render( (form = f)} />);
+ await form.validateFields().catch(() => null);
+ await waitFor(() =>
+ expect(screen.getByText("Group name is required")).toBeInTheDocument(),
+ );
+ });
+
+ it("resetFields clears the inputs", async () => {
+ let form;
+ render( (form = f)} />);
+ form.setFieldsValue({ name: "Temp" });
+ await waitFor(() => screen.getByDisplayValue("Temp"));
+ form.resetFields();
+ await waitFor(() =>
+ expect(screen.queryByDisplayValue("Temp")).not.toBeInTheDocument(),
+ );
+ });
+
+ it("accepts typed input and submits via onFinish", async () => {
+ const user = userEvent.setup();
+ const onFinish = vi.fn();
+ render( );
+ await user.type(screen.getAllByRole("textbox")[0], "Typed");
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() => expect(onFinish).toHaveBeenCalled());
+ expect(onFinish.mock.calls[0][0].name).toBe("Typed");
+ });
+
+ it("blocks onFinish while a required field is empty", async () => {
+ const user = userEvent.setup();
+ const onFinish = vi.fn();
+ render( );
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(screen.getByText("Group name is required")).toBeInTheDocument(),
+ );
+ expect(onFinish).not.toHaveBeenCalled();
+ });
+
+ it("renders a name-less Form.Item as plain layout", () => {
+ render(
+
+ content
+
+ ,
+ );
+ expect(screen.getByText("content")).toBeInTheDocument();
+ expect(screen.getByText("Static")).toBeInTheDocument();
+ });
+});
+
+/**
+ * antd's NamePath allows arrays. react-hook-form's Controller calls
+ * `.split(".")` on the name, so an array crashed with
+ * "TypeError: s.split is not a function" — taking down the whole route, not
+ * just the field. InviteEditUser uses `name={["email"]}`; the cloud
+ * StripeProductForm uses nested `name={["tier1", "up_to"]}`.
+ */
+describe("Form.Item accepts antd's array NamePath", () => {
+ it("renders a single-element array name without crashing", () => {
+ render(
+
+
+
+ ,
+ );
+ expect(screen.getByLabelText("Email")).toBeInTheDocument();
+ });
+
+ it("treats a nested array name as a dotted path", async () => {
+ const onFinish = vi.fn();
+ render(
+
+
+
+ Save
+ ,
+ );
+
+ fireEvent.change(screen.getByLabelText("Up to"), {
+ target: { value: "42" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
+
+ await waitFor(() => expect(onFinish).toHaveBeenCalled());
+ // The nested shape antd would have produced.
+ expect(onFinish.mock.calls[0][0]).toMatchObject({ tier1: { up_to: "42" } });
+ });
+ /**
+ * These four antd APIs were all missing, and together they broke every
+ * create/edit modal in the app: the Save button appeared to do nothing.
+ *
+ * onValuesChange -> never fired, so call-sites mirroring the form into
+ * component state kept their initial (empty) value and
+ * submitted an empty body
+ * setFields -> the handler those call-sites run on each keystroke
+ * validateStatus
+ * + help -> how the backend's 400 is surfaced per field; the
+ * call-sites deliberately do NOT validate client-side
+ */
+ describe("antd Form APIs the create/edit modals depend on", () => {
+ it("fires onValuesChange with the changed field and all values", async () => {
+ const onValuesChange = vi.fn();
+ render(
+
+
+
+ ,
+ );
+
+ fireEvent.change(screen.getByRole("textbox"), {
+ target: { value: "abc" },
+ });
+
+ await waitFor(() => expect(onValuesChange).toHaveBeenCalled());
+ const [changed, all] = onValuesChange.mock.calls.at(-1);
+ expect(changed).toMatchObject({ tool_name: "abc" });
+ expect(all).toMatchObject({ tool_name: "abc" });
+ });
+
+ it("seeds fields from initialValues", () => {
+ render(
+
+
+
+ ,
+ );
+ expect(screen.getByRole("textbox")).toHaveValue("seeded");
+ });
+
+ it("ignores later initialValues changes, as antd does", () => {
+ // The call-sites pass initialValues={state} AND write that state from
+ // onValuesChange. Re-seeding on change would clobber typing.
+ const { rerender } = render(
+
+
+
+ ,
+ );
+ fireEvent.change(screen.getByRole("textbox"), {
+ target: { value: "typed" },
+ });
+ rerender(
+
+
+
+ ,
+ );
+ expect(screen.getByRole("textbox")).toHaveValue("typed");
+ });
+
+ it("keeps values set before the Form mounted, as antd does", async () => {
+ // Agentic Table Settings (and its sibling modals) fetch first and render
+ // a spinner meanwhile, so setFieldsValue lands while the
+
+
+
+ );
+ }
+
+ render( );
+ await waitFor(() =>
+ expect(screen.getByRole("textbox")).toHaveValue("saved"),
+ );
+ });
+
+ it("renders the label tooltip antd's `tooltip` prop asks for", async () => {
+ // Undeclared, `tooltip` fell into ...props and landed on the wrapper
+ // div: the marker never rendered, so the Agentic Table settings fields
+ // lost their hints and the object form leaked onto the DOM.
+ render(
+
+
+
+ ,
+ );
+
+ const trigger = screen.getByRole("button", { name: "More info" });
+ expect(trigger).toBeInTheDocument();
+ // The config object must not reach the DOM as an attribute.
+ expect(document.querySelector("[tooltip]")).toBeNull();
+
+ await userEvent.hover(trigger);
+ await waitFor(() =>
+ expect(
+ screen.getAllByText("Pages processed in parallel.").length,
+ ).toBeGreaterThan(0),
+ );
+ });
+
+ it("accepts the bare-node tooltip form the rule editors use", () => {
+ render(
+
+
+
+ ,
+ );
+ expect(
+ screen.getByRole("button", { name: "More info" }),
+ ).toBeInTheDocument();
+ });
+
+ it("uses the icon a tooltip config supplies", () => {
+ render(
+ ICON }}
+ >
+
+
+ ,
+ );
+ expect(screen.getByText("ICON")).toBeInTheDocument();
+ });
+
+ it("renders no marker when there is nothing to explain", () => {
+ render(
+
+
+
+ ,
+ );
+ expect(screen.queryByRole("button", { name: "More info" })).toBeNull();
+ });
+
+ it("exposes form.setFields for clearing and setting errors", async () => {
+ let api;
+ render(
+
+
+
+ ,
+ );
+ // The instance form: assert the method exists on a useForm() result.
+ function Probe() {
+ const [form] = Form.useForm();
+ api = form;
+ return null;
+ }
+ render( );
+ expect(typeof api.setFields).toBe("function");
+ // Must not throw for either shape the call-sites use.
+ expect(() =>
+ api.setFields([{ name: "tool_name", errors: [] }]),
+ ).not.toThrow();
+ expect(() =>
+ api.setFields([{ name: "tool_name", errors: ["Bad name"] }]),
+ ).not.toThrow();
+ });
+
+ it("renders a backend error through validateStatus + help", () => {
+ render(
+
+
+
+ ,
+ );
+ const msg = screen.getByText("Tool name already exists");
+ expect(msg).toBeInTheDocument();
+ expect(msg.className).toContain("text-destructive");
+ expect(screen.getByRole("textbox").className).toContain(
+ "border-destructive",
+ );
+ });
+
+ it("does not leak validateStatus/help onto the DOM", () => {
+ const { container } = render(
+
+
+
+ ,
+ );
+ const item = container.querySelector(".ant-form-item");
+ expect(item.getAttribute("validateStatus")).toBeNull();
+ expect(item.getAttribute("help")).toBeNull();
+ });
+
+ /*
+ * `extra` is antd's static hint under a control — the token estimate under
+ * Chunk Size, the slug rules under an API name. It used to fall into the
+ * rest-props and land on the wrapper div as a stray attribute, so every
+ * one of those hints rendered nowhere while looking wired up in the JSX.
+ */
+ it("renders extra as a hint rather than leaking it onto the DOM", () => {
+ const { container } = render(
+
+
+
+ ,
+ );
+ const hint = screen.getByText("~= 2k tokens");
+ expect(hint).toBeInTheDocument();
+ expect(hint.className).toContain("text-muted-foreground");
+ expect(
+ container.querySelector(".ant-form-item").getAttribute("extra"),
+ ).toBeNull();
+ });
+
+ // antd shows both at once, error first: the hint explains the field, the
+ // message explains the rejection, and losing the hint on error is a
+ // regression the "renders extra" case above cannot catch on its own.
+ it("shows extra alongside an error message", () => {
+ render(
+
+
+
+ ,
+ );
+ expect(screen.getByText("Chunk size is too large").className).toContain(
+ "text-destructive",
+ );
+ expect(screen.getByText("~= 2k tokens").className).toContain(
+ "text-muted-foreground",
+ );
+ });
+
+ // The no-`name` branch is a separate return path in FormItem, and layout
+ // Form.Items carry hints too.
+ it("renders extra on a layout-only item with no name", () => {
+ render(
+
+
+
+ ,
+ );
+ expect(screen.getByText("hint text")).toBeInTheDocument();
+ });
+ });
+});
+
+describe("Form.useWatch", () => {
+ // GlobalApiDeploymentKeys drives a whole fieldset off this: an "allow all
+ // deployments" checkbox disables the deployment picker and drops its
+ // required-rule. If useWatch never re-renders, the picker stays enabled and
+ // the user can submit an incoherent scoped/unscoped pair.
+ function WatchHarness() {
+ const [form] = Form.useForm();
+ // The instance is held HERE and the
+
+
+ {String(allowAll)}
+
+ );
+ }
+
+ it("re-renders the watcher when the watched field changes", async () => {
+ render( );
+ expect(screen.getByTestId("watched")).toHaveTextContent("false");
+
+ await userEvent.click(screen.getByLabelText("Allow all"));
+
+ await waitFor(() =>
+ expect(screen.getByTestId("watched")).toHaveTextContent("true"),
+ );
+ });
+
+ it("returns undefined outside a Form instead of throwing", () => {
+ function Bare() {
+ return {String(Form.useWatch("nope"))} ;
+ }
+ render( );
+ expect(screen.getByTestId("bare")).toHaveTextContent("undefined");
+ });
+});
diff --git a/frontend/src/components/ui/shims/antd-form.tsx b/frontend/src/components/ui/shims/antd-form.tsx
new file mode 100644
index 0000000000..2b72d32dba
--- /dev/null
+++ b/frontend/src/components/ui/shims/antd-form.tsx
@@ -0,0 +1,676 @@
+import { CircleHelp } from "lucide-react";
+import * as React from "react";
+import {
+ Controller,
+ FormProvider,
+ type UseFormReturn,
+ useForm,
+ useFormContext,
+ useWatch,
+} from "react-hook-form";
+
+import { Label } from "@/components/ui/label";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+
+/**
+ * antd-compatible `Form` (P3-01/P3-02) implemented on react-hook-form.
+ *
+ * This is the highest-risk conversion in the migration: antd's Form bundles
+ * validation, layout and state into one component, and the codebase drives it
+ * imperatively through a form instance — `form.setFieldsValue()`,
+ * `form.validateFields()`, `form.resetFields()` (14 `useForm()` sites, 102
+ * `Form.Item`s). Rewriting each call-site by hand would mean 102 chances to
+ * change submit or validation behaviour.
+ *
+ * So the antd surface is preserved:
+ * - `Form.useForm()` returns a form instance exposing antd's imperative API
+ * - `` wrapping a single controlled child
+ *
+ * Under the hood it is react-hook-form, so the shadcn primitives receive the
+ * usual `value`/`onChange` and validation state renders as inline messages,
+ * exactly as antd did.
+ */
+
+/**
+ * The antd Form surface these shims accept.
+ *
+ * This file is the strongest case for typing the layer. Four of these props
+ * were MISSING and fell into `...props`, each failing silently:
+ *
+ * - `onValuesChange` — handlers mirroring the form into state never ran, so
+ * Save posted an empty body and looked like it did nothing
+ * - `setFields` — six modals called it and got
+ * `TypeError: form.setFields is not a function` on the first keystroke
+ * - `initialValues` — fields were never seeded
+ * - `validateStatus` / `help` — the backend's 400 message landed on a DOM
+ * div instead of being displayed
+ *
+ * Naming each one means the next omission is a compile error at the call-site,
+ * not a defect a user has to report.
+ */
+
+/** antd's NamePath: a string, or an array for nested fields. */
+type NamePath = string | Array;
+
+interface AntdRule {
+ required?: boolean;
+ message?: string;
+ max?: number;
+ min?: number;
+ pattern?: RegExp;
+ /** antd hands (rule, value); rejecting marks the field invalid. */
+ validator?: (rule: unknown, value: unknown) => Promise | unknown;
+}
+
+/** One entry of antd's `form.setFields([...])`. */
+interface FieldData {
+ name?: NamePath;
+ value?: unknown;
+ /** An empty array clears the error; a non-empty one sets it. */
+ errors?: string[];
+}
+
+type FormValues = Record;
+
+/** The instance returned by `Form.useForm()`. */
+interface FormInstance {
+ /** Escape hatch to the underlying react-hook-form methods. */
+ __methods: UseFormReturn;
+ setFieldsValue: (values?: FormValues) => void;
+ setFieldValue: (name: NamePath, value: unknown) => void;
+ getFieldsValue: () => FormValues;
+ getFieldValue: (name: NamePath) => unknown;
+ /** Resolves with the values, REJECTS when invalid, as antd does. */
+ validateFields: () => Promise;
+ setFields: (fields?: FieldData[]) => void;
+ resetFields: () => void;
+ submit: () => void;
+ isFieldsTouched: () => boolean;
+ getFieldsError: () => Array<[string, unknown]>;
+}
+
+interface AntFormProps
+ extends Omit, "onSubmit"> {
+ form?: FormInstance;
+ layout?: "horizontal" | "vertical" | "inline";
+ onFinish?: (values: FormValues) => void;
+ onFinishFailed?: (errors: unknown) => void;
+ /** Applied ON MOUNT ONLY, matching antd. */
+ initialValues?: FormValues;
+ onValuesChange?: (changed: FormValues, all: FormValues) => void;
+}
+
+interface FormItemProps
+ extends Omit, "children"> {
+ name?: NamePath;
+ label?: React.ReactNode;
+ rules?: AntdRule[];
+ required?: boolean;
+ /** `checked` for switches and checkboxes, `value` otherwise. */
+ valuePropName?: string;
+ /**
+ * antd's per-item seed, the sibling of `