Skip to content
C.W.K.
Lesson 02 of 06 · published

The Sidekick Frame

~13 min · iframe, postmessage, react, sidekick

Level 0Mise en Place
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

The family's frame, Sizzle's page inside

Every family web app can carry Pippa beside its own screens in the kit's Sidekick: a panel that docks at the side or the bottom, or floats, around an iframe. The kit's own header describes that iframe as cwkPippa's embed surface for the app, the whole Pippa with her avatar, emotions and attachments. Sizzle takes the same chrome and points the iframe somewhere else: at its own /talk?key=… page. The architecture doc calls this the Easel pattern. The family chrome sits outside, Sizzle's conversation body sits inside, and the chef avatar with its current emotion stays visible above it.

Why not frame cwkPippa directly? Because a Sizzle thread is more than a chat. Pippa may end a reply with proposals (correct the reading, adopt a class, create a variant), and those apply to Sizzle's record through Sizzle's own route. The thread key, the actions and their buttons all belong to Sizzle, so the page that draws them belongs to Sizzle too. It is served from Sizzle's origin and talks only to Sizzle's engine. The browser never needs a cwkPippa address.

One path renders only the conversation

The router is small enough to read in one sitting: a hook over history.pushState and popstate, and a Link that leaves modified clicks to the browser. The engine answers every path that is not an API path with the app shell, so a reload lands on the same page. Seven routes exist: /, /p/<ULID>, /tree, /c/<ULID>, /kitchen, /settings and /talk.

App checks for the last one before anything else. On /talk it renders nothing but Talk, with the thread key from the query string and kitchen when none is given. Every other path gets the Shell: the header, the navigation, the brain status chip and the Sidekick launcher. That is why the frame never shows a second header inside the first.

Which thread the frame opens

The shell derives the thread from the page you are on. A plate page opens encounter:<ULID> under the subtitle "This plate", a class page opens class:<ULID> under "This class", and every other page opens kitchen. The Kitchen page is a conversation in itself, so it renders Talk inline and hides both the Sidekick and its launcher. The engine accepts exactly those three shapes of key: kitchen, or encounter: or class: followed by a 26-character ULID.

The architecture doc writes these bindings as sizzle:encounter:<id>. The code has two spellings instead, one per side of the boundary. Inside Sizzle the key is encounter:<ULID>. Toward cwkPippa the engine turns it into a client id by prefixing sizzle_ and replacing every colon with an underscore, so the conversation is sizzle_encounter_<ULID>, created with the origin surface sizzle. Teach the two spellings the code uses; the doc's form exists in neither.

The frame tells the page

When Dad applies an action inside the frame, the record behind the page has changed. A corrected dish name or a newly adopted class should show at once, without a reload by hand. So Talk, after an apply succeeds, checks whether it is inside a frame and posts a message of type sizzle:changed to its parent, addressed to its own origin. The shell listens for messages, accepts one only when its origin equals the page's own origin and its type matches, and bumps a refresh counter. The plate, class and tree pages take that counter as a prop and load their data again.

Because the frame is Sizzle's own page, both ends share one origin by construction, so the check stays a single equality. That equality filters origins, not windows. A message from another site is ignored, but the shell never compares event.source with its frame, so any same-origin window that holds a reference to this one and posts sizzle:changed also bumps the counter. Here that costs one extra reload from the engine and nothing else. A message that changed state, instead of only asking for a reload, would need the source check too.

Her face stays above the conversation

Talk draws the avatar before the turns. While a turn is pending, the face is the serious one and the line under it reads "Pippa is thinking" followed by a timer that ticks every second. Otherwise it shows the emotion of the last finished turn. The timer comes from a family rule written beside it in ui.tsx: a waiting surface shows how long it has waited, because a bare spinner cannot tell a slow answer from a stuck one.

While any turn is pending, the thread is read again every 1.5 seconds. A send that fails keeps its request id, so pressing Send again asks the engine for the same turn, and the engine answers that replay instead of starting a second one. A turn that failed on the engine's side shows its reason and an "Ask again" button.

The phone has no frame

The iPhone and iPad app does not embed anything. Its TalkView is native SwiftUI on the same thread routes, with the same face above the turns, the same 1.5-second re-read while a turn is pending, and the same request id kept across a failed send. There is no iframe there, so there is no message either. The app model bumps a revision number whenever something an open screen shows may have changed, and the screens reload on it.

On the web, the Sidekick remembers whether it was open and in which mode, under sizzle. keys in local storage, and defaults to the side panel on screens at least 900 pixels wide and the dock below that. The kit's frame keeps its own three sizes beside them, side width, dock height and floating size, under sizzle.pippa. keys. Every read and write of those keys is wrapped, so when local storage refuses, the defaults stand. The code's comment calls that case private mode, which fits the older Safari private windows that threw on every write. A current private window usually keeps the choice while the window lives and forgets it when the window closes.

Principle: When you borrow a shared frame, keep the content that owns your data on your own origin. The frame stays the family's, the conversation's actions stay yours, and the bridge between the two is one message you can check in one line.

Code

cwkSizzle, frontend/src/App.tsx: the kit's Sidekick, pointed at Sizzle's own /talk page (verbatim, lines 158-172)·typescript
      <div className={`workspace sk-${open && showSidekick ? mode : "closed"}`}>
        <main className="page">{content}</main>
        {showSidekick && (
          <KitPippaSidekick
            open={open}
            mode={mode}
            storagePrefix="sizzle.pippa"
            embedUrl={`/talk?key=${encodeURIComponent(key)}`}
            appLabel="Sizzle"
            subtitle={subtitle}
            onModeChange={setMode}
            onClose={() => setOpen(false)}
            panelLayout="flow"
          />
        )}
cwkSizzle, frontend/src/Talk.tsx: after an apply, the frame tells its parent, addressed to its own origin (verbatim, lines 95-108)·typescript
  const apply = async (turn: Turn, index: number) => {
    const key = `${turn.id}:${index}`;
    setApplying(key);
    try {
      const { thread: next } = await api.apply(turn.id, index);
      setThread(next);
      onChanged?.();
      if (window.parent !== window) window.parent.postMessage({ type: CHANGED_MESSAGE, key: threadKey }, location.origin);
    } catch (exc) {
      setError((exc as Error).message);
    } finally {
      setApplying(null);
    }
  };
cwkSizzle, frontend/src/App.tsx: the shell accepts that message only from its own origin and reloads (verbatim, lines 86-94)·typescript
  // An action applied inside the Sidekick frame changes the record the
  // page is showing; the frame says so and the page reloads its data.
  useEffect(() => {
    const onMessage = (event: MessageEvent) => {
      if (event.origin === location.origin && event.data?.type === CHANGED_MESSAGE) setRefresh((n) => n + 1);
    };
    window.addEventListener("message", onMessage);
    return () => window.removeEventListener("message", onMessage);
  }, []);

External links

Exercise

Give a web app of yours a route that renders one panel and nothing else, for example a comment thread for the record on screen. Embed that route in an iframe on the record's own page. When an action inside the frame changes the record, post a message to the parent with your own origin as the target, and have the parent check the origin and the message type before it reloads. Finally, add a waiting line with an elapsed timer to any state where the panel waits for a server.
Hint
Check whether the window has a parent before posting, so the same panel works standalone. Keep the frame on the same origin as the page, then the receiving check is one equality. Drive the reload with a counter prop the pages already take, which needs no shared store between the frame and the page.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.