fix(ui): show Lens run reloads as a search spinner and a blurred body

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yujong Lee 2026-10-04 17:12:42 -07:00
parent 5e7b0afd5c
commit 03bec959bf
9 changed files with 33 additions and 37 deletions

View file

@ -469,12 +469,3 @@
left: 100%;
}
}
@keyframes rows-sweep {
from {
left: -25%;
}
to {
left: 100%;
}
}

View file

@ -671,12 +671,12 @@ describe("AgentTracesPage", () => {
}),
);
fireEvent.click(screen.getByTestId(`sort-header-${key}`));
expect(await screen.findByRole("progressbar", { name: "Updating rows" })).toBeInTheDocument();
expect(await screen.findByRole("status", { name: "Loading results" })).toBeInTheDocument();
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(rowsBefore.length);
expect(listRequest(-1)).toMatchObject({ order: { key, descending: true }, page: { cursor: null } });
act(release);
await waitFor(() => expect(screen.queryByRole("progressbar")).not.toBeInTheDocument());
await waitFor(() => expect(screen.queryByRole("status", { name: "Loading results" })).not.toBeInTheDocument());
expect(screen.getAllByTestId("agent-trace-row")[0]).toHaveTextContent(topAfter);
expect(lastUrl(onUrlUpdate).get("sort_by")).toBe(key);
expect(lastUrl(onUrlUpdate).has("sort_dir")).toBe(false);

View file

@ -171,7 +171,14 @@ export function AgentTracesSection({
/>
)}
</Inspector.Panel>
<RunsToolbar query={query} onQueryChange={setQuery} runs={traces.traces} range={zoom ?? window} order={order}>
<RunsToolbar
query={query}
onQueryChange={setQuery}
runs={traces.traces}
range={zoom ?? window}
order={order}
busy={traces.isPlaceholder}
>
{timeControls && (
<TimeRangeControls
range={range}

View file

@ -236,7 +236,6 @@ describe("AgentTracesTable sorting", () => {
),
);
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length);
expect(screen.getByRole("progressbar", { name: "Updating rows" })).toBeInTheDocument();
expect(screen.queryByTestId("runs-placeholder")).not.toBeInTheDocument();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(onLoadMore).not.toHaveBeenCalled();

View file

@ -287,10 +287,10 @@ export function AgentTracesTable({
<InspectorTable.Root table={table} data-testid="runs-table">
<ScrollToTop order={order} />
<InspectorTable.Grid aria-label="Agent runs" aria-busy={isFetching} className="min-w-[900px] text-xs">
<InspectorTable.Header busy={isPlaceholder} />
<InspectorTable.Header />
<InspectorTable.Body<TraceSummary>
rowHeight={() => ROW_HEIGHT}
className={cn(isPlaceholder && "opacity-60 transition-opacity motion-reduce:transition-none")}
className={cn("transition-[filter]", isPlaceholder && "blur-[1.5px]")}
after={
<>
{isLoading && SKELETON_ROWS.map((row) => <PlaceholderRow key={row} index={row} />)}

View file

@ -21,10 +21,11 @@ interface RunSearchProps {
/** The range and order the list shows; the copied query follows both. */
range?: TimeWindow;
order?: RunOrder;
busy?: boolean;
}
/** The runs list query box: free text plus `key:value` filters over run fields, copyable as a trace query. */
export function RunSearch({ value, onChange, runs, range, order = NEWEST }: RunSearchProps) {
export function RunSearch({ value, onChange, runs, range, order = NEWEST, busy = false }: RunSearchProps) {
const command = useMemo(() => runQueryCommand(range, order), [range, order]);
return (
<SearchBox.Root
@ -37,6 +38,7 @@ export function RunSearch({ value, onChange, runs, range, order = NEWEST }: RunS
>
<SearchBox.Input
className="h-full rounded-none border-0 px-3 focus-within:bg-muted/40 focus-within:ring-0 dark:bg-transparent"
busy={busy}
placeholder="Search runs, or filter like agent:researcher status:error"
/>
<SearchBox.Suggestions>

View file

@ -13,14 +13,16 @@ interface RunsToolbarProps {
/** The range and order the list shows, for the copied query. */
range?: TimeWindow;
order?: RunOrder;
/** Shows a spinner in the search box while the list reloads for a new query or order. */
busy?: boolean;
/** Extra controls (time range, live tail) rendered on the right. */
children?: React.ReactNode;
}
export function RunsToolbar({ query, onQueryChange, runs, range, order, children }: RunsToolbarProps) {
export function RunsToolbar({ query, onQueryChange, runs, range, order, busy, children }: RunsToolbarProps) {
return (
<div className="flex h-10 shrink-0 items-stretch border-b border-border bg-card">
<RunSearch value={query} onChange={onQueryChange} runs={runs} range={range} order={order} />
<RunSearch value={query} onChange={onQueryChange} runs={runs} range={range} order={order} busy={busy} />
{children && <div className="flex shrink-0 items-stretch">{children}</div>}
</div>
);

View file

@ -73,11 +73,9 @@ function ariaSort<T>(column: Column<T>): "ascending" | "descending" | "none" | u
interface HeaderProps {
readonly hidden?: boolean;
/** Shows a progress line under the headings while the rows below are being replaced. */
readonly busy?: boolean;
}
function Header({ hidden = false, busy = false }: HeaderProps) {
function Header({ hidden = false }: HeaderProps) {
const { table } = useInspectorTable();
return (
<TableHeader
@ -100,19 +98,6 @@ function Header({ hidden = false, busy = false }: HeaderProps) {
))}
</tr>
))}
{busy && (
<tr className="border-0">
<th colSpan={table.getVisibleLeafColumns().length} className="relative h-0 p-0">
<div
role="progressbar"
aria-label="Updating rows"
className="absolute inset-x-0 bottom-0 h-0.5 overflow-hidden bg-[#3b5bfd]/10"
>
<div className="absolute inset-y-0 w-1/4 bg-[#3b5bfd]/60 motion-safe:animate-[rows-sweep_1.2s_ease-in-out_infinite] motion-reduce:hidden" />
</div>
</th>
</tr>
)}
</TableHeader>
);
}

View file

@ -4,7 +4,7 @@ import "prosemirror-view/style/prosemirror.css";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { ProseMirror, ProseMirrorDoc, reactKeys, useEditorEventCallback } from "@handlewithcare/react-prosemirror";
import { Check, Copy, CornerDownLeft, type LucideIcon, Search } from "lucide-react";
import { Check, Copy, CornerDownLeft, Loader2, type LucideIcon, Search } from "lucide-react";
import { Schema } from "prosemirror-model";
import { EditorState, Plugin, TextSelection, type Transaction } from "prosemirror-state";
import { Decoration, DecorationSet, type EditorView } from "prosemirror-view";
@ -231,10 +231,12 @@ function Root<F extends string>({
export type SearchBoxInputProps = ComponentProps<"div"> & {
placeholder: string;
/** Swaps the search icon for a spinner while results for the current query load. */
busy?: boolean;
};
/** The bordered field holding the editor; shows `placeholder` while the query is empty. */
function Input({ placeholder, className, ...props }: SearchBoxInputProps) {
function Input({ placeholder, busy = false, className, ...props }: SearchBoxInputProps) {
const { text } = useSearchBox();
return (
<div
@ -245,7 +247,15 @@ function Input({ placeholder, className, ...props }: SearchBoxInputProps) {
)}
{...props}
>
<Search className="size-3.5 shrink-0 text-muted-foreground" />
{busy ? (
<Loader2
role="status"
aria-label="Loading results"
className="size-3.5 shrink-0 animate-spin text-muted-foreground"
/>
) : (
<Search className="size-3.5 shrink-0 text-muted-foreground" />
)}
<div className="relative flex min-w-0 flex-1">
<ProseMirrorDoc />
{!text && (