Files
traefik-llmgateway/webui/src/lib/provider-expand.ts
T
lukaszraczylo b39d4e92c9 fix(webui): query-safe expand state, prefixed search, accessible row toggle
Review fix round (5 Important + 3 folded) against the provider-model
accordion (f9b289f):

1. Manual expand state corruption during an active search. Clicking a
row that was only auto-expanded by the search (never manually opened)
read the RAW manual flag as false and added it to manuallyExpanded -
invisible immediately (already showing via auto-expand) but wrongly
left expanded after the search cleared. Fixed with a two-set design in
a new, Vue-free module (webui/src/lib/provider-expand.ts):
manuallyExpanded (the real, cross-search manual state) plus
manuallyCollapsed (an override a click can set to suppress an
auto-expand while a query is active). toggleProviderExpand now flips
against the caller-computed EFFECTIVE current state, not the raw
manual flag. manuallyCollapsed clears whenever the query becomes empty
(watched, not just the clear button, so backspacing to empty resets it
too). Verified two ways: a throwaway Node assertion script (node
--experimental-strip-types, no test-runner dependency added)
exercises the exact review-described sequence against the plain
module; the identical sequence was then reproduced live in a real
browser and screenshotted at each step.

2. Search matched raw model ids while chips display and copy the
prefixed routable id - copying a chip and pasting it into search found
nothing. Fixed: matching is now against routableModelId(p.name, m) for
both the provider-level filter and the per-provider visible-model
filter - strictly more permissive than the bare id. Verified live:
searching the full "openai/gpt-4o-mini" string now matches.

3. role="button" on a <tr> broke table AT semantics - overriding a
<tr>'s row semantics to "button" strips it from the table's row/column
structure for a screen reader. Moved the interactive control to a real
<button type="button"> inside the first cell (the chevron+name span);
the row's own @click stays as a click-anywhere-in-row convenience, and
the button needs no click handler of its own - its native click bubbles
up to the row's listener, so exactly one place ever runs the toggle.
Added a visible focus style matching the existing shadcn-vue
focus-visible convention used elsewhere in this panel - verified live,
keyboard Tab produces a clearly visible ring. aria-controls is set only
while a provider is expanded, never pointing at an id that does not
yet exist (the content row stays v-if, lazy) - verified live via a
direct DOM check. Provider names are interpolated into that id
unescaped, not sanitized in JS - providers.go's configNamePattern
(charset [a-zA-Z0-9._-]) already rejects any character an HTML
id/attribute value would need escaped, before this endpoint can ever
see one.

4. DRY: providerState.knownIDs() and snapshot() independently rebuilt
the identical explicit-union-discovered merge-and-sort. Extracted
knownIDsLocked() (no locking of its own - caller must already hold
st.mu), consumed by both knownIDs() (takes the lock itself) and
snapshot() (already holds the lock, calls the lock-free variant
directly - a call to the locking knownIDs() from inside snapshot would
deadlock the non-reentrant mutex). snapshot()'s own doc comment
rewritten to describe the actual shared-helper shape instead of the
now-removed duplication it used to explain away.

5. (folded) Corrected a wrong claim in OverviewView.vue's own "why not
Accordion/Collapsible" doc comment: a <div> inside a <tbody> does NOT
get reparented by the browser's HTML parser ("foster parenting") in
this app - Vue renders entirely via DOM APIs, never by parsing an HTML
string, so the parser's foster-parenting algorithm (which only runs
building a DOM tree from a token stream) never executes here. What
actually breaks is CSS table layout: CSS2.1 17.2.1's anonymous-table-
object generation misbehaves around a block box sitting directly
inside a table-row-group box. The comment now says this.

6. (folded) Clipboard fallback hardening: ModelChip.vue's doc comment
now states the select-text fallback is the ONLY path a click ever
takes on a plain-HTTP admin deployment (Clipboard API needs a secure
context), not a rare edge case. A genuine copy failure now sets a
distinct "failed" state (destructive-colored icon, "Copy failed"
title, auto-resets) instead of bailing silently.

Regenerated via make admin-ui; admin_assets_gen.go = 850,968 bytes.
Gate: go build/gofmt/vet/fieldalignment clean, go test -count=1 ok
6.4s, go test -race ok 7.6s, golangci-lint clean (1 pre-existing
unrelated resp.go finding), make yaegi-check OK, npm run build clean,
make integration ok 45.0s (needed 3 attempts - TestUsersFileHotReload
is a pre-existing flake, now observed at least 3 times across this
body of work, unrelated to this diff; flagged for follow-up, not
fixed here).
2026-08-21 10:42:48 +01:00

82 lines
4.0 KiB
TypeScript

/**
* Pure expand/collapse state for OverviewView.vue's provider-model
* accordion, extracted out of the component so the exact toggle sequence
* a review flagged (see toggleProviderExpand's own comment) is checkable
* in isolation, without mounting Vue.
*
* Two sets track state:
* - manuallyExpanded: providers the user explicitly opened, independent
* of any search query. This is the state a cleared search returns to.
* - manuallyCollapsed: providers the user explicitly closed WHILE a
* search query was auto-expanding them. Only ever consulted while a
* query is active (computeExpandedProviders below) — once the query
* clears, effective state is manuallyExpanded alone, so entries here
* become inert. clearExpandOverrides drops them anyway, so a stale
* collapse from one search session cannot silently suppress a match in
* an unrelated later one.
*/
export interface ExpandState {
manuallyExpanded: Set<string>
manuallyCollapsed: Set<string>
}
/**
* computeExpandedProviders returns the effective set of expanded
* provider names: manuallyExpanded verbatim when no query is active;
* with a query active, manuallyExpanded plus every name in
* matchingNames that is not in manuallyCollapsed (auto-expand every
* match, unless the user explicitly closed that one).
*/
export function computeExpandedProviders(state: ExpandState, hasQuery: boolean, matchingNames: string[]): Set<string> {
if (!hasQuery) return new Set(state.manuallyExpanded)
const expanded = new Set(state.manuallyExpanded)
for (const name of matchingNames) {
if (!state.manuallyCollapsed.has(name)) expanded.add(name)
}
return expanded
}
/**
* toggleProviderExpand mutates state for one provider, given whether it
* is CURRENTLY effectively expanded (computeExpandedProviders' own
* result for that name — the caller passes this, rather than the
* function re-deriving it, so callers stay in charge of what "current"
* means for their own render).
*
* This is the exact fix for a review-flagged bug: the original
* implementation toggled manuallyExpanded.has(name) directly — the RAW
* manual flag, not the effective (possibly auto-expanded) one. Clicking
* a row that was only auto-expanded (matching the search, never
* manually opened) read manuallyExpanded.has(name) as false and ADDED
* it to manuallyExpanded — invisible immediately (it was already
* showing via auto-expand), but wrongly left it expanded after the
* query cleared, since manuallyExpanded now contained a provider the
* user never actually chose to open. Toggling against the EFFECTIVE
* state fixes this in both directions:
* - Expanded only via auto-match, clicked to close: manuallyExpanded
* never gains the entry (nothing to remove); manuallyCollapsed gains
* it, suppressing the auto-expand for as long as the query stays
* active. Clearing the query drops back to manuallyExpanded, which
* never had it — correctly collapsed.
* - Expanded via a manual open from BEFORE a search started, still
* matches, clicked to close during the search: manuallyExpanded loses
* it (the user's own most recent action wins) and manuallyCollapsed
* gains it, so it also stays closed after the query clears — treating
* the in-search click as the user's current intent, not the older,
* pre-search one.
*/
export function toggleProviderExpand(state: ExpandState, name: string, currentlyExpanded: boolean): void {
if (currentlyExpanded) {
state.manuallyExpanded.delete(name)
state.manuallyCollapsed.add(name)
} else {
state.manuallyCollapsed.delete(name)
state.manuallyExpanded.add(name)
}
}
/** clearExpandOverrides drops every manuallyCollapsed entry — called when the search query becomes empty, so a suppression from one search session never carries into an unrelated later one (manuallyCollapsed is otherwise never consulted outside an active query anyway; this only matters for a FUTURE query). */
export function clearExpandOverrides(state: ExpandState): void {
state.manuallyCollapsed.clear()
}