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.
This commit is contained in:
Miguel Palhas
2026-09-03 10:42:53 +01:00
parent b36d571b21
commit f6b2cf3ab4
10 changed files with 598 additions and 26 deletions
+12 -5
View File
@@ -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() {
+30
View File
@@ -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<HTMLElement>(".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.
+9 -5
View File
@@ -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
+27 -2
View File
@@ -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();
+165 -12
View File
@@ -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<string> {
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<HTMLElement>(".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,
};
}
+218
View File
@@ -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);
});
});
+45 -2
View File
@@ -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<typeof renderSidebar> } {
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]));
function twoFolders(opts: Parameters<typeof renderSidebar>[1] = {}): {
el: HTMLElement;
handle: ReturnType<typeof renderSidebar>;
} {
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<HTMLElement>(".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<HTMLElement>(".tree-leaf")[0];
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
expect(seen).toEqual([]);
});
});
+17
View File
@@ -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;