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).
This commit is contained in:
2026-08-21 10:42:48 +01:00
parent f9b289f1ea
commit b39d4e92c9
5 changed files with 1062 additions and 893 deletions
+809 -801
View File
File diff suppressed because it is too large Load Diff
+26 -33
View File
@@ -68,11 +68,14 @@ func (st *providerState) hasModel(id string) bool {
return st.explicit[id] || st.discovered[id]
}
// knownIDs returns the sorted union of this provider's explicit and
// discovered model ids.
func (st *providerState) knownIDs() []string {
st.mu.Lock()
defer st.mu.Unlock()
// knownIDsLocked returns the sorted union of st.explicit and
// st.discovered. It takes no lock itself — the caller must already hold
// st.mu — so both knownIDs (below) and snapshot (which needs the
// identical merge inside its own, already-held, critical section
// alongside lastRefresh/lastErr) can share this one implementation
// without knownIDs' own st.mu.Lock() double-locking (sync.Mutex is not
// reentrant) when called from inside snapshot.
func (st *providerState) knownIDsLocked() []string {
set := make(map[string]bool, len(st.explicit)+len(st.discovered))
for id := range st.explicit {
set[id] = true
@@ -88,6 +91,14 @@ func (st *providerState) knownIDs() []string {
return ids
}
// knownIDs returns the sorted union of this provider's explicit and
// discovered model ids.
func (st *providerState) knownIDs() []string {
st.mu.Lock()
defer st.mu.Unlock()
return st.knownIDsLocked()
}
// tryBeginRefresh reports whether now is far enough past lastRefresh (or
// this is the first refresh) to start a new one, and if so marks the
// provider inFlight so a concurrent caller cannot start a second one. The
@@ -131,21 +142,15 @@ func (st *providerState) finishRefresh(now time.Time, ids []string, err error) {
// provider-model-accordion task; a caller wanting just the count uses
// len(models)), last refresh time, and last refresh error message.
//
// models is computed in the SAME critical section as lastRefresh/lastErr
// below, not via a separate call to knownIDs() (which does the identical
// explicit∪discovered merge-and-sort): st.mu is a plain sync.Mutex, not
// reentrant, so calling knownIDs() — which takes the same lock — from
// inside this already-locked method would deadlock. The duplication
// mirrors this method's own pre-existing pattern (it already rebuilt the
// same union set as knownIDs() independently, before this change, purely
// for the count) rather than introducing a new one. Keeping the merge
// inside one lock/unlock, rather than one call for the count and a
// second, later call to knownIDs() for the list, also matters
// functionally, not just stylistically: a concurrent finishRefresh
// landing between two separate locked sections could otherwise hand the
// admin dashboard a models list whose length disagrees with a
// separately-read modelCount — reading both from one locked pass makes
// that impossible.
// models comes from knownIDsLocked (this method already holds st.mu, so
// it uses the lock-free variant directly rather than the locking
// knownIDs, which would deadlock reentering the same non-reentrant
// sync.Mutex), read in the SAME critical section as lastRefresh/lastErr
// below — not two separate locked calls. That matters functionally, not
// just stylistically: a concurrent finishRefresh landing between two
// separate locked sections could otherwise hand the admin dashboard a
// models list whose length disagrees with a separately-read modelCount;
// reading all three from one locked pass makes that impossible.
//
// A provider whose discovery is mid-refresh (or has never refreshed
// since a config reload) returns exactly this stale-while-error set —
@@ -156,19 +161,7 @@ func (st *providerState) finishRefresh(now time.Time, ids []string, err error) {
func (st *providerState) snapshot() (models []string, lastRefresh time.Time, lastErr string) {
st.mu.Lock()
defer st.mu.Unlock()
set := make(map[string]bool, len(st.explicit)+len(st.discovered))
for id := range st.explicit {
set[id] = true
}
for id := range st.discovered {
set[id] = true
}
ids := make([]string, 0, len(set))
for id := range set {
ids = append(ids, id)
}
sort.Strings(ids)
return ids, st.lastRefresh, st.lastErr
return st.knownIDsLocked(), st.lastRefresh, st.lastErr
}
// modelRegistry aggregates every configured provider's explicit and
+38 -8
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { faCheck, faCopy } from '@fortawesome/free-solid-svg-icons'
import { faCheck, faCopy, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
import { ref, useTemplateRef } from 'vue'
import { Badge } from '@/components/ui/badge'
@@ -11,22 +11,51 @@ import { copyText } from '@/lib/clipboard'
* is the full routable id (lib/format.ts's routableModelId) — what
* actually gets copied, so a pasted value works straight into a
* `model: "..."` request field.
*
* The label span (labelRef below) is copyText's select-text fallback
* target — not a rare edge case here: navigator.clipboard is undefined
* outright on any plain-HTTP admin deployment (the Clipboard API
* requires a secure context — https, or localhost), so the fallback is
* the ONLY path a click ever takes there, not a backstop for an unlikely
* failure. labelRef is a template ref on an element this component
* always renders (below), so it is populated by the time onClick can
* ever run — a click handler cannot fire before mount.
*/
const props = defineProps<{ id: string }>()
const labelRef = useTemplateRef<HTMLElement>('label')
const state = ref<'idle' | 'copied' | 'selected'>('idle')
const state = ref<'idle' | 'copied' | 'selected' | 'failed'>('idle')
let resetTimer: ReturnType<typeof setTimeout> | undefined
async function onClick(): Promise<void> {
const result = await copyText(props.id, labelRef.value ?? undefined)
if (result === 'failed') return
state.value = result
function scheduleReset(): void {
clearTimeout(resetTimer)
resetTimer = setTimeout(() => {
state.value = 'idle'
}, 1500)
}
async function onClick(): Promise<void> {
const result = await copyText(props.id, labelRef.value ?? undefined)
// 'failed' (neither the Clipboard API nor the selection fallback
// worked) gets its own visible state rather than a silent no-op — a
// click that visibly does nothing reads as a broken button, not as
// "nothing to report".
state.value = result
scheduleReset()
}
const icon = {
idle: faCopy,
copied: faCheck,
selected: faCheck,
failed: faTriangleExclamation,
}
const title = {
idle: `Copy ${props.id}`,
copied: 'Copied',
selected: 'Selected — press Ctrl/Cmd+C',
failed: 'Copy failed',
}
</script>
<template>
@@ -35,10 +64,11 @@ async function onClick(): Promise<void> {
type="button"
variant="secondary"
class="cursor-pointer gap-1 font-mono text-xs font-normal transition-colors hover:bg-accent hover:text-accent-foreground"
:title="state === 'idle' ? `Copy ${id}` : state === 'copied' ? 'Copied' : 'Selected — press Ctrl/Cmd+C'"
:class="state === 'failed' ? 'text-destructive' : ''"
:title="title[state]"
@click="onClick"
>
<span ref="label">{{ id }}</span>
<FontAwesomeIcon :icon="state === 'idle' ? faCopy : faCheck" class="size-2.5 shrink-0" aria-hidden="true" />
<FontAwesomeIcon :icon="icon[state]" class="size-2.5 shrink-0" aria-hidden="true" />
</Badge>
</template>
+108 -51
View File
@@ -7,7 +7,7 @@ import {
faTriangleExclamation,
faXmark,
} from '@fortawesome/free-solid-svg-icons'
import { computed, reactive, ref } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import ModelChip from '@/components/ModelChip.vue'
import {
@@ -28,6 +28,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { formatAgo, formatTimestamp, routableModelId } from '@/lib/format'
import { type ExpandState, clearExpandOverrides, computeExpandedProviders, toggleProviderExpand } from '@/lib/provider-expand'
import { useDashboardStore } from '@/stores/dashboard'
import type { AdminAliasView, AdminProviderView } from '@/types/api'
@@ -39,36 +40,59 @@ const overview = computed(() => dashboard.overview)
// Providers table rows expand to a model list (operator feature). This is
// NOT shadcn-vue's Accordion or Collapsible component: both wrap
// trigger+content in one <div>, which cannot legally sit between two
// <tr> elements inside a <table>/<tbody> — a browser's HTML table parser
// reparents (hoists) a <div> found there out of the table, breaking the
// layout. (reka-ui's Collapsible primitives DO support an `as` prop that
// could in principle render Root as a <tbody> and Trigger/Content as
// <tr> — installed and inspected via the CLI to check, then not used:
// <tr> elements inside a <table>/<tbody>. This app renders entirely via
// Vue's DOM APIs (createElement/appendChild), never by parsing an HTML
// string, so the HTML5 parser's "foster parenting" algorithm — which
// only runs while building a DOM tree FROM a token stream — never fires
// here at all; a <div> placed as a <tbody> child by direct DOM
// manipulation stays exactly where it was put. What actually breaks is
// layout: CSS table rendering (CSS2.1 §17.2.1's anonymous-table-object
// generation) does not know what to do with a block-level box sitting
// directly inside a table-row-group box, so the table's visual layout
// misbehaves around it. Either way, a <div> does not belong there.
// (reka-ui's Collapsible primitives DO support an `as` prop that could in
// principle render Root as a <tbody> and Trigger/Content as <tr> —
// installed and inspected via the CLI to check, then not used:
// CollapsibleContent's animation-measurement code calls
// getBoundingClientRect() and sets inline transition/animation styles on
// whatever element `as` names, an interaction with a <tr> this component
// was never designed around and was not worth taking on for an
// instant show/hide with no transition.) A second, plain <TableRow>
// toggled by v-if is the correct table-native shape for an expandable
// row; it reuses Accordion's own visual language (a rotating chevron,
// the trigger row as a button) so it reads as the same interaction
// pattern without misusing components built for block content.
const manuallyExpanded = reactive(new Set<string>())
function toggleProvider(name: string): void {
if (manuallyExpanded.has(name)) manuallyExpanded.delete(name)
else manuallyExpanded.add(name)
}
// was never designed around and was not worth taking on for an instant
// show/hide with no transition.) A second, plain <TableRow> toggled by
// v-if is the correct table-native shape for an expandable row; it
// reuses Accordion's own visual language (a rotating chevron, a
// keyboard-accessible trigger) without misusing a component built for
// block content.
//
// Expand state itself (computeExpandedProviders/toggleProviderExpand/
// clearExpandOverrides) is a plain, Vue-free module — lib/provider-expand.ts
// — specifically so the exact toggle-during-search sequence a review
// flagged is checkable by a throwaway assertion script outside Vue,
// without standing up a component-test framework this project does not
// otherwise have.
const expandState: ExpandState = reactive({
manuallyExpanded: new Set<string>(),
manuallyCollapsed: new Set<string>(),
})
// --- model/alias search filter (operator feature) ---
const modelQuery = ref('')
const normalizedQuery = computed(() => modelQuery.value.trim().toLowerCase())
const hasQuery = computed(() => normalizedQuery.value.length > 0)
function modelMatches(modelId: string): boolean {
return modelId.toLowerCase().includes(normalizedQuery.value)
/**
* Matching is against each model's full ROUTABLE id
* (routableModelId(p.name, m) — the same string ModelChip both displays
* and copies), not the bare model id: copying a chip's text and pasting
* it back into search must find it, and the routable form is strictly
* more permissive (it contains the bare id as a substring, plus the
* provider prefix), so this never hides a match the bare-id form would
* have found.
*/
function modelMatches(providerName: string, modelId: string): boolean {
return routableModelId(providerName, modelId).toLowerCase().includes(normalizedQuery.value)
}
function providerMatches(p: AdminProviderView): boolean {
return p.models.some(modelMatches)
return p.models.some((m) => modelMatches(p.name, m))
}
function aliasMatches(a: AdminAliasView): boolean {
return a.alias.toLowerCase().includes(normalizedQuery.value) || a.target.toLowerCase().includes(normalizedQuery.value)
@@ -83,28 +107,45 @@ const filteredAliases = computed<AdminAliasView[]>(() => {
return hasQuery.value ? all.filter(aliasMatches) : all
})
/**
* expandedProviders is what the template reads to decide which rows show
* their model list: the user's own manual toggles (manuallyExpanded),
* plus — only while a search query is active — every provider
* filteredProviders kept (they were kept because they have a matching
* model, so auto-expanding them surfaces the match without an extra
* click). Clearing the query drops this back to exactly
* manuallyExpanded, which manualExpanded's own toggle is the only thing
* that ever writes to — the "restore collapsed state when cleared"
* requirement falls out for free, because the auto-expand set was never
* a manual toggle to begin with.
*/
const expandedProviders = computed<Set<string>>(() => {
if (!hasQuery.value) return manuallyExpanded
const expanded = new Set(manuallyExpanded)
for (const p of filteredProviders.value) expanded.add(p.name)
return expanded
/** expandedProviders is what the template reads to decide which rows show their model list — see lib/provider-expand.ts's own doc comments for the manual/auto-expand/override semantics. */
const expandedProviders = computed<Set<string>>(() =>
computeExpandedProviders(
expandState,
hasQuery.value,
filteredProviders.value.map((p) => p.name),
),
)
/** toggleProvider reads the CURRENT effective (visible) state for name before flipping it — see toggleProviderExpand's own doc comment for exactly which bug this avoids. */
function toggleProvider(name: string): void {
toggleProviderExpand(expandState, name, expandedProviders.value.has(name))
}
// A stale manuallyCollapsed suppression from one search must never
// silently carry into a later, unrelated one — see ExpandState's own doc
// comment. Watching modelQuery (not just the explicit clear button)
// covers backspacing to empty too.
watch(modelQuery, (value) => {
if (value.trim() === '') clearExpandOverrides(expandState)
})
/** visibleModels is p's own model list, filtered to matches while a query is active — p only appears in filteredProviders at all because it has one, so this is never empty in that case; it exists so the accordion shows WHICH models matched instead of re-showing all 70+ with no distinction. */
function visibleModels(p: AdminProviderView): string[] {
return hasQuery.value ? p.models.filter(modelMatches) : p.models
return hasQuery.value ? p.models.filter((m) => modelMatches(p.name, m)) : p.models
}
/**
* providerModelsId is the id shared by a provider's chevron button
* (aria-controls, only set while expanded — see the template) and its
* expanded content row (id). Provider names are not escaped before this
* template-literal interpolation: buildAdapters (providers.go) rejects
* any name that does not match configNamePattern
* (`^[a-zA-Z0-9._-]+$`, providers.go) before this app ever sees it, so
* no provider name this endpoint can return contains a character an
* HTML id/attribute value needs escaped.
*/
function providerModelsId(name: string): string {
return `provider-models-${name}`
}
function clearQuery(): void {
@@ -227,18 +268,34 @@ function clearQuery(): void {
no providers match &quot;{{ modelQuery }}&quot;
</TableEmpty>
<template v-for="p in filteredProviders" :key="p.name">
<TableRow
role="button"
tabindex="0"
class="cursor-pointer select-none hover:bg-accent/50"
:aria-expanded="expandedProviders.has(p.name)"
:aria-controls="`provider-models-${p.name}`"
@click="toggleProvider(p.name)"
@keydown.enter.prevent="toggleProvider(p.name)"
@keydown.space.prevent="toggleProvider(p.name)"
>
<TableRow class="cursor-pointer select-none hover:bg-accent/50" @click="toggleProvider(p.name)">
<TableCell class="font-medium">
<span class="flex items-center gap-2">
<!--
A real <button>, not role="button" on the <tr>: a <tr>
carries table-row AT semantics (a screen reader
announces it as part of the table's row/column
structure), and overriding that to "button" strips
those semantics from the whole row — exactly the kind
of ARIA-over-native mistake the "semantics first"
rule warns against. The button lives in the natural
host, the chevron+name span, and needs no click
handler of its own: its native click (mouse, or Enter/
Space while focused — free, standard <button>
behavior, no keydown handling to write) bubbles up to
the row's own @click above, so exactly one place
(the row) ever runs the actual toggle. aria-controls
is set ONLY while expanded (not unconditionally): the
content row is v-if, not v-show (Lazy rendering — see
the models div below), so an id it would point to
while collapsed does not exist in the DOM yet, and
ARIA requires aria-controls name an id that exists.
-->
<button
type="button"
class="flex items-center gap-2 rounded focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring"
:aria-expanded="expandedProviders.has(p.name)"
:aria-controls="expandedProviders.has(p.name) ? providerModelsId(p.name) : undefined"
>
<FontAwesomeIcon
:icon="faChevronRight"
class="size-3 shrink-0 text-muted-foreground transition-transform duration-150"
@@ -246,7 +303,7 @@ function clearQuery(): void {
aria-hidden="true"
/>
{{ p.name }}
</span>
</button>
</TableCell>
<TableCell class="text-muted-foreground">{{ p.type }}</TableCell>
<TableCell class="text-muted-foreground">{{ p.baseUrl }}</TableCell>
@@ -254,7 +311,7 @@ function clearQuery(): void {
<TableCell class="text-muted-foreground">{{ formatTimestamp(p.lastRefresh) }}</TableCell>
<TableCell class="text-destructive">{{ p.lastErr ?? '' }}</TableCell>
</TableRow>
<TableRow v-if="expandedProviders.has(p.name)" :id="`provider-models-${p.name}`">
<TableRow v-if="expandedProviders.has(p.name)" :id="providerModelsId(p.name)">
<TableCell colspan="6" class="whitespace-normal bg-muted/30">
<div v-if="visibleModels(p).length" class="flex flex-wrap gap-1.5 py-1">
<ModelChip v-for="m in visibleModels(p)" :key="m" :id="routableModelId(p.name, m)" />
+81
View File
@@ -0,0 +1,81 @@
/**
* 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()
}