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). Every downstream sub-issue in that epic implements directly against this document.
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.
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 in the singleton frameset tree. Exactly one live provider instance is mounted per frame at any moment. Has a stable frameId and an instanceId that survives provider swaps. |
| frameset | The singleton tree of frames and splits that represents the live UI layout for one app window. Exactly one frameset exists per window. Mutated by user actions (split, close, swap provider); rebuilt wholesale when the user clicks a pin. Persisted under AppSettings.frameset (singular). |
| pin | A saved structural template: a leaf tree (provider ids + split orientations + sizes) plus optional per-leaf providerProps. A pin is not a frameset reference and carries no framesetId. Clicking a pin always rebuilds the singleton frameset tree from the template. |
| provider | A ViewProvider object (defined in packages/). Exposes id, title, icon, description, serialize, deserialize, defaultProps, singletonScope, and optional layouts, Toolbar, Content, SettingsContent. The provider interface is unchanged from v1; this epic changes WHERE state lives, not how providers expose themselves. |
| instance cache | A module-scoped per-frameId registry of frame state, one per provider type that needs it (inbox-instance-cache.ts, external-file-editor-session-cache.ts, ai-assistant-instance-cache.ts, doc-cloud-frame-cache.ts). Entries are acquired when a frame mounts and released when it unmounts. State that would have lived in a singleton ref (e.g. activeDraftRef in useDraftManager) lives here instead. Most caches are session-scoped — they hold no persistence layer, so their state is lost on app quit. core.inbox is the exception: its cache implements snapshot/hydrateFromPersisted/subscribe/notifyChange and its state is persisted across quit via AppSettings.framesetLeafState. Stale entries (frameIds no longer in the live tree) are swept on every tree change. |
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
Both bugs dissolve structurally under the new model (see How the bugs dissolve below). No patches, no special cases.
The singleton frameset rule
Exactly one frameset tree exists per app window. No
framesets[]array.
The old model stored a framesets: Frameset[] array in AppSettings. The new model stores exactly one frameset: SingletonFrameset (a tree of frames and splits). The currentFramesetId and draftFrameset fields from the old Frameset–Pin Contract are replaced by the singleton model.
What the singleton rule implies:
There is no "current frameset id". There is only the live tree.
User actions (split, close, provider swap) mutate the tree in place.
Pin clicks rebuild the tree wholesale from the pin's template. There is no id-equality bail.
The tree is persisted debounce-saved to
AppSettings.frameseton every mutation.
Persistence key: AppSettings.frameset (singular, not framesets[]). The schema migration is covered in Sub 2 (#1963).
Pins are structural templates
A pin stores a structural template: leaf tree (provider ids + split orientations + sizes) + optional per-leaf
providerProps. Clicking always rebuilds the singleton tree. No id-equality bail.
Pin shape (v2)
interface HeaderLeftPin {
/** Display label shown in the header. */
label: string;
/** Icon id from the icon registry. */
iconId: string;
/** Route slug for deep-linking. */
routeSlug: string;
/** The structural template: leaf tree with provider ids + sizes. */
template: PinTemplate;
/** Optional per-leaf props to inject at rebuild time. */
providerProps?: PinProviderPropsByProviderId;
}
interface PinTemplate {
/** A FramesetTree-compatible node tree (leaf/split). */
tree: FramesetTree;
}Pin highlighting
A pin is highlighted iff the live singleton frameset tree is equivalent to the pin's template under the one shared equivalence rule — the same framesetsEquivalent matcher used by frameset persistence (packages/, the D1 rule). Equivalence holds iff at every leaf position the two trees share: the same structural shape (split direction + tree position), the same providerId, AND the same provider-defined matchIdentity(props) value.
findActivePinId (tauri-) delegates to framesetsEquivalent; it does not re-implement its own structural fingerprint. The function takes a ProviderRegistry so it can resolve each provider's matchIdentity (providers that declare none fall back to the matcher's default { layoutId } identity).
Why matchIdentity is part of the rule (#2787): a structure-and-providerId-only comparison treats two boards backed by different .md files (different boardPath), or two layouts of the same board (different layoutId), or EFE leaves rooted at different local directories, as the SAME pin — so the first such pin always highlighted, even for a live tree that belongs to a different one. Folding matchIdentity into the comparison makes those identity-distinct pins distinguishable, so the highlight tracks the pin the live tree actually belongs to. Equivalence is still NOT based on split ratio/sizing, frameId, instanceId, leaf state (normal/collapsed/zoomed), scroll positions, draft numbers, or view modes.
Pin click semantics
Clicking a pin always calls applyPin(pin):
Build a fresh
FramesetTreefrom the template, minting freshframeIdandinstanceIdfor each leaf (cloneTreeWithFreshIds).Replace
AppSettings.framesetwith the new tree and debounce-persist.Each new leaf mounts and calls its instance cache's
acquire(frameId), getting a fresh default entry under its newframeId.useStaleFramesetSweepruns, releasing the previous tree's now-orphaned instance-cache entries while retaining inactive temp-frame ids.For a user pin (non-null
routeSlug),useActivatePinthen callsmarkPinApplyForSlug(slug)(tauri-) before navigating toapp/ renderer/ services/ pin- apply- handshake. ts /. The host page's arrival effect consumes this one-shot marker — ahead of every skip path, so it can never linger past its arrival — to detect "this arrival was click-driven; the tree is already rebuilt" and skip the redundant secondp/ <slug> applyTemplate(the pin double-apply race, #4749). Cold arrivals (bookmark, reload, back/forward) find no marker and rebuild from the template as step 1 describes.
There is no "if the current tree already matches this pin, do nothing" path. Every click rebuilds. The highlight indicates whether the live tree currently matches — it does not gate the action.
Temp pin
The temp pin is a purely-derived, non-persisted UI state indicator. There is no draftFrameset slot in AppSettings and no originFramesetId breadcrumb anywhere in the codebase. Everything in this section is derived at render time from the singleton frameset and the pin list.
Precondition
activePinId === null && visiblePins.length > 0Where activePinId is the result of findActivePinId(liveTree, savedPins, registry) — an equivalence scan (delegating to framesetsEquivalent) that returns null when the live tree is equivalent to no saved pin's template.
The temp pin renders only when there is at least one saved pin AND none of them matches. If the user has no saved pins at all, no temp pin appears (there is nothing to contrast against).
Visual position
The temp pin renders at the end of the left-cluster pin row — after all visible saved pins, before the divider and the gear icon. It uses the LayoutGrid icon.
Highlight invariant
Exactly one pin in the header row is highlighted at all times. When the live tree matches a saved pin, that saved pin is highlighted and the temp pin is not shown. When no saved pin matches, the temp pin appears and it displays the active highlight (and carries role="tab" + aria-selected="true" for accessibility consistency). No saved pin is highlighted while the temp pin is present.
Click handlers
Default click (no modifier key): dispatches OPEN_SAVE_DRAFT_AS_PIN_DIALOG_EVENT which opens the Save current frameset as pin dialog. The dialog shows a layout preview, a label field, and an icon picker. Clicking Save in the dialog calls:
await saveScoped("base", {
headerLeftPins: [...(current.headerLeftPins ?? []), newPin],
frameset: liveTree,
});
const validated = await refreshSettings();
savedFramesetsRef.current?.syncFromSettings(validated);Both frameset and headerLeftPins are written atomically in a single saveScoped call to prevent the debounce-window stale-snapshot trap (if frameset were flushed first alone, a debounced tree-save could overwrite the just-saved tree before headerLeftPins lands).
After the save, syncFromSettings resynchronizes the live store from the freshly-validated settings without triggering a spurious debounced re-save. The new pin becomes highlighted immediately because findActivePinId now finds an equivalent template.
Cmd+click: calls restoreLastAppliedPin() (implemented in use-saved-frameset.ts by Sub 8 / #2075). This rebuilds the singleton frameset from the last saved pin the user explicitly navigated to in the current session. The lastAppliedPinId is a session-only in-memory ref set in applyPin(pin) each time a saved pin is activated. It is never written to AppSettings or any other persisted store — quitting and relaunching the app clears it.
If no pin has been applied yet this session, Cmd+click is a no-op; a brief toast appears ("No previous pin to restore").
Natural recovery path
Closing the extra frames that caused the divergence is a natural alternative to using the temp pin:
handleLiveTreeChangefires on every tree mutation and re-runsfindActivePinId.When the remaining tree matches a saved pin's template, that pin highlights automatically and the temp pin disappears.
Critically,
handleLiveTreeChangedoes not callcloneTreeWithFreshIds— it preserves the existinginstanceIds in the remaining leaves. Per-leaf instance-cache entries (inbox selected draft, search query, etc.) therefore survive the close and are immediately available when the now-matching pin is highlighted. The user does not lose in-session leaf state by closing a frame.
No persisted draft state
The v1 design (documented in Frameset–Pin Contract D2 and D4) stored a draftFrameset slot and an originFramesetId breadcrumb in AppSettings. The v2 model eliminates both:
AppSettings.framesetalways holds the live singleton tree — there is no separate draft slot.lastAppliedPinIdis a session-only in-memory ref — there is no on-disk breadcrumb.findActivePinIdderives highlight state on every render — there is no persisted "which pin is active" field.
Instance cache (module-scoped, per-frameId)
Per-frame, per-provider state lives in module-scoped instance caches — one cache module per provider type that needs to keep state alive across an unmount. They replace the singleton refs (e.g. activeDraftRef in useDraftManager) that caused #1955.
The shipped caches in tauri-:
inbox-instance-cache.ts—InboxCacheEntrywith per-leafdirectory,selectedDraft,viewMode,layoutId,timelineState,gridState. Shared by both the Inbox and Archives leaves (epic #3426 Note Tray —core.archives-list-viewandcore.search, and their dedicated caches, were retired; Archives is nowcore.inboxpointed atarchives/). Fixes #1955.external-file-editor-session-cache.ts— external-file-editor state per leaf.ai-assistant-instance-cache.ts— AI assistant (core.ai-assistant) state per leaf.doc-cloud-frame-cache.ts— the sole Doc Cloud frame session: project route, embedded outline, tabs/editors, guarded save machines, preview, and live service state. There is deliberately no separate outline/page cache.
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)
Inbox routing has a discriminated selection: selectedNamedFile names a file, while selectedDraft is the numbered-draft target and fallback. Switching from a named file to a numbered draft, or creating a numbered draft, must clear the named discriminant before an async store operation and commit the numbered target only after it resolves. That await gap must never be observable as a coherent numbered selection.
inbox-instance-cache.ts therefore exposes beginReconcile(frameId) and the paired endReconcile(token). Beginning returns an opaque token for the exact entry identity and raises the derived state.reconcilePending flag. Its private per-entry refcount means overlapping transitions cannot clear the gate until the final token ends. The begin/end pair intentionally does not notify subscribers: it is a read-side routing gate, not a render or persistence event.
The provider starts the token before clearing selectedNamedFile, then settles the numbered selection or restores the named selection in its try/catch. In finally, it must finish that synchronous reconcile critical section — no await may be inserted between the selection update and endReconcile — and only then send its one trailing notifyChange(). write-page.tsx's resolveInboxDraftReconcileTarget returns no numbered target while reconcilePending is true, so subscribers keep their last stable selection until that trailing notification observes the coherent result.
Ending is idempotent and uses a non-acquiring entry-identity check. A late completion after destroy, copy, or hydrate is therefore a no-op: it cannot recreate a destroyed entry or decrement a replacement that reused the same frameId. The flag and its refcount are session-ephemeral; snapshot, hydration, and copyState exclude them, so a quit, cold load, or copied leaf never resumes someone else's interrupted transition.
| Inbox cache state | Across-quit behavior | Reconcile rule |
|---|---|---|
selectedDraft, selectedNamedFile, view/layout/timeline/grid/search state | Persisted in framesetLeafState["core.inbox"] | Only a settled, coherent selection is meaningful to a subscriber. |
reconcilePending and its private refcount | Never persisted or copied; reset on hydrate/copy/destroy | Read-only to consumers; only the paired cache API changes it. |
Session-scoped vs across-quit
Most instance caches are session-scoped: plain module-scoped Maps with no persist/hydrate layer. Their state is lost on app quit — a user who selects draft 5 and quits relaunches at the cold-start default, not at draft 5.
core.inbox is the exception — it is across-quit. Its cache (inbox-instance-cache.ts) implements a snapshot/hydrateFromPersisted/subscribe/notifyChange API. The per-leaf state (selected draft, view mode, layout id, timeline state, grid state) is written into AppSettings.framesetLeafState["core.inbox"] — a Record<frameId, PersistedInboxCacheState> blob — by a debounced persist coordinator (use-inbox-leaf-state-persistence.ts, 300 ms debounce matching the tree-change debounce). The save is atomic: it merges both frameset and framesetLeafState in one settings.save call.
Cold-load hydration happens before any leaf mounts: use-saved-frameset.ts calls hydrateInboxFromPersisted(appSettings.framesetLeafState?.["core.inbox"] ?? {}) before calling setFrameset. Leaves that mount into the restored tree start warm (hydrated: true) and skip the inbox.getActive() IPC call.
Why frameId-keyed hydration works across quit: AppSettings.frameset already persists every leaf's stable frameId. Cold load does not regenerate ids — only a pin click mints fresh frameIds (intentionally discarding per-leaf state, since the template is a fresh layout). So the frameId→state mapping in framesetLeafState["core.inbox"] is stable across quit/relaunch.
Multi-window (popout) limitation: each Tauri window's renderer is a separate JS module instance with its own entries Map. Both windows read/write the same on-disk framesetLeafState["core.inbox"] via independent debounces — last-write-wins. If two windows hold inbox leaves with the same frameId (not currently possible by product design, but noted for future reference), one window's snapshot can clobber the other's.
The external-file-editor, AI assistant, and Doc Cloud caches remain session-scoped. Doc Cloud serializes only safe leaf seeds; raw source, guards, tabs, and fetched service state never enter framesetLeafState.
Inbox content routing
Epic #2002 completed the second half of the #1955 fix. Epic #1961 made selectedDraft per-leaf (each leaf owns its own InboxCacheEntry). Epic #2002 wired content rendering to respect that per-leaf ownership. Without #2002, getContentForPane still resolved content through the shared activeDraftRef / incomingContent broadcast — so state was per-leaf but rendering wasn't. Both parts together close #1955.
The routing contract, as shipped:
Per-leaf
selectedDraftis the source of truth for rendered content.write-page.tsxwiresgetDraftForPaneas(paneId) => acquire(paneId).state.selectedDraft. The store'sgetContentForPane(paneId)resolves the draft number through this injected resolver (not by callingacquireitself), then reads content frominboxDraftContentCache.getContent(N)for non-focused panes.activeDraftRefinuseDraftManageris a focused-leaf shadow. In multi-leaf mode it tracks only the focused leaf's active draft. In single-pane mode (popoutindependent: true) it is the only state. It MUST NOT serve as a content-routing fallback for non-focused panes — readingactiveDraftRef.currentfor a non-focused pane returns the wrong draft.inbox.setActive(N)in the store/focus-change path fires only on focused-inbox-leaf selection-routing events. That means: focus moves between inbox leaves, or the focused leaf switches its draft via pill or keyboard. The guard is theindependentflag inuseDraftManager— non-focused panes callswitchDraftwithout routing throughhandleDraftChangeon the focused path. Structural mutations like undo-archive callinbox.setActivedirectly inwrite-page.tsxoutside the store path — that is not a counter-example to the store contract.Same-draft live-mirror works through the
inbox-draft-content-cachesubscription +bumpContentTick. When the focused pane writes content viasetContentForPane, it callsinboxDraftContentCache.setContent, which notifies all subscribers synchronously.useInboxDraftStoresubscribes and debounces asetContentTickbump (100 ms). Non-focused panes on the same draft re-render and re-callgetContentForPane, which reads the updated cache entry. No sharedactiveDraftRefis involved.Typing in a non-focused inbox pane is blocked at the editor level. The focus-gate in
store.setContentForPanereturns early ifisFocused(paneId)is false. Only the focused leaf writes through toeditorContentRefand triggers auto-save. Auto-save remains single-writer keyed toactiveDraftRef.current, which shadows the focused leaf.The auto-save write is draft-tag-gated (#2847).
useDraftAutoSavecompareseditorContentDraftRef.currentagainstactiveDraftRef.currentbefore persisting. The two refs normally advance in lock-step on every render commit, but a synchronoussetContentForPanecall can tag the buffered content for a freshly-focused pane's draft beforeactiveDraftRefcatches up. During the hydration window of a newly-mounted or swapped inbox leaf, the editor surface briefly echoes the prior pane's body — the draft-tag guard makes that window write-free rather than letting it corrupt the active slot's file. A mismatch causes the auto-save timer callback to return early; the next render commit re-aligns both refs.handleFocusChangeis pre-hydration-gated (#2847).useInboxDraftStoreaccepts anisPaneHydratedpredicate wired toacquire(paneId).state.hydrated. When a newly-focused inbox leaf has not yet resolved its real active draft (hydrated: false),handleFocusChangeskips the draft-change handoff. Routing a draft change off the pre-hydration sentinel (1) would desync the page-level active slot from the leaf's eventual draft. The leaf's own hydration firesnotifyChange, and theinbox-instance-cachesubscription performs the routing once the real draft is known.Named-to-numbered handoff is reconcile-gated (#5238).
handleDraftChangeandhandleNewDraftarm a per-entry reconcile token before they clear a named-file selection and await the store.write-page.tsxdoes not route the temporary numbered fallback whilereconcilePendingis true; the provider settles or rolls back first, releases synchronously, then emits one trailing notification. This preserves the last stable route through unrelated broadcasts and overlapping transitions.A fresh inbox leaf adopts the active draft at swap time (#2848).
applyInitialOptionsinmakeInboxProvideraccepts agetActiveDraftcallback (wired inwrite-page.tsxto() => activeDraftRef.current). WhengetActiveDraftis provided and the cache entry is still pre-hydration,applyInitialOptionsseedsentry.state.selectedDraftfrom the current active draft and setshydrated: true. This eliminates the sentinel-1 flash that a brand-new inbox leaf would otherwise show until its asyncinbox.getActive()IPC call resolves.The mirror effect that previously lived in
use-inbox-draft-store.tsis gone. The old effect wroteactiveDraftRef → inboxDraftContentCacheon every render for every content source (initial load,keepIncoming,refreshDrafts,reloadDraftState). As of Epic #2002, each of those external-load paths inuse-draft-manager.tswrites directly intoinboxDraftContentCache.setContentat its ownsetEditorContentcall site. The inline comment at that removal point documents the responsibility transfer explicitly.
Stale-entry sweep — preventing the leak
Because frameIds are minted fresh on every pin click (cloneTreeWithFreshIds) 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
When the user swaps a provider in an existing frame (e.g., core.empty → core.inbox):
frameIdis preserved. The frame's position in the tree does not change.The prior provider's cache entry is
release()-d (passive — the entry survives, reusable if the original provider is swapped back).The new provider's leaf calls
acquire(frameId), getting a fresh default entry (no prior entry exists for that provider type under the newframeId's first mount).For
core.inbox, the empty-frame picker callsapplyInitialOptions(phase 2 commit, #2091) immediately before the leaf mounts.applyInitialOptionsseeds the fresh cache entry'sselectedDraftfromgetActiveDraft()and marks ithydrated: true(#2848), so the leaf's first render already shows the currently-active draft with no async round-trip and no sentinel-1 flash. TheisPaneHydratedgate inhandleFocusChangethen lets the focus handoff proceed normally — the entry is already warm.
Copy frame — cloning a sibling frame into an empty slot (#3004, #3005)
When the user splits the layout and one pane is empty, EmptyFrameCopyBar renders a band above EmptyFrameContent (and below RestorePoppedWindowCTA), offering one button per non-empty sibling frame. If there are no non-empty siblings the bar returns null. The bar is a pure presentational component in packages/; its data and callbacks are provided by the empty provider.
Entry point — tauri- (the helpers below are module-level functions in that file, called from the empty provider's Content component — not nested inside makeEmptyProvider):
buildCopyFrames(handle, currentFrameId, registry)walks the live frameset tree withforEachLeaf, skipping the current empty frame and any othercore.emptyleaves. For each non-empty leaf it reads the provider title from the registry; if two leaves share a title, a 1-based tree-order index is appended (e.g. "Inbox (1)" / "Inbox (2)"). Forcore.inboxleaves it peeks the instance cache (peek(frameId)) and populates asublabel: it renders the selected draft number only when a cache entry exists ANDentry.state.hydratedis true; otherwise (no entry yet, or still pre-hydration) it renders the placeholder"draft …"rather than the sentinelselectedDraft: 1, which would otherwise read as a real "draft 1".handleCopyFrame(sourceFrameId)is the click handler. It callshandle.getTree()to read the freshest tree at click time, then:For
core.inboxonly: callscaptureInboxSourceScroll(sourceFrameId, destFrameId)to snapshot the source leaf's active scroller offset while its DOM is still live, stashing the result as a one-shot hint ininbox-pending-scroll(keyed by the destinationframeId). This must happen beforecopyState/replaceProviderso the source scroller is still in the DOM.For
core.inboxonly: callsinboxInstanceCache.copyState(sourceFrameId, destFrameId)to seed the destination cache entry. See "copyState contract" below.Calls
handle.replaceProvider(destFrameId, { providerId: source.providerId, props: structuredClone(source.props) })to swap the empty leaf with the cloned provider. The destination keeps its ownframeIdand acquires its own independent instance-cache entry.
Generic providers — no cache seeding; only providerId + structuredClone(source.props) are copied via replaceProvider. The destination frame has its own frameId and therefore its own independent instance-cache entry.
copyState contract (inbox-instance-cache.ts):
Copies
selectedDraft,viewMode,layoutId,timelineState(spread),gridState(spread) from the source entry.editingDraftNumbersis NOT copied — the open-editor-card set is session-ephemeral and must boot empty on every fresh mount, including copied ones.hydratedis propagated from the source (not forced totrue). When the source is hydrated (the normal case — the user can only click the button on a mounted, resolved sibling), the dest starts warm soapplyInitialOptions's cold-start re-seed does NOT overwrite the copiedselectedDraft. When the source is itself unhydrated (rare boot race), copying its sentinelselectedDraft: 1withhydrated: truewould lock the dest to draft 1 until something forced a re-resolve; leavinghydrated: falseinstead lets the mount path'sinbox.getActive()resolve the real active draft normally.Calls
notifyChange()so the across-quit persist coordinator snapshots the new state.
Scroll carryover (inbox-pending-scroll.ts):
A one-shot module-scoped mailbox (set/take) carries the source scroll offset to the freshly-mounted dest leaf. The hint is keyed by destFrameId and consumed exactly once on mount (take() reads and removes it). It is transient — excluded from snapshot() / any across-quit persistence — so a hint that goes unconsumed simply vanishes when the renderer is torn down. Draft-bar position is NOT copied (out of scope for v1 — the bar renders from runtime state, not serialized props).
Invariants preserved:
The destination keeps a distinct
frameIdand its own instance-cache entry — the #1955 shared-ref bug is not reproduced.copyStatecallsnotifyChange()so the snapshot persists across quit if the source was across-quit state.The scroll hint is transient and one-shot — it cannot accumulate stale state.
Per-provider state policy
The table below records, for each provider, what per-leaf state the leaf owns, and whether that state survives a quit/relaunch. The "scope" column primarily uses two values; rows with both provider-props persistence and session-only interaction state call out the split explicitly.
session — state is held only in the module-scoped instance cache and is lost when the app quits.
across-quit — state is snapshotted into
AppSettings.framesetLeafStateand restored on the next cold load.
| Provider id | Per-leaf state | Scope | Notes |
|---|---|---|---|
core.inbox | directory, selected draft, view mode, layout id, timeline/grid UI | across-quit | Snapshotted to framesetLeafState["core.inbox"] by use-inbox-leaf-state-persistence.ts. Hydrated before leaves mount. Backs both the Inbox (directory: "inbox") and Archives (directory: "archives") trays (epic #3426 Note Tray) — core.search and core.archives-list-view are retired; Pile View (opened from any tray) is the search surface. |
core.external-file-editor | open external file set, active tab, tree root/collapse | session | Re-opened from the pin's local-absolute providerProps; ad-hoc opens are session context. |
core.doc-cloud | project navigation, embedded outline/search, tabs, raw page buffers, guarded save machines, preview/activity/history/hosted state | session | One cache and one frameset-scoped provider. Serialized props are only projectSlug, initialSurface, editorLayout, and outlineCollapsed; identity excludes collapse. |
core.empty | none | session | The empty frame is a placeholder; no state to cache. |
core.diff-view | none meaningful | session | Diff is tied to files open at the time; stale across quit. |
core.kanban-board | header callbacks, filter visibility, saved-view snapshot | session | kanban-instance-cache.ts bridges content to header actions by frame id. Stable subscribed snapshots and owner-guarded callbacks prevent stale board actions. Board path/layout remain provider props; cache entries are disposed with the frame and swept with other caches. |
core.dashboard | none | workspace settings | Widget layout/settings live in AppSettings.dashboard; edit mode is local. No per-leaf cache or snapshot. |
core.mindmap-board | loaded file path, layout id, collapsed node ids; transient pan/zoom/focus/find state | across-quit for provider-props state; session for transient interaction state | The file path, active layout, and collapsed ids are blob-backed provider props and survive cold start with the frameset. Pan/zoom position, focused node, find query, and focus mode are runtime interaction state and are not restored. No instance-cache layer is used. |
core.todo-board | scroll, filter state | session | Same as core.kanban-board. |
Lifecycle examples
(a) Cold start
(b) Split
(c) Pin click
(d) Close frame
Key invariants
From Epic #1961, as shipped:
One singleton frameset per app window. No
framesets[]array.AppSettings.frameset(singular) is the only persistence key.Pins are structural templates. No
framesetIdlinkage.applyPinalways rebuilds; no id-equality bail.State lives in per-leaf module-scoped instance caches. Not in singleton refs (the shared
activeDraftRefbug is fixed). The caches intauri-are keyed byapp/ renderer/ services/ frameId. The selected-draft isolation that dissolves #1955 is inInboxCacheEntry.state.selectedDraft.Most instance-cache state is session-scoped. Per-leaf state (layout, query, scroll) is lost on app quit for all providers except
core.inbox.core.inboxis across-quit: its state is snapshotted toAppSettings.framesetLeafState["core.inbox"](aRecord<frameId, PersistedInboxCacheState>blob) on every change and restored on cold load before leaves mount. The tree structure (AppSettings.frameset) and the inbox leaf state are written in the same atomicsettings.savecall.Stale instance-cache entries are swept on every tree/temp change.
useStaleFramesetSweepcalls the matching sweep on the inbox, external-file-editor, ai-assistant, pending-scroll, and Doc Cloud stores against the union of live and inactive-temp ids, so orphanedframeIdentries cannot accumulate without destroying a restorable temp frame.Embedded panes are not frames. Doc Cloud owns its outline and EFE owns its directory tree inside the provider body. Their seam controls patch that leaf's props; neither opens, focuses, or splits another leaf.
Pop-out is OUT of scope for this epic (keeps
independent: truemode). Holds.
How #1955 dissolves
The structural fix landed in two parts:
Part 1 — Epic #1961 (cache primitive). Selected-draft state was moved from a singleton activeDraftRef into InboxCacheEntry.state.selectedDraft, a per-leaf field keyed by frameId. Each inbox frame now holds its own cache entry with its own selected draft. When the user selects a draft in the left inbox frame, that frame's cache entry is updated. The right inbox frame's entry is independent. No shared ref, no broadcast leak. This closed the selectedDraft isolation half of #1955.
Part 2 — Epic #2002 (per-leaf content routing). After Epic #1961, getContentForPane still resolved content through the shared activeDraftRef and the old incomingContent broadcast effect — so selected-draft state was per-leaf but rendered content was not. Epic #2002 wired getContentForPane to resolve each pane's draft number through the injected getDraftForPane resolver (backed by acquire(paneId).state.selectedDraft) and removed the broadcast mirror effect from useInboxDraftStore. See Inbox content routing above.
The two parts together mean: different inbox leaves show different drafts AND type in different content without any cross-pane leak. #1955 is fully dissolved.
Half-landed gap (for future reference). Between Epic #1961's merge and Epic #2002's merge, a reader examining the code would see per-leaf selectedDraft in the cache but content routing still going through activeDraftRef. That gap existed intentionally as separate work items — the state isolation sub (S1) preceded the routing wiring subs (S2, S3). If you encounter a branch between these epics, do not assume the routing is complete until Epic #2002 is merged.
How the pin-aliasing bug dissolves
Pins are templates, not framesetId references. There is no setCurrentFramesetId(id) call anywhere in the pin click path. Every pin click calls applyPin(pin) which always rebuilds the singleton tree from scratch. There is no "if current id equals pin id, skip" path to trigger the bail. The bug is structurally absent from the new model.
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
Pop-out windows are explicitly out of scope for this epic. They continue to use independent: true mode. The relationship between pop-out windows and the singleton frameset will be revisited in a future epic.
Reference files
| Concept | File |
|---|---|
| Singleton frameset hook (cold-load hydrate call site) | tauri- |
| Pin activation orchestrator | applyPin / applyTemplate in use-saved-frameset.ts |
| Module-scoped per-leaf instance caches | tauri-, external-file-editor-session-cache.ts, ai-assistant-instance-cache.ts; tauri- |
| Inbox snapshot/hydrate/subscribe/notifyChange API | tauri- (snapshot, hydrateFromPersisted, subscribe, notifyChange) |
| Inbox across-quit persist coordinator | tauri- |
| Stale-entry sweep | tauri- (mounted by write-page.tsx) |
| Copy-frame bar (presentational) | packages/ |
Copy-frame entry point (buildCopyFrames, handleCopyFrame) | tauri- |
| Inbox copy-state seeding | inboxInstanceCache.copyState in tauri- |
| Transient scroll-carryover mailbox | tauri- |
| Last-active-draft pointer (cross-route first-paint seed, #3988) | tauri- |
| Pin-apply click↔arrival handshake (one-shot marker, #4749) | tauri- |
AppSettings schema (frameset, framesetLeafState) | packages/, packages/ |
| Inbox cache integration | tauri- |
| Inbox per-draft content cache (module-scoped, subscriber pattern) | tauri- |
| Per-pane focus-gated draft store (content routing + focus-gate) | tauri- |
| Default inbox serialized shape | tauri- (DEFAULT_INBOX_PROVIDER_SERIALIZED + CI drift guard) |
| Deep-equal frameset tree update | updateFramesetTreeDeepEqual in tauri- |
| ViewProvider type | packages/ |
| Frame chrome | packages/ |
| LeafRenderer | packages/ |
See also
Frame Component Contract — ViewProvider shape, toolbar slot, persisted leaf-state schema v2, active-frame border.
Frameset–Pin Contract — the v1 design decisions (D1–D10) for matcher equivalence, draft frameset slot, live-tree selector, pin highlight derivation, pristine-snapshot rollback, and debounced-save flush. This document supersedes D2 (draft frameset slot), D3 (live-tree selector), D4 (pin highlight derivation), D8 (pristine snapshot rollback), and D9 (debounced-save flush) from that contract. D1, D5, D10 remain relevant and are not changed by this epic.
Header Pins Architecture — data-flow and routing for the header pin bar.