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.
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:
.RData file
(no list view).data-load node from the
UI (only path-text editing in node params, or auto-binding when the
recognizer detects a literal load() + dataset
reference).This spec wires DataPanel into the app and hooks up the
Preview + Assign actions. It is intentionally
Phase 1 of three: see §11.
Make the multi-binding .RData view of
DataPanel visible and interactive in the app, with two
working actions:
DataViewer, without requiring the
pipeline to have run.data-load
node via a node-picker (modeled on the existing
UploadPrompt flow).DataPanel. Toolbar UploadZone stays untouched
in this phase. → Phase 2.useDatasetsStore.bindNode) with RData binding (via
params.file = "<path>::<binding>") under a
single picker UI. Toolbar DatasetStatus dropdown stays the
only path for CSV/DTA assignment. → Phase 3.DataPanel’s existing collapsible header. → future
polish.useDatasetsStore and
useWorkspaceStore at the storage layer. The two stores
model genuinely different things (typed Datasets ready for the executor
vs raw bytes the WebR worker also needs); merging is a risk multiplier
with no visible win for users. We unify the UI surface (one
DataPanel) and the bind API shape (one picker), not the
storage.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).
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.
useFilesStore.multiFileMode === false):
FileBrowser self-hides (existing behavior —
multiFileMode || fileTree.length === 0 short-circuit).
DataPanel renders alone with the one CSV row. The 220 px
column still appears; the 3-panel grid keeps its proportions.FileBrowser hidden by its own guard; DataPanel
hidden by its own guard
(if (files.size === 0) return null;). The whole 220 px
column collapses; the 3-panel grid takes the full width — no change from
today.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.
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.file →
inspectDatasetPath(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):
path.includes('::') →
WorkspaceStore.rdataDatasets.get(path) returns the typed
Dataset.useDatasetsStore.entries matched
by filename (basename equality). If multiple matches, the first is fine
— there’s no canonical disambiguation rule today, and Phase 3’s store
unification will revisit.usePipelineStore gains:
inspectedDatasetPath: string | null;
inspectDatasetPath: (path: string | null) => void;Mutual exclusivity invariant (matches existing
selectedNodeId/selectedGroupId pattern):
inspectNode(id) clears
inspectedDatasetPath.inspectDatasetPath(path) clears
inspectedNodeId.DataViewer calls
both setters with null).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).
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).
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):
data-load nodes from
usePipelineStore.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.setPendingAssign({ namespacedPath, suggestions }).Render <UploadPrompt> when
pendingAssign is non-null. On Confirm with a non-null
nodeId:
usePipelineStore.updateNodeParams(nodeId, { file: namespacedPath }).
The pipeline rebuild fires from the existing params-changed
subscription.pendingAssign.On Cancel or “Don’t assign” → clear pendingAssign, no
mutation.
DataPanel and RDataGroup props:
onBindDataset → onAssignDataset (public
callback prop)"bind" → "Assign".
No ellipsis (matches DatasetStatus’s bare “Assign” — both
are unambiguous in their respective row contexts).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.
New:
src/ui/components/panels/data-viewer-resolve.ts —
resolveDatasetByPath(path: string): Dataset | null helper
(pure function, returns null on miss). Unit-testable without React.e2e/datapanel-preview-and-assign.spec.ts — new E2E
(covered in §10).e2e/fixtures/datapanel-multi.RData — fixture binary,
generated once via scripts/make-datapanel-fixture.R
(committed alongside the binary so regeneration is reproducible).scripts/make-datapanel-fixture.R — generator for the
above fixture.Modified:
src/ui/App.tsx — wrap sidebar in a flex column
container; render
<DataPanel onPreviewDataset={...} onAssignDataset={...} />
below <FileBrowser />. Add pendingAssign
state and <UploadPrompt> render. Add
handlePreviewDataset and handleAssignDataset
handlers.src/ui/components/panels/data-panel.tsx — rename
onBindDataset → onAssignDataset. Pass
onAssignDataset only to RDataGroup
(multi-binding RData), never to FlatRow
(CSV/DTA/single-binding rows). FlatRow continues to support
absent onBind by suppressing the button.src/ui/components/panels/data-panel-rdata-group.tsx —
rename prop onBindDataset → onAssignDataset.
Change button text “bind” → “Assign”.src/ui/components/panels/data-viewer.tsx — read
inspectedDatasetPath in addition to
inspectedNodeId. Branch render between node-driven
(existing) and path-driven (new) modes.src/ui/store/pipeline.ts — add
inspectedDatasetPath: string | null field; add
inspectDatasetPath(path | null) action; ensure mutual
exclusivity with inspectNode.src/ui/components/toolbar/upload-prompt.tsx —
generalize fileName: string prop →
assignTarget: string. Update prompt text to
“<assignTarget> — assign to:”
(neutral phrasing covers both upload-time and sidebar-Assign use). The
existing call site in DatasetStatus passes the upload’s
fileName as assignTarget — semantics
unchanged.Tests touched:
src/ui/components/panels/data-panel.test.tsx — update
prop name + label assertions.src/ui/components/panels/data-panel-rdata-group.test.tsx
— same.src/ui/components/panels/data-viewer-resolve.test.ts —
new. Cover: namespacedPath resolution,
bare-path-by-filename resolution, miss returns null.src/ui/store/pipeline.test.ts (or equivalent) —
new test cases: inspectDatasetPath clears
inspectedNodeId; inspectNode clears
inspectedDatasetPath; setting null clears the field.e2e/datapanel-preview-and-assign.spec.ts — new
E2E. Covered in §10.data_with_charge_offs.RData. Worker
extracts: manifest =
{ datasets: ['dat', 'pdat'], globals: {}, opaqueBindings: [], ... }.
WorkspaceStore.rdataDatasets registers
data_with_charge_offs.RData::dat and
::pdat.felm(yfill ~ collateral + log_amt | timeInt + Disaster_Id, data = dats)
(no load() yet — recognizer can’t auto-bind).data-load node with
params.file = "" (or whatever the recognizer fell back to)
— flagged as missing-binding.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.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).updateNodeParams(nodeId, { file: 'data_with_charge_offs.RData::dat' }).
Pipeline rebuilds. Missing-binding warning clears.:: in
path, resolves via rdataDatasets, returns the cached
Dataset. Pipeline runs without a worker round-trip for the data
source.controls.csv via toolbar Upload button
(existing flow — Phase 2 will move this).useFilesStore.multiFileMode stays false;
FileBrowser self-hides.DataPanel renders with one row:
controls.csv [pre] (no Assign button — Phase 1 keeps CSV
binding routed through DatasetStatus).[pre]. DataViewer opens with the CSV’s
table (resolved by filename match into
useDatasetsStore.entries). No Run required.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).data-load node is
on canvas but no Run has happened in this session.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.
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.
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 (fileName →
assignTarget) 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.
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:
.RData fixture (place in
e2e/fixtures/datapanel-multi.RData — generate via
scripts/make-datapanel-fixture.R containing two data.frames
a and b).data-testid="data-panel" shows the multi-binding
tree with sub-rows a and b.[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).library(somepkg); m <- lm(y ~ x, data = mydata) —
produces a data-load node with
params.file = "".[Assign] on b. Assert UploadPrompt
picker appears with title containing the namespacedPath. Assert the
data-load node is in the radio list. Click Confirm.file: "<fixture>::b" (read via React Flow node-text
or via a data-node-params attribute).[Preview] on the canvas data-load node. Assert
DataViewer reopens with b’s table — no Run required.Run. Assert pipeline executes successfully and
the lm node produces a result.Existing E2E pass unchanged (the toolbar UploadZone + DatasetStatus + Run flow is untouched).
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.
None at time of writing. Open during review.