diff --git a/web/src/main.ts b/web/src/main.ts index 607716e..4cc30b2 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -114,8 +114,9 @@ let previewTimer: number | undefined; function schedulePreview(sessionId: string | null, source: "key" | "hover"): void { clearTimeout(previewTimer); previewTimer = window.setTimeout(() => { - showMultiView(); + ensureMultiView(); mv?.previewSession(sessionId); + updateShellRoute(); }, PREVIEW_DELAY[source]); } @@ -262,11 +263,18 @@ mv = createMultiView({ onLayoutChange: scheduleSave, }); let contentShowsMv = false; -function showMultiView(): void { +/** Put the multi-view in the content pane, without rendering it. A preview + * renders once, on its own terms (`docs/PREVIEW.md`); a render here would + * first re-mount the pane it is about to replace, and that mount takes the + * keyboard away from the list being walked. */ +function ensureMultiView(): void { if (!contentShowsMv) { content.replaceChildren(mv.el); contentShowsMv = true; } +} +function showMultiView(): void { + ensureMultiView(); mv.render(); updateShellRoute(); } diff --git a/web/src/multiview.ts b/web/src/multiview.ts index 165ced7..bc26b34 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -110,6 +110,20 @@ export function createMultiView(opts: MultiViewOptions): MultiView { tempTabId: string | null; } let preview: Preview | null = null; + /// Set while a preview-driven render runs: ending a preview re-mounts the + /// pane that was there, and that mount must not pull the keyboard out of the + /// sidebar the operator is still walking. + let quietRender = false; + + /** Render without any mount taking the keyboard. */ + function renderQuietly(): void { + quietRender = true; + try { + render(); + } finally { + quietRender = false; + } + } const isTempTab = (tabId: string) => preview?.tempTabId === tabId; @@ -152,7 +166,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { function previewSession(sessionId: string | null): void { if (preview?.sessionId === sessionId) return; if (sessionId === null) { - if (restorePreview()) render(); + if (restorePreview()) renderQuietly(); return; } restorePreview(); @@ -176,7 +190,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { layout.activeTabId = tab.id; preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id }; } - render(); + renderQuietly(); } /** Any user action that changes the layout for real ends the preview first, @@ -385,7 +399,8 @@ export function createMultiView(opts: MultiViewOptions): MultiView { } render(); }); - if (layout.tabs.length > 1) { + // A preview tab is not the operator's to close: it goes on its own. + if (layout.tabs.length > 1 && !isTempTab(t.id)) { const close = document.createElement("span"); close.className = "mv-tab-close"; close.textContent = "✕"; @@ -555,7 +570,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { // terminal, or j/k would stop walking the list after the first step. pool.setVisible( hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })), - { focus: !preview }, + { focus: !preview && !quietRender }, ); syncFocus(); persist(); @@ -579,7 +594,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { // Only the focused leaf's session is live on mobile; the rest park. pool.setVisible( focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [], - { focus: !preview }, + { focus: !preview && !quietRender }, ); if (!preview) pool.setFocus(focused.sessionId ?? null); sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null }); diff --git a/web/src/styles.css b/web/src/styles.css index c33ae9e..9b7a9b7 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2890,6 +2890,21 @@ input.leaf-rename:focus { border-style: dashed; font-style: italic; } +.mv.mv-previewing .mv-canvas::before { + content: "preview"; + position: absolute; + top: var(--sp-2); + right: var(--sp-3); + z-index: var(--z-sticky); + padding: 1px var(--sp-2); + border-radius: var(--r-1); + font: 500 var(--fs-11) / 1.5 var(--font-mono); + letter-spacing: 0.04em; + color: var(--accent-ink); + background: var(--accent); + opacity: 0.85; + pointer-events: none; +} .mv.mv-previewing .mv-canvas::after { content: ""; position: absolute;