* fix(explorer): label the graph view control "Focus"
The control read "Focused" before it was ever activated, which describes a
state the selection had already reached rather than the action available.
The view mode value, tooltip, enablement and active styling are unchanged.
The legend e2e drove this button by its accessible name, so the selector
moves with the label; it now also asserts the visible text, the
selection-dependent enablement and the active state.
Closes#1551
Co-Authored-By: Claude Code <noreply@anthropic.com>
* fix(explorer): name the control in the grouped-selection hint, and trim the test
"Activate Focused mode" instructed the reader to press a control that no
longer carries that name. The surrounding strings describe the mode itself,
which is still called focused, so they stay.
Drop the label and active-state assertions from the colour-legend test: the
getByRole locator already fails when the accessible name is wrong, and the
rest belonged to the control's contract rather than to the legend's.
Co-Authored-By: Claude Code <noreply@anthropic.com>
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
* fix(explorer): resolve ontology ownership on the backend
Which registered ontology owns an entity was decided twice: the backend
applies nested-namespace boundaries, while the Ontology Editor did a bare
prefix match. The two had already drifted, so a deep link to an entity in an
unregistered nested namespace selected the parent ontology whose /graph
response excludes that entity, and the selection silently failed.
/api/ontology/entity now returns owning_ontology, resolved with the same rule
the graph endpoint filters by, and the editor prefers it. The frontend
namespace guess stays as the fallback for a missing verdict, documented as
non-authoritative.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(explorer): keep the backend's no-owner verdict authoritative
Review follow-up: loadOntologyEntityOwner collapsed the backend's
explicit owning_ontology: null into undefined, re-activating the
namespace prefix guess for exactly the unregistered-nested-namespace
case this PR exists to fix. The owner verdict is now three-state
(owner / authoritative none / unavailable) and resolveEditorOntology
in the model suppresses inference on an authoritative none; only an
unavailable verdict may fall back. Model tests pin all three states.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(explorer): trust the backend to send owning_ontology
The Explorer bundle ships in the same wheel as the route that emits this
field, so the legacy-response branch could never run. Dropping it lets
the type say what the wire actually carries, leaving undefined to mean
only what it should: the request failed.
Note why the endpoint derives its ontology-URI set inline rather than
calling _known_ontology_uris, so the next reader does not consolidate a
graph scan back in.
Co-Authored-By: Claude Code <noreply@anthropic.com>
* fix(explorer): stop the registry default overriding a no-owner verdict
resolveEditorOntology returned string | undefined, so the caller wrote
`resolved || entries[0]?.uri` and an authoritative "nothing owns this
entity" fell straight through to an arbitrary registry entry. When that
entry happened to be the parent, the deep link opened the parent whose
graph excludes the entity — the bug the verdict exists to prevent. Only
the ordering of the registry in the earlier test hid it.
It now returns a union: unowned and unresolved both mean "no ontology to
open" but the editor treats them oppositely, so collapsing them with ||
is a type error rather than a silent regression. An unowned entity is
reported on the canvas instead of quietly opening the wrong ontology.
Also:
- /entity resolves ownership through _known_ontology_uris, the same
helper /graph uses, instead of deriving it from a get_nodes scan capped
at 999,999. Past that cap the set was silently truncated and the two
endpoints could disagree about who owns a node.
- _resolve_owning_ontology does one pass over the candidates rather than
one pass per candidate, each rescanning the whole set: 516us -> 9us at
50 ontologies, 125ms -> 138us at 800, same answers throughout. A test
pins it against _node_belongs_to_ontology so the hand-rolled version
cannot drift from the membership rule it has to mirror.
- An explicit scheme_uri is honoured even when the registry does not list
it, on both sides. Discarding it and guessing by namespace answered a
question nobody asked; an unregistered owner now surfaces as an
explicit error from /graph instead.
- A missing owning_ontology field reads as "no verdict", not as the
authoritative "nothing owns this". That claim now suppresses selection
outright, so it must not be inferred from an absent field.
Co-Authored-By: Claude Code <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
Co-authored-by: Sameer Kadam <sameerkadam@Mac.lan>
The deep-link protocol introduced in #1278 lived as bare "ontologyTab" /
"ontologyEntity" literals in five places across three files, each with its own
URLSearchParams plumbing and try/catch. Nothing tied the pieces together: in
particular the rule that a selection written under one ontology must be cleared
when the active ontology changes — otherwise a reload resolves the stale entity
and jumps back to the old ontology — was a comment at one call site with no
mechanism behind it.
ontologyUrlState.ts now owns the parameter names as private constants and
exposes the protocol as intent-named operations, with the write/clear pairing
documented where both halves live. Parsing and serialization are pure functions
over a search string, so they are covered by tests without a DOM; the window
and history.replaceState interaction stays in thin shells.
Absent parameters still read as undefined while blank ones read as empty
strings, which preserves the differing presence checks the workspace shell and
the tab selector each relied on.
One behavior change, inherited from all five original call sites: writing the
query string dropped any URL fragment, because replaceState with a bare "?..."
replaces the whole tail. updateSearch now carries window.location.hash across,
which fixes it for every writer at once — this module is the only place that
knows how the URL is written, so it is the only place the fix belongs.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
TimelinePanel fell back to a hardcoded 2030-01-01 whenever
/api/temporal/bounds reported max: null, then placed the playhead at the
midpoint of that fabricated window. session.get_temporal_bounds leaves max
open for any graph whose nodes carry valid_from instants and no
valid_until, so this was the normal response shape rather than bad data:
the header advertised a range no data supported and the workspace's first
/api/temporal/snapshot request asked about a time years ahead of the
present.
The fallback is now a `now` captured once per mount, and defaultTime is
that same `now` clamped into the range, so the initial snapshot describes
the current state. Three settings tuned for the fictional ~60-year window
follow from it: zoomMin drops from a year to a day, the timeAxis/format
pinning to 5-year ticks is removed so vis-timeline picks a granularity for
the real span, and the fixed 6-month play step becomes span/60 with a
one-day floor, which keeps a play-through at roughly 60 frames whether the
graph covers months or a decade.
The bound and step arithmetic moves into temporalScrubberBounds.ts so it
can be asserted directly, alongside the existing temporalLifecycle
predicate tests.
Closes#1536
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
Replace the static biomedical category key in Explorer with a live node color legend derived from the active display graph, sharing the canvas's baseColor -> color -> theme fallback resolver.
- Preserve original semantic colors on focused display clones via `semanticBaseColor` so interaction styling (selection, path, neighbor highlights) does not bleed into the legend.
- Suppress the semantic legend while distance visualization modes are active.
- Refactor plugin fallback legend panels to use the shared legend builder with composite (group, color) keys.
- Consolidate duplicate `"test:graph-workspace"` scripts in `explorer/package.json`, recovering 26 previously shadowed tests across markdown and explorer capability suites.
- Add `tests/graphColorLegend.test.ts` to workspace test runs and wire `test:graph-legend-e2e` into `.github/workflows/ci.yml`.
- Closes#1479
* feat(explorer): complete ARIA tab pattern for markdown viewer (#1117)
The Preview/Source controls exposed role="tablist"/role="tab"/aria-selected
but never connected to the content they switch, so assistive technology could
not tell which panel the tabs controlled, and the tablist was two separate Tab
stops with no arrow-key navigation.
Wire the full pattern:
- ids from useId(), matching the GraphWorkspace search combobox, so two viewers
mounted at once cannot collide on hardcoded ids
- aria-controls on both tabs, role="tabpanel" + aria-labelledby on the panel
- roving tabindex, so the tablist is a single Tab stop
- Arrow Left/Right with wrapping, plus Home/End, all preventDefault'd
Two decisions worth recording.
Activation is manual (arrows move focus, Enter/Space selects) rather than the
automatic activation the APG suggests by default. The APG permits manual
activation where switching panels is expensive, and here it is: activating
Preview re-runs the whole markdown parse, measured at 385ms for a 1000-row GFM
table and 1433ms at 2000 rows while profiling #1118. Automatic activation would
freeze the main thread on every arrow keypress.
The panel wraps all three render branches, including the empty state, and both
tabs point aria-controls at that one id. Only the active view is ever rendered,
so per-tab panel ids would leave the inactive tab referencing an element absent
from the DOM -- and scoping the panel to the two content branches would dangle
the reference for empty nodes.
No visible focus style was added: index.css already applies a global
:focus-visible ring and tabBtnStyle does not suppress it.
Five tests cover the wiring, the empty-state branch, roving tabindex and id
uniqueness; each was confirmed to fail against the previous component. Keyboard
behaviour is not reachable from the SSR-based suite, so it was verified
separately in headless Chromium: Tab enters the tablist on the selected tab,
ArrowRight moves focus without activating, Enter and Space activate, ArrowRight
wraps, and Home/End work. All 71 tests pass and lint is unchanged.
* feat(explorer): complete markdown viewer tab accessibility
Complete the Preview/Source ARIA tab pattern with collision-safe IDs, tabpanel wiring, roving tabindex, and manual keyboard activation.
Use ref-based focus tracking to prevent keyboard navigation from triggering Markdown re-parsing, and synchronize the roving tabindex after React renders.
Add regression coverage for keyboard navigation, wrapping, ARIA labelling, and focus-state invariants.
* fix(explorer): move focusedModeRef resource-reset out of render phase
The render-phase ref write (focusedModeRef.current = defaultMode inside
the activeResourceKey !== resourceKey guard) correctly reset the ref but
triggered a react-hooks/refs lint error: refs must not be written during
render.
Replace with a useLayoutEffect([resourceKey, defaultMode]) that fires
synchronously before paint after every resource/defaultMode change. This
achieves the same invariant — focusedModeRef is reset before the no-deps
tabIndex-correction effect reads it — while satisfying the linter.
Lint: npx eslint MarkdownContentViewer.tsx -> 0 errors
Tests: npm run test:graph-workspace -> 107/107 pass
---------
Co-authored-by: Pravit Ampapathini <pravit.amp@gmail.com>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
Co-authored-by: Sameer Kadam <sameerkadam@Mac.lan>
The Ontology Hub editor selected a registered ontology but left the canvas empty, and opening an ontology deep link landed on the Welcome workspace instead of the editor. Two independent causes: the application shell ignored `ontologyTab`/`ontologyEntity` URL state at startup, and the editor loaded registry metadata but never fetched the selected ontology's schema nodes and structural edges. The backend now exposes a bounded schema subgraph for one ontology at `GET /api/ontology/graph?uri=...`, and the editor maps that response into React Flow nodes and edges with loading, error, selection, and layout handling.
Five things came out of review on the new endpoint and the editor that consumes it.
The edge selection originally included an edge whenever either its source or its target was a core node. That let a property owned by a completely unrelated ontology leak into the requested one just because its `rdfs:domain` or `rdfs:range` happened to point at one of the requested ontology's classes. Edges are now selected only when their source is a core node, so the requested ontology can still reference outward to external vocabulary, but nothing from an unrelated ontology gets pulled in the other direction.
The backend accepts both compact and full-IRI forms for node types (`owl:Class` and `http://www.w3.org/2002/07/owl#Class` are equivalent), but the frontend classifier only recognized the compact strings, so a full-IRI class or ontology node fell through to `"external"`, wrong panel, wrongly read-only. Classification moved into `ontologyEditorModel.ts` as `classifyNodeType`, which compacts known full IRIs before matching.
An ontology imported through the fallback RDF parser, one with no `owl:Ontology` or `skos:ConceptScheme` declaration, minted a synthetic registry URI but never created a matching graph node or set `scheme_uri` on the classes and properties it imported. `_node_belongs_to_ontology` had nothing to associate those nodes with, so `core_node_ids` ended up empty and the endpoint 404'd for a registered ontology that genuinely had data. The fallback parser now records that ownership and emits a matching `owl:Ontology` node whenever it has to synthesize a URI.
Nested namespaces that were never registered as their own ontology got silently absorbed into whichever parent prefix matched, in both directions: a fragment-delimited nested name (`<stem>/child#Term`) and a path-delimited one (`<stem>/child/Term`). The first fix only handled the fragment form; prefix ownership now only extends to names minted directly in the ontology's own namespace (`<stem>#Term` or `<stem>/Term`), and any further delimiter of either kind marks a nested vocabulary that isn't absorbed until it's registered or carries an explicit owner. Once registered, the nested namespace owns its own nodes as before.
Selecting a node in the editor writes `ontologyEntity=<id>` into the URL. Switching ontologies via the dropdown cleared the in-memory selection but left that parameter pointing at the old ontology, so a reload after switching could resolve the stale ID and jump back. The dropdown now clears the parameter on change.
Regression tests cover each fix directly: inward-edge exclusion, the full-IRI classification matrix, an end-to-end fallback-import test that forces the parser path and opens the resulting ontology, and a nested-namespace ownership matrix covering both delimiter forms in both the registered and unregistered case.
The nodes/edges fetch loops were throwing away the response body whenever the request returned a non-OK status.
Because of that, errors like a `503` caused by a missing `SEMANTICA_API_KEY` only showed up as:
`Fetch failed: 503`
even though the backend was already returning a more useful message in the response `detail`.
This change reads the JSON error body and includes `detail` in the thrown error when it's a string, so `GraphLoadingOverlay` can show the actual backend error to the user.
Closes#1256
fix(explorer): dedupe temporal snapshot requests and apply latest-wins
The temporal snapshot effect fetched /api/temporal/snapshot with no
idempotency or ordering guards. Upstream churn (timeline recreation
while bounds settle, play ticks resetting the playhead, drag events)
could re-request the same `at` repeatedly, and with variable network
latency an older position's response could land after a newer one's,
overwriting the active-node count, so the chip visibly lagged the
scrubber.
Add a small stateful guard module (temporalSnapshotGuards.ts) built
around a per-position cache, keyed by the debounced timestamp's
primitive millisecond value rather than the Date object, so upstream
object-identity churn cannot defeat the dedup on its own:
- at most one in-flight request per scrubber position, so identical
`at` values arriving while a request is pending are dropped instead
of firing a fresh fetch, breaking the idle/play polling loop;
- successful snapshots are cached per position and re-applied when the
scrubber returns to it (play wrap-around, back-scrubbing) without a
network round trip;
- a response is applied only while the scrubber is still on the
position it was requested for, so an out-of-order response can never
clobber a newer position's count;
- failed, cancelled, or superseded requests release their position so
it can be fetched again the next time it's visited, rather than
stalling it permanently;
- reset() drops all cached and in-flight state when the underlying
graph summary changes (reload/retry), since snapshots cached against
the previous graph no longer describe anything real. Keyed on the
summary query's data identity, which react-query keeps stable
(staleTime: Infinity plus structural sharing) unless the graph data
itself was replaced, so reset fires exactly on a real reload and not
on cosmetic re-renders.
The snapshot effect is wired through the guards end to end: begin()
returns either a fresh sequence number to fetch under or a cached
snapshot to reapply directly; the same shouldApply()/apply() gate
handles both the network and cached-reapply paths so they can't drift
apart; finish() runs from both the fetch's failure branch and its
cleanup function, so a cancelled or failed request is always retryable
on the next visit instead of leaving its position stuck in-flight.
16 unit tests cover dedup, independent positions, revisit re-apply,
play wrap-around, failure retry, stale-sequence protection (a late
response or a late release from a superseded request cannot act on a
newer request's position), reset-on-reload, and cache-bound eviction.
Closes#1128
Profiling the viewer in headless Chromium (real DOM, production React)
separated remark parse time, React commit time and DOM node count across
large-prose, large-code-block, deep-nested-list and GFM-table fixtures.
Two findings, one of which is fixed here.
1. Every re-render re-parsed the whole document and remounted the whole
subtree. remarkPlugins and the ~20-entry components map were inline
literals, so each render allocated fresh arrow components; React saw a new
element type per mapped tag and replaced the DOM rather than updating it. A
DOM-identity probe confirmed the remount on every fixture. Because
react-markdown runs the remark pipeline inside its own render, an unrelated
state change -- clicking Copy, toggling Preview/Source -- re-paid the full
parse. Measured 364ms for a 1000-row GFM table and 1121ms for 2000 rows.
Hoisting both props to module scope and memoising the rendered element on
rawContent drops re-render cost to ~0.1ms across every fixture and removes
the remount (DOM identity now survives). Initial mount and node switching
are unchanged, since those are genuine parses.
2. Initial parse of large GFM tables is quadratic and lives upstream in
remark-gfm: the same table text parses in 12.5ms without the plugin and
1156ms with it at 2000 rows. Not addressed here -- any mitigation is a
product decision and is tracked on the issue.
Note that document size is the wrong threshold for this: 562KB of prose parses
in 85ms while a 27KB GFM table takes 102ms. Row count, not bytes, predicts cost.
Rendered output is unchanged; the components map is moved verbatim. All 66
Explorer graph-workspace tests pass.
Co-authored-by: Pravit Ampapathini <pravit.amp@gmail.com>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
MarkdownContentViewer.tsx exported the isSafeUrl helper alongside the
component so it could be unit tested, which tripped
react-refresh/only-export-components.
Move the helper into a sibling pure module, markdownUrlSafety.ts,
following the existing GraphWorkspace convention for testable non-component
logic (graphAnalytics.ts, pluginRegistryPredicates.ts,
temporalLifecyclePredicates.ts). The function body is moved verbatim — the
scheme allowlist, protocol-relative rejection, whitespace-only guard and
malformed-URL handling are unchanged — so the existing URL-safety tests pass
untouched apart from the import path.
The component module now exports only its component and prop type, clearing
the lint error without any change to the lint configuration.
Co-authored-by: Pravit Ampapathini <pravit.amp@gmail.com>
Two findings from the Qodo review:
- Sigma's edge label renderer draws data.label, but the graph stores the
relationship type in edgeType — enabling renderEdgeLabels alone left
edges blank. The edgeReducer now maps edgeType onto label (suppressed for
hidden edges).
- renderEdgeLabels was hardcoded on with no way to disable it. It now
follows a new edgeLabelsEnabled entry in the Effects panel (default on),
wired through the existing GraphEffectToggle/GraphEffectsState plumbing,
so dense graphs get their label-free edges back.
Explorer was firing temporal requests before the graph even loaded.
When the backend is down, /api/graph/nodes fails but the temporal
bounds and snapshot effects didn't care , they fired anyway, off in
their own corner, ignoring whether the graph actually came up. Every
page load with no backend meant three failed requests instead of one,
and a scrubber that had nothing to scrub.
Added two small predicate functions and gated the temporal effects on
them. Basically: don't ask for time-based data until you know the
graph itself loaded. An empty graph still counts as loaded, so that
case isn't broken.
Confirmed with the backend down, before and after: three failing
requests down to one.
Fixes#982.
* fix(explorer): show a retryable error when the graph fails to load
The dependency-pre-bundle overlay had no failure path: on a fetch
error it kept rendering the last progress frame forever with no
retry. Route isError/error out of the load query, surface a real
error card with the underlying message, and let retry re-fetch
without a full page reload.
* fix(explorer): reflect real backend connectivity on the landing page
The status dot and 'System Online' text were static, so a dead
backend still looked healthy. Track checking/online/offline explicitly
and drive both off the same state so they can't disagree.
* feat(explorer): let search results be dismissed, round relevance scores
The results strip had no close affordance and stayed pinned until the
next search. Add a header row with a dismiss button, and round scores
to whole numbers instead of showing three decimals of a raw relevance
value nobody can act on.
* feat(explorer): add typeahead suggestions to graph search
Typing in the search box now debounces a query against the existing
search endpoint and shows a combobox dropdown, with arrow-key
navigation, Enter/click to jump straight to a node, and Escape to
dismiss. Previously nothing happened until the full form was
submitted.
* fix(explorer): abort stale typeahead requests and clear suggestions on error
Clearing the search box while a suggestion fetch was in flight never
aborted it, so a late response could reopen the dropdown with results
for a query that was no longer typed. A non-OK response also left
whatever suggestions were already on screen untouched instead of
clearing them. Abort on every effect cleanup (not just unmount) and
clear suggestions on any non-abort failure.
* docs(changelog): add entry for Explorer backend failure states fix
Documents the (#980, closes#977) fix in the Unreleased/Fixed section.
---------
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
- Wire the Explorer frontend's node --test suites (test:graph-store,
test:graph-workspace, and the new test:plugin-registry regression
test) into CI. Previously only `npm run build` ran, so none of the
frontend tests -- including this fix's own regression coverage --
executed anywhere except a contributor's local machine.
- Broaden the diagnostics dedup's structureLayer comparison to also
cover disabledReason/curveCount/bridgeCurveCount/backboneCurveCount,
not just cacheKey/lastDrawAt/enabled, so a disabledReason-only
transition doesn't leave the dev diagnostics panel stale.
- pluginRegistryPredicates.ts: consolidate 8-line JSDoc to 5 lines,
removing redundant detail that restated implementation mechanics
already obvious from the code.
- GraphWorkspace.tsx: shorten the lastScrubberMsRef comment from 5 lines
to 2; trim the handleDiagnosticsChange block comment by removing the
'rather than bailing out' implementation-alternative sentence; tighten
the distanceVisual inline comment.
- pluginRegistry.temporal.test.mjs: replace 17-line file-level JSDoc
with 9 lines focused on the invariant rather than the root-cause
narrative (already covered in pluginRegistryPredicates.ts); remove
two tsx loader implementation-detail comments; tighten two test-level
inline comments.
No logic, types, or test assertions changed. All 42 tests pass.
Two issues addressed:
1. Plugin-loading useEffect unnecessarily depended on temporalState.
After the #830 fix, no shouldLoad predicate reads temporalState, but
the effect's dep array still included it, causing extra re-runs on
every scrubber update. Removed temporalState from the dep array and
the shouldLoad call site. Made temporalState optional in the
LazyPluginRegistryEntry shouldLoad context type to match.
2. Regression test imported a local copy of shouldLoad instead of the
production predicate. Extracted all three shouldLoad predicates into
pluginRegistryPredicates.ts (pure module, no React/DOM dependencies),
wired GraphWorkspace.tsx to use the imported functions, and updated
the test to import and exercise the real production code via tsx.
Verified: introducing the old broken condition causes the test to fail;
the correct implementation passes all 7 assertions.
temporalDiffState.ts belongs to feat/793-temporal-diff-ui and should not
appear in the #830 diff. Remove it from this branch's tracked files.
Add the pluginRegistry.temporal.test.mjs regression test that covers the
shouldLoad fix committed in the main #830 commit (it was never committed).
Add test:plugin-registry script to package.json so the regression test
can be run via npm run test:plugin-registry.
Two independent render loops were causing the Temporal panel to remain
stuck on 'Loading temporal...' in npm run dev:
Loop 1 — diagnostics state churn (GraphWorkspace.tsx):
handleDiagnosticsChange unconditionally called setGraphDiagnosticsState
with a new object on every invocation. buildEffectAvailability (called
inside GraphCanvas's diagnostics useEffect) always returns a new object,
so setGraphDiagnosticsState was called on every effect run, creating a
cycle: setGraphDiagnosticsState graphDiagnosticsState new
diagnosticsSnapshot new pluginContext new handleInteractionStateChange
new GraphCanvas re-renders diagnostics effect fires again.
Fix: before calling setGraphDiagnosticsState, compare the incoming
diagnostics field-by-field against the last accepted snapshot via a ref
(lastDiagnosticsRef). All effectAvailability entries, edgeClasses.updatedAt,
structureLayer.cacheKey/lastDrawAt/enabled, and distanceVisual identity
must differ for a state update to proceed. The ref approach avoids
scheduling a re-render at all, rather than bailing out inside a functional
updater after the render has already been committed.
Loop 2 — scrubberTime churn (GraphWorkspace.tsx + GraphWorkspaceShell.tsx):
TimelinePanel.tsx calls onTimeChange(defaultTime) whenever its useEffect
re-runs. React 18 concurrent mode re-runs effects with structurally-new
Date objects for the same timestamp when speculative renders discard
useMemo caches, causing setScrubberTime to be called repeatedly with a
new Date that has the same millisecond value — triggering temporalState
churn, the diagnostics effect, and eventually the same loop.
Fix: wrap setScrubberTime in an onTimeChange useCallback that compares the
incoming time's millisecond value against the last sent value (via
lastScrubberMsRef). Redundant calls with the same timestamp are dropped
before reaching setScrubberTime. Stable useCallback identity also prevents
TimelinePanel's useEffect from re-firing solely due to prop identity churn.
Both fixes applied to GraphWorkspace.tsx and identically to
GraphWorkspaceShell.tsx which has the same pattern.
Verified:
- npm run dev: 0 'Maximum update depth exceeded' errors
- Temporal panel renders with real data in dev mode
- Effects and Neighbors panels unaffected
- npm run build + preview: identical behavior, 0 errors
- All 42 frontend tests pass (34 graph-workspace, 1 graph-store, 7 plugin-registry)
fixed qodo review
applyDiffHighlight/clearDiffHighlight were writing baseColor only to
graphStore.graph (the store singleton), but Sigma is constructed with
displayGraphRef.current and the nodeReducer reads attributes from that
instance. When the display graph is a derived copy (aggregated,
focused, or grouped view), the store write has no effect on the
currently-rendered frame -- sigma.scheduleRefresh() flushes the
reducer over the display graph, which did not receive the mutation.
Fix: introduce writeBaseColor(context, nodeId, color) which writes to
BOTH the store graph (so the color propagates into the next display
graph rebuild via aggregateDisplayGraph's shallow attribute copy) AND
context.displayGraph (the live Graph instance currently bound to
Sigma, so the change is visible in the current frame immediately).
The dg !== graph guard skips the display-graph write when they happen
to be the same object (non-aggregated full view), avoiding a redundant
double-write in that case.
Original baseColor is still captured from the store graph (the
authoritative source, since aggregateDisplayGraph copies from there),
so restore remains correct across all view modes.
Adds a Compare section to the existing Temporal Context panel
(temporalOverlayPlugin.tsx) that lets a user pick two ISO timestamps
and diff the graph's node set between them via the existing, previously
UI-less GET /api/temporal/diff backend route.
- New temporalDiffState.ts: typed fetch wrapper (fetchTemporalDiff)
matching the route's added_nodes/removed_nodes response shape.
- Diff results recolor affected nodes via baseColor (not
ringColor/haloColor -- traced and confirmed those are only read by
the sigma reducer for hovered/selected/path-state nodes and are
silently discarded for default-state nodes).
- Validates both timestamps are present, parseable, and from < to
before firing a request.
- Distinct idle/loading/error/empty/success states -- an empty diff
(no changes) is rendered as its own state, not as an error.
- Cancels any in-flight request via AbortController on re-submission
and on unmount; restores each highlighted node's original baseColor
(captured before overwrite, not cleared to a fallback default) on
both paths.
- Reuses existing theme tokens (GRAPH_THEME.palette.semantic[2],
ui.control.dangerText) and existing button/input/loading/error
visual patterns already established in this same plugins directory
and in GraphInspectorPanel.tsx, rather than introducing new styling.
The set-state-in-effect refactor inlined each initial-fetch effect as a
standalone `fetchInitial`, duplicating the logic of the existing
reload/fetchOverview/fetchRegistry/loadVersions callbacks instead of
reusing them (required, since eslint-plugin-react-hooks v7 flags calling
an outside setState-touching function directly from an effect body, even
through an async gap - verified via a local lint probe). The duplicates
dropped the setError/flashMsg calls the originals had, so a failed
initial page load in AlignmentsTab, KGOverviewTab, OntologyManager, and
VersionsTab now failed silently instead of showing an error - a
regression of the exact bug #767/#790 fixed for these same files.
Also fixes LineageDiagram only clearing nodes/edges when the new
activeId was falsy, leaving the previous lineage view's stale diagram
on screen while switching directly between two ids.
Previously retryCount never reset on success (removed in adb4613 to
avoid resetting mid-Suspense-fallback), so unrelated transient errors
across a session could permanently exhaust the 3-retry budget even
though each prior retry had actually recovered. Now the counter resets
via a short settle timer after a retry stays error-free, avoiding both
the premature-reset and never-reset failure modes.
KGOverviewTab dropped the nodes-fetch 207 warning whenever stats also
returned 207; HealthTab and AlignmentsTab still had the exact
silent-swallow pattern this PR set out to fix elsewhere in the same
folder. Also documents all of #790's fixes in the changelog.
Ensures network errors, API failures, and 207 Partial Success statuses are surfaced correctly to the user instead of failing silently in the frontend UI.