Add click-to-edit schematic values and DOMPurify sanitization

SchematicViewer now supports inline editing of component values
directly on the SVG. Clicking an editable value opens an overlay
input that updates the SPICE netlist on commit, triggering an
auto-redraw after 800ms debounce.

Added DOMPurify for SVG sanitization, netlist-utils for safe
value substitution in netlists, and wired schematic generation
through the notebook store with generation counters to discard
stale responses.
This commit is contained in:
Ryan Malloy 2026-02-13 15:46:14 -07:00
parent 4f174e9d0f
commit a8c53f34f4
8 changed files with 414 additions and 17 deletions

View File

@ -1,5 +1,6 @@
"""Schematic generation endpoints.""" """Schematic generation endpoints."""
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
@ -10,6 +11,8 @@ from spicebook.engine.schematic import netlist_to_svg
from spicebook.models.notebook import CellOutput, CellType from spicebook.models.notebook import CellOutput, CellType
from spicebook.storage.filesystem import load_notebook, save_notebook from spicebook.storage.filesystem import load_notebook, save_notebook
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["schematics"]) router = APIRouter(prefix="/api", tags=["schematics"])
@ -17,6 +20,7 @@ class SchematicResponse(BaseModel):
svg: str | None = None svg: str | None = None
success: bool success: bool
error: str | None = None error: str | None = None
component_map: dict[str, str] | None = None
@router.post( @router.post(
@ -43,17 +47,25 @@ async def generate_schematic(notebook_id: str, cell_id: str):
raise HTTPException(status_code=400, detail="Cell source is empty") raise HTTPException(status_code=400, detail="Cell source is empty")
try: try:
svg = netlist_to_svg(cell.source) result = netlist_to_svg(cell.source)
except (ValueError, RuntimeError) as exc: except (ValueError, RuntimeError) as exc:
return SchematicResponse(success=False, error=str(exc)) return SchematicResponse(success=False, error=str(exc))
except Exception as exc: except Exception:
return SchematicResponse(success=False, error=f"Schematic generation failed: {exc}") logger.exception("Unexpected error during schematic generation")
return SchematicResponse(
success=False,
error="Schematic generation failed due to an internal error",
)
# Save schematic output to cell (additive — preserve simulation outputs) # Save schematic output to cell (additive — preserve simulation outputs)
now_iso = datetime.now(timezone.utc).isoformat() now_iso = datetime.now(timezone.utc).isoformat()
schematic_output = CellOutput( schematic_output = CellOutput(
output_type="schematic", output_type="schematic",
data={"svg": svg, "success": True}, data={
"svg": result.svg,
"component_map": result.component_map,
"success": True,
},
timestamp=now_iso, timestamp=now_iso,
) )
@ -63,4 +75,8 @@ async def generate_schematic(notebook_id: str, cell_id: str):
save_notebook(settings.notebook_dir, notebook_id, nb) save_notebook(settings.notebook_dir, notebook_id, nb)
return SchematicResponse(svg=svg, success=True) return SchematicResponse(
svg=result.svg,
component_map=result.component_map,
success=True,
)

View File

@ -22,6 +22,7 @@
"@lezer/lr": "^1.4.0", "@lezer/lr": "^1.4.0",
"astro": "^5.0.0", "astro": "^5.0.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"dompurify": "^3.3.1",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@ -31,6 +32,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/dompurify": "^3.0.5",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
@ -3026,6 +3028,16 @@
"@types/ms": "*" "@types/ms": "*"
} }
}, },
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -3083,6 +3095,13 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/unist": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@ -4661,6 +4680,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1" "url": "https://github.com/fb55/domhandler?sponsor=1"
} }
}, },
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": { "node_modules/domutils": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",

View File

@ -23,6 +23,7 @@
"@lezer/lr": "^1.4.0", "@lezer/lr": "^1.4.0",
"astro": "^5.0.0", "astro": "^5.0.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"dompurify": "^3.3.1",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@ -32,6 +33,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/dompurify": "^3.0.5",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",

View File

@ -1,7 +1,8 @@
import { useCallback } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import type { Cell } from '../../../lib/types'; import type { Cell } from '../../../lib/types';
import type { WaveformData } from '../../../lib/types'; import type { WaveformData } from '../../../lib/types';
import { useNotebookStore } from '../../../lib/notebook-store'; import { useNotebookStore } from '../../../lib/notebook-store';
import { updateNetlistValue } from '../../../lib/netlist-utils';
import { CellToolbar } from '../toolbar/CellToolbar'; import { CellToolbar } from '../toolbar/CellToolbar';
import { SpiceEditor } from '../editor/SpiceEditor'; import { SpiceEditor } from '../editor/SpiceEditor';
import { WaveformViewer } from '../output/WaveformViewer'; import { WaveformViewer } from '../output/WaveformViewer';
@ -60,6 +61,31 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
// Extract schematic output // Extract schematic output
const schematicOutput = cell.outputs.find((o) => o.output_type === 'schematic'); const schematicOutput = cell.outputs.find((o) => o.output_type === 'schematic');
const schematicSvg = schematicOutput?.data?.svg as string | null; const schematicSvg = schematicOutput?.data?.svg as string | null;
const componentMap = schematicOutput?.data?.component_map as Record<string, string> | undefined;
// Click-to-edit: update netlist when a schematic value is changed
const handleSchematicValueChange = useCallback(
(componentName: string, newValue: string) => {
const updated = updateNetlistValue(cell.source, componentName, newValue);
updateCellSource(cell.id, updated);
},
[cell.id, cell.source, updateCellSource],
);
// Debounced auto-redraw: regenerate schematic 800ms after source changes.
// Track the source at last generation to avoid retriggering from our own SVG updates.
const lastGeneratedSource = useRef<string | null>(null);
useEffect(() => {
if (!schematicSvg) return; // only auto-redraw when schematic is visible
if (cell.source === lastGeneratedSource.current) return; // skip if source unchanged since last gen
const timer = setTimeout(() => {
lastGeneratedSource.current = cell.source;
generateSchematic(cell.id);
}, 800);
return () => clearTimeout(timer);
}, [cell.source, cell.id, generateSchematic, schematicSvg]);
return ( return (
<div <div
@ -87,7 +113,11 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
{/* Schematic — shown above the editor when generated */} {/* Schematic — shown above the editor when generated */}
{schematicSvg && ( {schematicSvg && (
<div className="border-b border-slate-700/50"> <div className="border-b border-slate-700/50">
<SchematicViewer svg={schematicSvg} /> <SchematicViewer
svg={schematicSvg}
componentMap={componentMap}
onValueChange={handleSchematicValueChange}
/>
</div> </div>
)} )}

View File

@ -1,15 +1,37 @@
import { useState, useRef, useCallback } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react';
import { ZoomIn, ZoomOut, Maximize2, Download } from 'lucide-react'; import { ZoomIn, ZoomOut, Maximize2, Download } from 'lucide-react';
import DOMPurify from 'dompurify';
interface EditState {
component: string;
rect: { top: number; left: number; width: number; height: number };
currentValue: string;
}
interface SchematicViewerProps { interface SchematicViewerProps {
svg: string; svg: string;
componentMap?: Record<string, string>;
onValueChange?: (componentName: string, newValue: string) => void;
} }
const ZOOM_STEPS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; const ZOOM_STEPS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
export function SchematicViewer({ svg }: SchematicViewerProps) { // Allow SPICE values: digits, SI prefixes, decimal points, signs, parens, common SPICE chars
const SPICE_VALUE_RE = /^[0-9a-zA-Z._+\-*/(){}=, ]{1,200}$/;
function sanitizeSvg(raw: string): string {
return DOMPurify.sanitize(raw, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_ATTR: ['data-component', 'data-editable', 'data-raw-value'],
});
}
export function SchematicViewer({ svg, componentMap, onValueChange }: SchematicViewerProps) {
const [zoomIndex, setZoomIndex] = useState(2); // Start at 100% const [zoomIndex, setZoomIndex] = useState(2); // Start at 100%
const [editState, setEditState] = useState<EditState | null>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const svgWrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const zoom = ZOOM_STEPS[zoomIndex]; const zoom = ZOOM_STEPS[zoomIndex];
@ -35,6 +57,137 @@ export function SchematicViewer({ svg }: SchematicViewerProps) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [svg]); }, [svg]);
// Dismiss edit overlay when SVG changes (e.g., after auto-redraw)
useEffect(() => {
setEditState(null);
}, [svg]);
// Inject sanitized SVG into the DOM and attach interactive handlers
useEffect(() => {
const wrapper = svgWrapperRef.current;
if (!wrapper) return;
wrapper.innerHTML = sanitizeSvg(svg);
// Find all editable tspan elements and enhance them
const editables = wrapper.querySelectorAll<SVGTSpanElement>('tspan[data-editable="true"]');
// Track all handlers for proper cleanup
const allHandlers: Array<{ el: SVGTSpanElement; event: string; handler: EventListener }> = [];
const addHandler = (el: SVGTSpanElement, event: string, handler: EventListener) => {
el.addEventListener(event, handler);
allHandlers.push({ el, event, handler });
};
editables.forEach((tspan) => {
tspan.style.cursor = 'pointer';
addHandler(tspan, 'mouseenter', () => {
tspan.style.fill = '#60a5fa'; // blue-400
});
addHandler(tspan, 'mouseleave', () => {
tspan.style.fill = '';
});
// Click handler for inline editing
const handleClick = (e: Event) => {
(e as MouseEvent).stopPropagation();
const parentText = tspan.closest('text');
const componentName = parentText?.getAttribute('data-component');
const rawValue = tspan.getAttribute('data-raw-value');
if (!componentName || !rawValue) return;
const container = containerRef.current;
if (!container) return;
// Get tspan position relative to the scroll container
const tspanRect = tspan.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
setEditState({
component: componentName,
rect: {
top: tspanRect.top - containerRect.top + container.scrollTop,
left: tspanRect.left - containerRect.left + container.scrollLeft,
width: Math.max(tspanRect.width, 60),
height: tspanRect.height,
},
currentValue: rawValue,
});
};
addHandler(tspan, 'click', handleClick);
});
// Cleanup all event listeners on unmount or SVG change
return () => {
allHandlers.forEach(({ el, event, handler }) => {
el.removeEventListener(event, handler);
});
};
}, [svg]);
// Focus input when edit overlay appears
useEffect(() => {
if (editState && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editState]);
const commitEdit = useCallback(() => {
if (!editState) return;
const input = inputRef.current;
if (!input) return;
const newValue = input.value.trim();
if (!newValue || newValue === editState.currentValue) {
setEditState(null);
return;
}
// Validate value against SPICE-safe pattern
if (!SPICE_VALUE_RE.test(newValue)) {
setEditState(null);
return;
}
// Optimistically update the tspan text in the live DOM
const wrapper = svgWrapperRef.current;
if (wrapper) {
const escaped = CSS.escape(editState.component);
const parentText = wrapper.querySelector(`text[data-component="${escaped}"]`);
const valueTspan = parentText?.querySelector('tspan[data-editable="true"]');
if (valueTspan) {
valueTspan.textContent = newValue;
valueTspan.setAttribute('data-raw-value', newValue);
}
}
onValueChange?.(editState.component, newValue);
setEditState(null);
}, [editState, onValueChange]);
const cancelEdit = useCallback(() => {
setEditState(null);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
commitEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
},
[commitEdit, cancelEdit],
);
return ( return (
<div className="relative"> <div className="relative">
{/* Toolbar */} {/* Toolbar */}
@ -66,6 +219,11 @@ export function SchematicViewer({ svg }: SchematicViewerProps) {
<Maximize2 className="w-3.5 h-3.5" /> <Maximize2 className="w-3.5 h-3.5" />
</button> </button>
<div className="flex-1" /> <div className="flex-1" />
{componentMap && Object.keys(componentMap).length > 0 && (
<span className="text-[10px] text-slate-600 mr-2">
click values to edit
</span>
)}
<button <button
onClick={downloadSvg} onClick={downloadSvg}
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition-colors" className="flex items-center gap-1 px-2 py-1 rounded text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition-colors"
@ -79,17 +237,36 @@ export function SchematicViewer({ svg }: SchematicViewerProps) {
{/* SVG Canvas */} {/* SVG Canvas */}
<div <div
ref={containerRef} ref={containerRef}
className="overflow-auto bg-white rounded-b-lg" className="overflow-auto bg-white rounded-b-lg relative"
style={{ maxHeight: '500px' }} style={{ maxHeight: '500px' }}
> >
<div <div
ref={svgWrapperRef}
className="p-4" className="p-4"
style={{ style={{
transform: `scale(${zoom})`, transform: `scale(${zoom})`,
transformOrigin: 'top left', transformOrigin: 'top left',
}} }}
dangerouslySetInnerHTML={{ __html: svg }}
/> />
{/* Inline edit overlay */}
{editState && (
<input
ref={inputRef}
type="text"
defaultValue={editState.currentValue}
onKeyDown={handleKeyDown}
onBlur={commitEdit}
className="absolute z-10 px-1 py-0 text-sm font-mono bg-white border-2 border-blue-500 rounded shadow-lg outline-none text-slate-900"
style={{
top: editState.rect.top - 2,
left: editState.rect.left - 4,
minWidth: Math.max(editState.rect.width + 16, 72),
height: editState.rect.height + 6,
lineHeight: `${editState.rect.height + 2}px`,
}}
/>
)}
</div> </div>
</div> </div>
); );

View File

@ -177,6 +177,7 @@ export interface SchematicResponse {
svg: string | null; svg: string | null;
success: boolean; success: boolean;
error?: string; error?: string;
component_map?: Record<string, string>;
} }
export async function generateSchematic( export async function generateSchematic(

View File

@ -0,0 +1,126 @@
/**
* SPICE netlist manipulation utilities.
*
* Pure functions for updating component values in SPICE netlist text.
*/
/**
* Replace a component's value in a SPICE netlist string.
*
* Handles continuation lines (+ prefix) and preserves inline comments.
* Returns the original netlist unchanged if the component is not found.
*/
export function updateNetlistValue(
netlist: string,
componentName: string,
newValue: string,
): string {
const lines = netlist.split('\n');
const target = componentName.toUpperCase();
// First pass: find the line (and any continuation lines) for this component
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (!trimmed || trimmed.startsWith('*') || trimmed.startsWith(';') || trimmed.startsWith('.')) {
continue;
}
// Merge this line with any continuation lines that follow
let fullLine = trimmed;
let lastContinuation = i;
for (let j = i + 1; j < lines.length; j++) {
const next = lines[j].trim();
if (next.startsWith('+')) {
fullLine += ' ' + next.slice(1).trim();
lastContinuation = j;
} else {
break;
}
}
// Strip inline comment before tokenizing to avoid comment text in tokens
let comment = '';
let workLine = fullLine;
const commentIdx = fullLine.indexOf(';');
if (commentIdx >= 0) {
comment = ' ' + fullLine.slice(commentIdx);
workLine = fullLine.slice(0, commentIdx).trimEnd();
}
const tokens = workLine.split(/\s+/);
if (tokens.length === 0) continue;
if (tokens[0].toUpperCase() !== target) continue;
// Found the component — determine which token(s) hold the value
const prefix = tokens[0][0].toUpperCase();
const replaced = replaceValue(tokens, prefix, newValue, comment);
if (replaced === null) return netlist;
// Replace the original line(s) with the updated single line
// Preserve leading whitespace from the original first line
const leadingWs = lines[i].match(/^(\s*)/)?.[1] ?? '';
const newLines = [...lines];
newLines.splice(i, lastContinuation - i + 1, leadingWs + replaced);
return newLines.join('\n');
}
// Component not found — return unchanged
return netlist;
}
/**
* Replace the value portion of a component line based on its SPICE prefix.
*
* Token positions by prefix:
* R, C, L: NAME N+ N- VALUE token[3]
* V, I: NAME N+ N- VALUE_SPEC... tokens[3:]
* D: NAME N+ N- MODEL not editable (model name)
* Q: NAME NC NB NE [NS] MODEL not editable
* M: NAME ND NG NS [NB] MODEL not editable
* E, G, S: NAME N+ N- NC+ NC- VALUE... tokens[5:]
* F, H: NAME N+ N- VNAME GAIN tokens[3:] (value includes vname)
* B: NAME N+ N- EXPR tokens[3:]
*/
function replaceValue(
tokens: string[],
prefix: string,
newValue: string,
comment: string,
): string | null {
switch (prefix) {
case 'R':
case 'C':
case 'L': {
// NAME N+ N- VALUE
if (tokens.length < 4) return null;
return [tokens[0], tokens[1], tokens[2], newValue].join(' ') + comment;
}
case 'V':
case 'I':
case 'B': {
// NAME N+ N- <everything else is the value spec>
if (tokens.length < 3) return null;
return [tokens[0], tokens[1], tokens[2], newValue].join(' ') + comment;
}
case 'E':
case 'G':
case 'S': {
// NAME N+ N- NC+ NC- VALUE...
if (tokens.length < 6) return null;
return [tokens[0], tokens[1], tokens[2], tokens[3], tokens[4], newValue].join(' ') + comment;
}
case 'F':
case 'H': {
// NAME N+ N- VNAME GAIN → replace from token 3 onward
if (tokens.length < 4) return null;
return [tokens[0], tokens[1], tokens[2], newValue].join(' ') + comment;
}
default:
return null;
}
}

View File

@ -38,6 +38,7 @@ interface NotebookStore {
loading: boolean; loading: boolean;
error: string | null; error: string | null;
saving: boolean; saving: boolean;
schematicGeneration: Map<string, number>;
// Actions // Actions
loadNotebook: (id: string) => Promise<void>; loadNotebook: (id: string) => Promise<void>;
@ -65,6 +66,7 @@ export const useNotebookStore = create<NotebookStore>((set, get) => ({
loading: false, loading: false,
error: null, error: null,
saving: false, saving: false,
schematicGeneration: new Map(),
loadNotebook: async (id: string) => { loadNotebook: async (id: string) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
@ -265,23 +267,35 @@ export const useNotebookStore = create<NotebookStore>((set, get) => ({
}, },
generateSchematic: async (cellId: string) => { generateSchematic: async (cellId: string) => {
const { notebook, notebookId, runningCells } = get(); const { notebook, notebookId, runningCells, schematicGeneration } = get();
if (!notebook || !notebookId) return; if (!notebook || !notebookId) return;
const cell = notebook.cells.find((c) => c.id === cellId); const cell = notebook.cells.find((c) => c.id === cellId);
if (!cell || cell.type !== 'spice') return; if (!cell || cell.type !== 'spice') return;
// Increment generation counter to detect stale responses
const gen = (schematicGeneration.get(cellId) ?? 0) + 1;
const newGenMap = new Map(schematicGeneration);
newGenMap.set(cellId, gen);
const newRunning = new Set(runningCells); const newRunning = new Set(runningCells);
newRunning.add(cellId); newRunning.add(cellId);
set({ runningCells: newRunning }); set({ runningCells: newRunning, schematicGeneration: newGenMap });
try { try {
const result = await api.generateSchematic(notebookId, cellId); const result = await api.generateSchematic(notebookId, cellId);
// Discard stale response if a newer generation was started
if (get().schematicGeneration.get(cellId) !== gen) return;
if (result.success && result.svg) { if (result.success && result.svg) {
const output: CellOutput = { const output: CellOutput = {
output_type: 'schematic', output_type: 'schematic',
data: { svg: result.svg, success: true }, data: {
svg: result.svg,
component_map: result.component_map || {},
success: true,
},
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
@ -305,9 +319,12 @@ export const useNotebookStore = create<NotebookStore>((set, get) => ({
}); });
} }
} catch (err) { } catch (err) {
set({ // Only show error if this is still the current generation
error: err instanceof Error ? err.message : 'Schematic generation failed', if (get().schematicGeneration.get(cellId) === gen) {
}); set({
error: err instanceof Error ? err.message : 'Schematic generation failed',
});
}
} finally { } finally {
const currentRunning = new Set(get().runningCells); const currentRunning = new Set(get().runningCells);
currentRunning.delete(cellId); currentRunning.delete(cellId);