From 266978afce1f8f145a1a1fd00652cec466038d40 Mon Sep 17 00:00:00 2001 From: David Weedon Date: Tue, 17 Feb 2026 11:18:07 -0600 Subject: [PATCH 1/2] Support Basic roots in WidgetTemplate.build --- chatkit/widgets.py | 19 ++++++++++--------- tests/test_widgets.py | 31 +++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/chatkit/widgets.py b/chatkit/widgets.py index 80492d1..e8323c9 100644 --- a/chatkit/widgets.py +++ b/chatkit/widgets.py @@ -1098,8 +1098,14 @@ class DynamicWidgetComponent(WidgetComponentBase): ] +class BasicRoot(DynamicWidgetComponent): + """Layout root capable of nesting components or other roots.""" + + type: Literal["Basic"] = Field(default="Basic", frozen=True) # pyright: ignore + + StrictWidgetRoot = Annotated[ - Card | ListView, + Card | ListView | BasicRoot, Field(discriminator="type"), ] @@ -1107,13 +1113,7 @@ class DynamicWidgetComponent(WidgetComponentBase): class DynamicWidgetRoot(DynamicWidgetComponent): """Dynamic root widget restricted to root types.""" - type: Literal["Card", "ListView"] # pyright: ignore - - -class BasicRoot(DynamicWidgetComponent): - """Layout root capable of nesting components or other roots.""" - - type: Literal["Basic"] = Field(default="Basic", frozen=True) # pyright: ignore + type: Literal["Card", "ListView", "Basic"] # pyright: ignore WidgetComponent = StrictWidgetComponent | DynamicWidgetComponent @@ -1178,8 +1178,9 @@ def build( widget_dict = json.loads(rendered) return DynamicWidgetRoot.model_validate(widget_dict) + @deprecated("WidgetTemplate.build_basic is deprecated. Use WidgetTemplate.build instead.") def build_basic(self, data: dict[str, Any] | BaseModel | None = None) -> BasicRoot: - """Separate method for building basic root widgets until BasicRoot is supported for streamed widgets.""" + """Deprecated alias for building Basic root widgets.""" rendered = self.template.render(**self._normalize_data(data)) widget_dict = json.loads(rendered) return BasicRoot.model_validate(widget_dict) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index f10d53f..18c56a2 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -244,18 +244,45 @@ def test_widget_template_from_file( assert widget.model_dump(exclude_none=True) == expected_widget_dict -def test_widget_template_with_basic_root(): +def test_widget_template_build_with_basic_root(): template = WidgetTemplate.from_file("assets/widgets/basic_root.widget") with open("tests/assets/widgets/basic_root.json", "r") as file: expected_widget_dict = json.load(file) - widget = template.build_basic( + widget = template.build( { "name": "Harry Potter", "bio": "The boy who lived", }, ) + assert isinstance(widget, DynamicWidgetRoot) + assert widget.type == "Basic" + assert widget.model_dump(exclude_none=True) == expected_widget_dict + + widget_item = WidgetItem( + thread_id="1", widget=widget, id="1", created_at=datetime.now() + ) + assert widget_item.widget.type == "Basic" + + +def test_widget_template_build_basic_is_deprecated(): + template = WidgetTemplate.from_file("assets/widgets/basic_root.widget") + + with open("tests/assets/widgets/basic_root.json", "r") as file: + expected_widget_dict = json.load(file) + + with pytest.warns( + DeprecationWarning, + match="WidgetTemplate.build_basic is deprecated. Use WidgetTemplate.build instead.", + ): + widget = template.build_basic( + { + "name": "Harry Potter", + "bio": "The boy who lived", + }, + ) + assert isinstance(widget, BasicRoot) assert widget.model_dump(exclude_none=True) == expected_widget_dict From c603403ed4f75a48923a33f801b38ee1915d0190 Mon Sep 17 00:00:00 2001 From: David Weedon Date: Tue, 17 Feb 2026 11:17:14 -0600 Subject: [PATCH 2/2] Fix diff_widget traversal for single-child nodes --- chatkit/server.py | 9 ++++++--- tests/test_widgets.py | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/chatkit/server.py b/chatkit/server.py index a94764c..a9630a3 100644 --- a/chatkit/server.py +++ b/chatkit/server.py @@ -158,9 +158,12 @@ def recurse(component: WidgetComponent | WidgetRoot): components[component.id] = component if hasattr(component, "children"): - children = getattr(component, "children", None) or [] - for child in children: - recurse(child) + children = getattr(component, "children", None) + if isinstance(children, WidgetComponentBase): + recurse(children) + elif isinstance(children, list): + for child in children: + recurse(child) recurse(component) return components diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 18c56a2..07fbf86 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -113,6 +113,28 @@ ), ["widget.root.updated"], ), + # DynamicWidgetRoot with a single-child object + ( + DynamicWidgetRoot( + type="Card", + children=DynamicWidgetComponent.model_validate({ + "type": "Text", + "id": "text", + "value": "Hello", + "streaming": True, + }), + ), + DynamicWidgetRoot( + type="Card", + children=DynamicWidgetComponent.model_validate({ + "type": "Text", + "id": "text", + "value": "Hello, world!", + "streaming": True, + }), + ), + ["widget.streaming_text.value_delta"], + ), ], ) def test_diff(