SIGN IN SIGN UP

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