@adcops/autocore-react 3.3.83 → 3.3.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,7 @@ export type { AmsRole, AmsRoleRegistry } from './AmsProvider';
10
10
  export { AssetRegistryTable } from './AssetRegistryTable';
11
11
  export { AssetDetailView } from './AssetDetailView';
12
12
  export { CalibrationEntryDialog } from './CalibrationEntryDialog';
13
+ export { AssetEditDialog } from './AssetEditDialog';
13
14
  export { SubLocationPicker } from './SubLocationPicker';
14
15
  export { PlaceholderHealthPanel } from './PlaceholderHealthPanel';
15
16
  export { MissingAssetsBanner } from './MissingAssetsBanner';
@@ -118,7 +118,19 @@ export const NetworkPanel: React.FC<NetworkPanelProps> = ({ className }) => {
118
118
  }}>
119
119
  <div>
120
120
  <h3 style={{ margin: 0 }}>Network</h3>
121
- {activeWifi ? (
121
+ {/* Three states for the status line:
122
+ * - status hasn't loaded yet: "Loading…" — keeps
123
+ * the panel from flashing "No WiFi device" while
124
+ * the initial nw.list_interfaces is in flight.
125
+ * - status loaded, a wifi device exists: show its
126
+ * connection / state / IP.
127
+ * - status loaded, no wifi device: the genuine
128
+ * "No WiFi device detected" message. */}
129
+ {!net.statusLoaded ? (
130
+ <div style={{ fontSize: '0.875rem', color: '#9ca3af', marginTop: '0.25rem' }}>
131
+ Loading network status…
132
+ </div>
133
+ ) : activeWifi ? (
122
134
  <div style={{ fontSize: '0.875rem', color: '#9ca3af', marginTop: '0.25rem' }}>
123
135
  {activeWifi.state === 'connected'
124
136
  ? <>Connected to <code>{activeWifi.connection || '(unnamed)'}</code> on <code>{activeWifi.device}</code></>
@@ -60,6 +60,11 @@ export interface TestFieldDef {
60
60
  units?: string;
61
61
  required?: boolean;
62
62
  source?: string;
63
+ /** Optional display-time scale multiplier. `display = raw * scale`,
64
+ * default 1.0 = no conversion. Cycle Data and Results panels
65
+ * apply this when formatting numeric cells. Charts plot raw
66
+ * values (axis labels already carry units). Storage stays raw. */
67
+ scale?: number;
63
68
  }
64
69
 
65
70
  export interface ChartAxis { field?: string; column?: string; label?: string; }
@@ -127,6 +132,13 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
127
132
  const [rawOpen, setRawOpen] = useState(false);
128
133
  const [configOpen, setConfigOpen] = useState(false);
129
134
 
135
+ // Direct handle on the chart.js instance so the toolbar's reset-
136
+ // zoom button can call chart.resetZoom() — the zoom plugin's only
137
+ // imperative API. Customers like the wheel/pinch/drag zoom but
138
+ // can get lost in the chart with no obvious way back; the icon
139
+ // button in the chart's header row is the escape hatch.
140
+ const chartRef = useRef<any>(null);
141
+
130
142
  // Lazy-loaded blobs for the View Raw Data dialog. Fetched only
131
143
  // when the dialog opens, and re-fetched if the operator pins a
132
144
  // different run / cycle while the dialog is closed.
@@ -537,31 +549,63 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
537
549
  </span>
538
550
  </>
539
551
  )}
552
+ {/* Spacer pushes the reset-zoom button to the right
553
+ edge of the row, opposite the dropdown. The
554
+ pi-th-large icon mirrors a "view all" / "fit"
555
+ affordance — the chart.js zoom plugin calls
556
+ this resetZoom and we wire it on the chartRef
557
+ below. Customers wanted the affordance because
558
+ the chart's wheel/pinch zoom is easy to lose
559
+ track of mid-test. */}
560
+ <div style={{ flex: 1 }} />
561
+ <Button
562
+ icon="pi pi-th-large"
563
+ outlined
564
+ rounded
565
+ size="small"
566
+ onClick={() => chartRef.current?.resetZoom?.()}
567
+ disabled={!chartData}
568
+ tooltip="Reset chart zoom"
569
+ tooltipOptions={{ position: 'left' }}
570
+ aria-label="Reset chart zoom"
571
+ />
540
572
  </div>
541
573
  <div style={{ height: chartHeight, position: 'relative' }}>
542
574
  {isRawTraceView && traceFetch.loading &&
543
575
  <ChartOverlay>Loading raw data…</ChartOverlay>}
544
576
  {isRawTraceView && traceFetch.error &&
545
577
  <ChartOverlay>{traceFetch.error}</ChartOverlay>}
546
- {chartData && <Line data={chartData} options={chartOptions} />}
578
+ {chartData && <Line ref={chartRef} data={chartData} options={chartOptions} />}
547
579
  </div>
548
580
  </div>
549
581
 
550
582
  <div className="p-card" style={{ padding: '1rem' }}>
551
583
  <h3 style={{ marginTop: 0 }}>Cycle Data ({cycles.length})</h3>
552
- <DataTable
553
- value={cycles}
554
- scrollable
555
- scrollHeight={cycleTableHeight}
556
- virtualScrollerOptions={{ itemSize: 38 }}
557
- emptyMessage="No cycles yet."
558
- >
559
- {schema.cycle_fields.map(f => (
560
- <Column key={f.name} field={f.name}
561
- header={f.units ? `${f.name} (${f.units})` : f.name}
562
- body={(row) => formatCell(row[f.name], f.type)} />
563
- ))}
564
- </DataTable>
584
+ {/* Size-to-content for small runs (≤ CYCLE_VIRTUAL_THRESHOLD
585
+ rows) so the Results panel below isn't pushed off-screen
586
+ on tests with one or two cycles. Larger runs flip to
587
+ virtual scrolling against `cycleTableHeight` so a
588
+ 1000-cycle test doesn't render 1000 row elements at
589
+ once. Operators on test methods that always run a
590
+ fixed-size short cycle list see no scrollbar at all. */}
591
+ {(() => {
592
+ const useVirtual = cycles.length > CYCLE_VIRTUAL_THRESHOLD;
593
+ return (
594
+ <DataTable
595
+ value={cycles}
596
+ scrollable={useVirtual}
597
+ scrollHeight={useVirtual ? cycleTableHeight : undefined}
598
+ virtualScrollerOptions={useVirtual ? { itemSize: 38 } : undefined}
599
+ emptyMessage="No cycles yet."
600
+ >
601
+ {schema.cycle_fields.map(f => (
602
+ <Column key={f.name} field={f.name}
603
+ header={f.units ? `${f.name} (${f.units})` : f.name}
604
+ body={(row) => formatCell(row[f.name], f.type, f.scale)} />
605
+ ))}
606
+ </DataTable>
607
+ );
608
+ })()}
565
609
  </div>
566
610
 
567
611
  <div className="p-card" style={{ padding: '1rem' }}>
@@ -944,13 +988,22 @@ const ResultsGrid: React.FC<{ schema: TestFieldDef[]; values: any }> = ({ schema
944
988
  <div style={{ fontSize: '0.8em', color: 'var(--text-secondary-color)' }}>
945
989
  {f.name}{f.units ? ` (${f.units})` : ''}
946
990
  </div>
947
- <div>{formatCell(values[f.name], f.type)}</div>
991
+ <div>{formatCell(values[f.name], f.type, f.scale)}</div>
948
992
  </div>
949
993
  ))}
950
994
  </div>
951
995
  );
952
996
  };
953
997
 
998
+ /** Row count above which the Cycle Data table switches into virtual-
999
+ * scroll mode with a fixed `cycleTableHeight`. At or below the
1000
+ * threshold the table sizes to its content so the Results panel
1001
+ * below doesn't get pushed off-screen on short runs. 30 is enough to
1002
+ * comfortably show a typical hand-tuned test session without
1003
+ * scrolling but small enough that virtualization kicks in well
1004
+ * before performance becomes a concern. */
1005
+ const CYCLE_VIRTUAL_THRESHOLD = 30;
1006
+
954
1007
  const CHART_COLORS = [
955
1008
  '#4ea8de', '#f59e0b', '#22c55e', '#a855f7',
956
1009
  '#ef4444', '#14b8a6', '#eab308', '#ec4899',
@@ -977,8 +1030,19 @@ const leftAxisLabel = (v?: ChartView) =>
977
1030
  const rightAxisLabel = (v?: ChartView) =>
978
1031
  v?.y.filter(s => s.y_axis === 'right').map(seriesLabel).join(' / ') ?? '';
979
1032
 
980
- const formatCell = (v: any, type: string): string => {
1033
+ /**
1034
+ * Format one value for a cycle / results / config cell.
1035
+ *
1036
+ * `scale`, when present and != 1, is applied to numeric values so the
1037
+ * cell displays in operator units while storage stays raw. Mirrors
1038
+ * the convention used by AutoCoreTagContext: `display = raw * scale`.
1039
+ * Non-numeric values pass through unchanged.
1040
+ */
1041
+ const formatCell = (v: any, type: string, scale?: number): string => {
981
1042
  if (v === null || v === undefined) return '';
1043
+ if (scale && scale !== 1 && typeof v === 'number' && Number.isFinite(v)) {
1044
+ v = v * scale;
1045
+ }
982
1046
  if (type === 'f32' || type === 'f64') {
983
1047
  return typeof v === 'number' ? v.toFixed(4) : String(v);
984
1048
  }
@@ -43,8 +43,22 @@ export interface TestFieldDef {
43
43
  * the control program sees it; for non-source fields it's stashed
44
44
  * straight into stagedConfig. Operator edits override per-stage,
45
45
  * but the schema default never mutates — re-selecting the method
46
- * re-applies the default. */
46
+ * re-applies the default. **Authored in display units** when
47
+ * `scale` is set — the form divides by `scale` before writing to
48
+ * GM / stagedConfig so the underlying storage stays raw. */
47
49
  default?: any;
50
+ /** Optional unit-conversion multiplier. Mirrors AutoCoreTagContext:
51
+ * ```
52
+ * display = raw * scale
53
+ * raw = display / scale
54
+ * ```
55
+ * Example: backend stores degrees, operator enters revolutions →
56
+ * `scale: 0.00277778`. None / 1.0 = no conversion.
57
+ *
58
+ * Storage stays raw — the form scales only on input and display.
59
+ * Cycle and results values are scaled by the corresponding paths
60
+ * in TestDataView; the server scales CSV exports too. */
61
+ scale?: number;
48
62
  }
49
63
 
50
64
  export interface TestMethod {
@@ -104,6 +118,30 @@ const labelOf = (f: TestFieldDef): string => {
104
118
  return f.units ? `${base} [${f.units}]` : base;
105
119
  };
106
120
 
121
+ /**
122
+ * Two helpers for the display ↔ raw boundary on numeric fields.
123
+ *
124
+ * Convention: `display = raw * scale`, `raw = display / scale`. Matches
125
+ * AutoCoreTagContext. Default scale = 1.0 (no conversion). Non-numeric
126
+ * input values (string, null, undefined, NaN) pass through unchanged
127
+ * — the form has fields of other types that share the change handler.
128
+ *
129
+ * Storage (config map, GM, on-disk JSON) always holds the raw value.
130
+ * Only the inputs the operator looks at and the cells in TestDataView
131
+ * use the display value. This way scales can change in project.json
132
+ * without invalidating historical records.
133
+ */
134
+ const rawToDisplay = (raw: any, scale: number | undefined): any => {
135
+ if (!scale || scale === 1) return raw;
136
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) return raw;
137
+ return raw * scale;
138
+ };
139
+ const displayToRaw = (display: any, scale: number | undefined): any => {
140
+ if (!scale || scale === 1) return display;
141
+ if (typeof display !== 'number' || !Number.isFinite(display)) return display;
142
+ return display / scale;
143
+ };
144
+
107
145
  const hasDescription = (f: TestFieldDef): boolean =>
108
146
  typeof f.description === 'string' && f.description.length > 0;
109
147
 
@@ -265,14 +303,20 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
265
303
  if (field.name === 'sample_id') continue;
266
304
  if (field.default === undefined || field.default === null) continue;
267
305
  if (next === prev) next = { ...prev };
268
- next[field.name] = field.default;
306
+ // Schema defaults are authored in DISPLAY units (per
307
+ // the agreed convention) so the value the author reads
308
+ // in project.json matches the field's `units` label.
309
+ // Convert to raw before storing in stagedConfig / GM
310
+ // so the rest of the pipeline sees the canonical value.
311
+ const rawDefault = displayToRaw(field.default, field.scale);
312
+ next[field.name] = rawDefault;
269
313
  if (field.source) {
270
314
  // Mirror handleFieldChange: write to GM so the
271
315
  // control program sees the default. Errors here are
272
316
  // logged but non-fatal — the form still reflects
273
317
  // the default locally.
274
318
  void Promise.resolve()
275
- .then(() => write(field.source!, field.default))
319
+ .then(() => write(field.source!, rawDefault))
276
320
  .catch(e => console.error(
277
321
  `[TestSetupForm] Failed to seed default for ${field.name}:`, e));
278
322
  }
@@ -360,9 +404,13 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
360
404
  };
361
405
 
362
406
  const handleFieldChange = async (field: TestFieldDef, val: any) => {
363
- setConfig({ ...config, [field.name]: val });
407
+ // The operator typed a DISPLAY value; convert to RAW before
408
+ // storing or writing to GM. Non-numeric fields and identity
409
+ // scales pass through unchanged via the helper.
410
+ const rawVal = displayToRaw(val, field.scale);
411
+ setConfig({ ...config, [field.name]: rawVal });
364
412
  if (field.source) {
365
- try { await write(field.source, val); }
413
+ try { await write(field.source, rawVal); }
366
414
  catch (e) { console.error('Failed to write to source:', e); }
367
415
  }
368
416
  };
@@ -417,7 +465,13 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
417
465
  ) : isNum ? (
418
466
  <ValueInput
419
467
  label={undefined}
420
- value={config[field.name] != null ? Number(config[field.name]) : null}
468
+ // Storage is RAW; render the DISPLAY value so
469
+ // operator sees the units they configured. The
470
+ // change handler runs the inverse before
471
+ // committing back to storage.
472
+ value={config[field.name] != null
473
+ ? Number(rawToDisplay(Number(config[field.name]), field.scale))
474
+ : null}
421
475
  onValueChanged={(val) => handleFieldChange(field, val)}
422
476
  className={!valid ? 'p-invalid' : ''}
423
477
  />
@@ -11,12 +11,27 @@
11
11
  * (set `enabled` to false to short-circuit while
12
12
  * a cycle_scatter view is active).
13
13
  *
14
- * Keeping the fetch logic in one place means the two components can't
15
- * drift in how they discover cycles, default to "latest cycle", or
16
- * unwrap the per-cycle envelope.
14
+ * Live updates
15
+ * ------------
16
+ * The hook subscribes to the `tis.raw_data_added` broadcast and refreshes
17
+ * the cycle list whenever a new raw blob lands on disk for the matching
18
+ * (project, method, run, blob). When the operator is already viewing the
19
+ * latest cycle, the picker advances to the new latest and the chart
20
+ * re-paints — that's the customer-facing "chart updates each cycle"
21
+ * behaviour. When the operator has manually pinned an earlier cycle (e.g.
22
+ * examining cycle 3 while cycle 47 streams in), the picker stays put so
23
+ * the examination isn't interrupted.
24
+ *
25
+ * The hook intentionally suppresses `read_raw` until at least one cycle
26
+ * is known to be on disk for this slice. Otherwise the server returns
27
+ * "no raw_data file found" between `tis.start_test` and the first
28
+ * `add_raw_data` write, and the chart flashes an error overlay during
29
+ * what's really just "test just started, no data yet" — confusing for
30
+ * the operator. With the suppression the chart sits empty (or in a
31
+ * loading state) until the first raw_data_added broadcast arrives.
17
32
  */
18
33
 
19
- import { useContext, useEffect, useState } from 'react';
34
+ import { useCallback, useContext, useEffect, useRef, useState } from 'react';
20
35
  import { EventEmitterContext } from '../../core/EventEmitterContext';
21
36
  import { MessageType } from '../../hub/CommandMessage';
22
37
 
@@ -50,7 +65,7 @@ export interface UseRawCycleDataResult {
50
65
 
51
66
  export function useRawCycleData(opts: UseRawCycleDataOptions): UseRawCycleDataResult {
52
67
  const { projectId, methodId, runId, blobName, enabled } = opts;
53
- const { invoke } = useContext(EventEmitterContext);
68
+ const { invoke, subscribe, unsubscribe } = useContext(EventEmitterContext);
54
69
 
55
70
  const [cycles, setCycles] = useState<number[]>([]);
56
71
  const [selectedCycle, setSelectedCycle] = useState<number | null>(null);
@@ -59,14 +74,49 @@ export function useRawCycleData(opts: UseRawCycleDataOptions): UseRawCycleDataRe
59
74
  const [loading, setLoading] = useState(false);
60
75
  const [error, setError] = useState<string | null>(null);
61
76
 
77
+ // Track whether the user has explicitly pinned a cycle. When false,
78
+ // the live-follow path is free to advance the picker to the newest
79
+ // cycle as it arrives. Flipping to true on a manual pick freezes
80
+ // the picker so examining cycle 3 while cycle 47 streams in doesn't
81
+ // get yanked back to 47.
82
+ //
83
+ // Lives in a ref so updating it doesn't re-trigger any effect — it's
84
+ // a behavioural latch, not state the renderer cares about.
85
+ const userPinnedRef = useRef<boolean>(false);
86
+
87
+ /** Discover the per-cycle indices currently on disk for this slice.
88
+ * Returns the sorted-ascending list; never throws (a listing error
89
+ * is logged-as-empty rather than surfaced — the rendering layer
90
+ * has its own empty state). */
91
+ const listCycles = useCallback(async (): Promise<number[]> => {
92
+ if (!projectId || !methodId || !runId) return [];
93
+ try {
94
+ const resp: any = await invoke(
95
+ 'tis.list_raw' as any, MessageType.Request as any,
96
+ { project_id: projectId, method_id: methodId, run_id: runId } as any,
97
+ );
98
+ if (!resp?.success) return [];
99
+ const list: any[] = resp.data?.cycles ?? [];
100
+ return list
101
+ .filter(c => c?.name === blobName && typeof c?.cycle_index === 'number')
102
+ .map(c => c.cycle_index as number)
103
+ .sort((a, b) => a - b);
104
+ } catch {
105
+ return [];
106
+ }
107
+ }, [projectId, methodId, runId, blobName, invoke]);
108
+
62
109
  // Reset cycle state when the run identity (or blob) changes — the
63
110
  // new slice has its own cycle list and its own "latest" target.
111
+ // Clearing userPinnedRef too so the next slice is free to follow
112
+ // the latest cycle automatically.
64
113
  useEffect(() => {
65
114
  setCycles([]);
66
115
  setSelectedCycle(null);
67
116
  setRaw(null);
68
117
  setEnvelope(null);
69
118
  setError(null);
119
+ userPinnedRef.current = false;
70
120
  }, [projectId, methodId, runId, blobName]);
71
121
 
72
122
  // Cycle-list discovery. Runs ahead of the data fetch so the cycle
@@ -75,47 +125,86 @@ export function useRawCycleData(opts: UseRawCycleDataOptions): UseRawCycleDataRe
75
125
  if (!enabled || !projectId || !methodId || !runId) return;
76
126
  let cancelled = false;
77
127
  (async () => {
78
- try {
79
- const resp: any = await invoke(
80
- 'tis.list_raw' as any, MessageType.Request as any,
81
- { project_id: projectId, method_id: methodId, run_id: runId } as any,
82
- );
83
- if (cancelled || !resp?.success) return;
84
- const list: any[] = resp.data?.cycles ?? [];
85
- const indices = list
86
- .filter(c => c?.name === blobName && typeof c?.cycle_index === 'number')
87
- .map(c => c.cycle_index as number)
88
- .sort((a, b) => a - b);
89
- setCycles(indices);
90
- if (indices.length > 0) {
91
- setSelectedCycle(prev => prev ?? indices[indices.length - 1]);
92
- }
93
- } catch {
94
- // Listing failure is non-fatal — the data fetch below
95
- // still tries "latest" and the picker just stays empty.
128
+ const indices = await listCycles();
129
+ if (cancelled) return;
130
+ setCycles(indices);
131
+ if (indices.length > 0) {
132
+ setSelectedCycle(prev => prev ?? indices[indices.length - 1]);
96
133
  }
97
134
  })();
98
135
  return () => { cancelled = true; };
99
- }, [enabled, projectId, methodId, runId, blobName, invoke]);
136
+ }, [enabled, projectId, methodId, runId, blobName, listCycles]);
137
+
138
+ // Live updates: each new raw_data file landing on disk triggers a
139
+ // re-listing. The handler closes over `selectedCycle` via the
140
+ // functional setter and the live `cycles` snapshot, so we use refs
141
+ // to avoid re-subscribing every time those state values change.
142
+ const cyclesRef = useRef<number[]>(cycles);
143
+ cyclesRef.current = cycles;
144
+ const selectedRef = useRef<number | null>(selectedCycle);
145
+ selectedRef.current = selectedCycle;
146
+
147
+ useEffect(() => {
148
+ if (!enabled || !projectId || !methodId || !runId) return;
149
+ const onRawAdded = async (payload: any) => {
150
+ // Filter to the slice this hook instance is bound to.
151
+ // The server can also fire raw_data_added for the same
152
+ // run+method+project but a different blob name (rare, but
153
+ // legal); the blob-name check keeps us from refreshing
154
+ // for an unrelated dataset.
155
+ if (payload?.project_id !== projectId) return;
156
+ if (payload?.method_id !== methodId) return;
157
+ if (payload?.run_id !== runId) return;
158
+ if (payload?.name !== blobName) return;
159
+
160
+ const fresh = await listCycles();
161
+ if (fresh.length === 0) return;
162
+ setCycles(fresh);
163
+ // Auto-follow "latest" iff the operator hasn't manually
164
+ // moved away from it. The "was viewing the previous
165
+ // latest" check uses the snapshot taken via the ref so the
166
+ // closure stays stable.
167
+ const prevLatest = cyclesRef.current.length > 0
168
+ ? cyclesRef.current[cyclesRef.current.length - 1]
169
+ : null;
170
+ const newLatest = fresh[fresh.length - 1];
171
+ const onLatest = !userPinnedRef.current
172
+ && (selectedRef.current === null || selectedRef.current === prevLatest);
173
+ if (onLatest && newLatest !== selectedRef.current) {
174
+ setSelectedCycle(newLatest);
175
+ }
176
+ };
177
+ const id = subscribe('tis.raw_data_added', onRawAdded);
178
+ return () => { unsubscribe(id); };
179
+ }, [enabled, projectId, methodId, runId, blobName, listCycles, subscribe, unsubscribe]);
100
180
 
101
181
  // Lazy blob fetch — runs whenever identifiers / selectedCycle change.
182
+ // Suppressed entirely when no cycle is selected (e.g. test just
183
+ // started, no raw_data on disk yet): no point asking the server
184
+ // for a file that's known to not exist, and surfacing the resulting
185
+ // error would flash an unfriendly overlay over an otherwise valid
186
+ // "waiting for data" state.
102
187
  useEffect(() => {
103
188
  if (!enabled || !projectId || !methodId || !runId) {
104
189
  setRaw(null); setEnvelope(null); setLoading(false); setError(null);
105
190
  return;
106
191
  }
192
+ if (selectedCycle == null) {
193
+ setRaw(null); setEnvelope(null); setLoading(false); setError(null);
194
+ return;
195
+ }
107
196
  let cancelled = false;
108
197
  setLoading(true);
109
198
  setError(null);
110
199
  (async () => {
111
200
  try {
112
- const args: Record<string, any> = {
113
- project_id: projectId, method_id: methodId,
114
- run_id: runId, name: blobName,
115
- };
116
- if (selectedCycle != null) args.cycle_index = selectedCycle;
117
201
  const resp: any = await invoke(
118
- 'tis.read_raw' as any, MessageType.Request as any, args as any,
202
+ 'tis.read_raw' as any, MessageType.Request as any,
203
+ {
204
+ project_id: projectId, method_id: methodId,
205
+ run_id: runId, name: blobName,
206
+ cycle_index: selectedCycle,
207
+ } as any,
119
208
  );
120
209
  if (cancelled) return;
121
210
  if (resp?.success) {
@@ -134,7 +223,19 @@ export function useRawCycleData(opts: UseRawCycleDataOptions): UseRawCycleDataRe
134
223
  return () => { cancelled = true; };
135
224
  }, [enabled, projectId, methodId, runId, blobName, selectedCycle, invoke]);
136
225
 
137
- return { cycles, selectedCycle, setSelectedCycle, raw, envelope, loading, error };
226
+ // Public setter wraps setSelectedCycle and flips userPinnedRef so
227
+ // an operator's manual pick freezes the picker for the rest of the
228
+ // run. Re-mounting (new run) clears userPinnedRef in the
229
+ // identifier-change effect above.
230
+ const pickCycle = useCallback((n: number | null) => {
231
+ userPinnedRef.current = n != null;
232
+ setSelectedCycle(n);
233
+ }, []);
234
+
235
+ return {
236
+ cycles, selectedCycle, setSelectedCycle: pickCycle,
237
+ raw, envelope, loading, error,
238
+ };
138
239
  }
139
240
 
140
241
  /**