Frame / Frameset / Pin / Provider Instance Cache
Canonical architecture reference for the singleton-frameset model, pins-as-templates, and per-provider instance cache. Introduced in Epic #1961 (Frameset Arch v2). Updated for canonical note identity and scoped Note Trays.
Note
Originally the spec deliverable for sub-issue #1962 (Wave 1 of Epic #1961, Frameset Arch v2). Updated by Sub 7 (#1969) to reflect what actually shipped, then corrected in the post-epic review to describe the shipped module-scoped cache design directly (the unwired generic ProviderInstanceCache primitive and the unwrittenAppSettings.providerInstanceCache field were deleted). Updated again by Sub #1979 (Epic #1974, Frameset Leaf State Persistence) to document the across-quit tier for core.inbox — the first provider whose per-leaf state survives a quit/relaunch via AppSettings.framesetLeafState.
The current sections include scoped trays, multiple retained temporary trees and exact saved-configuration comparison. Historical examples below explain the original defects.
See also: Frame Component Contractfor the provider shape, toolbar slot, and leaf-state schema that this document builds on.
Vocabulary
| Term | Definition |
|---|---|
| frame | A leaf with a stable frameId and one mounted provider. Its runtime selection, scroll and editor session belong to that frame. |
| frameset | The one active tree per window, persisted in AppSettings.frameset. Inactive temporary trees may also be retained in memory. |
| pin | A detached structural template: split directions and sizing, provider configuration, and optional active-frame intent. It does not save note bodies, selected/open notes, editor buffers or conversations. |
| provider | A ViewProvider defining serialization, identity, content, toolbar and optional settings/layout surfaces. |
| instance cache | Provider-specific runtime state keyed by frameId. Warm remounts reuse it; sweeping retains the union of live and inactive temporary frame IDs. Note bodies have separate canonical ownership. |
What was wrong with the old model
Two user-visible bugs were rooted in structural problems, not surface-level code errors.
Bug 1 — #1955: LR inbox cross-leaf draft sync
The old model kept a singleton activeDraftRef inside useDraftManager. When the user had two inbox frames (left and right), both frames shared the same ref. Selecting a draft in one frame immediately reflected in the other because both rendered from the same broadcast source.
Structural cause: selected-draft state lived in a singleton that was independent of the frame tree. No amount of patching around the edges of activeDraftRef could fix this — the ref was the wrong unit of isolation.
Bug 2 — Pin click is a no-op (2026-05-20 screenshot)
When the user was on a custom pin whose backing frameset aliased the canonical framesets[id="inbox"], clicking the canonical Inbox pin triggered setCurrentFramesetId("inbox"). The hook saw the same id it already held and bailed without rebuilding the tree. The UI stayed unchanged even though the user explicitly asked for a switch.
Structural cause: pins stored framesetId links. Pin activation was "set current id and if already equal, do nothing." The bail-on-equality was correct for the old model but became visible as a bug once pin aliasing was possible.
The fix
Selection belongs to the frame; note content belongs to (workspaceId, noteId). Pins store configuration rather than references to a mutable saved frameset. An explicit Open applies a fresh template, while ordinary clicking of the active Pin opens its settings. Neither operation depends on a historical frameset alias.
The singleton frameset rule
Exactly one frameset tree is active per app window. AppSettings.frameset persists that tree; there is no saved framesets[] collection or draftFrameset slot. Split, close, resize and provider changes update the live tree. The existing serialized settings queue and debounced save persist it.
useTempFramesets separately retains inactive trees for this renderer session. Their existence does not create multiple active framesets. Only the active tree is restored from workspace settings after a cold start; the temporary-entry list is not persisted. There is no migration reader for retired layout formats.
Pins are structural templates
A Pin describes how to construct a working area. Saving detaches configuration from the live tree without reminting its IDs or replacing its mounted sessions. Opening the template creates new frame, instance and branch IDs.
Pin shape (v2)
interface PinTemplate {
tree: FramesetTree;
activeFrameId?: string;
}HeaderLeftPin also carries its stable Pin ID, label, icon, route metadata, visibility and optional typed providerProps. serializePinTree invokes provider serialization and normalizes Note Tray configuration. AI props contain only sourceTrayFrameId: string | null; chat text, prompts and conversation state do not enter the template.
Note Tray configuration is InboxProviderConfig: scope, creationDefaults, optional name, readOnly and restoreLastLayout. Scope is All folders (including Archives), or a workspace-relative folder and its descendants, optionally intersected with an exact sticker. It is a query over registered canonical notes, not a file-enrollment rule. Ordinary root documents are not implicitly enrolled.
Creation defaults are separate from scope. All folders defaults to Inbox with no sticker; folder/sticker scopes derive matching creation defaults. The form rejects creation outside the configured scope. Changing the configuration never moves, retags or closes existing notes. Each pending creation captures its folder and sticker before remote work, so later configuration changes cannot retarget it.
Pin highlighting
Two comparisons have different purposes:
| Comparison | Contract |
|---|---|
framesetsEquivalent | General lifetime equivalence: tree shape, provider ID and provider matchIdentity. Ignores split sizing, generated IDs and leaf visibility. |
pinConfigurationsEqual | Exact saved configuration: also includes split sizing, normalized provider configuration and AI source relationships by tree position. Excludes generated IDs and runtime selection/body/session state. |
findActivePinId prefers an exact saved configuration, then uses lifetime equivalence with structural AI-source comparison. A highlight is therefore not evidence that Save would be a duplicate. In particular, resizing a split can retain the highlight while producing a new savable configuration.
Note Tray lifetime identity includes normalized scope, creation defaults and read-only policy. TODO and Mind Map use captured (sourceWorkspaceId, noteId) when valid, plus layout identity: moving that note preserves the match, while another UUID at its old path does not inherit it. Ordinary root files retain path identity. Incomplete canonical bindings never fall back to ordinary-file identity. The exact saved comparator applies the same canonical-board distinction.
Pin click semantics
Ordinary clicking of the active saved Pin opens its settings. Clicking an inactive saved Pin, or choosing its explicit Open action, follows the guarded activation path:
Pass the existing close/boundary guards, then preserve any unregistered divergent live tree in the existing temporary registry before replacing it. Registered entries remain retained.
Instantiate the template with UUID-based frame, instance and branch IDs.
In a second pass, remap
activeFrameIdand each AIsourceTrayFrameIdthrough the old-to-new frame map. Missing or non-Note-Tray targets become disconnected (null).Install the new tree and persist it through the existing settings queue.
Apply the requested active frame once, after that exact tree reaches the host. A replaced tree or removed target is not focused. WritePage, FramesetHostPage and ArchivesPage all consume this request.
Retain caches belonging to inactive temporary trees while sweeping unreachable frame IDs.
The click-to-route handshake prevents a user-Pin navigation from applying its template twice. Cold route arrivals without that marker apply the template. Structural AI remapping does not implement conversation ownership or server-side admission; those are separate runtime contracts.
Temp pin
Temporary framesets are session-only working layouts managed by useTempFramesets. Multiple entries may coexist. Each holds a tree with stable frame IDs; interaction state remains in the corresponding provider sessions.
Precondition
The New Frameset action creates a new empty temporary entry and preserves an unregistered divergent tree before switching. Divergence registration uses exact saved-configuration comparison, so ratio-only changes are retained when creating or switching temporary entries even if the lifetime matcher recognizes a saved Pin.
Before an accepted saved-Pin or Restore previous Pin replacement, useSavedFrameset calls the temporary controller with the authoritative outgoing tree. The controller captures its latest accepted configuration synchronously, then detaches the active temporary entry before new IDs are installed. A canceled guard does not capture or mutate anything. Matching saved configurations do not manufacture temporary entries; cold-route initialization does not retain its transitional default tree. Rapid repeated applications cannot overwrite a retained entry with the newly installed tree.
Visual position
Temporary-entry buttons appear alongside saved Pins. The toolbar also retains a fallback temporary indicator for an unregistered divergent tree. The explicit Save current frameset as pin button is available even while a saved Pin is highlighted.
Highlight invariant
The live tree determines the highlighted saved Pin or temporary entry. Inactive temporary entries remain available; a saved Pin match does not erase them. The highlight must not be used as the duplicate-save guard.
Click handlers
Clicking an inactive temporary entry installs its tree through applyTreePreservingIds, reconnecting its sessions and remembered active frame. Clicking the active temporary entry opens Save; modifier-click restores the last explicitly applied saved Pin. Discard asks for confirmation and removes that entry from the cache keep-set.
Save is shared by the toolbar button, palette and shortcut. An exact duplicate, including a hidden Pin, opens that Pin's settings. A changed configuration opens Save current frameset as pin. The user chooses a label and icon; built-in icon search, local name suggestions and explicit random choice are available. Renaming does not silently replace an already selected icon.
saveCurrentPin captures the template before awaiting, then reads settings, rechecks duplicates and appends inside runSerializedSettingsWrite. It validates the result and writes the latest live tree with the new Pin. It never reinstalls the captured template over later live edits. Workspace checks reject stale completion. Failed saves leave the form available for retry, duplicate clicks are blocked, and an old completion cannot close a newly opened dialog session.
Natural recovery path
Closing an extra frame can make the remaining tree match a saved Pin again. This updates the highlight without cloning the remaining leaves or replacing their sessions. Saving a temporary entry likewise preserves its live IDs; once saved, the active entry no longer needs a duplicate temporary button.
No persisted draft state
AppSettings.frameset persists only the active tree. The temporary-entry list, last-applied-Pin reference and retained-tree active-frame memory are session-only. Pin templates contain configuration, while framesetLeafState is a separate persistence channel for supported leaf state. Neither is a note-body store.
Instance cache (module-scoped, per-frameId)
Provider-specific registries retain frame sessions across warm unmounts. inbox-instance-cache.ts owns Note Tray selection, layout, scroll and retained open-note membership. EFE, AI assistant and Doc Cloud have their own session caches. These registries do not own a second independent Note Tray autosave.
Cache key — frameId
Every instance cache is keyed by the Frameset leaf frameId, not by provider id. One entry per live leaf. external-file-editor-session-cache.ts explicitly justifies the frameId (not instanceId) key choice in its header comment — the entry must survive a provider swap that preserves frameId.
Cache operations
| Operation | Description |
|---|---|
acquire(frameId) | Returns the entry for frameId, creating a default entry on first call. Idempotent — repeated calls return the same live entry. StrictMode-safe. |
release(frameId) | Passive — the entry stays alive in the registry for a warm re-mount. Called from the leaf's useEffect cleanup so the lifecycle is explicit. |
destroy(frameId) | Permanently removes the entry. |
sweepExcept(keepFrameIds) | Destroys every entry whose frameId is not in keepFrameIds. |
destroyAll() | Destroys every entry. |
Inbox selection-reconcile contract (#5238)
A tray selects selectedNoteId: string | null, with a pending note using its already-issued UUID. Named and numbered notes follow the same path; a numeric label, filename or missing slot is not a fallback identity.
The cache's paired beginReconcile / endReconcile tokens protect in-flight selection transitions. Completion is tied to the exact entry, so a late response cannot resurrect a destroyed frame or modify a replacement entry. Reconcile flags/refcounts are ephemeral and excluded from snapshots and copied state.
Scope changes retain selected and edited UUIDs. Tabs mark retained notes that are outside the new scope instead of closing them or widening the saved query. Timeline, Grid and Pile use matching registered entries; frame-local open-note membership is a separate concern.
Session-scoped vs across-quit
The Note Tray cache snapshots selected UUID, view/layout state, timeline/grid presentation and search state into framesetLeafState["core.inbox"]. The existing coordinator saves tree and leaf-state changes through the settings queue. Hydration happens before restored leaves mount and updates an already-mounted default entry in place when necessary.
Scroll offsets, retained open-note membership, open card editors and reconcile tokens are session state. They are not Pin configuration. Cold-restoring the live tree preserves its IDs; opening a saved Pin creates fresh IDs and therefore fresh sessions. EFE, AI and Doc Cloud caches remain session-scoped. Popout handback and workspace teardown have their own guarded boundary flow; settings snapshots must never be treated as the authority for unsaved note bytes.
Inbox content routing
Selection and scroll belong to
frameId; canonical body and captured write authority belong to(workspaceId, noteId)innote-editor-buffers/inbox-draft-content-cache.useNotesTrayStoreresolves a pane's UUID and reads the shared canonical body. Two frames showing the same UUID share text while retaining independent selection, scroll and editor interaction state.All tray instances consume the shared workspace note index. Scope filtering does not add a per-tray backend scan or duplicate index subscription.
The canonical cache serializes writes with captured authority. Equal text, a current path or the latest remote head does not authorize rebasing another editor's write. Dirty and failed/queued text survives frame switching and cache sweeping through the existing recovery/boundary mechanisms.
The active-note pointer and addressed commands carry stable identity. Untargeted selection/creation commands are handled by the active leaf; hosts do not install a second legacy singleton autosave.
Creation publishes an editable UUID synchronously, seals the frozen creation intent into the existing journal before remote work, and keeps that UUID through acknowledgement. Number assignment is display metadata, not a new editor identity.
Stale-entry sweep — preventing the leak
Because frameIds are minted fresh on template application (instantiatePinTemplate) and a closed leaf's frameId falls out of the tree, entries would otherwise accumulate unbounded for the renderer's lifetime. use-stale-frameset-sweep.ts runs whenever the live tree or inactive-temp set changes. Its keep set is their union:
useEffect(() => {
const keep = new Set<string>();
forEachLeaf(frameset, (leaf) => keep.add(leaf.frameId));
for (const id of collectInactiveTempFrameIds()) keep.add(id);
sweepExternalFileEditorSessions(keep);
sweepInboxInstances(keep);
sweepAiAssistantInstances(keep);
sweepInboxPendingScroll(keep);
sweepDocCloudFramesExcept(keep);
}, [frameset, tempSweepKey]);Any frameId outside that union is unreachable, so its entry is destroyed. Hard renderer/workspace/account/origin boundaries additionally clear their registries so nothing leaks into a different resource identity.
Provider swap inside a frame
Provider replacement preserves the frame ID. The previous provider releases its session passively; the new provider acquires its own entry. A Note Tray normalizes its structural configuration and resolves a canonical initial selection. There is no numeric sentinel or named-to-numbered fallback. Copied/restored warm state is not overwritten by a late initial-selection result.
Copy frame — cloning a sibling frame into an empty slot (#3004, #3005)
The empty frame's copy bar lists non-empty siblings. Copying detaches provider props with structuredClone and replaces the destination provider while keeping the destination frame ID. For Note Tray, copyState seeds canonical selection and view/layout state from the source; transient card-editor ownership is not copied. The destination remains a separate frame session, even when both select the same note UUID and therefore share its body.
The pending-scroll mailbox captures source scroll before replacement and applies it once to the destination. It is transient and excluded from persisted snapshots. Cache copy notifications let the ordinary leaf-state coordinator persist the supported selection/layout fields. Generic providers copy serialized configuration, not another frame's live editor or service session.
Per-provider state policy
| Provider | Saved configuration / identity | Runtime state |
|---|---|---|
core.inbox | Normalized scope, creation defaults, name and policies | UUID selection, layouts and search have separate leaf-state persistence; scroll, retained open notes and card editors are session-local. Bodies are canonical shared buffers. |
core.ai-assistant | Only the structural sourceTrayFrameId relation | Conversation and composer state remain outside Pins. |
core.external-file-editor | Safe local file/tree seeds; provider-defined identity | Tabs and editing session stay in its frame cache. |
core.doc-cloud | Project, initial surface, editor layout and outline-collapse seeds | One cache owns tabs, buffers, save guards and service state; none enters a Pin. |
core.todo-board, core.mindmap-board | Captured workspace/UUID for canonical sources; path for ordinary root files; layout/configuration | Writer authority and unsaved content remain outside Pins. Mind Map can persist its supported presentation props. |
core.kanban-board | Provider-defined board path and layout | Header callbacks and interactive state stay frame-local. |
core.dashboard | Dashboard preferences live in workspace settings | Temporary sizing, drag and edit interaction remain local. |
core.empty, core.diff-view | Provider-defined safe props | No reusable note-body snapshot in the Pin. |
Lifecycle examples
(a) Cold start
Load AppSettings.frameset and supported framesetLeafState, hydrate frame entries, then mount the restored tree with its existing IDs. Resolve note bodies by workspace/UUID and captured authority. No Pin template is needed to restore the active live tree.
(b) Split
Insert a fresh leaf, mount its provider session, and debounce-save the live tree. Existing leaves keep their IDs and state. Copying a sibling can seed selection without copying body ownership or editor runtime.
(c) Pin click
For an inactive Pin or explicit Open: guard the transition, preserve divergent working trees and registered temporary entries, instantiate fresh IDs and remapped structural links, install once, restore the intended active frame once, and sweep with inactive temporary IDs retained. Ordinary clicking of the active Pin opens settings instead.
(d) Close frame
Pass the provider close/boundary guard, remove the leaf, persist the remaining tree and sweep unreachable entries. Remaining leaves keep their IDs. Removing a frame cache must not discard canonical dirty/queued note content.
Key invariants
One active frameset per window; inactive temporary trees are session-only.
Saving a Pin detaches configuration without remounting live leaves. Opening it instantiates fresh UUIDs and remaps active-frame and valid structural AI sources.
Exact saved-configuration comparison includes sizing; general lifetime comparison still ignores it.
Note identity is workspace plus UUID. Selection is frame-local; bodies and captured authority are shared by canonical note identity.
Pin templates do not contain selected/open note IDs, bodies, editor state, conversations or runtime prompt bindings.
Sweeping retains the union of live and inactive temporary frame IDs. Hard workspace/account boundaries use guarded teardown and recovery.
Embedded EFE/Doc Cloud panes are not separate frames.
How #1955 dissolves
The original fix separated per-leaf selection from shared singleton refs. The canonical model completes that separation: each frame selects a UUID, and body routing uses workspace plus UUID. Selecting a different note in one frame cannot change another frame's selection. Selecting the same note intentionally shares its body without sharing scroll, cursor or focus ownership.
How the pin-aliasing bug dissolves
Pins are detached templates, not framesetId references. Explicit Open always constructs a fresh tree; ordinary active-Pin click intentionally opens settings. Neither relies on an id-equality bailout that could mistake two aliases for the same live session.
Alternatives considered (rejected)
Note
Rejected alternative: Keep framesets[] storage, but make pins carry templates and have pin-click create new framesets with fresh ids.
Pros: smaller blast radius — the framesets[] storage and currentFramesetId tracking stay largely intact; only pin activation changes.
Cons: leaves the currentFramesetId footgun latent. Any code that compares the current id with a known id (e.g. "am I on the inbox frameset?") can still produce wrong results when the user has a custom alias. The singleton activeDraftRef bug (#1955) is also not dissolved by this approach — the root cause is the singleton ref, not pin aliasing.
Rejected per the user's first-principles argument in #1955 comment 4490974379: the two bugs share the same root (state attached to the wrong scope); the fix must change the scope, not paper over the symptoms.
What stays the same
The following parts of the architecture are unchanged by this epic:
ViewProviderinterface —id,layouts,serialize/deserialize,defaultProps,singletonScope,canPopOut,Toolbar,Content,SettingsContent. The frame component contract (section 2 offrame-component-contract.mdx) is normative and not modified.Frame chrome —
frame-chrome.tsxinpackages/frameset/. Active-frame border strategy, toolbar slot shape, and the per-frame collapse/zoom/popout/close cluster are all unchanged.LeafRendererboundaries — each leaf is still an isolated React subtree.Scroll-sync engine — the scroll-sync channel and payload shape are unchanged.
Focus-gate concept — the
activeprop onToolbar/Contentis unchanged.
Out of scope
This page describes Pin structure and frame/session lifetime. AI conversation binding and server-side enforcement are separate work, not implied by the saved sourceTrayFrameId field. Cross-window recovery and note conflict policy are owned by the existing canonical boundary/handback contracts; a Pin is never a substitute for them. No old-format layout migration or sample-Pin installation is part of this model.
Reference files
| Concept | File |
|---|---|
| Live tree, persistence and focus requests | tauri- |
| Temporary frameset lifetime | tauri- |
| Serialization, exact comparison and fresh-ID remapping | tauri- |
| Pin highlighting and duplicate lookup | tauri- |
| Serialized Save operation | tauri- |
| One-shot host focus | tauri- |
| Canonical board identity | tauri- |
| Tray configuration | packages/ |
| Tray cache and canonical body cache | tauri-, tauri- |
| Leaf-state persistence and sweeping | tauri-, tauri- |
| Provider contract | packages/ |
See also
Frame Component Contract — provider interface and frame chrome.
Frameset–Pin Contract — lifetime matcher, provider configuration and clearly marked historical v1 decisions.
Header Pins Architecture — header routing and metadata registries.