From 7f02662fa937ab0949c8f5ef6a1dd4df988feae3 Mon Sep 17 00:00:00 2001 From: joeyorlando Date: Wed, 26 Aug 2026 22:55:46 +0000 Subject: [PATCH 1/5] feat(frontend): report the debounced input's pending window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DebouncedInput` knows something the components around it cannot see: that a keystroke is sitting in its timer, unseen by the caller. Nothing could ask for it, so a search box had no way to acknowledge typing until the results changed. Add `onPendingChange`, reporting a window that opens on the keystroke rather than when the debounce fires — the gap being covered starts when the user types, not 400ms later when the request goes out. Closing it is the subtler half. It closes when the committed value catches up, because that is also the moment the caller's own fetch starts, so an indicator driven by this hands over to one driven by that without blinking off in between. A caller that keeps the query somewhere other than `initialValue` would never produce that moment, so the window is also bounded by a short handoff timer: a stuck indicator is the one failure worth ruling out entirely. --- .../src/components/debounced-input.test.tsx | 103 +++++++++++++++++- .../src/components/debounced-input.tsx | 76 ++++++++++++- 2 files changed, 172 insertions(+), 7 deletions(-) diff --git a/platform/frontend/src/components/debounced-input.test.tsx b/platform/frontend/src/components/debounced-input.test.tsx index f98a5ca1f45..503e7df92e1 100644 --- a/platform/frontend/src/components/debounced-input.test.tsx +++ b/platform/frontend/src/components/debounced-input.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DebouncedInput } from "./debounced-input"; @@ -129,4 +129,105 @@ describe("DebouncedInput", () => { expect(onChange).toHaveBeenCalledWith("hel"); expect(onChange).toHaveBeenCalledTimes(1); }); + /** + * The pending window drives the search box's spinner. Every case below is a + * way that indicator has to behave: start at the keystroke, survive the + * hand-off to whatever the commit triggers, and never stick on. + */ + describe("pending window", () => { + const lastPending = (onPendingChange: ReturnType) => + onPendingChange.mock.lastCall?.[0]; + + it("opens on the keystroke rather than when the debounce fires", () => { + const onPendingChange = vi.fn(); + render( + {}} + debounceMs={400} + onPendingChange={onPendingChange} + />, + ); + + act(() => typeInInput(screen.getByRole("textbox"), "no")); + + // The whole point: the gap being covered starts at the keystroke, not + // 400ms later once the request goes out. + expect(lastPending(onPendingChange)).toBe(true); + }); + + it("stays open across the commit until the caller takes the new value", () => { + const onPendingChange = vi.fn(); + const { rerender } = render( + {}} + debounceMs={400} + onPendingChange={onPendingChange} + />, + ); + + act(() => typeInInput(screen.getByRole("textbox"), "notion")); + act(() => void vi.advanceTimersByTime(400)); + + // Closing here would blink the indicator off in the gap between the + // commit and the request it triggers. + expect(lastPending(onPendingChange)).toBe(true); + + rerender( + {}} + debounceMs={400} + onPendingChange={onPendingChange} + />, + ); + + expect(lastPending(onPendingChange)).toBe(false); + }); + + it("closes on its own when the caller never commits the value back", () => { + const onPendingChange = vi.fn(); + render( + {}} + debounceMs={400} + onPendingChange={onPendingChange} + />, + ); + + act(() => typeInInput(screen.getByRole("textbox"), "notion")); + act(() => void vi.advanceTimersByTime(400)); + expect(lastPending(onPendingChange)).toBe(true); + + // A caller that keeps the query somewhere other than `initialValue` + // never closes the window, so the bound has to. + act(() => void vi.advanceTimersByTime(1000)); + expect(lastPending(onPendingChange)).toBe(false); + }); + + it("never opens for an edit that lands back on the committed value", () => { + const onPendingChange = vi.fn(); + render( + {}} + debounceMs={400} + onPendingChange={onPendingChange} + />, + ); + + const input = screen.getByRole("textbox"); + act(() => typeInInput(input, "notio")); + expect(lastPending(onPendingChange)).toBe(true); + + // Nothing will change, so nothing should claim to be loading. + act(() => typeInInput(input, "notion")); + expect(lastPending(onPendingChange)).toBe(false); + + act(() => void vi.advanceTimersByTime(400)); + expect(lastPending(onPendingChange)).toBe(false); + }); + }); }); diff --git a/platform/frontend/src/components/debounced-input.tsx b/platform/frontend/src/components/debounced-input.tsx index 0e1df29b95d..6af8633a4a6 100644 --- a/platform/frontend/src/components/debounced-input.tsx +++ b/platform/frontend/src/components/debounced-input.tsx @@ -1,6 +1,19 @@ import { forwardRef, useEffect, useRef, useState } from "react"; import { Input } from "./ui/input"; +/** + * How long the pending window is held open past the debounce when the caller + * never commits the new value back as `initialValue`. + * + * The window normally ends the moment the committed value catches up, which is + * also the moment the caller's own fetch starts — so an indicator driven by it + * hands over without blinking. Callers that keep the query somewhere other than + * `initialValue` (a purely local `onSearchChange`) would otherwise leave it + * open forever, so it is bounded: long enough to cover a client-side + * navigation, short enough that a stuck indicator is impossible. + */ +const PENDING_HANDOFF_MS = 200; + type DebouncedInputProps = Omit< React.ComponentProps, "onChange" | "value" @@ -8,28 +21,58 @@ type DebouncedInputProps = Omit< initialValue: string; onChange: (value: string) => void; debounceMs?: number; + /** + * Called when the input starts and stops holding a keystroke the caller has + * not seen yet. Pass a stable callback (a `useState` setter, or `useCallback`). + * + * The window opens on the keystroke rather than when the debounce fires: + * "I typed and nothing acknowledged it" is the whole gap it exists to cover. + */ + onPendingChange?: (isPending: boolean) => void; }; export const DebouncedInput = forwardRef( function DebouncedInput( - { initialValue, onChange, debounceMs = 800, ...props }: DebouncedInputProps, + { + initialValue, + onChange, + debounceMs = 800, + onPendingChange, + ...props + }: DebouncedInputProps, ref, ) { const [value, setValue] = useState(initialValue); + const [isPending, setIsPending] = useState(false); const timeoutRef = useRef | null>(null); + const handoffTimeoutRef = useRef | null>( + null, + ); const isTypingRef = useRef(false); + // The debounce callback closes over the value committed at the time it was + // scheduled, which is stale by the time it runs. + const committedValueRef = useRef(initialValue); // Sync internal state when initialValue changes externally (e.g., browser back/forward) // but not while the user is actively typing to prevent eating characters useEffect(() => { - if (!isTypingRef.current) { - setValue(initialValue); - } + committedValueRef.current = initialValue; + if (isTypingRef.current) return; + setValue(initialValue); + // The caller has the new query, so whatever it kicked off owns the + // feedback from here. + clearTimeoutRef(handoffTimeoutRef); + setIsPending(false); }, [initialValue]); + useEffect(() => { + onPendingChange?.(isPending); + }, [isPending, onPendingChange]); + useEffect(() => { return () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); + clearTimeoutRef(timeoutRef); + clearTimeoutRef(handoffTimeoutRef); }; }, []); @@ -37,11 +80,24 @@ export const DebouncedInput = forwardRef( const newValue = e.target.value; setValue(newValue); isTypingRef.current = true; + // Typing back to what the caller already has leaves nothing to wait for. + clearTimeoutRef(handoffTimeoutRef); + setIsPending(newValue !== committedValueRef.current); - if (timeoutRef.current) clearTimeout(timeoutRef.current); + clearTimeoutRef(timeoutRef); timeoutRef.current = setTimeout(() => { isTypingRef.current = false; onChange(newValue); + if (newValue === committedValueRef.current) { + // A commit that changes nothing produces no new committed value, so + // nothing else would ever close the window. + setIsPending(false); + return; + } + handoffTimeoutRef.current = setTimeout( + () => setIsPending(false), + PENDING_HANDOFF_MS, + ); }, debounceMs); }; @@ -49,3 +105,11 @@ export const DebouncedInput = forwardRef( }, ); DebouncedInput.displayName = "DebouncedInput"; + +function clearTimeoutRef(ref: { + current: ReturnType | null; +}) { + if (!ref.current) return; + clearTimeout(ref.current); + ref.current = null; +} From e8a679dae446d56a53c61d49dc3b146fa3126fe4 Mon Sep 17 00:00:00 2001 From: joeyorlando Date: Wed, 26 Aug 2026 22:56:44 +0000 Subject: [PATCH 2/5] feat(frontend): light the search box while a search is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing in a table's search box produced no feedback at all. The debounce, the commit and the request the commit triggers all passed under a static magnifier, and because these lists keep the previous page on screen while the next one loads, nothing else moved either — so a search read as a box that had swallowed the query. The magnifier now cross-fades into a spinner for the whole of that. Both icons stay mounted in the same 16px slot and swap opacity, so nothing in the field moves and a box that is at most one debounce away from busy does not flicker its icon on every keystroke. Two things drive it. `DebouncedInput`'s pending window covers the keystroke through the commit with no wiring at all, so every call site gets the acknowledgement typing was missing. The new `isLoading` prop covers the request that follows, for the pages whose search term actually reaches the query — passing the same flag their table already gets. The spinner itself is decorative (`aria-hidden`); the field carries the state as `aria-busy`, so assistive tech hears it without a live region announcing every pause between keystrokes. --- .../src/components/search-input.test.tsx | 52 ++++++++++++++++++- .../frontend/src/components/search-input.tsx | 47 ++++++++++++++++- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/platform/frontend/src/components/search-input.test.tsx b/platform/frontend/src/components/search-input.test.tsx index 505761c9cec..f99b6404bbe 100644 --- a/platform/frontend/src/components/search-input.test.tsx +++ b/platform/frontend/src/components/search-input.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { SearchInput } from "./search-input"; @@ -49,4 +49,54 @@ describe("SearchInput", () => { "pl-9", ); }); + /** + * Typing used to produce no feedback at all: the debounce, the commit and the + * request the commit triggers all passed under a static magnifier, which is + * what made a search feel like it had not registered. + */ + describe("search in flight", () => { + it("marks itself busy from the keystroke, before anything is requested", () => { + render(); + + const input = screen.getByPlaceholderText("Search skills"); + expect(input).toHaveAttribute("aria-busy", "false"); + + fireEvent.change(input, { target: { value: "not" } }); + + expect(input).toHaveAttribute("aria-busy", "true"); + }); + + it("stays busy while the list it filters is fetching", () => { + const { rerender } = render( + , + ); + + // Nothing has been typed in this render, so only the caller's flag can + // be holding the indicator on — the half that covers the request. + expect(screen.getByPlaceholderText("Search skills")).toHaveAttribute( + "aria-busy", + "true", + ); + + rerender(); + + expect(screen.getByPlaceholderText("Search skills")).toHaveAttribute( + "aria-busy", + "false", + ); + }); + + /** + * The spinner is the only animation in the field, and a search box is at + * most one debounce away from spinning. It has to stop for readers who ask + * motion to stop (WCAG 2.3.3). + */ + it("keeps the spinner still under prefers-reduced-motion", () => { + const { container } = render(); + + expect( + container.querySelector(".animate-spin.motion-reduce\\:animate-none"), + ).toBeInTheDocument(); + }); + }); }); diff --git a/platform/frontend/src/components/search-input.tsx b/platform/frontend/src/components/search-input.tsx index 64677e82e0a..cc5517ceeb6 100644 --- a/platform/frontend/src/components/search-input.tsx +++ b/platform/frontend/src/components/search-input.tsx @@ -2,7 +2,7 @@ import { Search } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { forwardRef, useCallback } from "react"; +import { forwardRef, useCallback, useState } from "react"; import { cn } from "@/lib/utils"; import { DebouncedInput } from "./debounced-input"; @@ -17,6 +17,15 @@ type SearchInputProps = { onSearchChange?: (value: string) => void; value?: string; syncQueryParams?: boolean; + /** + * Whether the list this box filters is currently fetching. + * + * The box already lights up on its own for the debounce and the commit that + * follows it; this extends the same indicator across the request the commit + * triggers, so one continuous signal covers keystroke to results. Pass the + * query's `isFetching` — the same flag the table gets as `isLoading`. + */ + isLoading?: boolean; }; export const SearchInput = forwardRef( @@ -32,12 +41,14 @@ export const SearchInput = forwardRef( onSearchChange, value, syncQueryParams = true, + isLoading = false, }: SearchInputProps, ref, ) { const router = useRouter(); const searchParams = useSearchParams(); const pathname = usePathname(); + const [isCommitPending, setIsCommitPending] = useState(false); const searchValue = value ?? searchParams.get(paramName) ?? ""; const computedPlaceholder = @@ -45,6 +56,10 @@ export const SearchInput = forwardRef( ? `Search ${objectNamePlural} by ${formatSearchFields(searchFields)}` : placeholder; + // Typing was the part with no feedback at all: the debounce, the commit and + // the request that follows it all used to pass under a static magnifier. + const isBusy = isCommitPending || isLoading; + const handleChange = useCallback( (value: string) => { onSearchChange?.(value); @@ -81,11 +96,39 @@ export const SearchInput = forwardRef( className ?? "w-full sm:w-[320px] sm:max-w-[320px]", )} > - + {/* The spinner takes the magnifier's exact place rather than sitting + beside it, so a search in flight reads as the icon changing state + and nothing in the field moves. Both are always mounted and cross + fade; swapping the elements instead made the icon flicker on every + keystroke, and the box is at most one debounce away from busy. */} + + + + + + + Date: Wed, 26 Aug 2026 22:56:54 +0000 Subject: [PATCH 3/5] feat(frontend): mark the data table busy while a fetch is out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isLoading` only ever suppressed the empty state. Once rows were on screen it did nothing, so a refetch over a kept page — which is what every search on a server-filtered list is — changed nothing on screen until the new rows landed. The table now says so, in the two places that cost no layout. An indeterminate bar sweeps the top edge, and the rows a refetch is about to replace fade back so they stop reading as current. Deliberately not a spinner in place of the rows: replacing them would collapse the table's height on every keystroke, which is the flash the empty-state handling already goes out of its way to avoid. Both are delayed ~150ms so a refetch that resolves immediately never registers as a flicker, and rows come back to full strength undelayed. The bar sits outside the horizontally scrolling container, because an absolutely positioned child of it scrolls away with the content on a table wide enough to scroll. It is a `progressbar` rather than a live region: `isLoading` is true for background refetches too, and those should not be announced. The table also carries `aria-busy`. --- platform/frontend/src/app/globals.css | 28 ++ .../src/components/ui/data-table.test.tsx | 25 +- .../frontend/src/components/ui/data-table.tsx | 314 ++++++++++-------- 3 files changed, 227 insertions(+), 140 deletions(-) diff --git a/platform/frontend/src/app/globals.css b/platform/frontend/src/app/globals.css index d191f6903d0..687a8de63dd 100644 --- a/platform/frontend/src/app/globals.css +++ b/platform/frontend/src/app/globals.css @@ -750,3 +750,31 @@ button.plugin-featured-action::after { [data-radix-popper-content-wrapper]:has(> [data-pointer-events-none]) { pointer-events: none; } + +/* ── Data table: request in flight ── + The sweeping half of ``'s loading bar. The bar's track, colours + and fade-in live in the component; only the animation needs a keyframe, and + the width belongs with it because the two are one motion. */ + +@keyframes archestra-table-loading-sweep { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(340%); + } +} + +.archestra-table-loading-sweep { + width: 30%; + animation: archestra-table-loading-sweep 1.1s ease-in-out infinite; +} + +/* Motion sensitivity: hold the bar still and fill the track instead. It still + marks the table as busy, which is the information the bar carries. */ +@media (prefers-reduced-motion: reduce) { + .archestra-table-loading-sweep { + width: 100%; + animation: none; + } +} diff --git a/platform/frontend/src/components/ui/data-table.test.tsx b/platform/frontend/src/components/ui/data-table.test.tsx index af51d4b7237..3b8095f6362 100644 --- a/platform/frontend/src/components/ui/data-table.test.tsx +++ b/platform/frontend/src/components/ui/data-table.test.tsx @@ -83,9 +83,10 @@ describe("DataTable page index clamping", () => { // rather than replacing a loader that sat at a different height. expect(container.querySelector("table")).not.toBeNull(); expect(screen.getByRole("columnheader", { name: "Name" })).toBeVisible(); - // Nothing claims the result is empty while a fetch is still out. + // Nothing claims the result is empty while a fetch is still out — but the + // table does say it is working, rather than sitting there blank. expect(screen.queryByText("No results")).toBeNull(); - expect(screen.queryByRole("status")).toBeNull(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); }); it("reports an empty result only once the fetch has settled", () => { @@ -121,11 +122,25 @@ describe("DataTable page index clamping", () => { expect(onClearFilters).toHaveBeenCalledTimes(1); }); - it("does not disturb rows already on screen while refetching", () => { - render(); + /** + * A search refetch keeps the previous page on screen, so the table cannot + * announce the request by swapping the rows for a spinner — that would + * collapse its height on every keystroke. It marks itself busy around them + * instead, which is the whole reason a search used to look like it had done + * nothing. + */ + it("announces a refetch without disturbing the rows already on screen", () => { + const { rerender } = render( + , + ); + + expect(screen.getByText("row-0")).toBeVisible(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + + rerender(); expect(screen.getByText("row-0")).toBeVisible(); - expect(screen.queryByRole("status")).toBeNull(); + expect(screen.queryByRole("progressbar")).toBeNull(); }); }); diff --git a/platform/frontend/src/components/ui/data-table.tsx b/platform/frontend/src/components/ui/data-table.tsx index 91a17242adb..e38c67417cd 100644 --- a/platform/frontend/src/components/ui/data-table.tsx +++ b/platform/frontend/src/components/ui/data-table.tsx @@ -282,153 +282,175 @@ export function DataTable({ return (
-
- {/* The table never shrinks below the columns' summed configured sizes + {/* The bar is a sibling of the scrolling container, not a child: an + absolutely positioned child of an `overflow-x-auto` element scrolls + away with the content on a table wide enough to scroll. */} +
+ {isLoading && } +
+ {/* The table never shrinks below the columns' summed configured sizes (tanstack defaults unsized columns to 150px) — on narrow screens the wrapper scrolls horizontally instead of crushing columns until headers stack letter-by-letter and cell contents overlap. */} - - {!hideHeader && ( - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const sorted = header.column.getIsSorted(); - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - ); - })} - - ))} - - )} - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - +
+ {!hideHeader && ( + + {table.getHeaderGroups().map((headerGroup) => ( onRowClick?.(row.original, e)} + key={headerGroup.id} + className="hover:bg-transparent" > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} + {headerGroup.headers.map((header) => { + const sorted = header.column.getIsSorted(); + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} - {renderSubComponent && row.getIsExpanded() && ( - - - {renderSubComponent({ row })} - + ))} + + )} + {/* Rows already on screen are the previous query's answer, so they + fade back while the next one is in flight rather than sitting + there looking current. The delay keeps a refetch that resolves + immediately from registering as a flicker; coming back is + undelayed so results land at full strength. */} + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + onRowClick?.(row.original, e)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} - )} - - )) - ) : ( - - - {/* An empty body while a fetch is still out is not an empty + {renderSubComponent && row.getIsExpanded() && ( + + + {renderSubComponent({ row })} + + + )} + + )) + ) : ( + + + {/* An empty body while a fetch is still out is not an empty result, so it says nothing: announcing "No Data" and then replacing it with rows a moment later is the flash this area used to produce. The row keeps its height either way, so the rows arrive without shifting the pagination controls underneath. */} -
- {!isLoading && ( - - )} -
-
-
- )} -
-
+
+ {!isLoading && ( + + )} +
+ + + )} + + +
{(pagination || !manualPagination) && (!hidePaginationWhenSinglePage || @@ -445,6 +467,28 @@ export function DataTable({ ); } +/** + * An indeterminate sweep pinned to the table's top edge while a request is out. + * + * Deliberately not a spinner in place of the rows: a search refetch keeps the + * previous page on screen, so replacing it would collapse the table's height on + * every keystroke. This states that what is on screen is about to be replaced + * without moving any of it. + */ +function TableLoadingBar() { + return ( +
+
+
+ ); +} + function getColumnClassName(columnId: string) { if (columnId === SELECT_COLUMN_ID) { return "!p-0 text-center [&>[role=checkbox]]:translate-y-0"; From 377c1a883714ce92c9317aa248544541c8ee91a6 Mon Sep 17 00:00:00 2001 From: joeyorlando Date: Wed, 26 Aug 2026 22:57:01 +0000 Subject: [PATCH 4/5] feat(frontend): carry list fetch state into the table search boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this the field's indicator stops when the debounce commits and the table's starts ~150ms later, leaving a visible hole in the middle of exactly the wait it exists to cover. Passing the query's fetch flag closes it: one continuous signal from keystroke to results. Only pages whose search term actually reaches the query are wired. Where the search is a client-side filter over an already-loaded list — connector members and user groups, LLM models, service accounts, plugins, API keys, the MCP catalog tabs — the page's `isFetching` describes loading that list, not searching it, so wiring it would light the box for something the user did not ask for. Those keep the debounce-only indicator, which is the honest one there: their results land the instant it ends. --- platform/frontend/src/app/agents/page.client.tsx | 1 + platform/frontend/src/app/apps/page.client.tsx | 1 + .../src/app/audit/logs/_components/audit-log-table.tsx | 1 + .../connectors/_parts/connector-documents-table.tsx | 1 + .../frontend/src/app/knowledge/connectors/page.client.tsx | 6 +++++- platform/frontend/src/app/knowledge/files/page.client.tsx | 1 + .../src/app/knowledge/knowledge-bases/page.client.tsx | 6 +++++- platform/frontend/src/app/llm/logs/page.client.tsx | 1 + platform/frontend/src/app/llm/model-providers/page.tsx | 1 + platform/frontend/src/app/llm/proxy/oauth-clients/page.tsx | 1 + platform/frontend/src/app/llm/proxy/virtual-keys/page.tsx | 1 + platform/frontend/src/app/mcp/gateways/page.client.tsx | 1 + platform/frontend/src/app/mcp/logs/page.client.tsx | 1 + .../app/mcp/tool-guardrails/_parts/assigned-tools-table.tsx | 1 + .../app/messaging-channels/_components/channels-section.tsx | 1 + platform/frontend/src/app/projects/page.client.tsx | 1 + platform/frontend/src/app/settings/users/page.client.tsx | 1 + platform/frontend/src/app/skills/page.client.tsx | 1 + platform/frontend/src/components/roles/roles-list.ee.tsx | 1 + platform/frontend/src/components/teams/teams-list.tsx | 1 + 20 files changed, 28 insertions(+), 2 deletions(-) diff --git a/platform/frontend/src/app/agents/page.client.tsx b/platform/frontend/src/app/agents/page.client.tsx index b546d84e01c..d7f356aac81 100644 --- a/platform/frontend/src/app/agents/page.client.tsx +++ b/platform/frontend/src/app/agents/page.client.tsx @@ -621,6 +621,7 @@ function Agents({ initialData }: { initialData?: AgentsInitialData }) { actions={!isDeletedView ? : undefined} > }> : undefined} > - +