Skip to content

Commit 589e3a2

Browse files
committed
feat(notebook): add interactive notebook-like runtime support. Closes #540
1 parent 1083490 commit 589e3a2

File tree

13 files changed

+1059
-750
lines changed

13 files changed

+1059
-750
lines changed

frontend/package-lock.json

Lines changed: 118 additions & 671 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/App.jsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { BrowserRouter as Router } from 'react-router-dom';
44

55
import Layout from './components/Layout';
66
import Dashboard from './components/pages/Dashboard';
7+
import NotebookView from './components/pages/NotebookView';
78
import { comm } from './utils/websocket';
89

910
const App = () => {
@@ -156,15 +157,25 @@ const App = () => {
156157
return (
157158
<Router>
158159
<Layout>
159-
{!isConnected ? (
160-
<LoadingState />
161-
) : (
162-
<Dashboard
163-
components={components}
164-
error={error}
165-
handleComponentUpdate={handleComponentUpdate}
160+
<Routes>
161+
<Route path="/notebook" element={<NotebookView />} />
162+
<Route
163+
path="/"
164+
element={
165+
!isConnected ? (
166+
<LoadingState />
167+
) : (
168+
<Dashboard
169+
components={components}
170+
error={error}
171+
handleComponentUpdate={(id, value) => {
172+
comm.updateComponentState(id, value);
173+
}}
174+
/>
175+
)
176+
}
166177
/>
167-
)}
178+
</Routes>
168179
</Layout>
169180
</Router>
170181
);
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import React, { useEffect, useRef } from 'react';
2+
3+
const HTMLRenderer = ({ html, className }) => {
4+
const containerRef = useRef(null);
5+
6+
useEffect(() => {
7+
if (!html) return;
8+
const container = containerRef.current;
9+
if (!container) return;
10+
11+
// Clear any existing content.
12+
container.innerHTML = '';
13+
14+
// Create a temporary element to parse the HTML.
15+
const tempDiv = document.createElement('div');
16+
tempDiv.innerHTML = html;
17+
18+
// Extract external and inline script elements.
19+
const externalScripts = Array.from(tempDiv.querySelectorAll('script[src]'));
20+
const inlineScripts = Array.from(tempDiv.querySelectorAll('script:not([src])'));
21+
22+
// Remove all script tags from tempDiv.
23+
externalScripts.forEach((script) => script.remove());
24+
inlineScripts.forEach((script) => script.remove());
25+
26+
// Append non-script nodes to the container.
27+
while (tempDiv.firstChild) {
28+
container.appendChild(tempDiv.firstChild);
29+
}
30+
31+
// Helper to load an external script.
32+
const loadScript = (script) => {
33+
return new Promise((resolve, reject) => {
34+
const newScript = document.createElement('script');
35+
// Copy all attributes.
36+
Array.from(script.attributes).forEach((attr) => {
37+
newScript.setAttribute(attr.name, attr.value);
38+
});
39+
newScript.async = false; // preserve execution order
40+
newScript.onload = resolve;
41+
newScript.onerror = () => reject(new Error(`Failed to load script: ${script.src}`));
42+
document.head.appendChild(newScript);
43+
});
44+
};
45+
46+
// Load external scripts sequentially.
47+
const loadExternalScripts = async () => {
48+
for (const script of externalScripts) {
49+
await loadScript(script);
50+
}
51+
};
52+
53+
loadExternalScripts()
54+
.then(() => {
55+
// Once external scripts are loaded, wait a brief moment then append inline scripts.
56+
setTimeout(() => {
57+
inlineScripts.forEach((script) => {
58+
const newScript = document.createElement('script');
59+
newScript.text = script.textContent;
60+
container.appendChild(newScript);
61+
});
62+
}, 50); // delay can be adjusted if needed
63+
})
64+
.catch((err) => {
65+
console.error('Error loading external scripts:', err);
66+
});
67+
}, [html]);
68+
69+
return <div ref={containerRef} className={className} />;
70+
};
71+
72+
export default HTMLRenderer;
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { python } from '@codemirror/lang-python';
2+
import { oneDark } from '@codemirror/theme-one-dark';
3+
import CodeMirror from '@uiw/react-codemirror';
4+
import { v4 as uuidv4 } from 'uuid';
5+
6+
import React, { useCallback, useEffect, useState } from 'react';
7+
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
8+
9+
import { Alert } from '@/components/ui/alert';
10+
import { Button } from '@/components/ui/button';
11+
12+
import { comm } from '@/utils/websocket';
13+
14+
import HTMLRenderer from '../common/HTMLRenderer';
15+
16+
const NotebookView = () => {
17+
// Each cell holds its id, code, output, status, error, and whether it’s been executed.
18+
const [cells, setCells] = useState([
19+
{ id: uuidv4(), code: '', output: '', status: 'idle', error: null, executed: false },
20+
]);
21+
22+
// Subscribe to WebSocket messages to update cell outputs and restore state.
23+
useEffect(() => {
24+
const unsubscribe = comm.subscribe((message) => {
25+
if (message.type === 'notebook_state' && message.cells) {
26+
// On refresh, clear the executed flag on all cells.
27+
const resetCells = message.cells.map((cell) => ({ ...cell, executed: false }));
28+
setCells(resetCells);
29+
}
30+
if (message.type === 'cell_result' && message.cell_id) {
31+
setCells((prevCells) => {
32+
const updatedCells = prevCells.map((cell) =>
33+
cell.id === message.cell_id
34+
? {
35+
...cell,
36+
output: message.output,
37+
status: message.error ? 'error' : 'idle',
38+
error: message.error || null,
39+
executed: true,
40+
}
41+
: cell
42+
);
43+
// Optionally persist this updated state to the server if needed.
44+
comm.send({ type: 'update_notebook', cells: updatedCells });
45+
return updatedCells;
46+
});
47+
}
48+
});
49+
return () => unsubscribe();
50+
}, []);
51+
52+
// Run a cell: send code via WebSocket and mark cell as running.
53+
const runCell = useCallback(
54+
(cellId) => {
55+
setCells((prevCells) =>
56+
prevCells.map((cell) =>
57+
cell.id === cellId
58+
? { ...cell, status: 'running', output: '', error: null, executed: false }
59+
: cell
60+
)
61+
);
62+
const cell = cells.find((c) => c.id === cellId);
63+
if (cell) {
64+
comm.send({ type: 'run_cell', cell_id: cell.id, code: cell.code });
65+
}
66+
},
67+
[cells]
68+
);
69+
70+
// Update cell code and notify the server.
71+
const updateCellCode = (cellId, newCode) => {
72+
setCells((prevCells) => {
73+
const updatedCells = prevCells.map((cell) =>
74+
cell.id === cellId ? { ...cell, code: newCode, executed: false } : cell
75+
);
76+
comm.send({ type: 'update_notebook', cells: updatedCells });
77+
return updatedCells;
78+
});
79+
};
80+
81+
// Append a new cell.
82+
const addCell = () => {
83+
const newCell = {
84+
id: uuidv4(),
85+
code: '',
86+
output: '',
87+
status: 'idle',
88+
error: null,
89+
executed: false,
90+
};
91+
setCells((prevCells) => {
92+
const updatedCells = [...prevCells, newCell];
93+
comm.send({ type: 'update_notebook', cells: updatedCells });
94+
return updatedCells;
95+
});
96+
};
97+
98+
// Remove a cell.
99+
const deleteCell = (cellId) => {
100+
setCells((prevCells) => {
101+
const updatedCells = prevCells.filter((cell) => cell.id !== cellId);
102+
comm.send({ type: 'update_notebook', cells: updatedCells });
103+
return updatedCells;
104+
});
105+
};
106+
107+
// Handle drag-and-drop reordering.
108+
const onDragEnd = (result) => {
109+
if (!result.destination) return;
110+
const newCells = Array.from(cells);
111+
const [removed] = newCells.splice(result.source.index, 1);
112+
newCells.splice(result.destination.index, 0, removed);
113+
setCells(newCells);
114+
comm.send({ type: 'update_notebook', cells: newCells });
115+
};
116+
117+
return (
118+
<div className="notebook-container max-w-4xl mx-auto p-6">
119+
<h1 className="text-3xl font-bold mb-6 text-center">Interactive Notebook</h1>
120+
<DragDropContext onDragEnd={onDragEnd}>
121+
<Droppable droppableId="notebook-cells">
122+
{(provided) => (
123+
<div {...provided.droppableProps} ref={provided.innerRef}>
124+
{cells.map((cell, index) => (
125+
<Draggable key={cell.id} draggableId={cell.id} index={index}>
126+
{(provided) => (
127+
<div
128+
ref={provided.innerRef}
129+
{...provided.draggableProps}
130+
{...provided.dragHandleProps}
131+
className="notebook-cell bg-white rounded shadow p-4 mb-6 border"
132+
>
133+
<div className="flex items-center justify-between mb-2">
134+
<div className="flex items-center">
135+
<span className="text-sm font-medium text-gray-600">
136+
Cell {index + 1}
137+
</span>
138+
{cell.executed && (
139+
<span title="Cell executed" className="ml-2 text-green-500">
140+
141+
</span>
142+
)}
143+
</div>
144+
<div className="flex space-x-2">
145+
<Button
146+
onClick={() => runCell(cell.id)}
147+
disabled={cell.status === 'running'}
148+
className="px-3 py-1 text-sm"
149+
>
150+
{cell.status === 'running' ? 'Running...' : 'Run'}
151+
</Button>
152+
<Button
153+
variant="destructive"
154+
onClick={() => deleteCell(cell.id)}
155+
className="px-3 py-1 text-sm"
156+
>
157+
Delete
158+
</Button>
159+
</div>
160+
</div>
161+
<div className="mb-4">
162+
{/* CodeMirror-based Python editor */}
163+
<CodeMirror
164+
value={cell.code}
165+
height="auto"
166+
theme={oneDark}
167+
extensions={[python()]}
168+
onChange={(value) => updateCellCode(cell.id, value)}
169+
/>
170+
</div>
171+
{(cell.status === 'running' || cell.status === 'error') && (
172+
<div className="mt-2 text-sm text-gray-500">
173+
{cell.status === 'error' ? 'Error occurred' : 'Executing cell...'}
174+
</div>
175+
)}
176+
{cell.output && (
177+
<div className="mt-4">
178+
<HTMLRenderer
179+
html={cell.output}
180+
className="p-3 border rounded bg-gray-50 text-sm"
181+
/>
182+
</div>
183+
)}
184+
{cell.error && (
185+
<Alert variant="destructive" className="mt-4">
186+
<span>{cell.error}</span>
187+
</Alert>
188+
)}
189+
</div>
190+
)}
191+
</Draggable>
192+
))}
193+
{provided.placeholder}
194+
</div>
195+
)}
196+
</Droppable>
197+
</DragDropContext>
198+
<div className="flex justify-center">
199+
<Button onClick={addCell} className="px-6 py-2 text-lg">
200+
+ Add Cell
201+
</Button>
202+
</div>
203+
</div>
204+
);
205+
};
206+
207+
export default NotebookView;

frontend/src/components/widgets/FastplotlibWidget.jsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import PropTypes from 'prop-types';
2+
23
import React, { useEffect, useState } from 'react';
34

45
import { Card } from '@/components/ui/card';
6+
57
import { cn } from '@/lib/utils';
68
import { comm } from '@/utils/websocket';
79

@@ -18,10 +20,7 @@ const FastplotlibWidget = ({ id, label, src, className, clientId }) => {
1820

1921
useEffect(() => {
2022
const unsubscribe = comm.subscribe((message) => {
21-
if (
22-
message.type === 'image_update' &&
23-
message.component_id === id
24-
) {
23+
if (message.type === 'image_update' && message.component_id === id) {
2524
if (message.value) {
2625
setCurrentSrc(message.value);
2726
setHasLoadedOnce(true);
@@ -40,11 +39,7 @@ const FastplotlibWidget = ({ id, label, src, className, clientId }) => {
4039
<Card className={cn('w-full p-4 flex justify-center items-center relative', className)}>
4140
{hasLoadedOnce ? (
4241
<>
43-
<img
44-
src={currentSrc}
45-
alt={label || 'Fastplotlib chart'}
46-
className="max-w-full h-auto"
47-
/>
42+
<img src={currentSrc} alt={label || 'Fastplotlib chart'} className="max-w-full h-auto" />
4843
{showWarning && (
4944
<div className="absolute bottom-0 left-0 right-0 bg-yellow-100 text-yellow-800 text-xs p-1 text-center">
5045
Warning: Latest update did not include data.

frontend/src/styles.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,20 @@
6969
.modebar-container {
7070
display: none !important;
7171
}
72+
73+
/* dataframe */
74+
.notebook-html-output table {
75+
border-collapse: collapse;
76+
width: 100%;
77+
}
78+
79+
.notebook-html-output th,
80+
.notebook-html-output td {
81+
border: 1px solid #ddd;
82+
padding: 8px;
83+
}
84+
85+
.notebook-html-output th {
86+
background-color: #f2f2f2;
87+
font-weight: bold;
88+
}

0 commit comments

Comments
 (0)