Skip to content

Commit 38c2413

Browse files
Merge pull request #58 from Demonstrandum/cursor/run-selection-persistence-348f
Run selection persistence
2 parents 3537982 + aa86eae commit 38c2413

9 files changed

Lines changed: 489 additions & 54 deletions

File tree

AGENTS_DEV.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,8 @@ The frontend persists state to browser localStorage. This is the core mechanism
249249
| `_tb_profile.*` | Saved profile data | JSON `ProfileData` | Profile effects |
250250
| `_tb_profiles_index` | List of profile names | JSON string array | Profile effects |
251251
| `_tb_active_profile` | Currently active profile name | Plain string | Profile effects |
252-
| `_tb_run_selection.v1` | Run visibility states | `{version: 1, runSelection: [[id, bool], ...]}` | Runs effects |
252+
| `_tb_run_selection.v1` | Run visibility states (NgRx) | `{version: 1, runSelection: [[id, bool], ...]}` | Runs effects |
253+
| `runSelectionState` | Run visibility states (Polymer) | Base64-encoded JSON `{runName: bool, ...}` | tf-runs-selector |
253254
| `_tb_run_colors.v1` | Color overrides | `{version: 1, runColorOverrides: [...], groupKeyToColorId: [...]}` | Runs effects |
254255
| `_tb_tag_filter.v1` | Tag filter regex | `{value: string, timestamp: number}` | Metrics effects |
255256
| `_tb_axis_scales.v1` | Axis scales | `{version: 1, yAxisScale?: string, xAxisScale?: string}` | Metrics effects |
@@ -260,6 +261,7 @@ The frontend persists state to browser localStorage. This is the core mechanism
260261
Important behaviors:
261262

262263
- When loading run selection from localStorage, if **all** runs would be hidden, the selection is discarded and all runs default to visible.
264+
- Run selection is stored in two formats: `_tb_run_selection.v1` (NgRx, used by time-series dashboard) and `runSelectionState` (Polymer, used by old-style plugin dashboards). The NgRx effects write both formats to keep them in sync, and fall back to the Polymer format when the NgRx format is empty.
263265
- Tag filter persistence uses timestamps: user-set values override profile defaults.
264266
- Pins are synced to localStorage both when saving profiles and when pinning/unpinning cards directly.
265267
- Section expansion state is restored from localStorage before tag metadata loads, so the auto-expand-first-2-groups default only applies on truly fresh sessions with no persisted state.
@@ -488,6 +490,7 @@ If no persisted state exists and no profile specifies `expandedTagGroups`, the d
488490
| Issue | Status | Description |
489491
| ----- | ----------- | -------------------------------------------------------------------------------------- |
490492
| #25 | Implemented | Shift-select runs to toggle a range (shift+click to select a contiguous range of runs) |
493+
| #53 | Implemented | Run selection persistence across plugin dashboards and page reloads |
491494

492495
---
493496

tensorbored/components/tf_color_scale/colorScale.ts

Lines changed: 34 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,40 +16,42 @@ import * as d3 from 'd3';
1616
import {BaseStore} from '../tf_backend/baseStore';
1717
import {experimentsStore} from '../tf_backend/experimentsStore';
1818
import {runsStore} from '../tf_backend/runsStore';
19-
import {standard} from './palettes';
2019

21-
// Example usage:
22-
// runs = ["train", "test", "test1", "test2"]
23-
// ccs = new ColorScale();
24-
// ccs.domain(runs);
25-
// ccs.getColor("train");
26-
// ccs.getColor("test1");
20+
/**
21+
* Read the run-name → hex-color map from NgRx (exposed on window by
22+
* RunsEffects). This is the single source of truth used by time-series.
23+
*/
24+
function readColorMap(): Record<string, string> {
25+
const live = (window as any).__tbRunColorMap as
26+
| Record<string, string>
27+
| undefined;
28+
if (!live) {
29+
throw new Error('Missing run color map on window.__tbRunColorMap');
30+
}
31+
return live;
32+
}
33+
2734
export class ColorScale {
2835
private identifiers = d3.map();
29-
/**
30-
* Creates a color scale with optional custom palette.
31-
* @param {Array<string>} palette The color palette to use, as an
32-
* Array of hex strings. Defaults to the standard palette.
33-
*/
34-
constructor(private readonly palette: string[] = standard) {}
35-
/**
36-
* Set the domain of strings.
37-
* @param {Array<string>} strings - An array of possible strings to use as the
38-
* domain for your scale.
39-
*/
36+
4037
public setDomain(strings: string[]): this {
4138
this.identifiers = d3.map();
42-
strings.forEach((s, i) => {
43-
this.identifiers.set(s, this.palette[i % this.palette.length]);
39+
// Module-level initialization runs before the NgRx bridge seeds
40+
// window.__tbRunColorMap. During that phase the run domain is empty.
41+
// Keep strict behavior for non-empty domains only.
42+
if (strings.length === 0) {
43+
return this;
44+
}
45+
const stored = readColorMap();
46+
strings.forEach((s) => {
47+
if (stored[s] === undefined) {
48+
throw new Error(`Missing run color for "${s}" in shared color map`);
49+
}
50+
this.identifiers.set(s, stored[s]);
4451
});
4552
return this;
4653
}
47-
/**
48-
* Use the color scale to transform an element in the domain into a color.
49-
* @param {string} The input string to map to a color.
50-
* @return {string} The color corresponding to that input string.
51-
* @throws Will error if input string is not in the scale's domain.
52-
*/
54+
5355
public getColor(s: string): string {
5456
if (!this.identifiers.has(s)) {
5557
throw new Error(`String ${s} was not in the domain.`);
@@ -58,21 +60,19 @@ export class ColorScale {
5860
}
5961
}
6062

61-
/**
62-
* A color scale of a domain from a store. Automatically updated when the store
63-
* emits a change.
64-
*/
6563
function createAutoUpdateColorScale(
6664
store: BaseStore,
6765
getDomain: () => string[]
6866
): (runName: string) => string {
6967
const colorScale = new ColorScale();
70-
function updateRunsColorScale(): void {
68+
function update(): void {
7169
colorScale.setDomain(getDomain());
7270
}
73-
store.addListener(updateRunsColorScale);
74-
updateRunsColorScale();
75-
return (domain) => colorScale.getColor(domain);
71+
store.addListener(update);
72+
// Re-read colors when the NgRx store subscription updates them.
73+
window.addEventListener('tb-run-color-map-changed', update);
74+
update();
75+
return (runName) => colorScale.getColor(runName);
7676
}
7777

7878
export const runsColorScale = createAutoUpdateColorScale(runsStore, () =>

tensorbored/components/tf_paginated_view/tf-category-paginated-view.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,37 @@ import {
2727
} from './paginatedViewStore';
2828
import {TfDomRepeat} from './tf-dom-repeat';
2929

30+
const TAG_GROUP_EXPANSION_KEY = '_tb_tag_group_expansion.v1';
31+
32+
/**
33+
* Read the persisted expansion map (shared with the NgRx time-series
34+
* dashboard). Returns null when nothing is stored.
35+
*/
36+
function readExpansionMap(): Map<string, boolean> | null {
37+
const raw = window.localStorage.getItem(TAG_GROUP_EXPANSION_KEY);
38+
if (!raw) return null;
39+
try {
40+
const parsed = JSON.parse(raw) as {
41+
version?: number;
42+
groups?: Array<[string, boolean]>;
43+
};
44+
if (parsed.version !== 1 || !Array.isArray(parsed.groups)) return null;
45+
return new Map(parsed.groups);
46+
} catch {
47+
return null;
48+
}
49+
}
50+
51+
function writeExpansionEntry(name: string, opened: boolean): void {
52+
const map = readExpansionMap() ?? new Map<string, boolean>();
53+
map.set(name, opened);
54+
window.localStorage.setItem(
55+
TAG_GROUP_EXPANSION_KEY,
56+
JSON.stringify({version: 1, groups: Array.from(map.entries())})
57+
);
58+
window.dispatchEvent(new CustomEvent('tb-tag-group-expansion-changed'));
59+
}
60+
3061
@customElement('tf-category-paginated-view')
3162
class TfCategoryPaginatedView<
3263
CategoryItem extends {}
@@ -375,6 +406,9 @@ class TfCategoryPaginatedView<
375406
}
376407
_togglePane() {
377408
this.opened = !this.opened;
409+
if (this.category?.name) {
410+
writeExpansionEntry(this.category.name, this.opened);
411+
}
378412
}
379413

380414
@observe('opened')
@@ -418,17 +452,53 @@ class TfCategoryPaginatedView<
418452
const {type, compositeSearch} = this.category.metadata as any;
419453
return compositeSearch && type === CategoryType.SEARCH_RESULTS;
420454
}
455+
private _expansionChangedListener: (() => void) | null = null;
456+
421457
ready() {
422458
super.ready();
423-
this.opened = this.initialOpened == null ? true : this.initialOpened;
459+
const stored = this.category?.name ? readExpansionMap() : null;
460+
if (
461+
stored !== null &&
462+
this.category?.name &&
463+
stored.has(this.category.name)
464+
) {
465+
this.opened = stored.get(this.category.name)!;
466+
} else {
467+
this.opened = this.initialOpened == null ? true : this.initialOpened;
468+
}
424469
this._limitListener = () => {
425470
this.set('_limit', getLimit());
426471
};
427472
addLimitListener(this._limitListener);
428473
this._limitListener();
474+
475+
this._expansionChangedListener = () => this._applyStoredExpansion();
476+
window.addEventListener(
477+
'tb-tag-group-expansion-changed',
478+
this._expansionChangedListener
479+
);
429480
}
481+
430482
detached() {
431483
removeLimitListener(this._limitListener);
484+
if (this._expansionChangedListener) {
485+
window.removeEventListener(
486+
'tb-tag-group-expansion-changed',
487+
this._expansionChangedListener
488+
);
489+
this._expansionChangedListener = null;
490+
}
491+
}
492+
493+
_applyStoredExpansion() {
494+
const stored = this.category?.name ? readExpansionMap() : null;
495+
if (
496+
stored !== null &&
497+
this.category?.name &&
498+
stored.has(this.category.name)
499+
) {
500+
this.opened = stored.get(this.category.name)!;
501+
}
432502
}
433503

434504
@observe(

tensorbored/components/tf_runs_selector/tf-runs-selector.ts

Lines changed: 118 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,90 @@ import {runsColorScale} from '../tf_color_scale/colorScale';
2424
import '../tf_dashboard_common/tf-multi-checkbox';
2525
import '../tf_wbr_string/tf-wbr-string';
2626

27+
const RUN_SELECTION_KEY = '_tb_run_selection.v1';
28+
29+
/**
30+
* Read the NgRx run-selection localStorage entry and return it as a
31+
* bare-run-name → boolean map suitable for tf-multi-checkbox.
32+
*/
33+
function readSelectionFromLocalStorage(): Record<string, boolean> {
34+
const raw = window.localStorage.getItem(RUN_SELECTION_KEY);
35+
if (!raw) return {};
36+
try {
37+
const parsed = JSON.parse(raw) as {
38+
version?: number;
39+
runSelection?: Array<[string, boolean]>;
40+
};
41+
if (parsed.version !== 1 || !Array.isArray(parsed.runSelection)) return {};
42+
const out: Record<string, boolean> = {};
43+
for (const [runId, selected] of parsed.runSelection) {
44+
const slashIdx = runId.indexOf('/');
45+
const name = slashIdx >= 0 ? runId.substring(slashIdx + 1) : runId;
46+
out[name] = selected;
47+
}
48+
return out;
49+
} catch {
50+
return {};
51+
}
52+
}
53+
54+
/**
55+
* Merge a bare-run-name selection map back into the NgRx localStorage
56+
* entry, preserving any run-IDs that we don't know about.
57+
*/
58+
function writeSelectionToLocalStorage(state: Record<string, boolean>): void {
59+
const raw = window.localStorage.getItem(RUN_SELECTION_KEY);
60+
let existing: Array<[string, boolean]> = [];
61+
if (raw) {
62+
try {
63+
const parsed = JSON.parse(raw) as {
64+
version?: number;
65+
runSelection?: Array<[string, boolean]>;
66+
};
67+
if (parsed.version === 1 && Array.isArray(parsed.runSelection)) {
68+
existing = parsed.runSelection;
69+
}
70+
} catch {
71+
// ignore
72+
}
73+
}
74+
75+
// Build a set of bare names we're about to write so we can detect
76+
// which existing entries to update vs. keep as-is.
77+
const updatedIds = new Set<string>();
78+
const result: Array<[string, boolean]> = [];
79+
80+
for (const [runId, _] of existing) {
81+
const slashIdx = runId.indexOf('/');
82+
const name = slashIdx >= 0 ? runId.substring(slashIdx + 1) : runId;
83+
if (name in state) {
84+
result.push([runId, state[name]]);
85+
updatedIds.add(runId);
86+
} else {
87+
result.push([runId, _]);
88+
updatedIds.add(runId);
89+
}
90+
}
91+
92+
// Add entries from `state` that weren't in `existing` (bare names).
93+
for (const [name, selected] of Object.entries(state)) {
94+
const alreadyCovered = existing.some(([runId]) => {
95+
const slashIdx = runId.indexOf('/');
96+
const n = slashIdx >= 0 ? runId.substring(slashIdx + 1) : runId;
97+
return n === name;
98+
});
99+
if (!alreadyCovered) {
100+
result.push([name, selected]);
101+
}
102+
}
103+
104+
window.localStorage.setItem(
105+
RUN_SELECTION_KEY,
106+
JSON.stringify({version: 1, runSelection: result})
107+
);
108+
window.dispatchEvent(new CustomEvent('tb-run-selection-changed'));
109+
}
110+
27111
@customElement('tf-runs-selector')
28112
class TfRunsSelector extends LegacyElementMixin(PolymerElement) {
29113
static readonly template = html`
@@ -115,8 +199,9 @@ class TfRunsSelector extends LegacyElementMixin(PolymerElement) {
115199

116200
@property({
117201
type: Object,
202+
observer: '_storeRunSelectionState',
118203
})
119-
runSelectionState: object = {};
204+
runSelectionState: object = readSelectionFromLocalStorage();
120205

121206
@property({
122207
type: String,
@@ -159,20 +244,44 @@ class TfRunsSelector extends LegacyElementMixin(PolymerElement) {
159244

160245
_envStoreListener: baseStore.ListenKey;
161246

247+
private _selectionChangedListener: (() => void) | null = null;
248+
private _syncingFromStorage = false;
249+
162250
override attached() {
251+
this._syncFromStorage();
252+
163253
this._runStoreListener = runsStore.addListener(() => {
164-
this.set('runs', runsStore.getRuns());
254+
this.set('runs', runsStore.getRuns().slice().sort());
165255
});
166-
this.set('runs', runsStore.getRuns());
256+
this.set('runs', runsStore.getRuns().slice().sort());
167257
this._envStoreListener = environmentStore.addListener(() => {
168258
this.set('dataLocation', environmentStore.getDataLocation());
169259
});
170260
this.set('dataLocation', environmentStore.getDataLocation());
261+
262+
this._selectionChangedListener = () => this._syncFromStorage();
263+
window.addEventListener(
264+
'tb-run-selection-changed',
265+
this._selectionChangedListener
266+
);
267+
}
268+
269+
private _syncFromStorage() {
270+
this._syncingFromStorage = true;
271+
this.set('runSelectionState', readSelectionFromLocalStorage());
272+
this._syncingFromStorage = false;
171273
}
172274

173275
override detached() {
174276
runsStore.removeListenerByKey(this._runStoreListener);
175277
environmentStore.removeListenerByKey(this._envStoreListener);
278+
if (this._selectionChangedListener) {
279+
window.removeEventListener(
280+
'tb-run-selection-changed',
281+
this._selectionChangedListener
282+
);
283+
this._selectionChangedListener = null;
284+
}
176285
}
177286

178287
_toggleAll() {
@@ -205,7 +314,10 @@ class TfRunsSelector extends LegacyElementMixin(PolymerElement) {
205314
return dataLocation && dataLocation.length > _dataLocationClipLength;
206315
}
207316

208-
// Run selection state and regex are no longer persisted to the URL hash.
209-
// Run selection is managed via localStorage (runs_effects.ts).
210-
// Regex filter is managed via Angular query params ('runFilter').
317+
_storeRunSelectionState() {
318+
if (this._syncingFromStorage) return;
319+
writeSelectionToLocalStorage(
320+
this.runSelectionState as Record<string, boolean>
321+
);
322+
}
211323
}

0 commit comments

Comments
 (0)