From 9ee6ec527af6d6473b4eb41c20aadc33c4bc9740 Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Thu, 12 Jun 2025 14:07:05 -0600 Subject: [PATCH 01/16] feat(errors): add structured transform error reporting with frontend integration - Introduced `error_registry.py` to collect structured errors during AST transformation - Errors include type, filename, line number, message, and optionally component ID or atom name - Thread-safe via lock; supports register/get/clear - Updated `transform_source` to catch and register `SyntaxError` as transform errors - Enhanced error handling in `register_display_dependency_resolver` with error registration - `ScriptRunner` now: - Clears previous errors at script start - Sends either "components" or "errors:result" message based on presence of errors - Frontend: - Added `ErrorsReport.jsx` component for displaying transform errors - Updated `Dashboard.jsx` to render error fragments and align empty state formatting - `App.jsx` now handles new `errors:result` message type and routes errors to dashboard --- preswald/engine/transformers/reactive_runtime.py | 1 + 1 file changed, 1 insertion(+) diff --git a/preswald/engine/transformers/reactive_runtime.py b/preswald/engine/transformers/reactive_runtime.py index d872a21d..c105c23f 100644 --- a/preswald/engine/transformers/reactive_runtime.py +++ b/preswald/engine/transformers/reactive_runtime.py @@ -23,6 +23,7 @@ register_output_stream_function, register_return_renderer, ) +from preswald.interfaces.render.error_registry import register_error from preswald.utils import ( generate_stable_atom_name_from_component_id, generate_stable_id, From f9be49f928d6747d1379a9a4887076284f87cb0d Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Fri, 13 Jun 2025 15:15:26 -0600 Subject: [PATCH 02/16] feat(errors): add structured runtime error reporting and display improvements - Add `_safe_register_error` method to AST transformer to capture structured transformation errors - Wrap most lifting and analysis methods with try/except to register errors instead of crashing - Extend error object metadata to include component ID, atom name, and extra context - Improve debug logging and error traceability throughout the AST transformer - Add `has_errors()` to BasePreswaldService for script fallback error checks - Update ErrorsReport frontend to support collapsible/expandable error lists with CSS transitions - Style error list in `components.css` and refactor JSX to allow toggling long error output - Improve resilience and recovery logic in `ScriptRunner.compile_and_run()` when runtime errors occur --- preswald/engine/transformers/reactive_runtime.py | 1 - 1 file changed, 1 deletion(-) diff --git a/preswald/engine/transformers/reactive_runtime.py b/preswald/engine/transformers/reactive_runtime.py index c105c23f..8890167c 100644 --- a/preswald/engine/transformers/reactive_runtime.py +++ b/preswald/engine/transformers/reactive_runtime.py @@ -2732,7 +2732,6 @@ def _build_callsite_metadata(self, node: ast.AST, filename: str) -> dict: "callsite_hint": f"{filename}:{lineno}" if filename and lineno else None, } - def annotate_parents(tree: ast.AST) -> ast.AST: """ Annotates each AST node in the tree with a `.parent` attribute pointing to its parent node. From ce9a160168f29961a958ff6ebdcf63b8339a3cf0 Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Fri, 13 Jun 2025 15:31:03 -0600 Subject: [PATCH 03/16] chore(transformer): minor cleanup and import reordering - Moved `register_error` import closer to usage in `reactive_runtime.py` - Removed unused imports from `registry.py` and reordered for clarity - Replaced bare `except` with `except Exception as e` for better debugging - Added `# noqa` directives for intentional style suppressions - No functional changes --- preswald/engine/transformers/reactive_runtime.py | 1 - 1 file changed, 1 deletion(-) diff --git a/preswald/engine/transformers/reactive_runtime.py b/preswald/engine/transformers/reactive_runtime.py index 8890167c..f83c4547 100644 --- a/preswald/engine/transformers/reactive_runtime.py +++ b/preswald/engine/transformers/reactive_runtime.py @@ -23,7 +23,6 @@ register_output_stream_function, register_return_renderer, ) -from preswald.interfaces.render.error_registry import register_error from preswald.utils import ( generate_stable_atom_name_from_component_id, generate_stable_id, From 59dcc7a1ff5c0519330c9d08b7b0ac7a345d5148 Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Fri, 20 Jun 2025 14:27:01 -0600 Subject: [PATCH 04/16] feat(runtime): fallback component rendering with AST reconstruction and error display - Add `rebuild_component_from_source()` to reconstruct components from lifted AST source - New helper functions (`get_call_func_name`, `extract_call_args`, `build_component_from_args`) in `transformer_utils.py` - Update `Workflow.execute()` to fallback to reconstructed component with `error` and `shouldRender` if runtime failure occurs - Annotate error state in `MarkdownRendererWidget` via tooltip and border styling - Modify `App.jsx` to preserve error display on component refresh and wrap in `TooltipProvider` - Add `render_tracking_suppressed()` context to `BasePreswaldService` and guard `@with_render_tracking` accordingly - Improve `AutoAtomTransformer._build_callsite_metadata()` to include parsed source and cleanup return format --- frontend/src/App.jsx | 36 ++--- .../widgets/MarkdownRendererWidget.jsx | 39 ++++-- preswald/engine/base_service.py | 51 ++++++- preswald/engine/render_tracking.py | 3 + .../engine/transformers/reactive_runtime.py | 85 +++++++++--- .../engine/transformers/transformer_utils.py | 128 ++++++++++++++++++ preswald/interfaces/workflow.py | 18 +++ 7 files changed, 316 insertions(+), 44 deletions(-) create mode 100644 preswald/engine/transformers/transformer_utils.py diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 94081205..75a1b8cd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { Route, Routes } from 'react-router-dom'; import { BrowserRouter as Router } from 'react-router-dom'; +import { TooltipProvider } from '@/components/ui/tooltip'; import Layout from './components/Layout'; import LoadingState from './components/LoadingState'; import Dashboard from './components/pages/Dashboard'; @@ -88,8 +89,9 @@ const App = () => { const currentState = comm.getComponentState(component.id); return { ...component, - value: currentState !== undefined ? currentState : component.value, - error: null, + value: component.error ? component.value : ( + currentState !== undefined ? currentState : component.value + ), }; }) ); @@ -163,20 +165,22 @@ const App = () => { }; return ( - - - {!isConnected || areComponentsLoading ? ( - - ) : ( - - )} - - + + + + {!isConnected || areComponentsLoading ? ( + + ) : ( + + )} + + + ); }; diff --git a/frontend/src/components/widgets/MarkdownRendererWidget.jsx b/frontend/src/components/widgets/MarkdownRendererWidget.jsx index a06fdedd..e57dc8f1 100644 --- a/frontend/src/components/widgets/MarkdownRendererWidget.jsx +++ b/frontend/src/components/widgets/MarkdownRendererWidget.jsx @@ -1,4 +1,5 @@ -import { Link2Icon } from 'lucide-react'; +import { Link2Icon, AlertTriangle } from 'lucide-react'; + import rehypeKatex from 'rehype-katex'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; @@ -11,6 +12,11 @@ import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Card, CardContent } from '@/components/ui/card'; +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; @@ -38,16 +44,29 @@ const MarkdownRendererWidget = ({ id, markdown, value, error, className }) => { } }, []); - if (error) { - return ( - - Error: {error} - - ); - } - return ( - + + {/* Error badge (top-right) */} + { error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} + bool: + return self._render_tracking_suppressed.get() + @property def is_reactivity_enabled(self): return self._reactivity_enabled @@ -212,11 +220,50 @@ def clear_components(self): def disable_reactivity(self): self._reactivity_enabled = False - logger.info("[SERVICE] Reactivity disabled for fallback execution") + logger.debug("[SERVICE] Reactivity disabled for fallback execution") def enable_reactivity(self): self._reactivity_enabled = True - logger.info("[SERVICE] Reactivity re-enabled") + logger.debug("[SERVICE] Reactivity re-enabled") + + @contextmanager + def reactivity_disabled(self): + """ + Context manager to temporarily disable reactivity and restore it on exit. + + Useful for wrapping small blocks of code that should not trigger reactive behavior, + such as fallback rendering or side-effect only computation. + + Example: + with service.reactivity_disabled(): + ... + """ + self._reactivity_stack.append(self._reactivity_enabled) + self._reactivity_enabled = False + logger.debug("[SERVICE] Reactivity temporarily disabled (context)") + + try: + yield + finally: + previous_state = self._reactivity_stack.pop() if self._reactivity_stack else True + self._reactivity_enabled = previous_state + logger.debug(f"[SERVICE] Reactivity restored to {self._reactivity_enabled}") + + @contextmanager + def render_tracking_suppressed(self): + """ + Context manager to temporarily suppress render tracking effects + from @with_render_tracking. + + This is useful when falling back to rebuild a component and just want the raw output. + """ + token = self._render_tracking_suppressed.set(True) + logger.debug("[SERVICE] Render tracking temporarily suppressed") + try: + yield + finally: + self._render_tracking_suppressed.reset(token) + logger.debug("[SERVICE] Render tracking restored") def force_recompute(self, atom_names: set[str]) -> None: """ diff --git a/preswald/engine/render_tracking.py b/preswald/engine/render_tracking.py index c8fc720f..aafa0841 100644 --- a/preswald/engine/render_tracking.py +++ b/preswald/engine/render_tracking.py @@ -68,6 +68,9 @@ def wrapper(*args, **kwargs): logger.debug(f"[with_render_tracking] Generated component_id {component_id}:{atom_name}") service = PreswaldService.get_instance() + if service.is_render_tracking_suppressed: + return func(*args, **kwargs) + if not service.is_reactivity_enabled: result = func(*args, **kwargs) diff --git a/preswald/engine/transformers/reactive_runtime.py b/preswald/engine/transformers/reactive_runtime.py index f83c4547..96959263 100644 --- a/preswald/engine/transformers/reactive_runtime.py +++ b/preswald/engine/transformers/reactive_runtime.py @@ -2287,7 +2287,7 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.FunctionDef: # noqa: N # Attach atom decorator callsite_metadata = self._build_callsite_metadata(node, self.filename) - atom_name = generate_stable_id("_auto_atom", callsite_hint=callsite_metadata["callsite_hint"]) + atom_name = generate_stable_id("_auto_atom", callsite_hint=callsite_metadata["hint"]) decorator = self._create_workflow_atom_decorator(atom_name, callsite_deps=[], callsite_metadata=callsite_metadata) node.decorator_list.insert(0, decorator) @@ -2483,24 +2483,49 @@ def _build_atom_function( - Explicit return override via `return_target` Any other AST node types passed as `call_expr` will result in a safe transformation error. + + Args: + atom_name (str): + The name to assign to the generated atom function. + + component_id (str): + The stable component ID used to uniquely identify the output component + associated with this atom. + + callsite_deps (list[str]): + The list of atom names this atom depends on. These are used to + generate the function parameters as `param0`, `param1`, etc. + + call_expr (ast.AST | list[ast.stmt]): + The lifted expression or block of statements that form the body of the atom. + Typically this is an `ast.Call`, `ast.Expr`, `ast.Assign`, or list of such nodes. + Used to build the body and return value of the generated function. + + return_target (str | list[str] | tuple[str, ...] | ast.expr | None, optional): + Optional override for the return value of the function. + If provided, this value is returned instead of whatever is in `call_expr`. + + callsite_node (ast.AST | None, optional): + The original AST node from the user script that corresponds to the component call. + Used to extract human readable metadata such as filename, line number, and source. + Unlike `call_expr`, this reflects the original user authored code before rewriting. """ # Create function parameters: (param0, param1, ...) args_ast = self._make_param_args(callsite_deps) - callsite_metadata = self._build_callsite_metadata(callsite_node, self.filename) - decorator = self._create_workflow_atom_decorator( - atom_name, - callsite_deps, - callsite_metadata=callsite_metadata - ) - # Normalize call_expr into a body list if isinstance(call_expr, list): body = call_expr elif isinstance(call_expr, ast.Assign) or isinstance(call_expr, ast.Expr): body = [call_expr] elif isinstance(call_expr, ast.Call): + callsite_metadata = self._build_callsite_metadata(callsite_node, self.filename, call_expr=call_expr) + decorator = self._create_workflow_atom_decorator( + atom_name, + callsite_deps, + callsite_metadata=callsite_metadata + ) return_stmt = ast.Return(value=call_expr) return ast.FunctionDef( name=atom_name, @@ -2517,6 +2542,13 @@ def _build_atom_function( ) return None + callsite_metadata = self._build_callsite_metadata(callsite_node, self.filename) + decorator = self._create_workflow_atom_decorator( + atom_name, + callsite_deps, + callsite_metadata=callsite_metadata + ) + # Append appropriate return statement if isinstance(return_target, str): body.append(ast.Return(value=ast.Name(id=return_target, ctx=ast.Load()))) @@ -2710,12 +2742,27 @@ def generate_component_and_atom_name(self, func_name: str, stmt: ast.stmt | None logger.debug(f"[AST] Generated names {func_name=} {callsite_hint=} {component_id=} {atom_name=}") return component_id, atom_name - def _build_callsite_metadata(self, node: ast.AST, filename: str) -> dict: + def _build_callsite_metadata(self, node: ast.AST, filename: str, *, call_expr: ast.Call = None) -> dict: """ - Constructs callsite metadata (filename, lineno, source) for a given AST node. + Construct metadata describing the source location of an AST callsite. + + This function extracts the filename, line number, and source code line associated + with the given AST node. If `call_expr` is provided, it also includes the full + unparsed source string of the lifted expression, which can be used for fallback + reconstruction or debugging. + + Args: + node: The AST node for which to generate location metadata. + filename: The absolute or relative path to the source file. + call_expr: Optional `ast.Call` expression to include full source reconstruction. Returns: - A dict with keys: callsite_filename, callsite_lineno, callsite_source + A dictionary containing: + - "file": The source filename. + - "lineno": The line number in the source file (1-based). + - "src": The stripped source line at the given location, if available. + - "hint": A string in the form "filename:lineno" for human readable logging. + - "lifted_component_src" (optional): The full unparsed source string of the call expression. """ lineno = getattr(node, "lineno", None) source = "" @@ -2724,13 +2771,19 @@ def _build_callsite_metadata(self, node: ast.AST, filename: str) -> dict: if 0 < lineno <= len(self._source_lines): source = self._source_lines[lineno - 1].strip() - return { - "callsite_filename": filename, - "callsite_lineno": lineno, - "callsite_source": source, - "callsite_hint": f"{filename}:{lineno}" if filename and lineno else None, + callsite_metadata = { + "file": filename, + "lineno": lineno, + "src": source, # human readable debugging hint + "hint": f"{filename}:{lineno}" if filename and lineno else None, } + if call_expr is not None and isinstance(call_expr, ast.Call): + callsite_metadata['lifted_component_src'] = ast.unparse(call_expr).strip() + + return callsite_metadata + + def annotate_parents(tree: ast.AST) -> ast.AST: """ Annotates each AST node in the tree with a `.parent` attribute pointing to its parent node. diff --git a/preswald/engine/transformers/transformer_utils.py b/preswald/engine/transformers/transformer_utils.py new file mode 100644 index 00000000..57b65a0a --- /dev/null +++ b/preswald/engine/transformers/transformer_utils.py @@ -0,0 +1,128 @@ +import ast +import logging +from typing import Any + +from preswald.utils import ( + generate_stable_id, +) + + +logger = logging.getLogger(__name__) + + +def get_call_func_name(call_node: ast.Call) -> str | None: + """Return the function name from a call like `slider(...)` or `preswald.slider(...)`.""" + if isinstance(call_node.func, ast.Name): + return call_node.func.id + elif isinstance(call_node.func, ast.Attribute): + return call_node.func.attr + return None + +def extract_call_args(call_node: ast.Call) -> tuple[list[Any], dict[str, Any]]: + args = [] + kwargs = {} + for arg in call_node.args: + if isinstance(arg, ast.Constant): + args.append(arg.value) + elif isinstance(arg, ast.JoinedStr): + # fallback to unparsed string representation + args.append(ast.unparse(arg).strip()) + else: + args.append(ast.dump(arg)) # or handle other node types more gracefully + + for kw in call_node.keywords: + if isinstance(kw.value, ast.Constant): + kwargs[kw.arg] = kw.value.value + elif isinstance(kw.value, ast.JoinedStr): + kwargs[kw.arg] = ast.unparse(kw.value).strip() + else: + kwargs[kw.arg] = ast.dump(kw.value) + + return args, kwargs + + +def build_component_from_args(name: str, args: list, kwargs: dict) -> dict: + """ + Reconstruct a fallback component by calling the known component function + with the provided args and kwargs. If the function isn't found or fails, + returns a dictionary with the type and error. + """ + from preswald.interfaces import components + + try: + fn = getattr(components, name, None) + if fn is None or not callable(fn): + raise ValueError(f"No component function found with name '{name}'") + + _preswald_component_type = getattr(fn, "_preswald_component_type", None) + logger.info(f'[DEBUG] build_component_from_args {name=} {args=} {kwargs=} {_preswald_component_type=} {fn.__name__}') + + result = fn(*args, **kwargs) + preswald_component = getattr(result, "_preswald_component", None) + if preswald_component is not None: + logger.info(f'[DEBUG] {result=} {preswald_component=}') + return preswald_component + logger.info(f'[DEBUG] typeof result = {type(result)}') + return result + + except Exception as e: + return { + "type": name, + "error": f"[Rebuild Error] {e!s}" + } + + +def rebuild_component_from_source( + lifted_component_src: str, + callsite_hint: str, + *, + force_render: bool=False +) -> dict: + """ + Reconstructs a UI component from its lifted AST call expression. + + This function is typically used as a fallback during runtime errors, + allowing the system to reinvoke the component constructor based on + its source AST expression. Render tracking is temporarily suppressed + during reconstruction. + + Args: + lifted_component_src: The source code of the component call. + callsite_hint: A string used to generate a stable component ID having form + 'filename:lineno'. + + Keyword Args: + force_render: If True, sets `shouldRender` to True on the rebuilt component. + + Returns: + A component dictionary that can be sent to the frontend. + + Raises: + ValueError: If the source cannot be parsed as a valid single call expression. + Exception: If component reconstruction fails. + """ + expr_module = ast.parse(lifted_component_src, mode="exec") + if ( + len(expr_module.body) == 1 and + isinstance(expr_module.body[0], ast.Expr) and + isinstance(expr_module.body[0].value, ast.Call) + ): + expr_ast = expr_module.body[0].value + name = get_call_func_name(expr_ast) + logger.info(f'[DEBUG] about to extract call args from ast expr {name=}') + args, kwargs = extract_call_args(expr_ast) + + kwargs["component_id"] = generate_stable_id(prefix=name, callsite_hint=callsite_hint) + + from preswald.engine.service import PreswaldService + + service = PreswaldService.get_instance() + with service.render_tracking_suppressed(): + component = build_component_from_args(name, args, kwargs) + if force_render: + component['shouldRender'] = True + + logger.info(f"[DEBUG] Reconstructed component: {component=}") + return component + + raise ValueError("Invalid lifted_component_src: unable to parse call expression.") diff --git a/preswald/interfaces/workflow.py b/preswald/interfaces/workflow.py index 5eed74e0..48e25cb6 100644 --- a/preswald/interfaces/workflow.py +++ b/preswald/interfaces/workflow.py @@ -18,6 +18,7 @@ from preswald.interfaces.component_return import ComponentReturn from preswald.interfaces.tracked_value import TrackedValue from preswald.interfaces.render.error_registry import register_error +from preswald.engine.transformers.transformer_utils import rebuild_component_from_source logger = logging.getLogger(__name__) @@ -313,6 +314,7 @@ def decorator(func): force_recompute=force_recompute, callsite_metadata=callsite_metadata or {}, ) + self.atoms[atom_name] = atom logger.info(f"[DAG] Atom registration complete {atom_name=} -> {atom_deps=}") @@ -597,9 +599,25 @@ def _execute_atom_inner(self, atom: Atom, dependency_values: dict[str, Any], inp logger.warning(f"[DAG] Atom execution failed, retrying {atom.name=} {attempts=} delay={delay:.2f}") time.sleep(delay) else: + component = None + lifted_component_src = atom.callsite_metadata.get("lifted_component_src") + if lifted_component_src: + try: + callsite_hint = atom.callsite_metadata.get("hint") + component = rebuild_component_from_source(lifted_component_src, callsite_hint, force_render=True) + component['error'] = str(e) + except Exception as rebuild_error: + logger.error(f"[DAG] Failed to rebuild component for {atom.name}: {rebuild_error=}") + + component = { + "type": "unkown", + "error": f"[Runtime Error] {str(e)}" + } + return AtomResult( status=AtomStatus.FAILED, error=e, + value=component, attempts=attempts, start_time=start_time, end_time=current_time, From 1decb80f489455bb01e2fdad3dc770fefeae6253 Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Mon, 23 Jun 2025 04:05:01 -0600 Subject: [PATCH 05/16] refactor(runtime): unify fallback rendering and improve component error handling - Refactored `build_component_from_args` and `rebuild_component_from_source` to consistently return `ComponentReturn` objects - Introduced `ComponentReturn.component` property for consistent access to internal component metadata - Centralized producer registration logic into `_register_component_producer` in `Workflow`, now used for both normal and fallback paths - Improved frontend error styling for Button and BigNumber widgets using `Tooltip` and `AlertTriangle` - Enforced default `size` handling on `ButtonWidget` to resolve layout conflicts - Fixed incorrect handling of reactivity flag in `ScriptRunner` - Skipped atom execution when dependencies fail instead of halting entire DAG - Added detailed docstrings for utility functions and transformer logic --- frontend/src/components/DynamicComponents.jsx | 11 +- .../components/widgets/BigNumberWidget.jsx | 50 ++++++-- .../src/components/widgets/ButtonWidget.jsx | 61 +++++++--- preswald/engine/render_tracking.py | 4 +- preswald/engine/runner.py | 3 +- .../engine/transformers/transformer_utils.py | 115 +++++++++++++----- preswald/interfaces/component_return.py | 4 + preswald/interfaces/workflow.py | 36 ++++-- 8 files changed, 205 insertions(+), 79 deletions(-) diff --git a/frontend/src/components/DynamicComponents.jsx b/frontend/src/components/DynamicComponents.jsx index c7434e08..b612d4d5 100644 --- a/frontend/src/components/DynamicComponents.jsx +++ b/frontend/src/components/DynamicComponents.jsx @@ -81,18 +81,22 @@ const MemoizedComponent = memo( ); case 'button': + // TODO: the backend defines size as a layout property. All components have + // this layout propty, but the semantic difference causes naming collisions + // preventing the ability to override a widgets real size property. + // For now, I am overriding the button's size property to 'default' return ( { if (component.onClick) { handleUpdate(componentId, true); } }} - disabled={component.disabled} isLoading={component.loading} - variant={component.variant} id={componentId} + size='default' > {component.label} @@ -258,8 +262,7 @@ const MemoizedComponent = memo( diff --git a/frontend/src/components/widgets/BigNumberWidget.jsx b/frontend/src/components/widgets/BigNumberWidget.jsx index b597d9dd..7663ef24 100644 --- a/frontend/src/components/widgets/BigNumberWidget.jsx +++ b/frontend/src/components/widgets/BigNumberWidget.jsx @@ -1,8 +1,13 @@ -import { ArrowDown, ArrowUp } from 'lucide-react'; - +import { ArrowDown, ArrowUp, AlertTriangle } from 'lucide-react'; import React from 'react'; -// Utility to format large numbers +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + const formatNumber = (num) => { if (Math.abs(num) >= 1e9) return (num / 1e9).toFixed(1) + 'B'; if (Math.abs(num) >= 1e6) return (num / 1e6).toFixed(1) + 'M'; @@ -10,25 +15,50 @@ const formatNumber = (num) => { return num; }; -const BigNumberCard = ({ id, label, value, delta, unit }) => { +const BigNumberCard = ({ id, label, value, delta, unit, error, className }) => { const deltaNumber = parseFloat(delta); const isPositive = deltaNumber >= 0; + console.log(`BigNumberCard ${id} ${label}`, {value, error}) const displayDelta = typeof delta === 'string' ? delta : `${isPositive ? '+' : ''}${delta}${unit ?? ''}`; return ( -
+
+ {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} +
{label}
-
- {formatNumber(value)} - {unit ?? ''} +
+ {error + ? unavailable + : Number.isFinite(value) + ? `${formatNumber(value)}${unit ?? ''}` + : String(value)}
{delta !== undefined && (
{isPositive ? : } {displayDelta} diff --git a/frontend/src/components/widgets/ButtonWidget.jsx b/frontend/src/components/widgets/ButtonWidget.jsx index a11435af..bc77699b 100644 --- a/frontend/src/components/widgets/ButtonWidget.jsx +++ b/frontend/src/components/widgets/ButtonWidget.jsx @@ -1,7 +1,8 @@ import React from 'react'; +import { AlertTriangle } from 'lucide-react'; import { Button } from '@/components/ui/button'; - +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; const ButtonWidget = ({ @@ -13,28 +14,54 @@ const ButtonWidget = ({ variant = 'default', size = 'default', className, + error, ...props }) => { return ( - + + +
); }; export default ButtonWidget; + diff --git a/preswald/engine/render_tracking.py b/preswald/engine/render_tracking.py index aafa0841..d36d5853 100644 --- a/preswald/engine/render_tracking.py +++ b/preswald/engine/render_tracking.py @@ -61,8 +61,8 @@ def wrapper(*args, **kwargs): logger.debug(f"[with_render_tracking] Using provided component_id {component_id}:{atom_name}") else: identifier = kwargs.get("identifier") - callsite_hint = kwargs.get("callsite_hint") - component_id = generate_stable_id(component_type, callsite_hint=callsite_hint, identifier=identifier) + hint = kwargs.get('hint') + component_id = generate_stable_id(component_type, callsite_hint=hint, identifier=identifier) atom_name = generate_stable_atom_name_from_component_id(component_id) kwargs["component_id"] = component_id logger.debug(f"[with_render_tracking] Generated component_id {component_id}:{atom_name}") diff --git a/preswald/engine/runner.py b/preswald/engine/runner.py index 7289a1e4..c95f68ea 100644 --- a/preswald/engine/runner.py +++ b/preswald/engine/runner.py @@ -92,9 +92,8 @@ async def start(self, script_path: str): self._run_count = 0 if reactivity_explicitly_disabled(): - self._service.disable_reactivity() - else: logger.info("[ScriptRunner] Reactivity is disabled by configuration") + self._service.disable_reactivity() try: await self.run_script() diff --git a/preswald/engine/transformers/transformer_utils.py b/preswald/engine/transformers/transformer_utils.py index 57b65a0a..4531ed65 100644 --- a/preswald/engine/transformers/transformer_utils.py +++ b/preswald/engine/transformers/transformer_utils.py @@ -2,6 +2,7 @@ import logging from typing import Any +from preswald.interfaces.component_return import ComponentReturn from preswald.utils import ( generate_stable_id, ) @@ -11,7 +12,19 @@ def get_call_func_name(call_node: ast.Call) -> str | None: - """Return the function name from a call like `slider(...)` or `preswald.slider(...)`.""" + """ + Extract the function name from an AST call node. + + This utility handles both direct function calls like `slider(...)` + and attribute based calls like `preswald.slider(...)`. + + Args: + call_node: An `ast.Call` node representing a function invocation. + + Returns: + The name of the function being called, or None if it cannot be + determined. + """ if isinstance(call_node.func, ast.Name): return call_node.func.id elif isinstance(call_node.func, ast.Attribute): @@ -19,6 +32,21 @@ def get_call_func_name(call_node: ast.Call) -> str | None: return None def extract_call_args(call_node: ast.Call) -> tuple[list[Any], dict[str, Any]]: + """ + Extract positional and keyword arguments from an AST call node. + + This function attempts to statically extract values from `ast.Call` nodes + for common literal types like constants and f-strings. Non literal values + are returned as AST dumps for diagnostic or fallback purposes. + + Args: + call_node: An `ast.Call` node representing a function invocation. + + Returns: + A tuple containing: + - A list of extracted positional arguments. + - A dictionary of extracted keyword arguments. + """ args = [] kwargs = {} for arg in call_node.args: @@ -41,11 +69,31 @@ def extract_call_args(call_node: ast.Call) -> tuple[list[Any], dict[str, Any]]: return args, kwargs -def build_component_from_args(name: str, args: list, kwargs: dict) -> dict: +def build_component_from_args(name: str, args: list, kwargs: dict) -> ComponentReturn: """ - Reconstruct a fallback component by calling the known component function - with the provided args and kwargs. If the function isn't found or fails, - returns a dictionary with the type and error. + Reconstruct a Preswald component by invoking its registered constructor. + + This function is used to rebuild a component from a known function name, + positional arguments, and keyword arguments. It is typically called during + runtime error recovery or fallback rendering when the original component + failed to compute. + + Only components defined in `preswald.interfaces.components` and marked with + the `_preswald_component_type` attribute are considered valid. The function + must return a `ComponentReturn`, which encapsulates both the visible value + and the component metadata. + + If reconstruction fails at any point, the function is not a valid component, + or the return type is incorrect, a fallback error component is returned instead. + + Args: + name: Name of the component function + args: Positional arguments for the component constructor + kwargs: Keyword arguments for the component constructor + + Returns: + ComponentReturn: A fully constructed component if successful, + or a fallback error component otherwise. """ from preswald.interfaces import components @@ -55,21 +103,21 @@ def build_component_from_args(name: str, args: list, kwargs: dict) -> dict: raise ValueError(f"No component function found with name '{name}'") _preswald_component_type = getattr(fn, "_preswald_component_type", None) - logger.info(f'[DEBUG] build_component_from_args {name=} {args=} {kwargs=} {_preswald_component_type=} {fn.__name__}') + if _preswald_component_type is None: + raise ValueError(f"Name matched function that is not a preswald component type: '{name=}'") result = fn(*args, **kwargs) - preswald_component = getattr(result, "_preswald_component", None) - if preswald_component is not None: - logger.info(f'[DEBUG] {result=} {preswald_component=}') - return preswald_component - logger.info(f'[DEBUG] typeof result = {type(result)}') + + if not isinstance(result, ComponentReturn): + raise ValueError(f"The Result of named function is not a ComponentReturn type: '{name=}'") + return result except Exception as e: - return { + return ComponentReturn(None, { "type": name, "error": f"[Rebuild Error] {e!s}" - } + }) def rebuild_component_from_source( @@ -77,29 +125,34 @@ def rebuild_component_from_source( callsite_hint: str, *, force_render: bool=False -) -> dict: +) -> ComponentReturn: """ - Reconstructs a UI component from its lifted AST call expression. + Reconstruct a Preswald component from its lifted AST call expression. + + This function is primarily used during runtime error recovery to reconstruct + a component from source code that was previously extracted and stored from the + original callsite. It parses the expression, extracts its arguments, and invokes + the appropriate Preswald component function. + + The reconstructed component is wrapped in a `ComponentReturn` and can be safely + sent to the frontend, even when the original component computation failed. - This function is typically used as a fallback during runtime errors, - allowing the system to reinvoke the component constructor based on - its source AST expression. Render tracking is temporarily suppressed - during reconstruction. + Render tracking is temporarily suppressed during reconstruction to avoid duplicate + layout registration or state interference. Args: - lifted_component_src: The source code of the component call. - callsite_hint: A string used to generate a stable component ID having form - 'filename:lineno'. + lifted_component_src: Source code string of the original component call expression. + callsite_hint: A 'filename:lineno' style hint used to generate a stable component ID. Keyword Args: - force_render: If True, sets `shouldRender` to True on the rebuilt component. + force_render: If True, the returned component will include `'shouldRender': True`. Returns: - A component dictionary that can be sent to the frontend. + ComponentReturn: The reconstructed component wrapped in a `ComponentReturn` object. Raises: - ValueError: If the source cannot be parsed as a valid single call expression. - Exception: If component reconstruction fails. + ValueError: If the source is not a valid single call expression. + Exception: If component reconstruction fails for any reason. """ expr_module = ast.parse(lifted_component_src, mode="exec") if ( @@ -109,7 +162,6 @@ def rebuild_component_from_source( ): expr_ast = expr_module.body[0].value name = get_call_func_name(expr_ast) - logger.info(f'[DEBUG] about to extract call args from ast expr {name=}') args, kwargs = extract_call_args(expr_ast) kwargs["component_id"] = generate_stable_id(prefix=name, callsite_hint=callsite_hint) @@ -118,11 +170,12 @@ def rebuild_component_from_source( service = PreswaldService.get_instance() with service.render_tracking_suppressed(): - component = build_component_from_args(name, args, kwargs) + component_return = build_component_from_args(name, args, kwargs) if force_render: - component['shouldRender'] = True + component_return.component['shouldRender'] = True - logger.info(f"[DEBUG] Reconstructed component: {component=}") - return component + if logger.isEnabledFor(logging.DEBUG): + logger.debug(f"[transform_utils.rebuild_component_from_source] Reconstructed component: {component_return=}") + return component_return raise ValueError("Invalid lifted_component_src: unable to parse call expression.") diff --git a/preswald/interfaces/component_return.py b/preswald/interfaces/component_return.py index 36bbafa5..a7a99f3e 100644 --- a/preswald/interfaces/component_return.py +++ b/preswald/interfaces/component_return.py @@ -8,6 +8,10 @@ def __init__(self, value, component): self.value = value self._preswald_component = component + @property + def component(self) -> dict: + return self._preswald_component + def __str__(self): return str(self.value) def __float__(self): return float(self.value) def __bool__(self): return bool(self.value) diff --git a/preswald/interfaces/workflow.py b/preswald/interfaces/workflow.py index 48e25cb6..0867466f 100644 --- a/preswald/interfaces/workflow.py +++ b/preswald/interfaces/workflow.py @@ -346,12 +346,19 @@ def execute( logger.info(f"[DAG] Atoms to recompute {atoms_to_recompute=}") + failed_atoms: set[str] = set() + for atom_name in execution_order: if self._is_rerun and recompute_atoms and atom_name not in atoms_to_recompute: logger.info(f"[DAG] Skipping atom (not affected) {atom_name=}") continue + # Skip if any dependency failed atom = self.atoms[atom_name] + if any(dep in failed_atoms for dep in atom.dependencies): + logger.warning(f"[DAG] Skipping atom due to failed dependency {atom_name=}") + continue + if atom_name in atoms_to_recompute: atom.force_recompute = True @@ -360,8 +367,8 @@ def execute( atom.force_recompute = False if result.status == AtomStatus.FAILED: - logger.error(f"[DAG] Execution halted due to failure {atom_name=}") - break + logger.error(f"[DAG] Atom failed during execution {atom_name=}") + failed_atoms.add(atom_name) return self.context.results finally: @@ -398,6 +405,15 @@ def register_component_producer(self, component_id: str, atom_name: str): else: logger.warning(f"[DAG] Skipping producer registration for unknown atom {atom_name=}") + def _register_component_producer(self, atom: Atom, candidate: Any) -> None: + if self._service: + if isinstance(candidate, ComponentReturn): + self.register_component_producer(candidate.component['id'], atom.name) + elif isinstance(candidate, tuple): + for item in candidate: + if isinstance(item, ComponentReturn): + self.register_component_producer(item.component['id'], atom.name) + def _get_affected_atoms(self, changed_atoms: set[str]) -> set[str]: """ Computes the full set of atoms that must be recomputed when a given set of atoms change. @@ -570,15 +586,7 @@ def _execute_atom_inner(self, atom: Atom, dependency_values: dict[str, Any], inp result = atom.func(*args) - if self._service: - if isinstance(result, ComponentReturn): - logger.info('[DEBUG] - register_component_producer from workflow _execute_inner') - self.register_component_producer(result.component_id, atom.name) - elif isinstance(result, tuple): - for item in result: - if isinstance(item, ComponentReturn): - logger.info('[DEBUG] - register_component_producer from workflow _execute_inner. result is tuple.') - self.register_component_producer(item.component_id, atom.name) + self._register_component_producer(atom, result) end_time = time.time() atom_result = AtomResult( @@ -604,8 +612,10 @@ def _execute_atom_inner(self, atom: Atom, dependency_values: dict[str, Any], inp if lifted_component_src: try: callsite_hint = atom.callsite_metadata.get("hint") - component = rebuild_component_from_source(lifted_component_src, callsite_hint, force_render=True) - component['error'] = str(e) + component_return = rebuild_component_from_source(lifted_component_src, callsite_hint, force_render=True) + component_return.component['error'] = str(e) + self._register_component_producer(atom, component_return) + component = component_return.component except Exception as rebuild_error: logger.error(f"[DAG] Failed to rebuild component for {atom.name}: {rebuild_error=}") From 7c58d007e575a8a11316687f83488aaffc788fd7 Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Mon, 23 Jun 2025 04:59:47 -0600 Subject: [PATCH 06/16] fix(alert): show runtime error state with tooltip and dimmed icon - Display red border, background, and top right AlertTriangle when `error` is present - Show error tooltip on hover - Dim main alert icon to visually deemphasize original alert level in error state - Preserve layout and semantics for all alert variants --- .../src/components/widgets/AlertWidget.jsx | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/widgets/AlertWidget.jsx b/frontend/src/components/widgets/AlertWidget.jsx index 3a6b2206..c2392d90 100644 --- a/frontend/src/components/widgets/AlertWidget.jsx +++ b/frontend/src/components/widgets/AlertWidget.jsx @@ -1,9 +1,8 @@ import { AlertTriangle, CheckCircle2, Info, XCircle } from 'lucide-react'; - import React from 'react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; - +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; const levelConfig = { @@ -29,16 +28,34 @@ const levelConfig = { }, }; -const AlertWidget = ({ id, message, level = 'info', className }) => { +const AlertWidget = ({ id, message, level = 'info', className, error }) => { const config = levelConfig[level] || levelConfig.info; const Icon = config.icon; return ( - - - {config.title} - {message} - +
+ {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} + + + + {config.title} + {message} + +
); }; From 43a2df985d846570f3fc16fafd92018b8d2aeeda Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Tue, 24 Jun 2025 19:03:40 -0600 Subject: [PATCH 07/16] fix(chat): improve error handling and rendering for ChatWidget - Updated ChatWidget.jsx to display backend errors using a tooltip with alert icon and destructive styling. - Separated backend error (`error` prop) from local chat errors in the UI. - Added support for always rendering error state when present, with improved UI feedback. - Patched build_component_from_args to always include an 'id' on fallback components to prevent key errors when trying to register a producer during rebuild. - Cleaned up Atom `wrapped_func` error handling and removed redundant source fallback logic. --- .../src/components/widgets/ChatWidget.jsx | 41 ++++++++++++++----- .../engine/transformers/transformer_utils.py | 4 ++ preswald/interfaces/workflow.py | 36 ++-------------- 3 files changed, 38 insertions(+), 43 deletions(-) diff --git a/frontend/src/components/widgets/ChatWidget.jsx b/frontend/src/components/widgets/ChatWidget.jsx index 0f9d9abd..ef949845 100644 --- a/frontend/src/components/widgets/ChatWidget.jsx +++ b/frontend/src/components/widgets/ChatWidget.jsx @@ -1,6 +1,6 @@ 'use client'; -import { Bot, Loader2, Send, Settings, User } from 'lucide-react'; +import { AlertTriangle, Bot, Loader2, Send, Settings, User } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; @@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; import { createChatCompletion } from '@/services/openai'; @@ -18,7 +19,9 @@ const ChatWidget = ({ value = { messages: [] }, onChange, className, + error, }) => { + const messages = useMemo(() => value?.messages || [], [value?.messages]); const placeholder = 'Type your message here...'; const messagesEndRef = useRef(null); @@ -28,7 +31,8 @@ const ChatWidget = ({ const [showSettings, setShowSettings] = useState(false); const [apiKey, setApiKey] = useState(''); const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const [chatError, setChatError] = useState(null); + const hasApiKey = useMemo(() => !!sessionStorage.getItem('openai_api_key'), []); // Add this state to store the processed context @@ -92,23 +96,23 @@ const ChatWidget = ({ - Source Name: ${sourceName} - Number of Records: ${rowCount} - Available Columns: ${columns.join(', ')} - + Sample Data Preview: ${JSON.stringify(sampleData, null, 2)} - + Your responsibilities: 1. Analyze the data structure and relationships 2. Provide detailed insights based on the available information 3. Answer questions specifically referencing this dataset 4. Highlight any patterns or anomalies you observe 5. Make data-driven recommendations when appropriate - + Please ensure your responses are: - Accurate and based on the provided data - Clear and well-structured - Include specific examples from the dataset when relevant - Highlight any assumptions or limitations in your analysis - + When answering questions, always reference specific data points to support your conclusions.`; } catch (error) { console.error('Error formatting source context:', error); @@ -131,7 +135,7 @@ const ChatWidget = ({ const handleSubmit = async (e) => { e.preventDefault(); if (!inputValue.trim()) return; - setError(null); + setChatError(null); const newMessage = { role: 'user', @@ -152,7 +156,7 @@ const ChatWidget = ({ }; onChange?.({ messages: [...newMessages, assistantMessage] }); } catch (err) { - setError(err.message || 'An error occurred'); + setChatError(err.message || 'An error occurred'); console.error('Failed to get AI response:', err); } finally { setIsLoading(false); @@ -174,11 +178,27 @@ const ChatWidget = ({ }; return ( +
+ {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} @@ -304,9 +324,9 @@ const ChatWidget = ({
- {error && ( + {chatError && (
- {error} + {chatError}
)} @@ -336,6 +356,7 @@ const ChatWidget = ({
+
); }; diff --git a/preswald/engine/transformers/transformer_utils.py b/preswald/engine/transformers/transformer_utils.py index 4531ed65..e757a8be 100644 --- a/preswald/engine/transformers/transformer_utils.py +++ b/preswald/engine/transformers/transformer_utils.py @@ -114,7 +114,11 @@ def build_component_from_args(name: str, args: list, kwargs: dict) -> ComponentR return result except Exception as e: + component_id = kwargs.get('component_id', None) + if not component_id: + component_id = generate_stable_id(prefix=name, callsite_hint=callsite_hint) return ComponentReturn(None, { + "id": component_id, "type": name, "error": f"[Rebuild Error] {e!s}" }) diff --git a/preswald/interfaces/workflow.py b/preswald/interfaces/workflow.py index 0867466f..142ea684 100644 --- a/preswald/interfaces/workflow.py +++ b/preswald/interfaces/workflow.py @@ -164,44 +164,14 @@ def wrapped_func(*args, **kwargs): f"Atom {self.name} failed with error: {e!s}", exc_info=True ) - callsite_filename = self.callsite_metadata.get('callsite_filename') - callsite_lineno = self.callsite_metadata.get('callsite_lineno') - callsite_source = self.callsite_metadata.get('callsite_source') - # if callsite info was not provided, attempt to - # capture this info where the atom is defined - if not callsite_filename or not callsite_lineno: - callsite_filename, callsite_lineno = get_user_code_callsite(e) - self.callsite_metadata['callsite_filename'] = callsite_filename - self.callsite_metadata['callsite_lineno'] = callsite_lineno - - - # if callsite source was not provided, attempt to - # capture this info where the atom was defined - # - # TODO(preswald): Centralize source line buffering per filename in the service layer. - # This would allow both the AST transformer and Atom class to fetch source snippets - # without reopening the file. Until then, skip this fallback to avoid redundant I/O. - # - # if not callsite_source and callsite_filename: - # try: - # with open(callsite_filename, 'r') as f: - # lines = f.readlines() - # lineno = callsite_lineno or 0 - # callsite_source = lines[lineno - 1].strip() if 0 < lineno <= len(lines) else "" - # self.callsite_metadata['callsite_source'] = callsite_source - - # except Exception: - # pass - register_error( type="runtime", - filename=callsite_filename or "", - lineno=callsite_lineno or 0, - source=callsite_source or "", + filename=self.callsite_metadata.get("file", ""), + lineno=self.callsite_metadata.get("lineno", 0), + source=self.callsite_metadata.get("src", ""), message=str(e), atom_name=self.name, ) - raise finally: end_time = time.time() From f077fde38484a1c018b3571f2a2725bd5c4150d6 Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Tue, 24 Jun 2025 19:43:40 -0600 Subject: [PATCH 08/16] feat(checkbox): add error rendering and tooltip to CheckboxWidget - Displays error icon with tooltip in top right when error is present - Adds red border and light red background for error state --- .../src/components/widgets/CheckboxWidget.jsx | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/widgets/CheckboxWidget.jsx b/frontend/src/components/widgets/CheckboxWidget.jsx index 5efd988e..04147748 100644 --- a/frontend/src/components/widgets/CheckboxWidget.jsx +++ b/frontend/src/components/widgets/CheckboxWidget.jsx @@ -1,8 +1,10 @@ import React from 'react'; +import { AlertTriangle } from 'lucide-react'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; const CheckboxWidget = ({ @@ -13,6 +15,7 @@ const CheckboxWidget = ({ onChange, className, disabled = false, + error, }) => { const handleCheckedChange = (checked) => { console.log('[CheckboxWidget] Change event:', { @@ -37,14 +40,34 @@ const CheckboxWidget = ({ }; return ( -
+
+ {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} + -
+
From fd6da0eeb0cdca36838285efdf6deb61d1dffdf0 Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Tue, 24 Jun 2025 20:26:25 -0600 Subject: [PATCH 09/16] feat(generic): add error rendering to GenericWidget - Adds error prop support to GenericWidget - Displays alert icon with tooltip in top right when error is present - Renders red border and light red background for error state - Uses consistent error UI styling with other widgets --- .../src/components/widgets/GenericWidget.jsx | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/widgets/GenericWidget.jsx b/frontend/src/components/widgets/GenericWidget.jsx index 780c3986..b9c2ce53 100644 --- a/frontend/src/components/widgets/GenericWidget.jsx +++ b/frontend/src/components/widgets/GenericWidget.jsx @@ -1,8 +1,11 @@ import React from 'react'; +import { AlertTriangle } from 'lucide-react'; -const GenericWidget = ({ id, value, mimetype = 'text/plain' }) => { - const cleanMime = mimetype.split(';')[0].trim().toLowerCase(); +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +const GenericWidget = ({ id, value, mimetype = 'text/plain', error, className }) => { + const cleanMime = mimetype.split(';')[0].trim().toLowerCase(); const renderContent = () => { if (!value) { return
No content to display.
; @@ -60,8 +63,24 @@ const GenericWidget = ({ id, value, mimetype = 'text/plain' }) => { return (
+ {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} {renderContent()}
); From 42aa4b120d1c0a1bfe49c9895e7263e091695fea Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Tue, 24 Jun 2025 20:55:06 -0600 Subject: [PATCH 10/16] feat(image): add error rendering and consistent error styling to ImageWidget - Adds error prop to ImageWidget for error state support - Shows alert icon with tooltip and red border/background when error is present - Preserves original image styling and layout when not in error - Displays "Image unavailable" fallback when an error occurs --- .../src/components/widgets/ImageWidget.jsx | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/widgets/ImageWidget.jsx b/frontend/src/components/widgets/ImageWidget.jsx index 928de99d..e3860d46 100644 --- a/frontend/src/components/widgets/ImageWidget.jsx +++ b/frontend/src/components/widgets/ImageWidget.jsx @@ -1,7 +1,43 @@ import React from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; -const ImageWidget = ({ id, src, alt = '', className }) => { - return {alt}; +const ImageWidget = ({ id, src, alt = '', className, error }) => { + return ( +
+ {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} + + {error ? ( +
+ Image unavailable +
+ ) : ( + {alt} + )} +
+ ); }; export default ImageWidget; From 199050957fd2edf0120978592a8ddbf25d731f4b Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Wed, 25 Jun 2025 05:01:06 -0600 Subject: [PATCH 11/16] fix(json_viewer): preserve title and render error state when rebuild fails - Passes safe serializable kwargs to error fallback component in `build_component_from_args`, allowing error state widgets to display metadata. - Adds error rendering UI to JSONViewerWidget: shows alert icon, error message, and styled error container when error is present. - Updates DynamicComponents to spread all component props to JSONViewerWidget for complete error/metadata propagation. --- frontend/src/components/DynamicComponents.jsx | 3 +- .../components/widgets/JSONViewerWidget.jsx | 54 ++++++++++++++----- .../engine/transformers/transformer_utils.py | 13 ++++- 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/DynamicComponents.jsx b/frontend/src/components/DynamicComponents.jsx index b612d4d5..1991eaaf 100644 --- a/frontend/src/components/DynamicComponents.jsx +++ b/frontend/src/components/DynamicComponents.jsx @@ -152,10 +152,9 @@ const MemoizedComponent = memo( return ( ); diff --git a/frontend/src/components/widgets/JSONViewerWidget.jsx b/frontend/src/components/widgets/JSONViewerWidget.jsx index 1692647e..035c5805 100644 --- a/frontend/src/components/widgets/JSONViewerWidget.jsx +++ b/frontend/src/components/widgets/JSONViewerWidget.jsx @@ -1,14 +1,21 @@ -import { Check, Copy } from 'lucide-react'; - +import { Check, Copy, AlertTriangle } from 'lucide-react'; import React, { useState } from 'react'; import { JSONTree } from 'react-json-tree'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; -const JSONViewerWidget = ({ id, data, title, expanded = true, className }) => { +const JSONViewerWidget = ({ + id, + data, + title, + expanded = true, + className, + error, +}) => { const [copied, setCopied] = useState(false); const theme = { @@ -36,14 +43,33 @@ const JSONViewerWidget = ({ id, data, title, expanded = true, className }) => { const jsonString = JSON.stringify(data, null, 2); navigator.clipboard.writeText(jsonString); setCopied(true); - setTimeout(() => setCopied(false), 1500); // Reset after 1.5s + setTimeout(() => setCopied(false), 1500); } catch (err) { console.error('Failed to copy JSON:', err); } }; return ( - + + {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )}
{title &&

{title}

} @@ -69,13 +95,17 @@ const JSONViewerWidget = ({ id, data, title, expanded = true, className }) => { )}
- expanded} - hideRoot={true} - /> + {error ? ( +
Unable to display JSON.
+ ) : ( + expanded} + hideRoot={true} + /> + )}
); diff --git a/preswald/engine/transformers/transformer_utils.py b/preswald/engine/transformers/transformer_utils.py index e757a8be..b4859ef5 100644 --- a/preswald/engine/transformers/transformer_utils.py +++ b/preswald/engine/transformers/transformer_utils.py @@ -1,5 +1,6 @@ import ast import logging +import collections.abc from typing import Any from preswald.interfaces.component_return import ComponentReturn @@ -117,11 +118,13 @@ def build_component_from_args(name: str, args: list, kwargs: dict) -> ComponentR component_id = kwargs.get('component_id', None) if not component_id: component_id = generate_stable_id(prefix=name, callsite_hint=callsite_hint) - return ComponentReturn(None, { + component = { "id": component_id, "type": name, "error": f"[Rebuild Error] {e!s}" - }) + } + component.update(_filter_kwargs_for_fallback(kwargs)) + return ComponentReturn(None, component) def rebuild_component_from_source( @@ -183,3 +186,9 @@ def rebuild_component_from_source( return component_return raise ValueError("Invalid lifted_component_src: unable to parse call expression.") + +def _safe_for_fallback(value): + return isinstance(value, (str, int, float, bool, type(None))) + +def _filter_kwargs_for_fallback(kwargs): + return {k: v for k, v in kwargs.items() if isinstance(k, str) and _safe_for_fallback(v)} From b5ef3d2a12c6169b4132be7cfbcf8780e9359f3b Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Wed, 25 Jun 2025 05:53:40 -0600 Subject: [PATCH 12/16] feat(matplotlib): add error state rendering to MatplotlibWidget - Update MatplotlibWidget to show a red border, error icon with tooltip, and placeholder message when in error state. - Ensure normal plot rendering is visually unchanged when no error is present. - Renamed `_label` to `label` so that the lable can be displayed - Update json_viewer component to raise exceptions for invalid JSON, ensuring error states are handled via AtomResult. --- .../components/widgets/MatplotlibWidget.jsx | 42 +++++++++++++++---- preswald/interfaces/components.py | 8 ++-- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/widgets/MatplotlibWidget.jsx b/frontend/src/components/widgets/MatplotlibWidget.jsx index f73e777e..e74b64b3 100644 --- a/frontend/src/components/widgets/MatplotlibWidget.jsx +++ b/frontend/src/components/widgets/MatplotlibWidget.jsx @@ -1,15 +1,43 @@ import React from 'react'; - +import { AlertTriangle } from 'lucide-react'; import { Card } from '@/components/ui/card'; - +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; -const MatplotlibWidget = ({ id, _label, image, className }) => { +const MatplotlibWidget = ({ id, label, image, className, error }) => { return ( - - {_label &&

{_label}

} - {image ? ( - Matplotlib Plot + + {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} + + {label &&

{label}

} + {error ? ( +
+ Plot unavailable +
+ ) : image ? ( + Matplotlib Plot ) : (

No plot available

)} diff --git a/preswald/interfaces/components.py b/preswald/interfaces/components.py index f0338faf..a000bf6b 100644 --- a/preswald/interfaces/components.py +++ b/preswald/interfaces/components.py @@ -378,7 +378,8 @@ def json_viewer( parsed_data = data serializable_data = convert_to_serializable(parsed_data) except Exception as e: - serializable_data = {"error": f"Invalid JSON: {e!s}"} + serializable_data = {"error": "Invalid JSON"} + raise component = { "type": "json_viewer", @@ -401,6 +402,7 @@ def matplotlib(fig: plt.Figure | None = None, label: str = "plot", component_id: ax.plot([0, 1, 2], [0, 1, 4]) # Save the figure as a base64-encoded PNG + buf = io.BytesIO() fig.savefig(buf, format="png") buf.seek(0) @@ -413,9 +415,7 @@ def matplotlib(fig: plt.Figure | None = None, label: str = "plot", component_id: "image": img_b64, # Store the image data } - return ComponentReturn( - component_id, component - ) # Returning ID for potential tracking + return ComponentReturn(component_id, component) @with_render_tracking("playground") From ecf5f40d76e6dea82645c7a1355ba6a9774828de Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Wed, 25 Jun 2025 06:10:56 -0600 Subject: [PATCH 13/16] feat(playground): add error rendering and styling to PlaygroundWidget - Add red border, background, and alert triangle tooltip for error state - Ensure error rendering matches patterns used in other widgets - Refactor markup to prevent layout shift and improve consistency - Error alert appears centered with detailed tooltip on error --- .../components/widgets/PlaygroundWidget.jsx | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/widgets/PlaygroundWidget.jsx b/frontend/src/components/widgets/PlaygroundWidget.jsx index b2b39f43..9e45a150 100644 --- a/frontend/src/components/widgets/PlaygroundWidget.jsx +++ b/frontend/src/components/widgets/PlaygroundWidget.jsx @@ -2,6 +2,7 @@ import Editor from '@monaco-editor/react'; import { ChevronLeftIcon, ChevronRightIcon, PlayIcon } from '@radix-ui/react-icons'; import React, { useEffect, useState } from 'react'; +import { AlertTriangle } from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -16,6 +17,9 @@ import { TableRow, } from '@/components/ui/table'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + const ROWS_PER_PAGE = 10; const editorOptions = { @@ -58,6 +62,7 @@ export default function PlaygroundWidget({ id, error, data = null, + className, }) { const [query, setQuery] = useState(); const [currentPage, setCurrentPage] = useState(1); @@ -82,14 +87,39 @@ export default function PlaygroundWidget({ }, [value]); return ( -
- +
+ {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} + +
{label}
- @@ -109,8 +139,8 @@ export default function PlaygroundWidget({
{error ? ( -
- +
+ {error}
From 102503412297fa0890005cc12e7595e239b51f82 Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Wed, 25 Jun 2025 06:32:18 -0600 Subject: [PATCH 14/16] feat(plotly): add consistent error rendering to DataVisualizationWidget - Adds error container styling when `error` or `plotError` is present. - Adds AlertTriangle icon with tooltip describing the error, positioned in the top right corner. - Ensures error content is shown in the same style as other widgets: centered, italic red text. - Unifies error handling so all error messages are presented via the same UX pattern as other widgets. --- .../widgets/DataVisualizationWidget.jsx | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/widgets/DataVisualizationWidget.jsx b/frontend/src/components/widgets/DataVisualizationWidget.jsx index a2c92094..80d4d3fe 100644 --- a/frontend/src/components/widgets/DataVisualizationWidget.jsx +++ b/frontend/src/components/widgets/DataVisualizationWidget.jsx @@ -1,16 +1,15 @@ import Plotly from 'plotly.js-dist'; - import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useInView } from 'react-intersection-observer'; import Plot from 'react-plotly.js'; -import { Alert, AlertDescription } from '@/components/ui/alert'; +import { AlertTriangle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; - import { FEATURES } from '../../config/features'; import { debounce, @@ -22,7 +21,13 @@ import { const INITIAL_POINTS_THRESHOLD = 1000; const PROGRESSIVE_LOADING_CHUNK_SIZE = 500; -const DataVisualizationWidget = ({ id, data: rawData, content, error, className }) => { +const DataVisualizationWidget = ({ + id, + data: rawData, + content, + error, + className, +}) => { const [isLoading, setIsLoading] = useState(true); const [plotError, setPlotError] = useState(null); const [processedData, setProcessedData] = useState(null); @@ -127,7 +132,6 @@ const DataVisualizationWidget = ({ id, data: rawData, content, error, className } }); }); - processDataInChunks(traces, PROGRESSIVE_LOADING_CHUNK_SIZE, (chunk, index, total) => { setProcessedData((prevData) => ({ ...data, @@ -137,11 +141,9 @@ const DataVisualizationWidget = ({ id, data: rawData, content, error, className ...prevData.data.slice(index + chunk.length), ], })); - processedPoints += chunk.reduce((acc, trace) => { return acc + (Array.isArray(trace.x) ? trace.x.length : 0); }, 0); - setLoadedDataPercentage((processedPoints / totalPoints) * 100); }); } @@ -180,18 +182,38 @@ const DataVisualizationWidget = ({ id, data: rawData, content, error, className setShowDropdown(false); }; - if (error || plotError) { - return ( - - Error: {error || plotError} - - ); - } + const hasAnyError = !!(error || plotError); return ( - + + {hasAnyError && ( + + +
+ +
+
+ + {(error || plotError)?.toString()} + +
+ )} - {!inView || isLoading ? ( + {hasAnyError ? ( +
+ + {error || plotError || 'Unable to render plot.'} + +
+ ) : !inView || isLoading ? (

Loading visualization...

From a5f2eebfb37c91e4eddcc0445acc87208b40de00 Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Wed, 25 Jun 2025 08:53:44 -0600 Subject: [PATCH 15/16] feat(progress-widget): add error state rendering with tooltip and improved layout - Adds error state rendering to ProgressWidget. When an error is present, a red border, light background, and alert triangle with tooltip are shown. - Ensures the error icon does not overlap the percent value by keeping layout consistent and adding padding as needed. - Displays a clear error message when progress cannot be shown, but still shows the label and percent. - Minor cleanup: removes unused console.log from BigNumberCard. --- .../components/widgets/BigNumberWidget.jsx | 2 -- .../src/components/widgets/ProgressWidget.jsx | 32 +++++++++++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/widgets/BigNumberWidget.jsx b/frontend/src/components/widgets/BigNumberWidget.jsx index 7663ef24..ba657e7c 100644 --- a/frontend/src/components/widgets/BigNumberWidget.jsx +++ b/frontend/src/components/widgets/BigNumberWidget.jsx @@ -18,8 +18,6 @@ const formatNumber = (num) => { const BigNumberCard = ({ id, label, value, delta, unit, error, className }) => { const deltaNumber = parseFloat(delta); const isPositive = deltaNumber >= 0; - - console.log(`BigNumberCard ${id} ${label}`, {value, error}) const displayDelta = typeof delta === 'string' ? delta : `${isPositive ? '+' : ''}${delta}${unit ?? ''}`; diff --git a/frontend/src/components/widgets/ProgressWidget.jsx b/frontend/src/components/widgets/ProgressWidget.jsx index bd8d91fe..6f5ff9a2 100644 --- a/frontend/src/components/widgets/ProgressWidget.jsx +++ b/frontend/src/components/widgets/ProgressWidget.jsx @@ -1,22 +1,48 @@ 'use client'; import React from 'react'; +import { AlertTriangle } from 'lucide-react'; import { Progress } from '@/components/ui/progress'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; -const ProgressWidget = ({ id, value = 0, label }) => { +const ProgressWidget = ({ id, value = 0, label, error, className }) => { // Ensure value is between 0 and 100 const normalizedValue = Math.min(Math.max(parseFloat(value) || 0, 0), 100); return ( -
+
+ {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} {label && (
{label} {Math.round(normalizedValue)}%
)} - + {error ? ( +
Unable to display progress.
+ ) : ( + + )}
); }; From 8d2051ce5b85d018fb42bd085d8a9ebb7b3d03fc Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Wed, 25 Jun 2025 10:26:44 -0600 Subject: [PATCH 16/16] feat(ui): add consistent error state and spacing for SelectboxWidget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update SelectboxWidget to apply padding so that select and dropdown contents stay inside the error border, with correct spacing. - Add support for a `label` prop and display it above the select control, matching other widgets’ label style. - Show an alert triangle with tooltip in the error state. - Ensure the select and dropdown maintain a consistent width and do not overflow the error container. - Minor: right-padding on ProgressWidget error state to prevent overlap between error icon and percent text. Let me know if you'd like the full expanded PR template filled out as well! --- .../src/components/widgets/ProgressWidget.jsx | 2 +- .../components/widgets/SelectboxWidget.jsx | 88 +++++++++++++++---- 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/widgets/ProgressWidget.jsx b/frontend/src/components/widgets/ProgressWidget.jsx index 6f5ff9a2..87533ae7 100644 --- a/frontend/src/components/widgets/ProgressWidget.jsx +++ b/frontend/src/components/widgets/ProgressWidget.jsx @@ -16,7 +16,7 @@ const ProgressWidget = ({ id, value = 0, label, error, className }) => { id={id} className={cn( 'w-full space-y-2 relative', - error && 'border-destructive border-2 bg-red-50 rounded-md', + error && 'border-destructive border-2 bg-red-50 rounded-md pr-8', className )} > diff --git a/frontend/src/components/widgets/SelectboxWidget.jsx b/frontend/src/components/widgets/SelectboxWidget.jsx index 8c58c3a8..dae1f5d6 100644 --- a/frontend/src/components/widgets/SelectboxWidget.jsx +++ b/frontend/src/components/widgets/SelectboxWidget.jsx @@ -1,5 +1,8 @@ 'use client'; +import React from 'react'; +import { AlertTriangle } from 'lucide-react'; + import { Select, SelectContent, @@ -7,27 +10,82 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; const SelectboxWidget = ({ id, + label, options = [], value, onChange, placeholder = 'Select an option', -}) => ( - -); + error, + className, +}) => { + + const _options = Array.isArray(options) ? options : []; + + return ( +
+ {label && ( + + )} + + {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} + + + {error && ( +
+ Unable to display options. +
+ )} +
+ ); +}; export default SelectboxWidget;