zudo-text

検索したい単語を入力

いつでも検索バーを開ける

Frame Pop-Out

The frame pop-out subsystem (W9.1, #1442) lets users detach any editor frame into its own native window and dock it back. Three Tauri commands handle the native-window operations; the bridge.framePopout facade adds a typed event layer including the cross-window restore protocol (#1920) used to move live provider state back into the main window.

Source: tauri-app/src/commands/window.rs

Tauri Commands

frame_pop_out

Open a new native window for the given frame.

const windowLabel = await invoke<string>('frame_pop_out', {
  frameId: 'frame-abc123',
  providerId: 'inbox',
  propsJson: JSON.stringify({ draftNum: 1 }),
});

Parameters:

NameTypeDescription
frame_idstringStable identifier of the frame being popped out
provider_idstringProvider type identifier (e.g. "inbox", "search")
props_jsonstringJSON-serialized initial provider props (max 32 KB)

Returns: The Tauri window label (popout-{frameId}) on success. Throws a string error on failure or if props_json exceeds 32 KB.

Behavior:

  • Opens a new WebviewWindow with label popout-{frameId}, loading index.html#/popped-out/{frameId}?providerId={providerId}&props={encoded}.

  • Idempotent: if a window with the same label already exists, focuses it and returns its label without opening a second window.

frame_dock

Close a popped-out window programmatically.

await invoke('frame_dock', { windowLabel: 'popout-frame-abc123' });

Parameters:

NameTypeDescription
window_labelstringThe Tauri window label returned by frame_pop_out

Returns: null on success. Throws a string error if the window is not found.

Behavior: Closes the named window. The Rust window-destroy handler emits frame:popout-closed so the main window can update its frame state (dock the leaf back to normal).

frame_popout_emit_event

Relay a named Tauri event between the main window and a popped-out window.

await invoke('frame_popout_emit_event', {
  eventName: 'frame:popout-restore-request',
  payload: { windowLabel: 'popout-frame-abc123' },
});

Parameters:

NameTypeDescription
event_namestringEvent name — must be one of the allowed events (see below)
payloadanyJSON-serializable event payload

Returns: null on success. Throws a string error if event_name is not in the allowlist.

Allowed event names (allowlist enforced by Rust):

Event nameDirectionPurpose
frame:popout-restore-requestmain → popoutAsk popout to send its current state
frame:popout-restore-payloadpopout → mainPopout's serialized state
frame:popout-restore-ackmain → popoutMain has restored state; popout may close
frame:popout-close-requestpopout → mainVersion-3 native-close request or receipt on the existing close channel
frame:popout-close-ackmain → popoutVersion-3 result for one native-close attempt
frame:popout-return-requestpopout → mainUser clicked "return to frame" inside popout
frame:popout-notifypopout → mainForward a serialized toast spec to the main window notification stack
frame:popout-sync-state-requestpopout → mainAsk main for a fresh sync-state snapshot
frame:popout-sync-statemain → popoutBroadcast the serializable sync-state snapshot
frame:popout-auth-login-requestpopout → mainRun the interactive auth login flow in the main window

The allowlist prevents the relay from being used as a general-purpose cross-window event bus.

Bridge Facade (bridge.framePopout)

The bridge facade wraps the Tauri commands and events with typed helper methods. The cross-window restore protocol is coordinated via frame_popout_emit_event relays — window.postMessage does not cross separate Tauri WebviewWindows.

Window Lifecycle

// Open a popped-out window
const windowLabel = await bridge.framePopout.popOut(frameId, providerId, propsJson);

// Programmatically close it
await bridge.framePopout.dock(windowLabel);

// Listen for window-closed events (emitted by Rust destroy handler)
const unlisten = bridge.framePopout.onPopoutClosed((frameId, windowLabel) => {
  // Dock the leaf back to normal state in the main window
});

Cross-Window Restore Protocol

Main-initiated restore (user clicks empty-frame "Restore popped window" CTA):

// 1. Main requests the popout's current state
await bridge.framePopout.requestRestore(windowLabel);

// 2. Popout receives the request (registered in the popout renderer)
const unlisten = bridge.framePopout.onRestoreRequest((windowLabel) => {
  // Serialize current provider state and send it back
  bridge.framePopout.sendRestorePayload(windowLabel, JSON.stringify(currentProps));
});

// 3. Main receives the payload and restores into an empty leaf
const unlisten = bridge.framePopout.onRestorePayload((windowLabel, propsJson) => {
  const props = JSON.parse(propsJson);
  targetLeaf.replaceProvider(providerId, props);
  // Acknowledge so popout knows it can close
  bridge.framePopout.sendRestoreAck(windowLabel);
});

// 4. Popout receives the ack and closes itself
const unlisten = bridge.framePopout.onRestoreAck((windowLabel) => {
  window.close();
});

Popout-initiated return (user clicks "return to frame" inside the popout):

// 1. Popout sends a return request with current state
await bridge.framePopout.sendReturnRequest(windowLabel, JSON.stringify(currentProps));

// 2. Main receives it — decides where to restore (empty leaf, or prompts user)
const unlisten = bridge.framePopout.onReturnRequest((windowLabel, propsJson) => {
  // Find an empty leaf, or show a "open an empty frame first" toast
  // Then call sendRestoreAck to close the popout (same as main-initiated path)
});

Notify, Sync, and Auth Relays

Popped-out windows do not own the main app's notification stack, sync state, or auth login flow. The bridge.framePopout facade therefore exposes typed relay helpers on top of the same frame_popout_emit_event allowlist:

HelperEventDirectionNotes
sendNotify(windowLabel, specJson) / onNotifyForwarded(cb)frame:popout-notifypopout → mainspecJson is the serializable NotificationSpec subset; React icon and actions are stripped before serialization.
requestSyncState(windowLabel) / onSyncStateRequest(cb)frame:popout-sync-state-requestpopout → mainPopouts request a snapshot on mount and retry until one lands.
sendSyncState(snapshotJson) / onSyncState(cb)frame:popout-sync-statemain → popoutSync state is global, so this is a label-less broadcast.
requestAuthLogin(windowLabel) / onAuthLoginRequest(cb)frame:popout-auth-login-requestpopout → mainThe Better Auth browser handoff and deep-link callback stay in the main window process.

Events

frame:popout-closed

Emitted by the Rust window-destroy handler when a popped-out window is closed (by the user or by frame_dock).

import { listen } from '@tauri-apps/api/event';

const unlisten = await listen<{ frameId: string; windowLabel: string }>(
  'frame:popout-closed',
  (event) => {
    const { frameId, windowLabel } = event.payload;
    // Dock the leaf back to normal
  }
);

The restore-protocol events (frame:popout-restore-request, frame:popout-restore-payload, frame:popout-restore-ack, frame:popout-return-request) and the notify/sync/auth relay events listed above are relayed via frame_popout_emit_event — use bridge.framePopout helpers rather than listen() directly.

Native-close request and acknowledgement (#6439)

The native close path uses version-3 messages on the existing frame:popout-close-request channel. A kind: "close" message asks the host to apply the child's verified note handback and durably restore it into its original empty frame. After receiving a restored acknowledgement, the child sends a kind: "receipt" message on the same channel before destroying its native window. The receipt is a protocol message, not a new event or a replacement for the raw frame:popout-closed lifecycle signal.

The wire shapes are defined in tauri-app/renderer/lib/popout-native-close.ts:

type PopoutCloseRequest = {
  version: 3;
  kind: "close";
  windowLabel: string;
  sourceFrameId: string;
  sourceFramesetId: string;
  sourceInstanceId: string;
  sourceGeneration: string;
  workspaceId: string;
  providerId: string;
  attemptToken: string;
  propsJson: string;
  deadlineEpochMs: number;
  selectedNote: WorkspaceNoteIdentity | null;
  notes: Array<{
    identity: WorkspaceNoteIdentity;
    revision: `sha256:${string}`;
    content?: string;
  }>;
};

type PopoutCloseReceipt = {
  version: 3;
  kind: "receipt";
  windowLabel: string;
  sourceFrameId: string;
  sourceFramesetId: string;
  sourceInstanceId: string;
  sourceGeneration: string;
  workspaceId: string;
  providerId: string;
  attemptToken: string;
};

type PopoutCloseAck = {
  version: 3;
  windowLabel: string;
  sourceFrameId: string;
  sourceFramesetId: string;
  sourceInstanceId: string;
  sourceGeneration: string;
  workspaceId: string;
  providerId: string;
  attemptToken: string;
  status: "restored" | "recovery-pending" | "rejected" | "failed";
  message?: string;
};

The close request carries the complete frame, frameset, instance, source generation, workspace, and provider identity. propsJson is the serialized layout/provider state; it is not the note body. selectedNote identifies the materialized selected note, and notes contains every materialized note the child wrote during that popout generation. Each revision is the lowercase SHA-256 of the exact UTF-8 bytes of content, prefixed with sha256:. An omitted content field means that the host must perform an authoritative read; an empty string is real content. Identities are canonicalized and the selected identity must occur in the note manifest.

deadlineEpochMs is a finite, positive epoch-millisecond value; malformed or nonfinite deadlines are rejected. The receipt echoes the same complete identity and accepted attempt token, but never carries note bodies or a deadline. The acknowledgement travels on frame:popout-close-ack and echoes the identity and token so a child can ignore a result for another generation or attempt. The protocol accepts v3 only; v1/v2 close, receipt, and ack messages are rejected.

StatusMeaning
restoredThe host applied and verified the complete note batch, replaced the eligible target, and completed its durability boundary; the child may send its receipt.
recovery-pendingThe host kept the persisted recovery entry because work is busy, the target is unavailable, or a retry is required.
rejectedThe request does not match a recovery entry or its full generation identity.
failedThe host could not complete or publish the requested durability result because of a boundary failure or the bounded deadline.

The child owns the close attempt's reply budget. It first freezes new edits, awaits already-started writers, and persists its current state through the workspace-boundary path. The page-owned native-close listener is registered once above the workspace gate and remains active while the child is hydrating, locked, blocked, or ready; unlocking must not create a listener gap. Listener registration is bounded to 1,000 ms; a failed or late registration sends no request and leaves the child open. Only after registration succeeds does the child capture the selected note and complete note snapshots, stamp deadlineEpochMs with a fresh five-second deadline, and start its local monotonic reply timer. Snapshot capture has its own 5,000 ms bound and is separate from the request-to-ack budget. Registration time is not charged twice.

The relay envelope is measured as the UTF-8 byte length of JSON.stringify({ requestJson }), including escaping and the final deadline. If the full request exceeds the 32,768-byte relay cap, the child removes every content field and retries the same metadata-only request. If that still does not fit, it sends nothing and keeps the child open. The host resolves omitted bodies authoritatively and requires the requested digest; a missing note, different head, lock, timeout, or transport failure retains recovery.

The host converts the epoch deadline to one local monotonic deadline and never resets it for queue acquisition or another boundary phase. It computes the remaining time as the smaller of five seconds and the request's remaining epoch time, reserving the final 500 ms for best-effort acknowledgement delivery. The host work deadline covers the complete restore operation, including note reads and hashing, conflict checks, cache/buffer adoption, the tree turn, writer phases, settings queue, and outbox durability wait. If that budget expires, the host sends a bounded failed result when it can. Already-started writer or upload work is not cancelled; the per-window lease remains held until that underlying work settles, and a late completion cannot publish a restored result or remove recovery.

The host persists an accepted request as unverified, resolves every note body, validates every digest, and preflights the entire batch before mutating the model. It refuses a dirty or pending host writer, a changed workspace or generation, a stale lease, or a late read. It then applies all note bytes through the model-only remote-apply seam, updates the exact workspace, directory, selection, and target-frame caches, seeds the restored selection, and persists the layout. This complete apply-before-ack sequence is atomic from the protocol's point of view: no restored acknowledgement is sent until all note and layout work succeeds, and no host writer is flushed as a shortcut to obtain the child's bytes.

On restored, the host records the durable proof — including selected-note identity and the sorted note (path, revision) manifest — before sending the acknowledgement. A lost acknowledgement therefore leaves no receipt and keeps the persisted recovery entry available. A new token may replay that success only when the full current identity and context still match, the child properties are byte-for-byte equivalent, the selected identity and note manifest are unchanged, and the durable target is unchanged. Changed properties or changed note bytes require a fresh guarded restore; the host never caches a failure as a generation-wide result.

The reply deadline gates issuing a successful result, but it does not make a receipt for an already-issued success too late. A receipt remains eligible while its durable proof is retained, even after the reply deadline has passed. Busy requests with a new token receive a bounded recovery-pending result echoing that new token; they do not start a parallel restore.

The child bounds receipt delivery to 500 ms and native destruction to 1,000 ms. Receipt failure keeps the child open and retryable. A rejected destruction is likewise retryable, but a destruction that merely exceeds its bound leaves the child paused: the native call may still be in flight, so a repeated close gesture is refused with the same destruction-unknown state rather than issuing a competing destroy. Only settlement of that native call, or a restart, releases the pause. The host removes a recovery entry only after all of the following are true: it has a validated successful durability proof, it has received the matching receipt, the current identity and workspace context still match, no competing mutation lease is held, the durable target and snapshot are unchanged, and a fresh bounded framePopout.isOpen(windowLabel) probe returns false. Fences are checked again immediately before removal. Probe failure, an open child, storage failure, or a context/generation change retains the entry for a later receipt or raw-close retry. The raw frame:popout-closed event can trigger a probe but is never proof of durability or receipt by itself.

Only the recovery entry is persisted under zudotext:popout-recovery:v1; leases, deadlines, receipts, and replay proofs are renderer-memory state. A persisted entry has required contentState ("verified" or "unverified") and recoveryNoteIdentities, plus optional lastHandback metadata containing identities, revisions, and the selected identity — never note bodies or transient attempt data. New Note Tray generations start unverified. A raw frame:popout-closed/Destroyed event cannot certify the child's final content: the host keeps the entry, performs a bounded best-effort authoritative read of known identities, and may restore only safely available saved bytes. It never unregisters solely because the host-origin props were restored.

An unverified entry shows "current child content could not be verified" and keeps the recovery action "Retry content check", including after reload or after a target has already been published. The helper text is "Saved content may be older than the last edit in the closed window. This recovery entry has been kept." Restart discards transient proof and lease state, so recovery must be revalidated through the notice/CTA or a fresh child attempt.

macOS Handoff Note

The MockAdapter jsdom tests cover the in-process relay path and the Rust core tests cover the event allowlist, but real two-window delivery depends on the Tauri runtime and WebviewWindow event broadcast behavior. Before shipping the popout notify relay, verify on a macOS Tauri build:

  1. Open a kanban or todo board in a popped-out frame.

  2. Trigger an in-app toast in the popped-out frame and confirm the main window shows it.

Help window

Help uses the fixed native label help, outside the popout-* namespace and frame restore protocol. BackendAPI.helpWindow exposes isSupported(), open(entryId?, shortcuts?), close(), isOpen(), and asynchronous onClosed(callback) (resolves to an unsubscribe function). Shortcuts are a map of command keys to formatted display strings, serialized as JSON by the adapter.

On desktop, open_help_window creates an 1100 × 760 window titled <app> — Help at index.html#/help?entryId=…&shortcuts=…. Query values are URL-encoded. Reopening focuses and unminimizes the existing window and returns { disposition: "reused" }; creation returns { disposition: "created" }. Reusing a window preserves its current catalog navigation. close_help_window is idempotent, is_help_window_open probes the fixed label, and native destruction emits help:window-closed.

The renderer declares secondary scope help before loading appearance. It mounts only HelpWindowPage: no auth resumption, workspace boot gate, sync arming, or host outbox. The external button carries the currently selected catalog entry and effective shortcut labels. Closing the native window clears external state; the next Help request opens the modal. Subscription precedes liveness queries, and close events invalidate stale query/open results.

“Try it now” focuses the main window with focus_main_window, then emits help:invoke-command with only commandId. The host resolves that identifier against its current command list. Executable callbacks never cross the boundary.

The button is hidden on mobile and unsupported backends. REST advertises isSupported() === false. The mock adapter also advertises no native-window support, keeping Help visible as a modal in dev:mock; its in-process open/reuse/close/subscription lifecycle remains available for deterministic tests.

Native verification must cover creating/reusing Help, retaining the selected entry and shortcut display, invoking a command in the main window, closing the window, and reopening the modal. Unit tests cover the bridge shapes, isolated bootstrap, route parsing, and close-to-modal restoration.

The child subscribes to help:available-commands before emitting help:commands-request. The host responds with its current executable command IDs and republishes when that list changes, so catalog actions remain disabled until the main window confirms availability.

The isolated native-e2e fixture composes production Help transport with mock workspace data and a dedicated incognito WebView store. e2e/native/help-window.e2e.ts checks actual native creation/reuse and probes main focus from the Help realm before switching WebDriver context. This is separate from ordinary browser mock coverage and does not use a personal account.