From f6b2cf3ab4200ba8791d525e92f0bef0d4f11002 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 10:42:53 +0100 Subject: [PATCH] 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;