feat(performance): identify and select the GPU behind the live readings (#1944)
* feat(performance): identify and select the GPU behind the live readings The Performance Screen owns every live GPU reading but never said which adapter produced them, and grouped navigation had no GPU selection control at all - the only one in the app lives in the classic Hardware Dashboard. A user with a discrete and an integrated GPU could neither tell which adapter the numbers described nor reach the other one. - The GPU instrument names its adapter, and offers a named switcher once there is more than one to choose between. - An explicit selection now wins over live-data availability: a selected adapter that reports nothing keeps the selection and says so, instead of silently falling back to another adapter's numbers under the same label. - That claim is withheld until some adapter has reported, so an empty map at startup reads as "not measured yet" rather than "unavailable". - Compact names the adapter behind its GPU row in the footer, where the mini monitor's row tracks have no space for a device name. - Labels drop only what cannot tell two adapters apart (trademark marks, a leading vendor word, a leading run of words every adapter repeats), and fall back to full names if shortening would make two identical. Closes #1806 * fix(performance): ask every live GPU map before calling an adapter silent Review found the honesty check itself was dishonest: it consulted only the usage and temperature maps, so an adapter reporting a fan speed or VRAM but neither usage nor temperature was declared "not reporting live readings" - and, because the note replaced the substats, the fan and VRAM values it did report were hidden under that claim. - The four live maps travel together as GpuLiveMaps, and every "did this adapter report" question consults all four. - The note is additive: it is added below whatever the adapter reported rather than taking its place. - An id the static fetch has not returned is named from the temperature or fan map instead of rendering a raw LUID as the adapter's name. - The selector is a labelled group of toggles, not a tablist: it has no tabpanel, roving tabindex, or arrow-key contract to back that role up. - The Compact footer string composes in the language files. * fix(performance): stop joining GPU identity across two id namespaces Codex review raised a P1 that I verified in the Rust sources: the getHardwareInfo inventory and the monitor stream key their GPUs in different namespaces on every platform. Windows NVIDIA reports the raw NVAPI id as GraphicInfo.id but samples as `nvapi:<id>`; macOS pairs `0x<registry_id>` with `iokit:<name>`; Linux pairs `card<n>` with the PCI BDF. The id spaces are disjoint, so unioning them rendered one physical GPU as two adapters, the inventory half of which reports nothing and was then declared silent. - The adapter list comes from the live side alone: every id the stream named, via a new gpuNamesAtom fed from the gpuName every sample already carries, plus any id only a value map knows. - The VRAM total, where the two sides genuinely have to meet, joins on the name - the same join the classic dashboard uses. The id lookup it had always missed, silently dropping the total. - useSelectedGpuPersistence no longer validates the stored id against the inventory, which it could never match. It also no longer overwrites the user's stored intent with an auto-selection (#1946). - CompactStrip no longer needs the static facts at all, so the one-shot IPC call it had gained is gone again. The e2e fixture used one id for both sources, which is what let this pass. It now uses distinct inventory and live ids like real hardware, and the selector asserts one control per physical adapter. Also addresses the CodeRabbit notes: the ADR now states the full fallback order, and the single-adapter label exposes the full name to assistive technology rather than relying on a title attribute. * test(performance): pin the GPU name fan-out and finish the Compact label Both from CodeRabbit's round-2 review. - The event listener now has a focused regression test for gpuNamesAtom, including a GPU that reports nothing but its own identity - the case the Performance unavailable state depends on. - The Compact footer keeps the full adapter name for assistive technology and hover, rather than only the shortened visible label. Footer entries gained a small shape so later entries can do the same. * fix(performance): keep the stored GPU and disambiguate identical cards Four findings from the round-3 review, all confirmed in the code. P1 - the persistence hook erased the preference it had just restored. The write-back effect runs in the same commit as hydration, where a ref flipped inside the hydration effect already reads true but the atom still holds its pre-hydration value. It persisted that stale null straight over the restored id. The gate is now state, so write-back is skipped until a later render, and a focused test covers it. P2 - two identical cards were indistinguishable. Both controls fell back to the same full name and the same accessible name, so the selector could not say which card was being chosen. Duplicated names now get an ordinal, applied before the full-name fallback so the label stays short. P2 - the VRAM total joined on a name that two identical cards share, so selecting the second one showed its usage against the first one's capacity. A duplicated name now disqualifies the join and the denominator is dropped rather than guessed. P2 - the name map was replaced wholesale each sample, so a provider that omitted one adapter from a single tick erased the only record that it exists, and the selector jumped to another GPU. Names are identity rather than readings, so the map merges. * fix(performance): check both sides of the VRAM name join, keep Compact's ordinal Two further round-4 findings, both confirmed. The ambiguity guard counted live adapters only, so an inventory holding two identically named cards while just one of them reports passed the check and `.find()` attached whichever capacity came first. The join now requires the name to be unique on both sides, with tests for each direction. Compact's accessible text used the raw name, which is exactly the string two identical cards share, while marking the ordinal-bearing visible label aria-hidden. Assistive technology heard a name that could not identify the card being reported; it now hears the ordinal-bearing label. * fix(dashboard): reconcile the shared GPU selection with the classic card Making a second adapter selectable on Performance turned a latent inconsistency into a reachable one. The Performance selector writes a live id into the shared selectedGpuIdAtom, but the classic card resolved that atom against the inventory by id - which never matches across the two namespaces - so it fell back to the first inventory entry. It then rendered that adapter's name, badge, and highlight next to the *selected* adapter's usage, source, and VRAM, which resolve through the live maps. Both directions now go through the name, and only while the name picks out exactly one entry: findInventoryGpu reads the shared selection back into an inventory entry, toLiveGpuId lets the classic selector write it in the namespace every reading is keyed by. gpuIdentity moves up to the hardware feature root now that both surfaces use it. The classic e2e asserted only the pressed state, so it passed throughout. It now asserts the rendered readings actually change, and fails without this commit. * feat(performance): attribute the Monitor graph and stop guessing twin cards Two round-6 findings. Monitor mounts only the usage graph, so the adapter behind its GPU series had nowhere to be named and no way to be changed - the Instrument Strip that normally carries that is not mounted there. The selector now sits in the Monitor toolbar whenever GPU is among the graph's display targets. The classic card fell back to the first inventory entry when the name join was ambiguous, which put back exactly the mislabelling the join was added to prevent, just for two identically named cards. With nothing selected the first entry is still the honest default, but a selection that cannot be resolved now claims no identity at all. Pairing the two sides by position would look plausible and is a guess: the inventory's enumeration order and the stream's are different enumerations. The three GPU surfaces now read their adapter list, effective adapter, and reporting state from one useGpuAdapters hook, so they cannot drift. * fix(performance): bind Monitor's graph to its named adapter, off the parent Two regressions from adding the Monitor selector. The selector named the effective adapter while UsageGraphPanel read graphicUsageHistoryAtom, which fell back to the first history whenever the selected key was missing. Selecting an adapter that reports a temperature but no usage therefore labelled the integrated GPU and graphed the discrete one. All three derived atoms now resolve the selection through the same getEffectiveGpuId rule the selectors use, so a valid selection with no readings yields nothing instead of another adapter's numbers. This also fixes the same borrowing in the classic Usage screen and dashboard. Calling useGpuAdapters from the Performance parent subscribed the whole screen to atoms the event listener rewrites every sample, so Panels rerendered its toolbar and every panel once a second. The subscription moved into a Monitor-only child. Both regressions have tests that fail without their fix. * fix(performance): report silent selections in Monitor, reconcile Classic's id Two of three round-8 findings; the third is the append-only live-map gap already tracked in #1945. Monitor shows only the graph, so an adapter selected while it reports nothing produced a named selection above a blank series with no explanation - the same state Panels labels. Monitor now renders the existing localized unavailable message beside its selector. The classic card is reachable before the first sample, when no live id exists yet, so a selection made there could only be an inventory id, which addresses no readings: the card highlighted the requested adapter while the graphs kept showing the first live one, and persistence carried the mismatch across restarts. The card now adopts the live id as soon as the stream names that adapter. * fix(performance): say why a Compact GPU row is dashed Compact showed a bare dash for a selected adapter that reports nothing, which reads identically to the not-yet-measured state at startup - the distinction Panels and Monitor both make. The footer, which already names the adapter, now carries the same localized unavailable message. * fix(performance): migrate stored inventory ids and explain all-null samples Two round-10 findings. Shipped versions' classic card wrote inventory ids into the selectedGpuId store key, and the reconciliation added last round lived inside GPUInfo - which grouped navigation never mounts. An upgrading user who had selected a non-first GPU would have had that choice silently ignored forever. The translation moves to useSelectedGpuPersistence, mounted for the whole app. A stored live id that is merely absent this session is still left alone. hasNoLiveGpuReadings required a value in one of the four maps as proof that sampling had happened, so a machine whose only GPU reports its name and no values - an Intel adapter whose PDH usage query fails - kept every map empty and never got the explanation, only blanks. A detected adapter is now itself that proof. * docs(adr): tidy two ADR 0016 paragraphs * fix(performance): fetch the inventory from the GPU id migration itself Round-11 finding, confirmed: the app-level migration reads hardwareInfo.gpus, but the only caller of the one-shot inventory fetch on the Performance screen is InstrumentStrip - which a restart into a persisted Monitor or Compact view never mounts. The stored legacy id then stayed untranslated and those views fell back to the first live GPU. The migration now triggers the fetch itself, and only when it actually has an id to translate: it waits for the first sample, since before that every id looks unresolved, so steady-state startups whose stored id is already live cost no extra IPC. Tests cover both directions. * fix(performance): drop the lint suppression on the migration effect lint:ci rejected the biome-ignore because it sat above the dependency array rather than the hook call. The suppression is not needed at all: `init` joins the dependency list, and a ref keeps the inventory request single-shot, since init is a new function every render and would otherwise re-trigger the effect while the fetch is still in flight.
S
shm committed
6f003666c24e9d314eaf60097269d3cc62eb0c90
Parent: bde242f
Committed by GitHub <noreply@github.com>
on 8/22/2026, 2:35:05 AM