@ai-matrx/summon

中文文档
0.1.65发布于 2026/10/30MITTypeScriptESM + CJS近 30 天下载 3万

Render any React component by dispatch and never mount it until it is needed: single or multi instance, handles for callbacks and refs beside Redux, keep-alive and saved component state, address-bar links bound to data, optional persistence, and a full wi

原始使用文档
文档目录18 节
  1. Install
  2. Quick start
  3. Definition options
  4. Keep-alive and saved state
  5. Callbacks without functions in Redux
  6. Large apps: the render map lives on the host
  7. Mounting into an existing Redux store
  8. Pay only when something opens: the gate
  9. Ports
  10. Links
  11. Persistence
  12. Handles
  13. WindowPanel
  14. Dialog, sheet, light window
  15. Dock (floating chrome-less cards, pills and docks)
  16. Styling
  17. Testing
  18. Gates

@ai-matrx/summon

Summon any React component from anywhere — with its handlers, refs and state — and never mount it until it is called.

  • Dispatch to open. Open a window, dialog, sheet or any component from anywhere with a hook or an action. A closed overlay puts nothing in the React tree and fetches no code.
  • Single or multi instance per overlay.
  • Handles beside Redux. Callbacks, event channels, one-shot requests and imperative refs live in a registry next to the store, owned by the open instance and disposed when it closes.
  • Keep-alive. On close an overlay unmounts (none), stays alive hidden (hidden), or unmounts and remembers its saved state (restore). useOverlayState is the saved-state hook.
  • Address-bar links. ?panels=notes:42 names an open overlay, loads its data and survives reload.
  • Persistence, off or on: browser-local, your own database through a small port, or @ai-matrx/data.
  • Surfaces. WindowPanel is a desktop in the browser (drag, resize, snap, arrange, tray with thumbnails, minimize, pop-out to a real browser window, three phone presentations). dialog, sheet and lite (a light floating window) share the same close binding.

The package owns its Redux slices. It runs on a private store, or mounts into the store you already have.

Install

pnpm add @ai-matrx/summon react react-dom react-redux @reduxjs/toolkit
PeerNeeded for
react ≥ 19, react-dom, react-redux ≥ 9, @reduxjs/toolkit ≥ 2everything except the framework-free . and ./handles entries
next ≥ 16 (optional)only ./next
@supabase/supabase-jsonly ./persistence/data, through @ai-matrx/data

@ai-matrx/data and @ai-matrx/design-system are ordinary dependencies (the platform's sibling rule), so they install with summon, and so do theirs: Supabase, @ai-matrx/auth and the Radix packages arrive too (a fresh Vite app installing only summon measured about 310 MB of node_modules). They add install weight, not bundle weight: every entry is its own module, unused entries are tree-shaken away, Supabase is reached only by ./persistence/data, and src/importCost.test.ts + src/firstLoadBudget.test.ts keep the first-load boot set (./boot, ./boot-root, ./boot-kit) free of them under a byte budget. The one cost every page does pay is the stylesheet in Styling.

Entries:

EntryWhat it isReact?
./bootfirst load. The gate (createBootGate), the plain openers (summon, dismiss, openOverlay, closeOverlay, …), isOverlayOpen, mintInstanceId, DEFAULT_INSTANCE_IDno
./boot-rootfirst load. <SummonRoot>, its contexts, useSummonStore, useOptionalSummonStore, useOverlayLink, usePopoutContaineryes
./boot-kitfirst load, optional. Handle entries without the registry, lazyOpener, the ?panels= writer (formatLinkToken), three light reads, expectNavigation, locationRouterno
./openercreateOpener(id, { multi }) → { openAction, useOpen, Controller, useIsOpen, useClose, close }: the one opener ./define and ./react are built on, light enough for first loadyes
./openthe classic creators (openOverlay with .type/.match), every selector, the registry, ports, createSummonGate (the same function as createBootGate). About twice the boot set's bytes: prefer ./boot + ./boot-kit in first loadno
./root./boot-root plus the selector, dispatch and ports hooks, dispatchers, the instance contextyes
./definedefineOverlay (openers, Controller, open/close hooks, emitter). Import it only from definition modulesyes
.the core: slices, middleware, createSummonStore, window geometryno
./handlescallbacks, event channels, requests, refsno Redux, no React
./requestawaitable dialogs. requestOverlay(overlayId, request, { cancel, open, close }) opens an overlay and resolves with its answer (useOverlayRequest on ./react answers). Closing it any way, or a render that never shows the request, answers cancelno
./load-onceloadOnce(load): run a loader once while it succeeds, retry a failed one (the memo lazyOpener uses)no
./hook-controllerhookController(useOpen, toOptions?): the open-on-mount Controller for an opener whose open logic is your own, without createOpeneryes
./instance-valueregisterInstanceValue / getInstanceValue: what one open instance's opener captured, disposed when that instance closesno
./reactSummonProvider, SummonHost, useOverlayState, useOverlayInstanceyes
./windowWindowPanel and the desktopyes
./window-hostthe root-mountable half of the window section: WindowsProvider, WindowTraySync, registerTrayPreview, usePopout, usePopoutContainer, useOverlaySurfaceRenderAck. Never loads WindowPanelyes
./dialog, ./sheet, ./lite, ./dockdialog, sheet/drawer, light window, floating dockyes
./urlOverlayLinkManager, useOverlayLink, the ?panels= read sideyes
./persistence, ./persistence/datasaved windows and component state; the @ai-matrx/data adapteryes / no
./next, ./testing, ./styles.cssNext router port, test fakes, structural CSS

Quick start

The recommended setup keeps summon out of every route's first load: the root mounts only <SummonRoot> (from ./boot-root), the store carries the gate from ./boot, definitions use ./define, and callers open with the plain actions from ./boot (or createOpener from ./opener). The summon core, the host and the window code load on the first open.

1. A store the gate can grow, and the root. The gate queues the first summon action, loads the core once, injects its slices into your reducer and replays the queue.

"use client";
import { combineReducers, configureStore, type Reducer, type ReducersMapObject } from "@reduxjs/toolkit";
import { createBootGate } from "@ai-matrx/summon/boot";
import { SummonRoot } from "@ai-matrx/summon/boot-root";

let reducers: ReducersMapObject = { app: (state: number = 0) => state };
let root: Reducer = combineReducers(reducers);
const gate = createBootGate();
gate.attachReducer((injected) => {
  reducers = { ...reducers, ...injected };
  root = combineReducers(reducers);
});
export const store = configureStore({
  reducer: ((state, action) => root(state, action)) as Reducer,
  middleware: (getDefault) => getDefault().concat(gate.middleware),
});

export function Root({ children }: { children: React.ReactNode }) {
  return (
    <SummonRoot store={store} runtime={() => import("./SummonRuntime")}>
      {children}
    </SummonRoot>
  );
}

2. The runtime: mounted by <SummonRoot> once the core has loaded. It renders whatever is open.

// SummonRuntime.tsx
import { SummonHost } from "@ai-matrx/summon/react";
import { APP_OVERLAYS } from "./overlays";

export default function SummonRuntime() {
  return <SummonHost definitions={APP_OVERLAYS} />;
}

3. Declare an overlay once: a light definition file and a render module (the lazy chunk).

// notes.overlay.ts
import { defineOverlay } from "@ai-matrx/summon/define";

export interface NotesWindowData {
  noteId?: string;
}

export const notesWindow = defineOverlay<NotesWindowData>()({
  id: "notesWindow",
  label: "Notes",
  render: () => import("./notes.render"),
});

// One line per overlay: opens are type-checked and a mistyped id is a compile error.
declare module "@ai-matrx/summon" {
  interface OverlayRegistry {
    notesWindow: NotesWindowData;
  }
}
// notes.render.tsx — loaded only when the overlay is first opened
import type { OverlayRenderProps } from "@ai-matrx/summon/react";
import { WindowPanel } from "@ai-matrx/summon/window";

export default function render({ data }: OverlayRenderProps) {
  const noteId = typeof data?.noteId === "string" ? data.noteId : null;
  return (
    <WindowPanel id="notes" title="Notes" boundToOverlay width={480} height={420}>
      <NoteEditor noteId={noteId} />
    </WindowPanel>
  );
}

data arrives as untrusted Readonly<Record<string, unknown>> | null (it crossed Redux, a URL or storage). Narrow it in the render module, or give the definition a parse(raw) that returns the typed value (or null to refuse and close with a warning).

// overlays.ts — imported only by the runtime
import { notesWindow } from "./notes.overlay";
export const APP_OVERLAYS = [notesWindow];

4. Open it from anywhere: a plain action from ./boot (no React needed), a light opener from ./opener, or the definition's hooks.

import { summon } from "@ai-matrx/summon/boot";
import { createOpener } from "@ai-matrx/summon/opener";
import { notesWindow } from "./notes.overlay";
import { store } from "./Root";

// Anywhere, even outside React:
store.dispatch(summon("notesWindow", { noteId: "42" }));

// A caller that must stay light (first load) opens through createOpener, never the definition.
const notes = createOpener<{ noteId?: string }>("notesWindow");
function NotesButton() {
  const open = notes.useOpen();
  return <button onClick={() => open({ data: { noteId: "42" } })}>Notes</button>;
}

function OpenNotes() {
  const open = notesWindow.useSummon();
  return <button onClick={() => open({ data: { noteId: "42" } })}>Notes</button>;
}

// Declarative: opens on mount, closes on unmount, re-opens when the (serialisable) props change.
const Always = () => <notesWindow.Controller noteId="42" />;

open() returns { overlayId, instanceId, close() }. The definition also gives you useIsOpen(instanceId?), useClose() and, inside the render tree, useEmit(). Keep files that callers import for opening free of definitions (./define): a module that imports a definition pays for it.

5. Ask and await. An awaitable dialog is an overlay whose render answers a request. The caller awaits the answer; closing the dialog any way answers cancel, and so does a dialog that never renders (reported through the errors port, never a hang).

import { useCallback, useEffect, useState } from "react";
import { summonAction } from "@ai-matrx/summon/boot";
import { defineOverlay } from "@ai-matrx/summon/define";
import { hookController } from "@ai-matrx/summon/hook-controller";
import { loadOnce } from "@ai-matrx/summon/load-once";
import { useOverlayRequest, type OverlayRenderProps } from "@ai-matrx/summon/react";
import { requestOverlay } from "@ai-matrx/summon/request";
import { store } from "./Root";

// The dialog's render answers the request its open data names (and claims it, so it never times out).
function ConfirmBody({ data, close }: OverlayRenderProps) {
  const { request, settle } = useOverlayRequest<{ question: string }, boolean>(data, close);
  if (!request) return null;
  return (
    <div role="dialog">
      <p>{request.question}</p>
      <button onClick={() => settle(true)}>Yes</button>
      <button onClick={() => settle(false)}>No</button>
    </div>
  );
}

export const confirmDialog = defineOverlay<{ requestId: string }>()({
  id: "confirmDialog",
  label: "Confirm",
  render: () => Promise.resolve({ default: ConfirmBody }),
});

declare module "@ai-matrx/summon" {
  interface OverlayRegistry {
    confirmDialog: { requestId: string };
  }
}

// The caller awaits a boolean; any close answers `cancel` (false).
export function confirm(question: string): Promise<boolean> {
  return requestOverlay<{ question: string }, boolean>("confirmDialog", { question }, {
    cancel: false,
    open: (target) => void store.dispatch(summonAction("overlays/openOverlay", target)),
    close: (target) => void store.dispatch(summonAction("overlays/closeOverlay", target)),
  });
}

// One loader shared by many callers: it runs once while it succeeds; a failed load is retried next call.
const loadEditor = loadOnce(() => import("./notes.overlay"));

// Your own open logic still gets the one open-on-mount Controller (no createOpener in this route).
// Return a stable function (useCallback): a new identity re-opens.
function useOpenConfirm() {
  return useCallback((props: { question: string }) => {
    void confirm(props.question);
    return { close: () => void store.dispatch(summonAction("overlays/closeOverlay", { overlayId: "confirmDialog" })) };
  }, []);
}
const ConfirmOnMount = hookController(useOpenConfirm);

function DeleteButton() {
  const [ready, setReady] = useState(false);
  useEffect(() => void loadEditor().then(() => setReady(true)), []);
  return <button disabled={!ready} onClick={async () => (await confirm("Delete this note?")) && console.log("deleted")}>Delete</button>;
}

Styling is required for a correct look: see Styling.

Everything in one provider (small apps, tests). <SummonProvider> from ./react makes a private store with the core installed; nothing is deferred:

import { SummonProvider, SummonHost } from "@ai-matrx/summon/react";
import { APP_OVERLAYS } from "./overlays";

export function SmallApp({ children }: { children: React.ReactNode }) {
  return (
    <SummonProvider>
      {children}
      <SummonHost definitions={APP_OVERLAYS} />
    </SummonProvider>
  );
}

Definition options

FieldMeaning
id, labelid is the registry key; label shows in the tray, menus and diagnostics
instance"single" (default, instance id "default") or "multi" (a fresh id per open, or pass instanceId)
keepAlive"none" (default), "hidden" or "restore"
closesOnNavigationclose when the route changes (the overlay shows a piece of the page that opened it); a reload restores it only on the page it was open on
defaultsmerged under every open's data
window{ mobile, mobileSidebarAs, ephemeral, preservation, restoreDefaults, windowId, deprecated, unbiddenHome } for window overlays (every window is remembered unless preservation: false; see Persistence)
link{ key, aliases?, hydrate }, see Links
parsenarrow raw data once; null refuses
render() => import("./x.render"); optional when the host supplies it (see below), and wins over the host map when present

Minimizing a window always keeps its component mounted, whatever keepAlive says.

Keep-alive and saved state

import { useOverlayState, useOverlayInstance } from "@ai-matrx/summon/react";

function Draft() {
  const [text, setText] = useOverlayState("draft", ""); // like useState; scoped to this open instance
  const { close, hidden } = useOverlayInstance();
  return <textarea value={text} onChange={(e) => setText(e.target.value)} />;
}
  • keepAlive: "hidden": closing hides the live component (<Activity mode="hidden">); reopening the same instance shows it again, state intact. A single overlay reopens its one instance. A multi overlay opened without an instanceId makes a NEW instance (a second window, a new chat); the closed one comes back only when reopened with its instanceId. At most keepAliveLimit (default 8) closed instances stay mounted; the oldest is evicted and released. An identity change (sign-out, account swap) releases every kept-alive instance (discardAllOverlays), so the next person never sees the previous one's live component.
  • keepAlive: "restore": closing unmounts; useOverlayState values come back on reopen (and persist when persistence is on).
  • useOverlayState values must be serialisable. Any descendant of the overlay may call it.

Callbacks without functions in Redux

Declare an event map and pass plain functions when opening. They travel through an event channel owned by the instance; only its id rides in the data.

export const uploader = defineOverlay<{ folder?: string }, { uploaded: { url: string }; cleared: void }>()({
  id: "uploader", label: "Uploader", render: () => import("./uploader.render"),
});

const open = uploader.useSummon();
open({ data: { folder: "logos" }, handlers: { uploaded: (r) => setUrl(r.url) } });

// inside the overlay's render tree
const emit = uploader.useEmit();
emit("uploaded", { url });

Closing the instance disposes the channel. Emitting after that is a silent no-op.

Large apps: the render map lives on the host

Every file that imports a definition to open it also imports that definition's render: () => import(…). To a bundler, each such file gains an edge to the overlay's whole window subtree. With a few overlays that is free. With hundreds of overlays and hundreds of opener files it multiplies the build graph: in AI Matrx, about 450 opener files each reached about 220 window subtrees, and a sub-app build went from about 10 s to an out-of-memory kill. This is the host-build law: never fan out import() edges.

The fix keeps "declare once" at two files plus one line. Leave render off the definitions, and give the host one id → import map, generated from the file names, that only the host imports:

// definitions/notes.overlay.ts — no render: importing it costs nothing
export const notes = defineOverlay<{ noteId?: string }>()({ id: "notes", label: "Notes" });

// definitions/render-map.ts — generated, imported ONLY by the host (behind its own lazy boundary)
import type { OverlayRenderMap } from "@ai-matrx/summon/react";
export const RENDER_MAP: OverlayRenderMap = { notes: () => import("./notes.render") };

// the host
<SummonHost definitions={APP_OVERLAYS} renders={RENDER_MAP} />

OverlayRenderMap erases each module's data type (OverlayRenderProps<never>), so differently typed render modules share one map without any. An overlay that is opened with no render source at all (no render, no map entry) shows its error card (OverlayRenderMissingError, exported as MISSING_RENDER_ERROR) and logs in development. It is never a silent blank.

Mounting into an existing Redux store

import { configureStore } from "@reduxjs/toolkit";
import { summonReducers, createSummonMiddleware } from "@ai-matrx/summon";

const ports = { /* see Ports */ };
export const store = configureStore({
  reducer: { ...appReducers, ...summonReducers }, // keys: overlays, windowManager, urlSync, overlayState
  middleware: (getDefault) => getDefault().concat(createSummonMiddleware(ports)),
});
<SummonProvider store={store} ports={ports}>…</SummonProvider>

Pass the same ports object to both. The overlays slice keys are fixed. Without store, the provider makes a private store (createSummonStore({ ports })).

Provider knobs: fallbacks (loading, error components), errorReportExtras (extra controls in the error card), renderWrapper (wrap every rendered overlay), keepAliveLimit (8), importTimeoutMs (12 s; a render module that has not loaded becomes a ChunkLoadError with Try again), stallWarnMs (10 s).

Pay only when something opens: the gate

The quick start is this setup: the store carries no summon slice or middleware until the first summon action, which the gate queues; it starts one import() of the core, which injects the slices through gate.attachReducer, adds the middleware and replays the queue. Pass the gate the same ports as the root (createSummonGate({ ports }), <SummonRoot ports={ports}>). A module that imports the core statically (a page's WindowPanel) installs it before it renders. runtime (the host, tray sync, router port) mounts once the core is installed. prefetchSummonRuntime() starts the load early (idle, pointer intent). Openers and selectors come from ./open, defineOverlay from ./define, <SummonRoot> from ./root.

Ports

Every port has a working default, so a bare host needs none. Override only what you have; pass them as ports on the provider (a partial object).

PortShapeDefault
identitycurrent(): { key, ready }, subscribe(listener)anonymous, always ready. A changing key closes and releases everything (kept-alive instances included) and loads the next person's saved state
routerpathname(), search(), replaceSearch(next), subscribe(listener)window.location + history.replaceState (inert on the server)
notifyinfo, warn, error(message, options?), optional dismiss(id)console
errorscapture(error, context)console.error
debugenabled(), optional subscribeoff
persistencean OverlayPersistencePort, or nullnull (persistence off)

Next.js: import { useNextRouterPort } from "@ai-matrx/summon/next" returns a stable RouterPort (call it inside a client component under Suspense; it reads useSearchParams). Testing fakes: see Testing.

OverlayLinkManager keeps ?panels= and open overlays in sync through ports.router. Mount it once inside the provider.

import { OverlayLinkManager, useOverlayLink } from "@ai-matrx/summon/url";

<SummonProvider><OverlayLinkManager /><SummonHost definitions={APP_OVERLAYS} /></SummonProvider>

Give the definition a link:

defineOverlay<NotesWindowData>()({
  id: "notesWindow", label: "Notes", render: () => import("./notes.render"),
  link: {
    key: "notes",
    hydrate: ({ id, args, open }) => open({ data: { noteId: id } }), // return "refused" to drop the token
  },
});
  • Opening /page?panels=notes:42 runs hydrate({ id: "42", args, open, dispatch }). hydrate may be async (load a record, then open).
  • A mounted overlay publishes its own address with useOverlayLink("notes", noteId, { tab: "history" }) (also exported from ./react, so a render module can publish without loading the link section). WindowPanel does it for you from its urlSyncKey, urlSyncId and urlSyncArgs props.
  • Unknown keys are announced at once; tokens whose overlay never registers are announced at a deadline and kept verbatim.
  • Grammar v2: panels = token ("," token)*, token = key[:id[:k-v(_k-v)*]]. Ids are percent-encoded for % , :, arg keys and values for % , : _ -, so any value survives; v1 links still parse. Helpers: parseLinkToken, parseLinkParam, formatLinkToken, readPanelsParam, withPanelsParam.
  • One door: openFromLink(searchOrUrl, { dispatch, requireAll?, onError? }) returns { opened, refused, unknown, failed } and serves the address bar, notifications and any bridge. requireAll: true opens nothing if any key is unknown. openPanelRequest(payload, { dispatch }) validates and opens a { panelId, instanceId?, data? } request from a bridge (returns { ok: true, … } or { ok: false, error: "invalid_payload" | "unknown_panel" }).

Persistence

Off by default. Turn it on with a persistence port plus one mounted component:

import { createLocalPersistence, OverlayPersistence } from "@ai-matrx/summon/persistence";

const persistence = createLocalPersistence(); // create once, module level
<SummonProvider ports={{ persistence }}>
  <OverlayPersistence />            {/* mount once */}
  <SummonHost definitions={APP_OVERLAYS} />
</SummonProvider>

What is remembered, and where:

WhatWhereComes back
The open windows (the workspace)this tab's sessionStorageon a reload of the same tab. A new tab starts with no windows; another device never sees them. Never written to the port
Where each window was last placed, and its sizethis device's localStorage (one map, at most 200 windows)the next time that window opens on this device
Component state (useOverlayState) of keepAlive: "restore" instances and remembered windowsthe port, per person (state:<overlayId>:<instanceId>)wherever the person opens that instance, any tab or device

Every window is remembered by default: its position, size, state and its JSON open data (handle ids such as callbackGroupId are never saved), and its useOverlayState values. window.preservation: { dataKeys, requiredDataKeys?, allowedDataValues?, maxDataBytes? } narrows the saved data to an allowlist and validates it on restore; window.preservation: false (or window.ephemeral: true) never saves the window. Caps: 256 KiB workspace, 64 open windows, 32 KiB per window's data and per state entry; overruns report through ports.errors.capture (source: "overlay-persistence").

<OverlayPersistence> options: tabStorage (default window.sessionStorage; null = open windows are not remembered), deviceStorage (default window.localStorage; null = windows always open at their declared position), saveDebounceMs (250), viewport. useOverlayPersistence(options) returns { hydrated }. Boot makes no port read: saved component state is read once per identity (loadAll, else list + loads) on first use: a useOverlayState mount (it suspends until the values are in the store), the first value to save, or a keepAlive "restore" instance opening. Port writes wait for that read; nothing is written to the port until a component state value changes. Code reading saved state without useOverlayState calls savedStateGateFor(store)?.ensure() first. Sign-out clears the tab's open windows; an identity change discards every kept-alive instance.

Browser-local store. createLocalPersistence({ namespace?, backend?, storage?, locks?, idbReadBudgetMs? }): a localStorage mirror plus IndexedDB, keys per identity. It also has purgeIdentity(identityKey) for sign-out cleanup.

Your own database. Implement the port:

import type { OverlayPersistencePort } from "@ai-matrx/summon";

export const myPersistence: OverlayPersistencePort = {
  perPerson: true, // true: rows are already scoped to the signed-in person (plain keys). Omit/false: keys arrive prefixed "<identityKey>/"
  load: async (key) => /* { value, version } | null */ null,
  save: async (key, value, options) => {
    /* if options?.expectedVersion is set and differs from the stored version, throw an error named "PersistenceVersionConflictError" */
    return { version: 1 };
  },
  remove: async (key) => {},
  list: async (prefix) => [], // keys starting with prefix
};

Keys written: state:<overlayId>:<instanceId> (component state only). Values are JSON. Suggested table:

ColumnType
person_iduuidowner; rows are read and written only for the signed-in person
keytextone of the keys above; unique with person_id
valuejsonbthe saved value
versionintstarts at 1, +1 per save (optimistic concurrency)

On a conflict the controller re-reads the version and writes once more, so the latest write of a component state wins. Version conflicts are recognized by error name (isVersionConflict(error)).

@ai-matrx/data hosts. createDataPersistence({ db, userId?, table?, feature? }) from @ai-matrx/summon/persistence/data maps each key to a row of users.user_surface_state (feature = "summon", surface_key = key, state = value, version), through the data package's doors. The organization comes from createDb({ client, organization }) and the insert is refused when there is none.

import { createDb } from "@ai-matrx/data/db";
import { createDataPersistence } from "@ai-matrx/summon/persistence/data";

const persistence = createDataPersistence({ db: createDb({ client: supabase, organization: () => activeOrgId }) });

Handles

@ai-matrx/summon/handles is framework-free. State lives in one global slot, so duplicated bundles share one registry.

  • callbackManager (verbs): register, registerWithContext(callback, { context, groupId, owner }), subscribe, unsubscribe, trigger, triggerWithContext, triggerGroup, triggerGroupCommand, updateProgress, createGroup, removeGroup, remove, get, has, registerWidgetHandle, ownedBy, groupMembers, disposeOwner, size, subscribeToRegistry.
  • Typed helpers, each accepting { owner }:
import { createEventChannel, getEventChannel, createRequest, getRequest, registerHandle, getHandle } from "@ai-matrx/summon/handles";

const owner = "notesWindow:default"; // overlayId:instanceId

const channel = createEventChannel<{ saved: string }>({ owner });   // many-shot; pass channel.id in open data
channel.subscribe((e) => console.log(e)); channel.emit({ saved: "42" });
getEventChannel<{ saved: string }>(channel.id)?.emit({ saved: "43" }); // overlay side

const request = createRequest<{ prompt: string }, boolean>({ prompt: "Discard?" }, { owner }); // one-shot
getRequest<{ prompt: string }, boolean>(request.id)?.resolve(true);   // overlay answers
const answer = await request.promise; // rejects with HandleDisposedError if disposed unanswered

const dispose = registerHandle("editor:42", { focus() {} }, { owner }); // imperative ref
getHandle<{ focus(): void }>("editor:42")?.focus();
dispose();

Owner disposal. When an instance closes, everything registered with its overlayId:instanceId as owner is disposed: pending requests reject, channels drop subscribers, handles are removed. No opener disposes by hand. Disposing a group member removes it from the group.

WindowPanel

import { WindowsProvider, WindowPanel } from "@ai-matrx/summon/window";

Slots contract: the body is content only. Everything that is window chrome goes in a slot, never in the body.

SlotProps
Titletitle, titleNode
Header actionsactionsLeft, actionsRight
Footerfooter, or zoned footerLeft / footerCenter / footerRight; footerVariant: "bar" | "rich"
Left sidebarsidebar, sidebarDefaultSize, sidebarMinSize, defaultSidebarOpen, sidebarExpandsWindow
Right panelsecondaryPanel, secondaryPanelOpen, secondaryPanelDefaultSize, secondaryPanelMinSize

Close binding is required and checked by the type system: exactly one of overlayId (close that overlay), boundToOverlay (close the enclosing SummonHost instance) or onClose (page-local). A window with none of them does not compile.

Other props: id, width/height (numbers or "60vw"), minWidth/minHeight, position, fitContent, closeOnEscape, hidePopOutButton, urlSyncKey/urlSyncId/urlSyncArgs, onCollectData, captureTraySnapshot.

For screenshot thumbnails, pass ports.elementCapture to SummonRoot or SummonProvider. Its render(element, options) returns a canvas; a host above Alchemy can lazily delegate to renderElement from @ai-matrx/alchemy/operate/capture. A host without the capability still gets the labeled tile. Explicit captureTraySnapshot and registered snapshot/preview components retain priority. The standalone captureElementThumbnail helper uses the configured host port, or its explicit elementCapture option.

Behavior you can rely on: maximize, minimize, pop-out and dock never remount the body (state, refs and effects survive; minimize keeps the body mounted and captures the tray thumbnail). Pop-out uses a real browser window (Document Picture-in-Picture where available). On phones a window renders as fullscreen, drawer or card per the definition's window.mobile.

WindowsProvider is optional; options: escapeYieldSelector (visible elements matching it own the Escape key; default [data-overlay-escape-yield]), mobileTopOffset (default var(--header-height, 0px)), selectionSurface (a component wrapping every window body, for a host's selection or right-click menu).

Also exported from ./window: WindowTraySync and registerTrayPreview (a semantic preview for a minimized tile; both also on the light ./window-host entry — mount providers from there at the app root), LayoutIcon, usePopoutControl, MobileDrawerSurface, MobileCardSurface, useWindowPanel.

Dialog, sheet, light window

import { OverlayDialog } from "@ai-matrx/summon/dialog";
import { OverlaySheet, OverlayDrawer, OverlayBottomSheet } from "@ai-matrx/summon/sheet";
import { LiteWindow } from "@ai-matrx/summon/lite";

// inside a render module: closes through the overlay instance
<OverlayDialog boundToOverlay>{/* DialogContent, DialogTitle … from @ai-matrx/design-system */}</OverlayDialog>

// page-local: you own the state
<OverlayDialog open={open} onClose={() => setOpen(false)}>…</OverlayDialog>

<LiteWindow id="inspector" title="Inspector">…</LiteWindow> {/* inside an SummonHost overlay, onClose can be omitted */}

OverlayDialog, OverlaySheet, OverlayDrawer and OverlayBottomSheet take the design-system component's props plus the close binding: boundToOverlay (open while the instance is shown) or onClose (+ open). LiteWindow has drag, edge resize, Escape when topmost, focus in and out, and a bottom drawer on phones; no minimize, tray, persistence or links.

Dock (floating chrome-less cards, pills and docks)

import { Dock, DockHandle, DockProvider, useDock, useDockContext } from "@ai-matrx/summon/dock";

<Dock id="attention" corner="bottom-right" defaultPosition={{ x: 16, y: 16 }} zIndex={50} className="w-80">
  <DockHandle>⋮⋮ Alerts <button onClick={…}>Close</button></DockHandle>
  …
</Dock>

// Your own markup: spread the bindings. Destructure them: the React compiler treats an object
// carrying a `ref` as a ref, so reading `dock.style` during render counts as a ref access.
const { ref, style, handleProps } = useDock({ id: "assists", corner: "bottom-right", position, onPositionChange: save, touch: false, clamp: { margin: 0, minVisible: { width: 120, height: 32 } } });
<div ref={ref} style={style}><button {...handleProps}>pill</button></div>

// Hand every dock the host's storage (declared keys, per-user sync).
<DockProvider store={{ read: (id) => …, write: (id, position) => … }}>…</DockProvider>

Pointer drag that never re-renders while moving: each move paints the CSS translate property in one animation frame and the position is committed to React state once, on release (dragging flips twice per gesture, never per move), so a dock with a streaming answer inside stays cheap. Positions are offsets from a corner (corner, default bottom-right), clamped to the viewport on every render (clamp: { margin = 8, minVisible }; a dock bigger than the room keeps its top-left reachable); snapToCorner re-anchors to the nearest corner on release; avoid: { selector, gap } keeps it off fixed controls. Without a position it rests on defaultPosition, or on a CSS anchor (top/bottom/left/right/centerX). Remembered per id: the dock's store, else the nearest DockProvider, else localStorage (matrx:summon:dock:<id>); persist={false} for per-moment docks; a controlled host passes position + onPositionChange (told once per drag, and null on reset()). A press passes a 4px threshold before it is a drag, and a drag swallows its trailing click; controls inside the handle (ignore, default buttons/links/fields/[data-no-drag]) never start one; touch={false} leaves touch to page scroll; draggable={false} pins it (phones). On press it joins the window manager's z-order lane (raiseTransientWindow, above windows, below modals) when a SummonProvider is present, and the drag holds grabbing through design-system holdDragCursor. No listener exists outside an active press.

Styling

The windows, dialogs and sheets draw with design-system theme utilities (bg-card, text-foreground, border-border, …) and need design-system's structural CSS. A host needs, in its Tailwind v4 CSS entry:

@layer matrx-design-system-tokens, matrx-design-system; /* first line: your own tokens always win */
@import "tailwindcss";
@import "@ai-matrx/design-system/tokens.css"; /* default token values (skip if your theme declares them all) */
@import "@ai-matrx/design-system/styles.css"; /* design-system structural CSS: required */
@import "@ai-matrx/design-system/theme.css"; /* maps bg-card, text-foreground, … onto the tokens and scans design-system */
@import "@ai-matrx/summon/styles.css"; /* summon's structural CSS: required */
@source "../node_modules/@ai-matrx/summon/dist"; /* path relative to this CSS file */

Without styles.css and the theme mapping the windows render transparent and borderless. A host that already maps the semantic color vocabulary (AI Matrx does) skips theme.css and keeps its own, plus @source "../node_modules/@ai-matrx/design-system/dist". Tailwind v4 is required (the package CSS uses v4 at-rules). Colors come only from design-system tokens, so the host's theme applies; the traffic-light colors are --matrx-window-light-* custom properties you can override.

Testing

import { createTestPorts, createTestSummonStore, createMemoryPersistence } from "@ai-matrx/summon/testing";

const ports = createTestPorts({ persistence: createMemoryPersistence() });
const store = createTestSummonStore(ports);

render(<SummonProvider store={store} ports={ports}><SummonHost definitions={[notesWindow]} /></SummonProvider>);

createTestPorts gives a controllable identity (set(key, ready?)), a controllable router (navigate(path, search?)) and recording notify and errors (messages, captured). createMemoryPersistence is an in-memory, versioned port. No test-runner dependency.

Gates

pnpm typecheck (also compiles the package with a host-augmented registry), npx vitest run, pnpm check:package.

版本与发布记录

@ai-matrx/summon 共发布 68 个版本,首次发布于 2026/10/17;过去 12 个月发布了 68 个版本。

版本发布日期距今
0.1.65latest2026/10/306 天前
0.1.642026/10/288 天前
0.1.632026/10/2611 天前
0.1.622026/10/2611 天前
0.1.612026/10/2512 天前
0.1.602026/10/2512 天前

其他发布通道:bootstrap → 0.0.0。

关于 @ai-matrx/summon 的常见问题

@ai-matrx/summon 是什么?

Render any React component by dispatch and never mount it until it is needed: single or multi instance, handles for callbacks and refs beside Redux, keep-alive and saved component state, address-bar links bound to data, optional persistence, and a full wi

如何安装 @ai-matrx/summon?

在项目目录运行 npm install @ai-matrx/summon,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@reduxjs/toolkit(>=2.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@ai-matrx/summon 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@ai-matrx/summon 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@ai-matrx/summon 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@ai-matrx/summon 可以商用吗?

@ai-matrx/summon 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@ai-matrx/summon 的下载量有多少?

根据 npm 官方下载统计,@ai-matrx/summon 在 2026/10/05 – 2026/11/03 这 30 天内被下载 30,022 次,日均约 1,001 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@ai-matrx/summon 还在维护吗?

最新版本 0.1.65 发布于 2026/10/30(6 天前);至今共发布 68 个版本,其中过去 12 个月发布了 68 个;首次发布于 2026/10/17。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。