From f6b2cf3ab4200ba8791d525e92f0bef0d4f11002 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 10:42:53 +0100 Subject: [PATCH 1/3] feat(maestro): preview the highlighted session in the web pane Moving the sidebar highlight (keyboard or hover) now shows that session in the content pane without opening it: its own tab comes forward, or a throwaway tab carries it. The layout, the saved layout and the daemon's focus report are all left untouched, and the keyboard stays in the list. Contract in docs/PREVIEW.md. --- CLAUDE.md | 4 + docs/PREVIEW.md | 71 ++++++++++++ web/src/attach.ts | 17 ++- web/src/catalog.ts | 30 +++++ web/src/connections.ts | 14 ++- web/src/main.ts | 29 ++++- web/src/multiview.ts | 177 ++++++++++++++++++++++++++++-- web/src/preview.test.ts | 218 +++++++++++++++++++++++++++++++++++++ web/src/sidebarNav.test.ts | 47 +++++++- web/src/styles.css | 17 +++ 10 files changed, 598 insertions(+), 26 deletions(-) create mode 100644 docs/PREVIEW.md create mode 100644 web/src/preview.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7051173..eb58c61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,10 @@ before changing anything that creates or removes one. sidebars — line layout, activity colours, duration format, state precedence. Read it before changing sidebar rendering. +`docs/PREVIEW.md` is the contract for what the content pane shows while the +operator moves the sidebar highlight — what a preview may touch, what ends it, +what commits it. Read it before changing sidebar-driven navigation. + `docs/RESTART.md` is the contract for what clients do when the daemon restarts or is upgraded under them — the build stamp on `/api/info`, the status indicator's states, the blocking overlay, grace and poll timings. Read it diff --git a/docs/PREVIEW.md b/docs/PREVIEW.md new file mode 100644 index 0000000..34d973b --- /dev/null +++ b/docs/PREVIEW.md @@ -0,0 +1,71 @@ +# PREVIEW.md — the sidebar preview contract + +One rule for what the content pane shows while the operator is only moving +around the sidebar, so the web client and the TUI behave the same. Read this +before changing either. If the behaviour needs to change, change it here first. + +## The rule + +Highlighting a session in the sidebar shows that session in the content pane, +as if it were focused, **without focusing it and without changing the layout**. +Moving the highlight elsewhere puts back exactly what was on screen before. + +A preview is a view, not a state. Nothing about it is durable: + +- The layout is byte-for-byte identical before the preview and after it ends. +- Nothing is persisted while a preview is up (the web client's layout is saved + to `localStorage`; a preview never reaches it). +- The daemon is never told the previewed pane is focused, so a preview never + clears a session's `AwaitingInput` glow (`docs/SIDEBAR.md`). +- The keyboard stays in the sidebar: the previewed terminal is on screen but + does not take focus, so the next `j`/`k` still walks the list. + +## Where the preview is shown + +| The previewed session is… | What happens | +|---|---| +| Already in a pane, in the active tab | That pane comes forward (its tab's focus moves to it). | +| Already in a pane, in another tab | That tab is brought forward. The active tab is restored when the preview ends. | +| Not in any pane | A throwaway tab carries it. It is removed when the preview ends. | + +The throwaway tab never counts as an open session: the tab bar does not appear +because of it, the mobile shell does not switch away from the session list for +it, and the sidebar does not mark the session as open. + +## What ends a preview + +- The highlight moves to another session — that session is previewed instead. +- The highlight leaves every session (a folder row, or the list losing the + keyboard, or the pointer leaving the list): the previous view comes back. +- Any real layout change (split, close, new tab, tab switch, drag): the preview + is discarded first, so a throwaway tab is never baked into the arrangement. + +## What commits a preview + +Choosing the previewed session — clicking or pressing Enter on its row, +clicking into its pane, or handing the keyboard to the pane (web: `Tab` from +the list; TUI: the attach binding) — keeps exactly what is on screen. A +throwaway tab becomes a real one; a focus move inside an existing tab stands. +Nothing moves a second time on commit. + +## Timing + +A preview follows the highlight closely enough to be usable as a way to look +around, and lags a pointer enough not to flash rows crossed on the way +somewhere else. + +| Trigger | Delay | +|---|---| +| Keyboard step (`j`/`k`, arrows) | 60 ms | +| Pointer hover (web, mouse only — never touch) | 150 ms | +| Clearing (highlight left every session) | same delay as the trigger that set it | + +Touch has no hover, and a tap already opens a session, so touch never previews. + +## Cost + +A previewed session that no pane holds needs a live attach. Both clients keep +it connected after the preview ends — the web client parks it in the connection +pool's LRU, the TUI keeps its emulator until it falls out of the preview +budget — so stepping back onto a row is instant rather than re-fetching +scrollback. diff --git a/web/src/attach.ts b/web/src/attach.ts index d6618ff..e85be1a 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -32,8 +32,10 @@ export interface AttachConnection { readonly sessionId: Uuid; /** The `.attach` element (header + terminal body) to mount into a pane. */ readonly el: HTMLElement; - /** Becomes the visible view: fit to the pane, focus, sync size. */ - onMount(): void; + /** Becomes the visible view: fit to the pane, focus, sync size. + * `focus: false` mounts without taking the keyboard — what a sidebar + * preview needs, so j/k keeps working while the pane is on screen. */ + onMount(opts?: { focus?: boolean }): void; /** Parked into the cache: keep streaming, just stop driving focus/resize. */ onUnmount(): void; /** Put the keyboard back into this terminal — what leaving the sidebar does. */ @@ -309,6 +311,10 @@ export function createAttachConnection( } let mounted = false; + // A previewed pane is visible but must not steal the keyboard from the + // sidebar, so mounting can opt out of the focus grab (and out of the one + // the socket does when it finishes connecting). + let focusOnMount = true; // We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts // a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`, // so with the scrollbar hidden it falls back to a phantom 15px and the grid @@ -387,7 +393,7 @@ export function createAttachConnection( reconnectMs = INITIAL_RECONNECT_MS; setState("connected", "open"); sendControl({ type: "Resize", cols: term.cols, rows: term.rows }); - if (mounted) term.focus(); + if (mounted && focusOnMount) term.focus(); }; ws.onmessage = (ev) => { @@ -472,14 +478,15 @@ export function createAttachConnection( return { sessionId, el, - onMount() { + onMount(mountOpts?: { focus?: boolean }) { mounted = true; + focusOnMount = mountOpts?.focus !== false; acquireWebgl(); // Defer a tick so the element has its content-pane dimensions before fit. requestAnimationFrame(() => { tryFit(); sendControl({ type: "Resize", cols: term.cols, rows: term.rows }); - if (ws.readyState === WebSocket.OPEN) term.focus(); + if (focusOnMount && ws.readyState === WebSocket.OPEN) term.focus(); }); }, onUnmount() { diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 8bfecec..3aaf7af 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -364,6 +364,11 @@ export interface SidebarOptions { /** Daemon link behind the footer's status dot. Omitted in tests and in the * design kit, which render the sidebar without a daemon. */ link?: DaemonLink; + /** The highlight moved onto a session, or off every session (`null`): the + * shell shows it in the content pane without opening it (docs/PREVIEW.md). + * `source` is there so a mouse sweep can be debounced differently from a + * keyboard step. */ + onPreview?: (sessionId: string | null, source: "key" | "hover") => void; } export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle { @@ -528,6 +533,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey); }; + const sessionOf = (key: string | null): string | null => + key?.startsWith("session:") ? key.slice("session:".length) : null; + const moveNav = (delta: number) => { const rows = navRows(); if (rows.length === 0) return; @@ -539,6 +547,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S navKey = next.dataset.navKey ?? null; applyNav(); next.scrollIntoView({ block: "nearest" }); + opts.onPreview?.(sessionOf(navKey), "key"); }; /** Enter: open the highlighted session, or expand the highlighted folder. */ @@ -563,9 +572,30 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S applyNav(); }; + // Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left + // out: there is no hover there, and a tap already opens the session. + let hovered: string | null = null; + const setHovered = (id: string | null) => { + if (hovered === id) return; + hovered = id; + opts.onPreview?.(id, "hover"); + }; + treeHost.addEventListener("pointerover", (e) => { + if (e.pointerType === "touch") return; + const row = (e.target as HTMLElement | null)?.closest(".tree-leaf"); + setHovered(row?.dataset.sessionId ?? null); + }); + treeHost.addEventListener("pointerleave", (e) => { + if (e.pointerType === "touch") return; + setHovered(null); + }); + // Focusable as a unit: the walk moves a highlight, not DOM focus, so the // keyboard stays here while j/k runs. treeHost.tabIndex = -1; + // The keyboard walk's preview belongs to the list having the keyboard: once + // it doesn't, the content pane goes back to what the operator actually opened. + treeHost.addEventListener("blur", () => opts.onPreview?.(null, "key")); treeHost.addEventListener("keydown", (e) => { if (e.ctrlKey || e.altKey || e.metaKey) return; // The inline rename field lives inside a row and owns its own keys. diff --git a/web/src/connections.ts b/web/src/connections.ts index abc7b87..53b7082 100644 --- a/web/src/connections.ts +++ b/web/src/connections.ts @@ -74,7 +74,7 @@ export class ConnectionPool { * Ensure `id` is live and shown inside `pane`, reusing its connection if one * exists (refreshing LRU recency). Does not touch other mounted panes. */ - mount(id: Uuid, pane: HTMLElement, info?: AttachInfo): void { + mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void { let conn = this.cache.get(id); if (conn) { this.cache.delete(id); @@ -93,9 +93,9 @@ export class ConnectionPool { } if (conn.el.parentElement !== pane) { pane.replaceChildren(conn.el); - conn.onMount(); + conn.onMount(opts); } else if (!this.mounted.has(id)) { - conn.onMount(); + conn.onMount(opts); } this.mounted.add(id); this.evict(); @@ -114,13 +114,17 @@ export class ConnectionPool { /** * Reconcile the visible set to exactly `entries` (in render order): park any * mounted session not listed, mount/refresh each listed one in its pane. + * `focus: false` shows the panes without taking the keyboard (preview). */ - setVisible(entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[]): void { + setVisible( + entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[], + opts?: { focus?: boolean }, + ): void { const next = new Set(entries.map((e) => e.id)); for (const id of [...this.mounted]) { if (!next.has(id)) this.unmount(id); } - for (const e of entries) this.mount(e.id, e.pane, e.info); + for (const e of entries) this.mount(e.id, e.pane, e.info, opts); } /** Also tells the daemon which pane is focused (docs/SIDEBAR.md's diff --git a/web/src/main.ts b/web/src/main.ts index f635094..607716e 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -104,9 +104,25 @@ shellEl.dataset.route = "catalog"; // after the welcome renderer). The hook only fires on user interaction, by which // point `mv` is assigned. let mv: MultiView; + +// Preview timing (docs/PREVIEW.md): a keyboard step shows almost at once; a +// mouse sweep waits long enough that rows crossed on the way somewhere else +// never flash up. The same delay applies to clearing, so the gap between two +// rows doesn't blink the previous view back. +const PREVIEW_DELAY = { key: 60, hover: 150 } as const; +let previewTimer: number | undefined; +function schedulePreview(sessionId: string | null, source: "key" | "hover"): void { + clearTimeout(previewTimer); + previewTimer = window.setTimeout(() => { + showMultiView(); + mv?.previewSession(sessionId); + }, PREVIEW_DELAY[source]); +} + const sidebar = renderSidebar(store, { onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e), link, + onPreview: schedulePreview, }); const content = document.createElement("section"); content.className = "content"; @@ -184,7 +200,9 @@ function infoFor(sessionId: string) { /** Drive the shell's responsive layout off whether any pane is open. */ function updateShellRoute(): void { - const hasPanes = openSessions(layout).size > 0; + // A preview is not an open pane (docs/PREVIEW.md), so the mobile shell does + // not swap the list out for a terminal on the way past a row. + const hasPanes = (mv?.openSessions() ?? openSessions(layout)).size > 0; shellEl.dataset.route = hasPanes ? "attach" : "catalog"; } @@ -375,7 +393,13 @@ window.addEventListener("keydown", (e) => { // Tab in the session list hands the keyboard to the pane; Escape in the pane // hands it back — the pair the TUI gets from `tab` and `esc`. -document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal()); +document.addEventListener(FOCUS_PANE_EVENT, () => { + // Handing the keyboard to a previewed pane is choosing it: the preview stops + // being provisional rather than snapping back the moment the list blurs. + const previewed = mv.previewedSessionId(); + if (previewed) focusSession(previewed); + pool.focusTerminal(); +}); document.addEventListener(FOCUS_SIDEBAR_EVENT, () => { drawer.open(); // no-op on desktop; on mobile the list is off-canvas sidebar.focusList(); @@ -386,6 +410,7 @@ document.addEventListener(FOCUS_SIDEBAR_EVENT, () => { * on `hashchange` alone — a split or drag can move focus without the hash * changing, leaving a click back onto that session's row silently ignored. */ function focusSession(sessionId: string): void { + clearTimeout(previewTimer); showMultiView(); mv.openSession(sessionId); updateShellRoute(); diff --git a/web/src/multiview.ts b/web/src/multiview.ts index dc16132..165ced7 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -11,6 +11,7 @@ import { addTab, closeTab, findLeaf, + freshId, type Dir, type Layout, leaves, @@ -19,6 +20,7 @@ import { openSessions, removeLeaf, splitLeaf, + type Tab, } from "./layout.js"; import type { AttachInfo } from "./attach.js"; @@ -57,11 +59,22 @@ export interface MultiView { /** Rebuild the view from the current layout. */ render: () => void; focusedSessionId: () => string | null; + /** Show `sessionId` in the content pane without committing to it + * (docs/PREVIEW.md): the tab holding it is brought forward, or a throwaway + * tab carries it. `null` puts back whatever was on screen before. */ + previewSession: (sessionId: string | null) => void; + /** The session being previewed, or null. */ + previewedSessionId: () => string | null; } export function createMultiView(opts: MultiViewOptions): MultiView { const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts; - const persist = () => opts.onLayoutChange?.(); + // A preview must never reach localStorage: it is undone the moment the + // highlight moves on, and the layout it would save is not the operator's. + const persist = () => { + if (preview) return; + opts.onLayoutChange?.(); + }; const el = document.createElement("div"); el.className = "mv"; @@ -83,8 +96,105 @@ export function createMultiView(opts: MultiViewOptions): MultiView { render(); }); + // ---- preview (docs/PREVIEW.md) ---- + // A preview is a temporary *view* change, never a layout change: it records + // exactly what it moved so ending it puts the layout back byte for byte, and + // it neither persists nor reports pane focus to the daemon while it is up. + interface Preview { + sessionId: string; + /** Tab that was active before the preview. */ + prevActiveTabId: string; + /** Focused leaf to restore, when the preview stole focus in a real tab. */ + prevFocus: { tabId: string; leafId: string | null } | null; + /** Throwaway tab built for a session that no pane was showing. */ + tempTabId: string | null; + } + let preview: Preview | null = null; + + const isTempTab = (tabId: string) => preview?.tempTabId === tabId; + + /** Undo the preview's moves. Mutates only — the caller renders. */ + function restorePreview(): boolean { + const p = preview; + if (!p) return false; + preview = null; + if (p.tempTabId) { + const i = layout.tabs.findIndex((t) => t.id === p.tempTabId); + if (i >= 0) layout.tabs.splice(i, 1); + } else if (p.prevFocus) { + const t = layout.tabs.find((x) => x.id === p.prevFocus!.tabId); + if (t) t.focusedLeafId = p.prevFocus.leafId; + } + layout.activeTabId = layout.tabs.some((t) => t.id === p.prevActiveTabId) + ? p.prevActiveTabId + : layout.tabs[0].id; + return true; + } + + /** Keep what the preview is showing: its throwaway tab becomes a real one, + * and the focus it moved stands. Returns true when `sessionId` was the + * session being previewed; any other preview is simply discarded. */ + function settlePreview(sessionId: string): boolean { + const p = preview; + if (!p) return false; + if (p.sessionId !== sessionId) { + restorePreview(); + return false; + } + preview = null; + if (p.tempTabId) { + const t = layout.tabs.find((x) => x.id === p.tempTabId); + if (t) t.title = `Tab ${layout.tabs.length}`; + } + return true; + } + + function previewSession(sessionId: string | null): void { + if (preview?.sessionId === sessionId) return; + if (sessionId === null) { + if (restorePreview()) render(); + return; + } + restorePreview(); + const prevActiveTabId = layout.activeTabId; + const loc = locateSession(layout, sessionId); + if (loc) { + preview = { + sessionId, + prevActiveTabId, + prevFocus: { tabId: loc.tab.id, leafId: loc.tab.focusedLeafId }, + tempTabId: null, + }; + layout.activeTabId = loc.tab.id; + loc.tab.focusedLeafId = loc.leaf.id; + } else { + // Not on screen anywhere: a throwaway tab carries it, so no existing + // pane is evicted and nothing the operator arranged moves. + const tab: Tab = { id: freshId("t"), title: "Preview", root: null, focusedLeafId: null }; + tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true); + layout.tabs.push(tab); + layout.activeTabId = tab.id; + preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id }; + } + render(); + } + + /** Any user action that changes the layout for real ends the preview first, + * so it never bakes a throwaway tab into the arrangement. */ + function dropPreview(): void { + restorePreview(); + } + // ---- highlight helpers ---- + /** Sessions the operator actually has open — the preview's throwaway tab + * does not count, or hovering the sidebar would flip the shell's route. */ + function realOpenSessions(): Set { + const out = openSessions(layout); + if (preview?.tempTabId) out.delete(preview.sessionId); + return out; + } + function focusedLeafSession(): string | null { const tab = activeTab(layout); const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null; @@ -98,9 +208,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView { for (const pane of canvas.querySelectorAll(".mv-pane")) { pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId); } - pool.setFocus(focusedLeafSession()); + // A preview is not a focused pane: reporting it would clear the session's + // `AwaitingInput` glow just because the operator scrolled past it. + if (!preview) pool.setFocus(focusedLeafSession()); sidebar.setHighlights({ - open: openSessions(layout), + open: realOpenSessions(), focused: focusedLeafSession(), }); } @@ -168,6 +280,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView { pane.addEventListener( "pointerdown", (e) => { + // Clicking into a previewed pane is the operator choosing it. + if (preview && leaf.sessionId === preview.sessionId) { + settlePreview(leaf.sessionId); + persist(); + } focusLeaf(leaf.id); // Drag a pane by its title bar (desktop only — no docking on mobile). if (isMobile) return; @@ -252,16 +369,21 @@ export function createMultiView(opts: MultiViewOptions): MultiView { for (const t of layout.tabs) { const tab = document.createElement("button"); tab.type = "button"; - tab.className = "mv-tab" + (t.id === layout.activeTabId ? " is-active" : ""); + tab.className = + "mv-tab" + + (t.id === layout.activeTabId ? " is-active" : "") + + (isTempTab(t.id) ? " is-preview" : ""); const n = leaves(t.root).filter((l) => l.sessionId).length; const label = document.createElement("span"); label.textContent = t.title + (n ? ` · ${n}` : ""); tab.appendChild(label); tab.addEventListener("click", () => { + if (isTempTab(t.id)) return; // a preview tab is not a place to go + dropPreview(); if (layout.activeTabId !== t.id) { layout.activeTabId = t.id; - render(); } + render(); }); if (layout.tabs.length > 1) { const close = document.createElement("span"); @@ -270,6 +392,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { close.title = "Close tab"; close.addEventListener("click", (e) => { e.stopPropagation(); + dropPreview(); closeTab(layout, t.id); render(); }); @@ -283,6 +406,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { add.textContent = "+"; add.title = "New tab"; add.addEventListener("click", () => { + dropPreview(); addTab(layout); render(); }); @@ -292,12 +416,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView { // ---- mutations ---- function splitFocused(leafId: string, dir: Dir): void { + dropPreview(); const tab = activeTab(layout); tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true); render(); } function closePane(leafId: string): void { + dropPreview(); const tab = activeTab(layout); removeLeaf(tab, leafId); if (tab.focusedLeafId === leafId) { @@ -307,6 +433,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView { } function openSession(sessionId: string): void { + // Opening what is already previewed keeps exactly what is on screen: the + // throwaway tab becomes a real one instead of the session moving again. + if (settlePreview(sessionId)) { + render(); + return; + } const loc = locateSession(layout, sessionId); if (loc) { layout.activeTabId = loc.tab.id; @@ -337,6 +469,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView { /// (#67 review). `newTab` opens its own tab instead of halving whatever the /// operator was reading (#100) — what the Spawn button asks for. function openSessionBeside(sessionId: string, placement?: { newTab?: boolean }): void { + if (settlePreview(sessionId)) { + render(); + opts.onOpenSessionsChange?.(); + return; + } const loc = locateSession(layout, sessionId); if (loc) { layout.activeTabId = loc.tab.id; @@ -384,13 +521,19 @@ export function createMultiView(opts: MultiViewOptions): MultiView { // A lone tab has nothing to switch between, so the bar is only a strip of // chrome; CSS hides it until a second tab exists. Spawning still opens its // own tab (#100), which brings the bar back with the "+" button on it. - el.classList.toggle("mv-solo-tab", layout.tabs.length === 1); + // The preview's throwaway tab must not make the bar appear and vanish as + // the highlight moves, so it does not count towards "one tab only". + el.classList.toggle( + "mv-solo-tab", + layout.tabs.filter((t) => !isTempTab(t.id)).length === 1, + ); + el.classList.toggle("mv-previewing", preview !== null); if (!tab.root) { pool.setVisible([]); - pool.setFocus(null); + if (!preview) pool.setFocus(null); canvas.appendChild(renderWelcome()); - sidebar.setHighlights({ open: openSessions(layout), focused: null }); + sidebar.setHighlights({ open: realOpenSessions(), focused: null }); persist(); return; } @@ -408,7 +551,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView { const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = []; canvas.appendChild(buildTree(tab.root, hosts)); - pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info }))); + // A preview keeps the keyboard in the sidebar: mounting must not focus the + // 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 }, + ); syncFocus(); persist(); } @@ -431,12 +579,14 @@ 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 }, ); - pool.setFocus(focused.sessionId ?? null); - sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null }); + if (!preview) pool.setFocus(focused.sessionId ?? null); + sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null }); } function closeSession(sessionId: string): void { + dropPreview(); const loc = locateSession(layout, sessionId); if (!loc) return; removeLeaf(loc.tab, loc.leaf.id); @@ -628,6 +778,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { } function beginSessionDrag(sessionId: string, e: PointerEvent): void { + dropPreview(); runDrag({ kind: "session", sessionId }, e, { deferred: true }); } @@ -636,9 +787,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView { openSession, openSessionBeside, closeSession, - openSessions: () => openSessions(layout), + openSessions: realOpenSessions, beginSessionDrag, render, focusedSessionId: focusedLeafSession, + previewSession, + previewedSessionId: () => preview?.sessionId ?? null, }; } diff --git a/web/src/preview.test.ts b/web/src/preview.test.ts new file mode 100644 index 0000000..4f39d7b --- /dev/null +++ b/web/src/preview.test.ts @@ -0,0 +1,218 @@ +// @vitest-environment happy-dom +// Sidebar preview (docs/PREVIEW.md): highlighting a session shows it in the +// content pane without changing the layout the operator built. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { addTab, emptyLayout, type Layout, splitLeaf } from "./layout.js"; +import { serializeLayout } from "./persistence.js"; +import { createMultiView, type MultiView } from "./multiview.js"; +import type { ConnectionPool } from "./connections.js"; +import type { SidebarHandle } from "./catalog.js"; + +function stubFetch(): void { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const body = url.includes("/api/info") + ? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] } + : undefined; + return new Response(JSON.stringify(body), { status: 200 }); + }), + ); +} + +interface Spy { + pool: ConnectionPool; + visible: () => string[]; + focusCalls: (string | null)[]; + mountedWithFocus: (boolean | undefined)[]; +} + +function fakePool(): Spy { + let visible: string[] = []; + const focusCalls: (string | null)[] = []; + const mountedWithFocus: (boolean | undefined)[] = []; + const pool = { + setVisible(entries: { id: string }[], opts?: { focus?: boolean }) { + visible = entries.map((e) => e.id); + mountedWithFocus.push(opts?.focus); + }, + setFocus(id: string | null) { + focusCalls.push(id); + }, + } as unknown as ConnectionPool; + return { pool, visible: () => visible, focusCalls, mountedWithFocus }; +} + +function fakeSidebar(): SidebarHandle { + return { + el: document.createElement("aside"), + setActive() {}, + setHighlights() {}, + focusList() {}, + hasListFocus() { + return false; + }, + dispose() {}, + }; +} + +function makeMv(layout: Layout, pool: ConnectionPool, onLayoutChange?: () => void): MultiView { + const mv = createMultiView({ + pool, + layout, + sidebar: fakeSidebar(), + infoFor: () => ({ name: "sess", command: "bash" }), + onDisconnect: () => {}, + renderWelcome: () => document.createElement("div"), + onLayoutChange, + }); + document.body.appendChild(mv.el); + mv.render(); + return mv; +} + +/** Tab 1 holds `s1`; tab 2 holds `s2` and is the active one. */ +function twoTabs(): Layout { + const layout = emptyLayout(); + const first = layout.tabs[0]; + first.focusedLeafId = splitLeaf(first, "", "s1", "row", true); + const second = addTab(layout); + second.focusedLeafId = splitLeaf(second, "", "s2", "row", true); + layout.activeTabId = second.id; + return layout; +} + +describe("sidebar preview", () => { + beforeEach(() => { + stubFetch(); + document.body.replaceChildren(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("brings a session's own tab forward, then puts the layout back", () => { + const layout = twoTabs(); + const before = serializeLayout(layout); + const spy = fakePool(); + const mv = makeMv(layout, spy.pool); + + mv.previewSession("s1"); + expect(spy.visible()).toEqual(["s1"]); + expect(mv.previewedSessionId()).toBe("s1"); + + mv.previewSession(null); + expect(spy.visible()).toEqual(["s2"]); + expect(mv.previewedSessionId()).toBeNull(); + expect(serializeLayout(layout)).toEqual(before); + }); + + it("shows a session no pane holds in a throwaway tab that leaves no trace", () => { + const layout = twoTabs(); + const before = serializeLayout(layout); + const spy = fakePool(); + const mv = makeMv(layout, spy.pool); + + mv.previewSession("s9"); + expect(spy.visible()).toEqual(["s9"]); + expect(layout.tabs).toHaveLength(3); + // The throwaway tab is not something the operator has open. + expect(mv.openSessions().has("s9")).toBe(false); + + mv.previewSession(null); + expect(layout.tabs).toHaveLength(2); + expect(serializeLayout(layout)).toEqual(before); + }); + + it("stepping between previews leaves only the newest one on screen", () => { + const layout = twoTabs(); + const before = serializeLayout(layout); + const spy = fakePool(); + const mv = makeMv(layout, spy.pool); + + mv.previewSession("s9"); + mv.previewSession("s1"); + mv.previewSession("s8"); + expect(spy.visible()).toEqual(["s8"]); + expect(layout.tabs).toHaveLength(3); + + mv.previewSession(null); + expect(serializeLayout(layout)).toEqual(before); + }); + + it("opening what is previewed keeps it: the throwaway tab becomes real", () => { + const layout = twoTabs(); + const spy = fakePool(); + const mv = makeMv(layout, spy.pool); + + mv.previewSession("s9"); + mv.openSession("s9"); + + expect(mv.previewedSessionId()).toBeNull(); + expect(layout.tabs).toHaveLength(3); + expect(mv.openSessions().has("s9")).toBe(true); + expect(spy.visible()).toEqual(["s9"]); + // Nothing the operator had open was evicted to make room. + expect(mv.openSessions().has("s2")).toBe(true); + }); + + it("never persists, and saves again once the preview is settled", () => { + const layout = twoTabs(); + const spy = fakePool(); + const saves = vi.fn(); + const mv = makeMv(layout, spy.pool, saves); + saves.mockClear(); + + mv.previewSession("s9"); + expect(saves).not.toHaveBeenCalled(); + + mv.previewSession(null); + expect(saves).toHaveBeenCalled(); + }); + + it("never reports a previewed pane as focused to the daemon", () => { + const layout = twoTabs(); + const spy = fakePool(); + const mv = makeMv(layout, spy.pool); + spy.focusCalls.length = 0; + + mv.previewSession("s1"); + mv.previewSession("s9"); + expect(spy.focusCalls).toEqual([]); + + mv.previewSession(null); + expect(spy.focusCalls).toEqual(["s2"]); + }); + + it("mounts a preview without taking the keyboard", () => { + const layout = twoTabs(); + const spy = fakePool(); + const mv = makeMv(layout, spy.pool); + spy.mountedWithFocus.length = 0; + + mv.previewSession("s1"); + expect(spy.mountedWithFocus).toEqual([false]); + + mv.openSession("s1"); + expect(spy.mountedWithFocus.at(-1)).toBe(true); + }); + + it("keeps the tab bar hidden while a lone tab is previewing", () => { + const layout = emptyLayout(); + const tab = layout.tabs[0]; + tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true); + const spy = fakePool(); + const mv = makeMv(layout, spy.pool); + expect(mv.el.classList.contains("mv-solo-tab")).toBe(true); + + mv.previewSession("s9"); + expect(mv.el.classList.contains("mv-solo-tab")).toBe(true); + expect(mv.el.classList.contains("mv-previewing")).toBe(true); + + mv.previewSession(null); + expect(mv.el.classList.contains("mv-previewing")).toBe(false); + }); +}); diff --git a/web/src/sidebarNav.test.ts b/web/src/sidebarNav.test.ts index ccbe9da..3b92305 100644 --- a/web/src/sidebarNav.test.ts +++ b/web/src/sidebarNav.test.ts @@ -39,8 +39,11 @@ const A = "aaaaaaaa-0000-0000-0000-00000000000a"; const B = "aaaaaaaa-0000-0000-0000-00000000000b"; /** Two sessions in sibling directories: two folder headers, two cards. */ -function twoFolders(): { el: HTMLElement; handle: ReturnType } { - const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")])); +function twoFolders(opts: Parameters[1] = {}): { + el: HTMLElement; + handle: ReturnType; +} { + const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]), opts); // Mounted: `focusList` moves real DOM focus, which needs a live document. document.body.replaceChildren(handle.el); return { el: handle.el, handle }; @@ -126,3 +129,43 @@ describe("sidebar keyboard navigation", () => { expect(highlighted(el)).toBe(`session:${A}`); }); }); + +describe("sidebar preview requests (docs/PREVIEW.md)", () => { + it("asks for a preview on every keyboard step, and for none once the list blurs", () => { + const seen: [string | null, string][] = []; + const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) }); + + press(tree(el), "j"); + press(tree(el), "j"); + expect(seen).toEqual([ + [A, "key"], + [B, "key"], + ]); + + tree(el).dispatchEvent(new FocusEvent("blur")); + expect(seen.at(-1)).toEqual([null, "key"]); + }); + + it("asks for a preview of the hovered card, and clears it on the way out", () => { + const seen: [string | null, string][] = []; + const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) }); + const card = el.querySelectorAll(".tree-leaf")[0]; + + card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" })); + expect(seen).toEqual([[A, "hover"]]); + // Moving within the same card is not a new preview. + card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" })); + expect(seen).toHaveLength(1); + + tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" })); + expect(seen.at(-1)).toEqual([null, "hover"]); + }); + + it("ignores touch, where a tap already opens the session", () => { + const seen: (string | null)[] = []; + const { el } = twoFolders({ onPreview: (id) => seen.push(id) }); + const card = el.querySelectorAll(".tree-leaf")[0]; + card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" })); + expect(seen).toEqual([]); + }); +}); diff --git a/web/src/styles.css b/web/src/styles.css index 40c4ea1..c33ae9e 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2883,6 +2883,23 @@ input.leaf-rename:focus { color: var(--ink); } +/* Preview (docs/PREVIEW.md): the content pane is showing a session the + operator only highlighted, so it reads as provisional — a dashed tab and a + dashed inset ring on the canvas — and reverts when the highlight moves. */ +.mv-tab.is-preview { + border-style: dashed; + font-style: italic; +} +.mv.mv-previewing .mv-canvas::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + border: 1px dashed var(--accent-ring); + border-radius: var(--r-1); + z-index: var(--z-sticky); +} + /* canvas + welcome */ .mv-canvas { position: relative; From 6635cff46ef9e093dfe8c6f2da7b3519c15843c4 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 10:46:45 +0100 Subject: [PATCH 2/3] feat(maestro): preview the highlighted session in the TUI pane The sidebar highlight now shows its session in the pane after a 120ms debounce, attaching if nothing already is, without the tab taking it on and without reporting focus to the daemon. Preview-only attaches stay warm behind the current one, bounded at three. docs/PREVIEW.md. --- crates/maestro-tui/src/app.rs | 305 ++++++++++++++++++++++++++++++-- crates/maestro-tui/src/state.rs | 16 ++ 2 files changed, 307 insertions(+), 14 deletions(-) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index d8ce619..0ef9e71 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -87,6 +87,14 @@ pub struct App { /// that can move `Focus::Pane` (attach, tab switch, sidebar toggle, /// mouse) is covered without each one remembering to report it. focused_session: Option, + /// A sidebar highlight waiting out its debounce before it becomes a + /// preview (`docs/PREVIEW.md`). Walking the list with `j` held down must + /// not open an attach per row it passes over. + pending_preview: Option<(Uuid, std::time::Instant)>, + /// Sessions attached only because they were previewed, oldest first. Kept + /// connected past the preview so stepping back onto a row is instant, and + /// bounded so a long walk doesn't leave a socket per session behind. + preview_lru: Vec, /// Fingerprint of the last frame drawn. The loop compares a freshly /// computed key against this and skips the repaint when they match — an /// idle TUI used to emit a full diff 30 times a second forever, which over @@ -117,6 +125,14 @@ struct FrameKey { /// else changed. const CLOCK_REFRESH: Duration = Duration::from_secs(1); +/// How long a highlight has to stand still before the pane previews it +/// (`docs/PREVIEW.md`). Long enough that a fast walk down the list doesn't +/// attach to every row it crosses, short enough to read as immediate. +const PREVIEW_DELAY: Duration = Duration::from_millis(120); + +/// How many preview-only attaches to keep connected behind the current one. +const PREVIEW_BUDGET: usize = 3; + impl std::fmt::Debug for App { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("App") @@ -196,6 +212,8 @@ impl App { last_sent_size: HashMap::new(), clicks: ClickTracker::default(), focused_session: None, + pending_preview: None, + preview_lru: Vec::new(), last_frame: None, last_drawn_at: std::time::Instant::now(), epoch: 0, @@ -234,7 +252,9 @@ impl App { self.epoch = self.epoch.wrapping_add(1); } _ = tokio::time::sleep(Duration::from_millis(33)) => { - let effects = self.poll_due_probes_at(std::time::Instant::now()); + let now = std::time::Instant::now(); + let mut effects = self.poll_due_probes_at(now); + effects.extend(self.poll_due_preview_at(now)); if !effects.is_empty() { self.epoch = self.epoch.wrapping_add(1); } @@ -332,6 +352,7 @@ impl App { self.state.sidebar_highlight = Some(SidebarItem::Session(id)); } } + self.refresh_preview(); true } @@ -453,8 +474,15 @@ impl App { if let Some(previous) = self.state.active_session() { self.release_session(previous); } - effects.push(Effect::Attach(sid)); + // A previewed session is already attached: committing + // to it keeps that stream rather than reopening one. + if self.emulators.get(sid).is_none() { + effects.push(Effect::Attach(sid)); + } } + // It is the pane's own session now, not a loan from the + // preview budget. + self.preview_lru.retain(|s| *s != sid); self.state.attach_to_focused(sid); } } @@ -481,9 +509,89 @@ impl App { } } } + // Every action can move the highlight or the focus, and the preview is + // a function of both (`docs/PREVIEW.md`). + self.refresh_preview(); effects } + /// Recompute what the pane should be previewing. A session highlighted in + /// the sidebar is previewed after [`PREVIEW_DELAY`]; anything else — a + /// group row, the pane holding the keyboard, or the highlight already + /// sitting on the attached session — clears the preview at once. + fn refresh_preview(&mut self) { + let wanted = match (self.state.focus, &self.state.sidebar_highlight) { + (Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid), + _ => None, + }; + match wanted { + Some(sid) if self.state.active_session() != Some(sid) => { + if self.state.preview != Some(sid) { + self.pending_preview = Some((sid, std::time::Instant::now() + PREVIEW_DELAY)); + } + } + _ => { + self.pending_preview = None; + self.clear_preview(); + } + } + } + + /// The debounce elapsed: show the highlighted session, attaching it if + /// nothing already is. + fn poll_due_preview_at(&mut self, now: std::time::Instant) -> Vec { + let Some((sid, due)) = self.pending_preview else { + return Vec::new(); + }; + if now < due { + return Vec::new(); + } + self.pending_preview = None; + if self.state.preview == Some(sid) { + return Vec::new(); + } + self.state.preview = Some(sid); + // Set outside the input path, so nothing else marks the frame stale. + self.epoch = self.epoch.wrapping_add(1); + let fresh = self.emulators.get(sid).is_none(); + if fresh { + self.preview_lru.retain(|s| *s != sid); + self.preview_lru.push(sid); + } + self.trim_preview_budget(); + if fresh { + vec![Effect::Attach(sid)] + } else { + Vec::new() + } + } + + fn clear_preview(&mut self) { + if self.state.preview.take().is_some() { + self.epoch = self.epoch.wrapping_add(1); + } + self.trim_preview_budget(); + } + + /// Drop preview-only attaches past the budget. What a tab holds, and what + /// is on screen right now, are never candidates. + fn trim_preview_budget(&mut self) { + while self.preview_lru.len() > PREVIEW_BUDGET { + let held: HashSet = self + .state + .tabs + .iter() + .filter_map(crate::state::Tab::session) + .chain(self.state.preview) + .collect(); + let Some(pos) = self.preview_lru.iter().position(|sid| !held.contains(sid)) else { + break; + }; + let victim = self.preview_lru.remove(pos); + self.release_session(victim); + } + } + /// Open a stream for `sid` if one isn't already running. fn attach(&mut self, sid: Uuid) { if self.emulators.get(sid).is_some() { @@ -1000,7 +1108,7 @@ impl App { /// Sample everything a frame depends on. Cheap: two atomic loads, an /// `RwLock` read of a `u64`, and the backend's size. fn frame_key(&self, area: ratatui::layout::Size) -> FrameKey { - let pane = self.state.active_session().and_then(|sid| { + let pane = self.state.displayed_session().and_then(|sid| { self.emulators .get(sid) .map(|h| (sid, h.revision(), h.state())) @@ -1050,21 +1158,31 @@ impl App { highlight: self.state.sidebar_highlight.clone(), attached, }; - let active_sid = self.state.active_session(); - let pane = active_sid.and_then(|sid| { + let shown_sid = self.state.displayed_session(); + let previewing = self.state.preview.is_some(); + let pane = shown_sid.and_then(|sid| { self.emulators.get(sid).map(|h| Pane { screen: h.screen.clone(), status: PaneStatus::from(h.state()), - focused: matches!(self.state.focus, Focus::Pane), + // A preview never owns the keyboard, so it never draws a + // cursor either (`docs/PREVIEW.md`). + focused: matches!(self.state.focus, Focus::Pane) && !previewing, }) }); - let pane_title = match active_sid { - Some(sid) => snap - .sessions - .iter() - .find(|session| session.id == sid) - .map(crate::sidebar::name_for) - .unwrap_or_else(|| "session".into()), + let pane_title = match shown_sid { + Some(sid) => { + let name = snap + .sessions + .iter() + .find(|session| session.id == sid) + .map(crate::sidebar::name_for) + .unwrap_or_else(|| "session".into()); + if previewing { + format!("{name} · preview") + } else { + name + } + } None => "session".into(), }; @@ -1092,7 +1210,7 @@ impl App { // Resize propagation: record the active pane's rect, recompute the // per-dimension min for every attached session, send Resize when // changed. - if let (Some(sid), Some(rect)) = (active_sid, self.region.pane) { + if let (Some(sid), Some(rect)) = (shown_sid, self.region.pane) { self.emulators.resize_tracker_mut().set_pane_size( sid, "active", @@ -1501,6 +1619,165 @@ mod tests { assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn)); } + /// Seed the catalog with `ids` as sessions in one directory. + fn app_with_sessions(ids: &[Uuid]) -> App { + use chrono::Utc; + use maestro_protocol::{CatalogEvent, Session, SessionStatus}; + let app = make_app(); + app.catalog.apply_event(CatalogEvent::Snapshot { + sessions: ids + .iter() + .map(|id| Session { + id: *id, + command: "bash".into(), + cwd: std::path::PathBuf::from("/"), + env: Default::default(), + metadata: Default::default(), + worktree: None, + status: SessionStatus::Running, + created_at: Utc::now(), + foreground: None, + activity: Default::default(), + activity_detail: None, + last_activity_at: Utc::now(), + activity_since: chrono::Utc::now(), + current_cwd: None, + blocked_work: None, + }) + .collect(), + }); + app + } + + fn fake_handle() -> crate::session_task::SessionHandle { + use crate::session_task::{SessionHandle, SessionState}; + use std::sync::atomic::{AtomicU64, AtomicU8}; + use std::sync::{Arc, Mutex}; + SessionHandle { + tx: tokio::sync::mpsc::channel(1).0, + screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))), + state: Arc::new(AtomicU8::new(SessionState::Live as u8)), + revision: Arc::new(AtomicU64::new(0)), + } + } + + fn after_debounce() -> std::time::Instant { + std::time::Instant::now() + PREVIEW_DELAY + Duration::from_millis(1) + } + + /// docs/PREVIEW.md: the highlight shows a session in the pane without the + /// tab taking it on. + #[test] + fn highlighting_a_session_previews_it_once_the_debounce_elapses() { + let a = Uuid::from_u128(1); + let mut app = app_with_sessions(&[a]); + app.dispatch(Action::SidebarNext); + + // Nothing yet: the highlight has to stand still first. + assert_eq!(app.poll_due_preview_at(std::time::Instant::now()), vec![]); + assert_eq!(app.state.preview, None); + + assert_eq!( + app.poll_due_preview_at(after_debounce()), + vec![Effect::Attach(a)] + ); + assert_eq!(app.state.preview, Some(a)); + assert_eq!(app.state.displayed_session(), Some(a)); + // The tab is untouched — the preview is a view, not a layout change. + assert_eq!(app.state.active_tab().session(), None); + } + + /// A fast walk down the list attaches to where it stopped, not to every + /// row it crossed. + #[test] + fn walking_past_rows_only_previews_the_one_it_lands_on() { + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + let mut app = app_with_sessions(&[a, b]); + app.dispatch(Action::SidebarNext); + app.dispatch(Action::SidebarNext); + + let effects = app.poll_due_preview_at(after_debounce()); + assert_eq!(effects, vec![Effect::Attach(b)]); + assert_eq!(app.state.preview, Some(b)); + } + + /// The pane's own session is not something to preview. + #[test] + fn highlighting_the_attached_session_clears_the_preview() { + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + let mut app = app_with_sessions(&[a, b]); + app.state.attach_to_focused(a); + app.state.focus = Focus::Sidebar; + + app.state.sidebar_highlight = Some(SidebarItem::Session(b)); + app.refresh_preview(); + app.poll_due_preview_at(after_debounce()); + assert_eq!(app.state.preview, Some(b)); + + app.state.sidebar_highlight = Some(SidebarItem::Session(a)); + app.refresh_preview(); + assert_eq!(app.state.preview, None); + assert_eq!(app.state.displayed_session(), Some(a)); + } + + /// Moving off every session — onto a group row, or into the pane — puts + /// back what the tab was showing. + #[test] + fn leaving_the_session_rows_ends_the_preview() { + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + let mut app = app_with_sessions(&[a, b]); + app.state.attach_to_focused(a); + app.state.focus = Focus::Sidebar; + app.state.sidebar_highlight = Some(SidebarItem::Session(b)); + app.refresh_preview(); + app.poll_due_preview_at(after_debounce()); + + app.dispatch(Action::FocusPane(crate::action::Direction::Right)); + assert_eq!(app.state.preview, None); + assert_eq!(app.state.displayed_session(), Some(a)); + } + + /// Committing keeps the stream the preview already opened. + #[test] + fn attaching_to_a_previewed_session_reuses_its_stream() { + let a = Uuid::from_u128(1); + let mut app = app_with_sessions(&[a]); + app.dispatch(Action::SidebarNext); + app.poll_due_preview_at(after_debounce()); + // Stand in for the attach the effect would have opened. + app.emulators.insert(a, fake_handle()); + + let effects = app.dispatch(Action::AttachHighlighted); + assert_eq!(effects, vec![]); + assert_eq!(app.state.active_tab().session(), Some(a)); + assert_eq!(app.state.preview, None); + // No longer on loan from the preview budget. + assert!(!app.preview_lru.contains(&a)); + } + + /// Preview-only attaches are kept for a step back, but bounded. + #[test] + fn preview_attaches_are_dropped_past_the_budget() { + let ids: Vec = (1..=(PREVIEW_BUDGET as u128 + 2)) + .map(Uuid::from_u128) + .collect(); + let mut app = app_with_sessions(&ids); + for id in &ids { + app.state.sidebar_highlight = Some(SidebarItem::Session(*id)); + app.refresh_preview(); + app.poll_due_preview_at(after_debounce()); + app.emulators.insert(*id, fake_handle()); + } + + assert_eq!(app.preview_lru.len(), PREVIEW_BUDGET); + // The oldest rows previewed are gone; the newest are still warm. + assert!(app.emulators.get(ids[0]).is_none()); + assert!(app.emulators.get(*ids.last().unwrap()).is_some()); + } + /// Seed the catalog with one session and highlight it. fn app_with_highlighted_session(sid: Uuid) -> App { use chrono::Utc; diff --git a/crates/maestro-tui/src/state.rs b/crates/maestro-tui/src/state.rs index 1e3d7cc..6614cec 100644 --- a/crates/maestro-tui/src/state.rs +++ b/crates/maestro-tui/src/state.rs @@ -63,6 +63,11 @@ pub struct AppState { pub mode: Mode, pub sidebar_visible: bool, pub sidebar_highlight: Option, + /// The session the pane is *previewing* (`docs/PREVIEW.md`): shown because + /// the sidebar highlight is on it, not because a tab holds it. It never + /// enters `tabs`, is never reported to the daemon as focused, and is + /// dropped the moment the highlight moves on. + pub preview: Option, /// Group paths whose cards are hidden. This is UI state rather than /// catalog state, so it survives every catalog redraw. pub collapsed_sidebar_groups: HashSet, @@ -91,6 +96,7 @@ impl AppState { mode: Mode::Normal, sidebar_visible: true, sidebar_highlight: None, + preview: None, collapsed_sidebar_groups: HashSet::new(), overlay: None, } @@ -126,6 +132,9 @@ impl AppState { /// drop straight into Insert mode so keystrokes go to the session /// without an extra `i` press. The leader returns to Normal. pub fn attach_to_focused(&mut self, session_id: Uuid) { + // Committing to what was previewed: the pane owns it now, so the + // preview has nothing left to say. + self.preview = None; self.active_tab_mut().pane = Some(session_id); self.focus = Focus::Pane; self.mode = Mode::Insert; @@ -150,6 +159,13 @@ impl AppState { self.active_tab().session() } + /// What the pane actually draws: the preview when one is up, otherwise the + /// active tab's session. A preview covers the pane without replacing it, + /// so ending one gives back exactly what was there (`docs/PREVIEW.md`). + pub fn displayed_session(&self) -> Option { + self.preview.or_else(|| self.active_session()) + } + /// Move the sidebar highlight one step through the rendered item /// sequence, in the direction of `delta` (positive = down), landing only /// on items `is_stop` accepts. Only the sign of `delta` matters. Wraps. From 2319b4e47bbd33730a56546ce629ec152f8b5439 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 10:52:43 +0100 Subject: [PATCH 3/3] fix(maestro): keep the keyboard in the list while previewing Two mounts were taking focus out of the sidebar mid-walk: the render that put the multi-view on screen before the preview applied, and the render that restores the previous pane when a preview ends. Both now mount without focusing, so j/k keeps walking. Also marks the preview with a badge on the canvas and drops the close button from its tab. --- web/src/main.ts | 12 ++++++++++-- web/src/multiview.ts | 25 ++++++++++++++++++++----- web/src/styles.css | 15 +++++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) 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;