DataPanel wiring (Phase 1 of 3) — design spec

DataPanel wiring (Phase 1 of 3)

Date: 2026-05-03 Status: Draft — under review Tracks: BACKLOG.md:304 (“Wire DataPanel into App.tsx (or merge with FileBrowser sidebar)”) and the unfinished tail of 2026-05-01-rdata-upload-extraction-design.md §4.9.

1. Context

The RData upload-extraction feature added a DataPanel component with multi-binding tree rendering for .RData files (datasets / globals / opaque sections per file). The component has unit tests and is functional, but App.tsx never renders it — the existing sidebar uses FileBrowser only.

Today users can load(...) an RData file in their R code and the resulting data-load node + Run produces correct output. They cannot:

This spec wires DataPanel into the app and hooks up the Preview + Assign actions. It is intentionally Phase 1 of three: see §11.

2. Goal

Make the multi-binding .RData view of DataPanel visible and interactive in the app, with two working actions:

3. Non-goals (deferred to Phase 2/3)

4. Layout

The sidebar is a single 220 px column containing two stacked components: FileBrowser on top, DataPanel below. Both visible whenever they have content; each scrolls independently.

┌─ Sidebar (220 px) ─────────┐  ┌── 3-panel grid ──────────────┐
│ FILES (FileBrowser)        │  │  Code  │  Pipeline │ Results │
│   ▾ replication/           │  │        │  (DAG)    │         │
│     ● 01_clean.R   active  │  └────────┴───────────┴─────────┘
│     ● 02_model.R           │
│     ● charge_offs.RData    │
│     ● controls.csv         │
│     ● paper.pdf            │
├────────────────────────────┤
│ ▾ Data       2 files       │  ← DataPanel
│   ▾ ● charge_offs.RData ✓  │
│       Datasets             │
│         dat   [pre] [Assign]│
│         pdat  [pre] [Assign]│
│       Globals              │
│         n_max=42  [pre]    │
│       Opaque               │
│         model_results [pre]│
│   ● controls.csv   [pre]   │  ← Phase 1: no Assign on CSV rows
└────────────────────────────┘

Mockups: https://278007e6.interlyse-mockups.pages.dev (Option A panel).

Why “both panels visible” instead of merging or tabbing

FileBrowser and DataPanel answer different questions:

A file with 4 bindings is 1 row in FileBrowser (it’s one file) but 5 rows in DataPanel (1 group + 4 bindings). Hiding it from one view loses information either way. Data files in FileBrowser are already non-clickable rows (only .R files open as tabs), so the duplication isn’t actionable noise — it’s the file-tree mirror doing its job.

Mode behavior

Future affordances (out of scope)

The user has identified three sidebar refinements as long-term goals not in this phase:

These are deliberately deferred. DataPanel’s existing collapsible “Data” header handles the only collapse the user can do today — sufficient for Phase 1.

5. Preview wiring

Resolution model

Every dataset that can be previewed has either (a) a namespacedPath identity (it lives in a store), or (b) only a node-result identity (it’s a derived result computed by the executor). The Preview action prefers (a) wherever available because (a) doesn’t require the pipeline to have run.

Click source Resolution Run required?
Sidebar Preview click in DataPanel inspectDatasetPath(namespacedPath) No
Canvas data-load node click resolve params.fileinspectDatasetPath(namespacedPath) No
Canvas derived node click (data-filter, data-mutate, data-summarise, data-arrange, data-rename, data-select) inspectNode(nodeId) (uses node.result) Yes

Resolution rule for inspectDatasetPath(path):

Store changes

usePipelineStore gains:

inspectedDatasetPath: string | null;
inspectDatasetPath: (path: string | null) => void;

Mutual exclusivity invariant (matches existing selectedNodeId/selectedGroupId pattern):

Component changes

DataViewer reads from whichever inspection field is set. The existing inspect-by-node code path is preserved; the new path is an additional branch:

const inspectedNode = ...;
const inspectedPath = usePipelineStore((s) => s.inspectedDatasetPath);
const directDataset = inspectedPath
  ? resolveDatasetByPath(inspectedPath)  // see Resolution rule above
  : null;

If inspectedNode is set → existing render path (label = node.label, dataset = node.result). If inspectedPath is set → label = the path’s basename or <file>::<binding> form, dataset = the resolved Dataset, no “verb” prefix in the header.

resolveDatasetByPath lives in a new tiny helper module src/ui/components/panels/data-viewer-resolve.ts (one exported function, no React, easy to unit-test).

6. Assign wiring (RData-only in Phase 1)

Trigger

DataPanel rows render an Assign button only on multi-binding RData rows (i.e. rows inside an RDataGroup). CSV/DTA rows and single-binding RData flat rows render only Preview in this phase. CSVs already auto-bind on upload; their explicit assignment is in DatasetStatus until Phase 3.

The existing RDataGroup component already conditionally renders the button based on the onBindDataset prop being defined; we rename to onAssignDataset and let the App-level wiring decide which rows pass the callback. For Phase 1, App.tsx passes onAssignDataset only — DataPanel.tsx’s FlatRow for non-RData files receives onPreview but no onAssign. The FlatRow’s onBind prop becomes optional and absent for CSV rows (the existing component already supports an absent onBind — it suppresses the button).

Picker

Reuse UploadPrompt (already in src/ui/components/toolbar/upload-prompt.tsx). Generalize its fileName: string prop → assignTarget: string, with the prompt text “<assignTarget> — assign to:” instead of the upload-specific “doesn’t match any expected file. Assign to:”. This keeps the radio-list, fuzzy-match suggestions, Confirm/Cancel chrome unchanged.

App.tsx state addition (lifted next to the existing pendingZip pattern):

const [pendingAssign, setPendingAssign] = useState<{
  namespacedPath: string;
  suggestions: MatchResult[];
} | null>(null);

Handler handleAssignDataset(namespacedPath: string):

  1. Read all data-load nodes from usePipelineStore.
  2. Compute fuzzy-match suggestions: for each node, call suggestMatch(node.id, nodes, edges, columnNames) where columnNames come from the resolved Dataset’s columns (same resolution rule as Preview). The fuzzy ranker scores by column-name overlap with consumers’ downstream references — same logic that powers the post-upload prompt today.
  3. setPendingAssign({ namespacedPath, suggestions }).

Render <UploadPrompt> when pendingAssign is non-null. On Confirm with a non-null nodeId:

On Cancel or “Don’t assign” → clear pendingAssign, no mutation.

Rename

DataPanel and RDataGroup props:

The internal store bindings, the action bindNode, and the DatasetBinding type all keep their existing names. They’re internal concepts unaffected by user-visible terminology.

7. Files touched

New:

Modified:

Tests touched:

8. Behavior walkthroughs

8.1 Multi-binding RData: preview-then-assign

  1. User uploads data_with_charge_offs.RData. Worker extracts: manifest = { datasets: ['dat', 'pdat'], globals: {}, opaqueBindings: [], ... }. WorkspaceStore.rdataDatasets registers data_with_charge_offs.RData::dat and ::pdat.
  2. User pastes R: felm(yfill ~ collateral + log_amt | timeInt + Disaster_Id, data = dats) (no load() yet — recognizer can’t auto-bind).
  3. Pipeline shows a data-load node with params.file = "" (or whatever the recognizer fell back to) — flagged as missing-binding.
  4. User opens DataPanel sidebar, sees charge_offs.RData group expanded with dat and pdat. Clicks [pre] on dat. DataViewer opens at the bottom showing dat’s 42k×7 table — immediately, no Run.
  5. Inspects dat‘s columns, decides this is the right one. Clicks [Assign] next to dat. UploadPrompt picker opens with the title “data_with_charge_offs.RData::dat — assign to:” and lists the data-load node (with a fuzzy-match suggestion if column overlap with consumers’ references is non-zero).
  6. User confirms. updateNodeParams(nodeId, { file: 'data_with_charge_offs.RData::dat' }). Pipeline rebuilds. Missing-binding warning clears.
  7. User clicks Run. The data-load executor sees :: in path, resolves via rdataDatasets, returns the cached Dataset. Pipeline runs without a worker round-trip for the data source.

8.2 Single-file CSV mode (no zip)

  1. User uploads controls.csv via toolbar Upload button (existing flow — Phase 2 will move this).
  2. useFilesStore.multiFileMode stays false; FileBrowser self-hides.
  3. DataPanel renders with one row: controls.csv [pre] (no Assign button — Phase 1 keeps CSV binding routed through DatasetStatus).
  4. User clicks [pre]. DataViewer opens with the CSV’s table (resolved by filename match into useDatasetsStore.entries). No Run required.

8.3 Canvas data-load preview without Run

  1. Pipeline has a data-load node with params.file = 'data/charge_offs.RData::dat' from a prior successful Run (or from auto-binding via a literal load() in the source).
  2. User reopens the app the next day, restoring workspace from OPFS (future feature; today they re-upload). data-load node is on canvas but no Run has happened in this session.
  3. User clicks the data-load node. Today this would show “No data yet — run the pipeline first.” With the new wiring: the click handler resolves params.file to a namespacedPath, calls inspectDatasetPath(...), DataViewer opens with the cached Dataset. No Run.

This is a real UX improvement on top of the original task — preview-before-run for data-load nodes — falling out of treating namespacedPath as the canonical preview identity.

8.4 Derived node preview (unchanged)

User clicks a data-filter node mid-pipeline. inspectNode(nodeId) runs as today; if node.result exists (post-Run), DataViewer shows it; otherwise empty-state with the existing “No data yet — run the pipeline first” copy.

9. Risk & rollback

Additive change. The new inspectedDatasetPath field defaults to null; the existing node-driven path is unchanged when null. If Preview misbehaves: setting the field to always-null reverts to today’s behavior. If Assign misbehaves: removing onAssignDataset from the App.tsx wiring suppresses the button (DataPanel/RDataGroup already handle absent callbacks by hiding the button). If the entire integration is bad: removing <DataPanel> and the picker render from App.tsx restores prior UX, and the unwired component continues to live as it does today.

The UploadPrompt prop rename (fileNameassignTarget) is the only invasive bit — touches one external call site (DatasetStatus). That call site keeps passing the upload’s filename, which still reads correctly under the neutral phrasing.

No store schema migrations. No serialized state changes. No worker-protocol changes.

10. Verification

Standard four (build + unit + lint + E2E) plus paper-match if any recognizer/executor code touched (it isn’t here).

New E2E e2e/datapanel-preview-and-assign.spec.ts:

  1. Upload a small .RData fixture (place in e2e/fixtures/datapanel-multi.RData — generate via scripts/make-datapanel-fixture.R containing two data.frames a and b).
  2. Wait for extraction-status to clear.
  3. Assert data-testid="data-panel" shows the multi-binding tree with sub-rows a and b.
  4. Click [Preview] on a. Assert data-testid="data-viewer" opens; assert the table contains row count and a sample header from a. Assert no Run was triggered (pipeline-store executing stayed false; executionGeneration unchanged).
  5. Paste library(somepkg); m <- lm(y ~ x, data = mydata) — produces a data-load node with params.file = "".
  6. Click [Assign] on b. Assert UploadPrompt picker appears with title containing the namespacedPath. Assert the data-load node is in the radio list. Click Confirm.
  7. Assert the canvas data-load node’s params include file: "<fixture>::b" (read via React Flow node-text or via a data-node-params attribute).
  8. Click [Preview] on the canvas data-load node. Assert DataViewer reopens with b’s table — no Run required.
  9. Click Run. Assert pipeline executes successfully and the lm node produces a result.

Existing E2E pass unchanged (the toolbar UploadZone + DatasetStatus + Run flow is untouched).

11. Phasing (this is Phase 1 of 3)

For visibility — none of these are scoped into this spec.

Phase Goal Key migrations Risk
1 (this) DataPanel rendered + Preview + Assign (RData-only) None Low — additive, both panels coexist
2 DataPanel becomes the upload home Upload button + drop-zone empty state moved into DataPanel; toolbar UploadZone retired; ZIP wipe-confirm modal, single-R-file load, single-binary-file load all rehomed; Run button stays in toolbar; bound-count pill stays visible (toolbar or DataPanel header) Medium — touches every upload path
3 Unified bind picker One picker UX covers both CSV (bindNode) and RData (updateNodeParams); DatasetStatus dropdown retired; CSV rows in DataPanel get the Assign button; fuzzy suggestMatch logic preserved Medium — touches CSV auto-match + bindings store; do NOT merge underlying stores

Each phase is one spec + one implementation plan + one PR.

12. Open questions

None at time of writing. Open during review.