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%; 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}`)); 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(screen.getAllByTestId("agent-trace-row")).toHaveLength(rowsBefore.length);
expect(listRequest(-1)).toMatchObject({ order: { key, descending: true }, page: { cursor: null } }); expect(listRequest(-1)).toMatchObject({ order: { key, descending: true }, page: { cursor: null } });
act(release); 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(screen.getAllByTestId("agent-trace-row")[0]).toHaveTextContent(topAfter);
expect(lastUrl(onUrlUpdate).get("sort_by")).toBe(key); expect(lastUrl(onUrlUpdate).get("sort_by")).toBe(key);
expect(lastUrl(onUrlUpdate).has("sort_dir")).toBe(false); expect(lastUrl(onUrlUpdate).has("sort_dir")).toBe(false);

View file

@ -171,7 +171,14 @@ export function AgentTracesSection({
/> />
)} )}
</Inspector.Panel> </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 && ( {timeControls && (
<TimeRangeControls <TimeRangeControls
range={range} range={range}

View file

@ -236,7 +236,6 @@ describe("AgentTracesTable sorting", () => {
), ),
); );
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length); 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.queryByTestId("runs-placeholder")).not.toBeInTheDocument();
expect(screen.queryByRole("status")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(onLoadMore).not.toHaveBeenCalled(); expect(onLoadMore).not.toHaveBeenCalled();

View file

@ -287,10 +287,10 @@ export function AgentTracesTable({
<InspectorTable.Root table={table} data-testid="runs-table"> <InspectorTable.Root table={table} data-testid="runs-table">
<ScrollToTop order={order} /> <ScrollToTop order={order} />
<InspectorTable.Grid aria-label="Agent runs" aria-busy={isFetching} className="min-w-[900px] text-xs"> <InspectorTable.Grid aria-label="Agent runs" aria-busy={isFetching} className="min-w-[900px] text-xs">
<InspectorTable.Header busy={isPlaceholder} /> <InspectorTable.Header />
<InspectorTable.Body<TraceSummary> <InspectorTable.Body<TraceSummary>
rowHeight={() => ROW_HEIGHT} rowHeight={() => ROW_HEIGHT}
className={cn(isPlaceholder && "opacity-60 transition-opacity motion-reduce:transition-none")} className={cn("transition-[filter]", isPlaceholder && "blur-[1.5px]")}
after={ after={
<> <>
{isLoading && SKELETON_ROWS.map((row) => <PlaceholderRow key={row} index={row} />)} {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. */ /** The range and order the list shows; the copied query follows both. */
range?: TimeWindow; range?: TimeWindow;
order?: RunOrder; order?: RunOrder;
busy?: boolean;
} }
/** The runs list query box: free text plus `key:value` filters over run fields, copyable as a trace query. */ /** 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]); const command = useMemo(() => runQueryCommand(range, order), [range, order]);
return ( return (
<SearchBox.Root <SearchBox.Root
@ -37,6 +38,7 @@ export function RunSearch({ value, onChange, runs, range, order = NEWEST }: RunS
> >
<SearchBox.Input <SearchBox.Input
className="h-full rounded-none border-0 px-3 focus-within:bg-muted/40 focus-within:ring-0 dark:bg-transparent" 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" placeholder="Search runs, or filter like agent:researcher status:error"
/> />
<SearchBox.Suggestions> <SearchBox.Suggestions>

View file

@ -13,14 +13,16 @@ interface RunsToolbarProps {
/** The range and order the list shows, for the copied query. */ /** The range and order the list shows, for the copied query. */
range?: TimeWindow; range?: TimeWindow;
order?: RunOrder; 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. */ /** Extra controls (time range, live tail) rendered on the right. */
children?: React.ReactNode; 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 ( return (
<div className="flex h-10 shrink-0 items-stretch border-b border-border bg-card"> <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>} {children && <div className="flex shrink-0 items-stretch">{children}</div>}
</div> </div>
); );

View file

@ -73,11 +73,9 @@ function ariaSort<T>(column: Column<T>): "ascending" | "descending" | "none" | u
interface HeaderProps { interface HeaderProps {
readonly hidden?: boolean; 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(); const { table } = useInspectorTable();
return ( return (
<TableHeader <TableHeader
@ -100,19 +98,6 @@ function Header({ hidden = false, busy = false }: HeaderProps) {
))} ))}
</tr> </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> </TableHeader>
); );
} }

View file

@ -4,7 +4,7 @@ import "prosemirror-view/style/prosemirror.css";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { ProseMirror, ProseMirrorDoc, reactKeys, useEditorEventCallback } from "@handlewithcare/react-prosemirror"; 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 { Schema } from "prosemirror-model";
import { EditorState, Plugin, TextSelection, type Transaction } from "prosemirror-state"; import { EditorState, Plugin, TextSelection, type Transaction } from "prosemirror-state";
import { Decoration, DecorationSet, type EditorView } from "prosemirror-view"; import { Decoration, DecorationSet, type EditorView } from "prosemirror-view";
@ -231,10 +231,12 @@ function Root<F extends string>({
export type SearchBoxInputProps = ComponentProps<"div"> & { export type SearchBoxInputProps = ComponentProps<"div"> & {
placeholder: string; 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. */ /** 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(); const { text } = useSearchBox();
return ( return (
<div <div
@ -245,7 +247,15 @@ function Input({ placeholder, className, ...props }: SearchBoxInputProps) {
)} )}
{...props} {...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"> <div className="relative flex min-w-0 flex-1">
<ProseMirrorDoc /> <ProseMirrorDoc />
{!text && ( {!text && (