Skip to content

feat(frontend): make a table search show it is loading, from keystroke to results - #7497

Merged
joeyorlando merged 5 commits into
mainfrom
task-envs/hi4mt1
Aug 27, 2026
Merged

feat(frontend): make a table search show it is loading, from keystroke to results#7497
joeyorlando merged 5 commits into
mainfrom
task-envs/hi4mt1

Conversation

@archestra-task-envs

@archestra-task-envs archestra-task-envs Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Problem

Typing in a table's search box produced no feedback of any kind until the
results themselves changed.

Three things had to line up for that to be as silent as it was:

  • SearchInput renders a static <Search> magnifier and hands the field to
    DebouncedInput, which sits on the keystroke for 400ms before telling anyone.
    Nothing in the tree could even ask whether a keystroke was waiting in that
    timer, so the first 400ms of every search were unacknowledged by construction.
  • DataTable's isLoading prop only ever suppressed the empty state — the
    {!isLoading && <EmptyState .../>} branch in the fallback row. Once rows were
    on screen it did nothing at all.
  • These lists use placeholderData: (previousData) => previousData, so the
    previous page stays on screen while the next one loads. Combined with the
    point above, a refetch changed nothing on screen until the new rows landed.

So the whole wait — debounce, commit, request — passed under an unchanged
magnifier above an unchanged table. A slow search was indistinguishable from a
box that had swallowed the query.

The fix

One continuous signal from the keystroke to the results, in three parts.

DebouncedInput reports its pending window

New onPendingChange callback. The window opens on the keystroke, not when
the debounce fires — the gap being covered starts when the user types.

Closing it is the subtler half. It closes when the committed value catches up,
because that is also the moment the caller's fetch starts, so an indicator
driven by this hands over to one driven by that without blinking off in
between. A caller that keeps its query somewhere other than initialValue (a
purely local onSearchChange) never produces that moment, so the window is
also bounded by a short handoff timer — a stuck indicator is the one failure
worth ruling out entirely.

The search box lights up

The magnifier cross-fades into a spinner. 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.

The pending window drives it with no wiring at all, so every call site gets
the acknowledgement typing was missing. A new isLoading prop extends it
across the request that follows.

The spinner is aria-hidden; the field carries aria-busy, so assistive tech
hears the state without a live region announcing every pause between
keystrokes.

The table marks itself busy

An indeterminate bar sweeps the table's 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 existing
empty-state handling already goes out of its way to avoid. Both effects are
delayed ~150ms so a refetch that resolves immediately never registers as a
flicker; rows return to full strength undelayed.

Two details worth flagging for review:

  • The bar is a sibling of the overflow-x-auto container, not a child. An
    absolutely positioned child of it scrolls away with the content on a table
    wide enough to scroll. This is why data-table.tsx gains a wrapper <div>
    and why its diff is mostly re-indentation.
  • It is a progressbar, not a live region. isLoading is true for background
    refetches too, and those should not be announced. The table also carries
    aria-busy.

Which pages get the fetch flag

Only pages whose search term actually reaches the query. 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.

Scope

The report was about one page's search box; the cause was in the shared
components behind it, so the fix is applied there and every table search
benefits. Verified in a browser against the running stack at both a desktop
width and narrowed, with the request artificially held open to make each state
observable, and with the previous-page-retained case exercised specifically.

Tests pin the pending window's four transitions (opens at the keystroke,
survives the commit, self-closes when no commit arrives, never opens for an
edit that lands back on the committed value), that the field reports busy for
both halves, and that the table announces a refetch without disturbing the rows
already on screen. The two DataTable tests that asserted the absence of an
indicator during loading were updated rather than left to pass by accident.

motion-reduce covers both animations: the spinner stops, and the bar holds
still and fills its track instead of sweeping.


Follow-up: the sidebar spinner sits out searches

Steered mid-flight — the first version left the sidebar toggle's spinner
spinning during a search, which put three indicators on screen for one
wait: the lit search box, the table's progress bar, and a spinner in the corner
furthest from where the user is looking.

That spinner is still the right thing for boot and page transitions, so it is
not removed — it now sits out searches specifically. SearchInput reports its
in-flight state (the same flag that drives its own spinner, so the two cannot
disagree) and useIsAppLoading stands down while any search box is waiting. A
counter rather than a flag, because a page can hold more than one search box
and the first wait to finish must not speak for the second.

Why the search box has to be the one to say so. This is not inferable from
the query. Changing the search term changes the query key, so the entry being
fetched has data === undefined and matches useIsAppLoading's predicate —
even on the lists that keep their previous page on screen, because that page
lives on the observer, not in the new cache entry. Confirmed by measurement,
not by reading: on the skills list the spinner reached 0.8 opacity mid-search
before this change.

The two halves stay separate by construction. The toggle is
isNavigating || isAppLoading, and only the second half is suppressed.
isNavigating is set by clicks on internal anchors and never by the
router.push a search commit makes, so page transitions are untouched.

Verified in a browser: sampling the toggle's spinner opacity every 50ms across
an entire slow search (100 samples) it never leaves 0, while a page transition
still takes it to 0.8.


Archestra Contributor

`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.
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.
`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`.
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.
A search now reports itself twice — a lit search box above a table drawing a
progress bar across its top edge. The sidebar toggle's spinner was joining in,
putting a third indicator on screen for one wait, in the corner furthest from
where the user is looking.

That spinner is still the right thing for boot and page transitions, so it is
not being removed — it now sits out searches specifically. `SearchInput`
reports its in-flight state, using the same flag that drives its own spinner so
the two cannot disagree, and `useIsAppLoading` stands down while any search box
is waiting. The count is a counter rather than a flag because a page can hold
more than one search box, and the first wait to finish must not speak for the
second.

This cannot be inferred from the query alone, which is why the search box has
to say so. Changing the search term changes the query key, so the entry being
fetched has `data === undefined` and matches the hook's predicate even on lists
that keep their previous page on screen — that page lives on the observer, not
in the new entry. Measured on the skills list, the spinner reached 0.8 opacity
mid-search before this and never leaves 0 after, while a page transition still
takes it to 0.8.

The page-transition half runs through `isNavigating`, which is set by clicks on
internal anchors and never by the `router.push` a search commit makes, so the
two paths stay cleanly separated.
@joeyorlando
joeyorlando added this pull request to the merge queue Aug 27, 2026
Merged via the queue into main with commit c1ba071 Aug 27, 2026
47 of 48 checks passed
@joeyorlando
joeyorlando deleted the task-envs/hi4mt1 branch August 27, 2026 01:47
pull Bot pushed a commit to bryanwills/archestra-ai that referenced this pull request Aug 28, 2026
🤖 I have created a release *beep* *boop*
---


##
[1.3.45](archestra-ai/archestra@platform-v1.3.44...platform-v1.3.45)
(2026-08-28)


### Features

* add provider key action to empty models page
([archestra-ai#7524](archestra-ai#7524))
([19bf907](archestra-ai@19bf907))
* **agents:** let an agent disable individual knowledge sources,
replacing Auto's preview list
([archestra-ai#7498](archestra-ai#7498))
([ca67097](archestra-ai@ca67097))
* **apps:** let an app carry an icon, and label the one MCP servers
already had
([archestra-ai#7473](archestra-ai#7473))
([7e120e6](archestra-ai@7e120e6))
* **chat:** add provider keys from credential selector
([archestra-ai#7505](archestra-ai#7505))
([0a9ce81](archestra-ai@0a9ce81))
* **chatops:** per-channel instructions for messaging channels
([archestra-ai#7479](archestra-ai#7479))
([1c4755a](archestra-ai@1c4755a))
* **frontend:** make a table search show it is loading, from keystroke
to results
([archestra-ai#7497](archestra-ai#7497))
([c1ba071](archestra-ai@c1ba071))
* **frontend:** name every studio page in the sidebar, and manage every
OAuth client in one place
([archestra-ai#7507](archestra-ai#7507))
([a8612c4](archestra-ai@a8612c4))
* **frontend:** preview SVG files in the Files panel
([archestra-ai#7530](archestra-ai#7530))
([c693559](archestra-ai@c693559))
* **llm-logs:** name the virtual key a request used, and the user it
stands for
([archestra-ai#7499](archestra-ai#7499))
([5676c73](archestra-ai@5676c73))
* **llm-providers:** make the OpenAI-compatible provider discoverable
([archestra-ai#7466](archestra-ai#7466))
([c4d17da](archestra-ai@c4d17da))
* **llm-proxy:** a single LLM Proxy per organization
([archestra-ai#7448](archestra-ai#7448))
([0ee2ef4](archestra-ai@0ee2ef4))
* **model-providers:** surface personal subscriptions as connect cards
above the credentials table
([archestra-ai#7501](archestra-ai#7501))
([f8d4b23](archestra-ai@f8d4b23))
* **plugins:** manage plugins over MCP and reuse their skills
([archestra-ai#7503](archestra-ai#7503))
([d521e32](archestra-ai@d521e32))
* polish MCP registry interactions
([archestra-ai#7517](archestra-ai#7517))
([ae9565f](archestra-ai@ae9565f))
* separate messaging setup from agent assignments
([archestra-ai#7526](archestra-ai#7526))
([7267c98](archestra-ai@7267c98))
* **service-accounts:** show whether an account can actually
authenticate, and rebuild its screens
([archestra-ai#7489](archestra-ai#7489))
([0f2cb6a](archestra-ai@0f2cb6a))
* **skills:** a static marketplace URL every user installs with their
own token
([archestra-ai#7451](archestra-ai#7451))
([6dd07da](archestra-ai@6dd07da))
* **skills:** activate plugin skills across clients
([archestra-ai#7509](archestra-ai#7509))
([36fce6c](archestra-ai@36fce6c))
* **skills:** merge skill source collections
([archestra-ai#7532](archestra-ai#7532))
([fe2d105](archestra-ai@fe2d105))


### Bug Fixes

* accept valid HTTP header names
([archestra-ai#7520](archestra-ai#7520))
([7029e55](archestra-ai@7029e55))
* **agents:** apply prompt caching to headless runs
([archestra-ai#7502](archestra-ai#7502))
([ddfd688](archestra-ai@ddfd688))
* **agents:** make an agent's Auto and Custom tool/knowledge fields read
as one form
([archestra-ai#7506](archestra-ai#7506))
([923b2ce](archestra-ai@923b2ce))
* **agents:** publish skills over MCP from gateways only, not agents
([archestra-ai#7486](archestra-ai#7486))
([a27bf4a](archestra-ai@a27bf4a))
* **agents:** streamline Advisor setup
([archestra-ai#7461](archestra-ai#7461))
([6ec3652](archestra-ai@6ec3652))
* **api:** hold idle keep-alive connections longer than upstream proxies
([archestra-ai#7477](archestra-ai#7477))
([b6449b9](archestra-ai@b6449b9))
* **chatops:** name the channel in the agent's framing, and stop channel
instructions reading as an allow-list
([archestra-ai#7490](archestra-ai#7490))
([f727e7d](archestra-ai@f727e7d))
* **chat:** show the browser setup card only once the check has an
answer ([archestra-ai#7470](archestra-ai#7470))
([d697649](archestra-ai@d697649))
* **chat:** unwedge conversations poisoned by an unpaired surrogate
([archestra-ai#7459](archestra-ai#7459))
([5cef99c](archestra-ai@5cef99c))
* **docker:** pin openssl &gt;=3.5.8-r0 in both scanned images
([archestra-ai#7472](archestra-ai#7472))
([d272c73](archestra-ai@d272c73))
* **frontend:** drop the tools picker's per-catalog fan-out, and show
when its lists fail
([archestra-ai#7485](archestra-ai#7485))
([3e5958e](archestra-ai@3e5958e))
* **frontend:** keep MCP alert dismiss on current page
([archestra-ai#7528](archestra-ai#7528))
([c42aed8](archestra-ai@c42aed8))
* **frontend:** keep OAuth client list requests within API limits
([archestra-ai#7522](archestra-ai#7522))
([1e25d58](archestra-ai@1e25d58))
* **frontend:** normalize bulk action spacing
([archestra-ai#7482](archestra-ai#7482))
([bb3ce25](archestra-ai@bb3ce25))
* **frontend:** normalize collection layouts
([archestra-ai#7534](archestra-ai#7534))
([d606775](archestra-ai@d606775))
* **frontend:** remove redundant Knowledge navigation
([archestra-ai#7533](archestra-ai#7533))
([2ae6618](archestra-ai@2ae6618))
* **frontend:** remove redundant page header navigation
([archestra-ai#7531](archestra-ai#7531))
([276ab87](archestra-ai@276ab87))
* **frontend:** render the real Slack logo in the logo picker
([archestra-ai#7495](archestra-ai#7495))
([88fe59e](archestra-ai@88fe59e))
* **frontend:** render the sidebar attention count as a circle
([archestra-ai#7474](archestra-ai#7474))
([e09bb5a](archestra-ai@e09bb5a))
* **frontend:** reset the re-auth marker when starting a fresh MCP OAuth
install ([archestra-ai#7491](archestra-ai#7491))
([f844a60](archestra-ai@f844a60))
* **frontend:** show every role in the role pickers, not just the first
page ([archestra-ai#7484](archestra-ai#7484))
([ac321ef](archestra-ai@ac321ef))
* **frontend:** stop the MCP Usage tab calling an unknown owner
"Personal"
([archestra-ai#7480](archestra-ai#7480))
([a2bd3ef](archestra-ai@a2bd3ef))
* **frontend:** stop the new chat screen waiting on data it does not
draw ([archestra-ai#7458](archestra-ai#7458))
([48b8c6f](archestra-ai@48b8c6f))
* **knowledge:** stop the tier notice claiming Knowledge is disabled
([archestra-ai#7469](archestra-ai#7469))
([3e22155](archestra-ai@3e22155))
* **llm-costs:** report the LLM Proxy as one entity instead of a list of
retired proxies
([archestra-ai#7496](archestra-ai#7496))
([5161afc](archestra-ai@5161afc))
* **llm-proxy:** pre-filter virtual keys and OAuth clients from the
blocked-delete dialog
([archestra-ai#7476](archestra-ai#7476))
([e59b5d3](archestra-ai@e59b5d3))
* **llm-proxy:** repair direct calls to app tools hidden behind run_tool
([archestra-ai#7488](archestra-ai#7488))
([9b9194e](archestra-ai@9b9194e))
* **llm-proxy:** validate provider-key filter param with the server's
zod schema
([archestra-ai#7478](archestra-ai#7478))
([a2976c7](archestra-ai@a2976c7))
* **mcp-apps:** keep the sidebar visible in fullscreen, and give every
app a way into it
([archestra-ai#7481](archestra-ai#7481))
([478f7e4](archestra-ai@478f7e4))
* **mcp-registry:** stop the Inspector from testing another member's
connection
([archestra-ai#7500](archestra-ai#7500))
([0e7f8b9](archestra-ai@0e7f8b9))
* **mcp:** wait for cold servers to be ready
([archestra-ai#7464](archestra-ai#7464))
([5c37d2b](archestra-ai@5c37d2b))
* **plugins:** edit GitHub source and authentication
([archestra-ai#7460](archestra-ai#7460))
([ac6f244](archestra-ai@ac6f244))
* **plugins:** make GitHub imports reliable and surface failures
([archestra-ai#7463](archestra-ai#7463))
([750d198](archestra-ai@750d198))
* preserve tool names across provider constraints
([archestra-ai#7535](archestra-ai#7535))
([35349fa](archestra-ai@35349fa))
* **rbac:** enforce the enterprise tier on custom roles server-side
([archestra-ai#7467](archestra-ai#7467))
([6950e12](archestra-ai@6950e12))
* **skills:** enforce the online skill catalog org setting server-side
([archestra-ai#7468](archestra-ai#7468))
([470f1a0](archestra-ai@470f1a0))
* **skills:** filter plugin artifacts from derived skills
([archestra-ai#7519](archestra-ai#7519))
([619016d](archestra-ai@619016d))
* **skills:** narrow plugin artifact filtering
([archestra-ai#7527](archestra-ai#7527))
([bdb9bf7](archestra-ai@bdb9bf7))
* **skills:** resolve projected skills by short name
([archestra-ai#7529](archestra-ai#7529))
([668838e](archestra-ai@668838e))
* **sso:** make GitHub SSO configurable through the UI
([archestra-ai#7465](archestra-ai#7465))
([c888a95](archestra-ai@c888a95))
* **users:** an empty trailing page in the users table, and an
invitations tab on deployments without invitations
([archestra-ai#7471](archestra-ai#7471))
([affded2](archestra-ai@affded2))


### Code Refactoring

* **frontend:** unify collection bulk actions
([archestra-ai#7475](archestra-ai#7475))
([e1e457e](archestra-ai@e1e457e))
* **logs:** give log and connector detail pages their own header
([archestra-ai#7504](archestra-ai#7504))
([e534dcc](archestra-ai@e534dcc))


### Miscellaneous Chores

* create knowledge file directories during upload
([archestra-ai#7523](archestra-ai#7523))
([8f8880d](archestra-ai@8f8880d))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: archestra-ci[bot] <222894074+archestra-ci[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant