Master-detail panel
Sidebar list (left) + editor (center) — minimal copy-paste
The most common multi-panel layout: a list on the left, an editor in the center. The list drives the editor via ui.Call. The editor returns ui.Empty() at batch discovery so the left panel owns when center is claimed. auto_action on the sidebar root claims the center slot for the most-recent item on first load.
from __future__ import annotations
from imperal_sdk import Extension, ui
ext = Extension(
"my-ext",
display_name="My Extension",
description="A master-detail extension for managing items.",
actions_explicit=True,
)
# ── Inline stub data — replace with your real data fetch in production ─────
_ITEMS = [
{"id": "item-1", "title": "First item", "body": "Content for item one."},
{"id": "item-2", "title": "Second item", "body": "Content for item two."},
{"id": "item-3", "title": "Third item", "body": "Content for item three."},
]
def _get_item(item_id: str) -> dict | None:
return next((i for i in _ITEMS if i["id"] == item_id), None)
# ── Left sidebar — discovery-safe, sets auto_action on root ────────────────
@ext.panel(
"sidebar",
slot="left",
title="Items",
icon="📜",
default_width=280,
min_width=200,
max_width=500,
)
async def sidebar(ctx: object, active_item_id: str = "", **kwargs: object) -> object:
items = _ITEMS # replace with: items = await fetch_items(ctx)
root = ui.Stack(
children=[
ui.List(
items=[
ui.ListItem(
id=item["id"],
title=item["title"],
selected=(item["id"] == active_item_id),
on_click=ui.Call(
"__panel__editor",
item_id=item["id"],
),
)
for item in items
]
)
],
gap=2,
)
# Set auto_action only when there is content to show and no item is
# already active (first load). The host fires this once per discovery
# cycle to claim the center slot automatically.
if items and not active_item_id:
root.props["auto_action"] = ui.Call(
"__panel__editor", item_id=items[0]["id"]
)
return root
# ── Center editor — returns ui.Empty() at batch discovery ─────────────────
@ext.panel(
"editor",
slot="center",
center_overlay=True,
title="Editor",
icon="✏️",
)
async def editor(ctx: object, item_id: str = "", **kwargs: object) -> object:
# At batch discovery the host calls this with params={} — item_id is empty.
# Return ui.Empty() so the slot is held but visually indicates "select an item".
# This keeps leftPanel non-null (auto_action can fire) while leaving center
# available for the sidebar's auto_action to claim.
if not item_id:
return ui.Empty(message="Select an item", icon="🖱️")
item = _get_item(item_id)
if item is None:
return ui.Error(message="Item not found")
return ui.Stack(
children=[
ui.Text(item["title"], variant="h2"),
ui.RichEditor(
content=item["body"],
on_change=ui.Call("save_item", item_id=item_id),
),
],
gap=4,
)Walk-through
Why ui.Empty() and not return None?
Returning None at batch discovery causes the frontend to leave leftPanel as null after discovery, which prevents auto_action from firing (the auto_action useEffect guards on leftPanel being non-null). ui.Empty() returns a valid UINode so leftPanel is set, the slot shows a placeholder, and auto_action can fire to claim the center with the first real item. return None is appropriate only in edge cases where you do not want the slot populated at all (for example, a center panel that should only ever appear as a center overlay — see the center-overlay recipe).
Why auto_action on the sidebar root, not a child node?
The host reads auto_action only from leftPanel.props.auto_action. It is not read from nested children or the right panel. The root is the ui.Stack returned from the sidebar handler — set root.props["auto_action"] after building the root, before returning.
Why the if items and not active_item_id guard?
auto_action fires once per discovery cycle (guarded by the discovery-once guard). Setting it unconditionally is harmless at first load, but the not active_item_id guard prevents the sidebar from declaring auto_action when a specific item is already active (e.g., when the sidebar is refreshed after a note list update). The items guard prevents setting auto_action when there is nothing to open — opening an empty editor is worse UX than showing the empty-state placeholder.
How item_id flows from click to editor handler.
ui.Call("__panel__editor", item_id=item["id"]) serializes to a UIAction with params: {item_id: "..."}. The host merges these params into the accumulated panel params["editor"] and calls __panel__editor with the merged params dict. The editor handler receives item_id as a kwarg and branches on it. Declared and undeclared kwargs are dispatched identically — there is no web-kernel-side split.
Cross-links
- Build a panel layout — master-detail — full guide with pitfalls and extension patterns.
- Concepts: slot ownership — who renders what — the mental model for replace semantics and batch discovery.
- Concepts: auto_action prop — when it fires, what resets it, what it dispatches.