Refresh a panel after a write
Return ActionResult.refresh_panels=[panel_id] from a chat.function so the host re-fetches your panel after the action commits
Two paths, three slots — design panels to be idempotent
refresh_panels has two distinct code paths depending on how the action was triggered:
Path A — Panel-initiated (button click → /call endpoint): The frontend reads refresh_panels from the HTTP response body and calls the panel host's callPanel(panel_id, {}, slot) for each listed panel_id — targeted, per-panel re-fetch. Since SDK v4.1.8, the host's slotFor(panel_id) resolves the listed id to its declared slot (left / right / center), so a write handler that updates a center workshop can return refresh_panels=["workshop"] and the host re-renders the center overlay — no manual ui.Call chain required.
Path B — Chat-initiated (Webbee chat → web-kernel workflow → SSE push): The web-kernel publishes a panel_refresh SSE event with data.panels: [...]. However, the frontend SSE consumer fires refreshAll() unconditionally — it does not read data.panels. Every discovered panel is re-fetched (left + right + center are all in discoveredRef), regardless of which panel_ids you listed.
Cross-extension refresh is not supported on either path. Values in refresh_panels are resolved against the current extension's panel namespace only. refresh_panels=["mail.inbox"] would attempt to call __panel__mail.inbox on your extension's call endpoint — not the mail extension.
Design your panels to be cheap and idempotent on re-fetch: a no-op refresh (no data changed) should render identically to the previous render.
ActionResult.refresh_panels takes a list of bare panel_ids — the panel_id you passed to @ext.panel(), without the __panel__ prefix. For example, @ext.panel("sidebar", ...) → refresh_panels=["sidebar"]. The host prepends __panel__ internally when routing the re-fetch call.
from __future__ import annotations
from pydantic import BaseModel, Field
from imperal_sdk import Extension, ActionResult, ui
ext = Extension(
"my-ext",
display_name="My Extension",
description="An extension demonstrating panel refresh after a write action.",
actions_explicit=True,
)
# ── Shared stub store — replace with your real persistence layer ───────────
_STORE: dict[str, dict] = {
"note-1": {"id": "note-1", "title": "First note", "body": "Original body."},
"note-2": {"id": "note-2", "title": "Second note", "body": "Original body."},
}
# ── Pydantic params model (must be module-scope — federal invariant) ───────
class SaveNoteParams(BaseModel):
note_id: str = Field(
description="UUID of the note to save. Obtain from list_notes — never invent.",
)
body: str = Field(
description="The full updated body of the note.",
)
# ── chat.function — save and refresh ──────────────────────────────────────
@ext.chat.function( # type: ignore[attr-defined]
"save_note",
description="Save the full body of an existing note.",
action_type="write",
chain_callable=True,
effects=["note.update"],
)
async def save_note(ctx: object, params: SaveNoteParams) -> ActionResult:
# Replace with your real persistence call:
# await ctx.http.patch(f"/notes/{params.note_id}", json={"body": params.body})
if params.note_id not in _STORE:
return ActionResult(
status="error",
summary=f"Note {params.note_id!r} not found.",
)
_STORE[params.note_id]["body"] = params.body
# refresh_panels lists bare panel_ids (no "__panel__" prefix).
# "sidebar" → the host calls __panel__sidebar on Path A (direct button call).
# On Path B (chat-initiated), the host fires refreshAll() regardless of this
# list — see the callout above.
return ActionResult(
status="ok",
summary="Note saved.",
refresh_panels=["sidebar"],
)
# ── Left sidebar — shown here for a complete runnable example ─────────────
@ext.panel(
"sidebar",
slot="left",
title="Notes",
icon="🗒️",
default_width=280,
min_width=200,
max_width=500,
)
async def sidebar(ctx: object, **kwargs: object) -> object:
notes = list(_STORE.values())
return ui.Stack(
children=[
ui.List(
items=[
ui.ListItem(
id=note["id"],
title=note["title"],
on_click=ui.Call("__panel__editor", note_id=note["id"]),
)
for note in notes
]
)
],
gap=2,
)
# ── Center editor — panel that triggers save_note ─────────────────────────
@ext.panel(
"editor",
slot="center",
center_overlay=True,
title="Editor",
icon="📝",
)
async def editor(ctx: object, note_id: str = "", **kwargs: object) -> object:
if not note_id:
return ui.Empty(message="Select a note to edit", icon="📄")
note = _STORE.get(note_id)
if note is None:
return ui.Error(message="Note not found")
return ui.Stack(
children=[
ui.Text(note["title"], variant="h2"),
ui.RichEditor(
content=note["body"],
# The on_change calls save_note directly. After save_note returns
# ActionResult(refresh_panels=["sidebar"]), the host re-fetches
# the sidebar to reflect any list-level changes (e.g., updated title).
on_change=ui.Call("save_note", note_id=note_id),
),
],
gap=4,
)Walk-through
status="ok", not ok=True.
ActionResult uses status: str with the value "ok" — not a boolean ok=True. This was verified in Task 1.4 (see troubleshoot case 9 for the full API correction list). The correct constructors are ActionResult(status="ok", ...) or the factory ActionResult.success(...).
You CAN list a center panel_id since v4.1.8.
Earlier SDK lines treated center as exempt from refresh_panels because the host's refreshAll() only iterated left+right. SDK v4.1.8 added center to the discovery batch + slotFor(panel_id) routing in the panel hook, so refresh_panels=["editor"] (or any declared center panel_id) now correctly re-renders the center overlay. The example here lists only ["sidebar"] because the editor's body is what the user is actively editing — re-fetching would clobber their cursor. Choose your list based on what changed, not what's possible.
Why bare panel_id, not "__panel__sidebar"?
the panel call in the panel host receives p2 from refresh_panels and constructs __panel__${p2} internally. Passing "__panel__sidebar" would double-prefix to "__panel____panel__sidebar" — a 404. Always use the bare panel_id string as declared in @ext.panel("sidebar", ...).
None vs [] vs ["sidebar"] on Path A.
refresh_panels=None(default): Path A falls through torefreshAll()— ALL panels re-fetch.refresh_panels=[](empty list): same fallback —refreshAll()fires despite the SDK docstring claiming[]means "no refresh". This is a known contract gap.refresh_panels=["sidebar"]: targeted — only__panel__sidebaris re-fetched on Path A.
For Path B (chat-initiated), all three trigger refreshAll() or nothing — the data.panels list is not consumed by the SSE handler.
Cross-links
- Build a panel layout — master-detail — full guide that covers the write + refresh workflow end to end.
- Concepts: four ways a panel updates — refresh_panels — full two-path contract.
- Troubleshoot: refresh_panels stale or over-broad — diagnostic for Path A / Path B scoping surprises.
- Concepts: panels — panel_id semantics — why bare panel_id, not
__panel__prefix.