fix(maestro): mobile drawer access, focus preservation, stale rAF focus (#140)
Three more fixes from acceptance review: - structured.ts's header omitted the .sessions-btn attach.ts's terminal header carries. The sidebar is off-canvas on mobile, so a structured session had no way to reach it — add the same button and requestToggleDrawer wiring. - ConnectionPool.reconcileViews always remounted a repaired connection with focus:false. If the mistyped pane was the one actually focused, disposing it dropped DOM focus while `focusedId` still named it. Pass `focus: this.focusedId === id` instead — `focusedId` only ever names a real focus (multiview.ts skips setFocus while previewing), so a preview or a merely-visible split neighbour still comes back unfocused. - onMount's deferred requestAnimationFrame focus had no way to tell it had been superseded by an unmount (or a remount elsewhere) before it fired, so a parked pane could steal focus a frame later. A token bumped on every mount/unmount lets the callback recognize it's stale and no-op. Regression tests: 7 new (structured.test.ts, connections.test.ts). 495 tests total.
This commit is contained in:
+18
-1
@@ -12,6 +12,7 @@ import {
|
||||
sendStructuredPrompt,
|
||||
structuredWsUrl,
|
||||
} from "./api.js";
|
||||
import { requestToggleDrawer } from "./drawer.js";
|
||||
import type { CatalogState, CatalogStore } from "./events.js";
|
||||
import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js";
|
||||
import type { PaneConnection } from "./pane.js";
|
||||
@@ -57,6 +58,7 @@ export function createStructuredConnection(
|
||||
el.className = "attach structured";
|
||||
el.innerHTML = `
|
||||
<header class="attach-header">
|
||||
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
||||
<span class="attach-title" title="${paneTitleAttr(sessionId, info)}">${paneTitleHtml(sessionId, info)}</span>
|
||||
<span class="st-pills"></span>
|
||||
<span class="attach-state" data-state="connecting">connecting…</span>
|
||||
@@ -87,6 +89,12 @@ export function createStructuredConnection(
|
||||
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
|
||||
|
||||
disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId));
|
||||
// Mobile-only (CSS hides it on desktop, where the sidebar is always in
|
||||
// view): the session list is an off-canvas drawer and this opens it —
|
||||
// same control attach.ts's terminal header carries.
|
||||
(el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () =>
|
||||
requestToggleDrawer(),
|
||||
);
|
||||
|
||||
const setConnState = (text: string, kind: "connecting" | "open" | "closed" | "error") => {
|
||||
stateEl.textContent = text;
|
||||
@@ -346,17 +354,26 @@ export function createStructuredConnection(
|
||||
}
|
||||
connect();
|
||||
|
||||
// A pane can be parked again before its deferred focus frame fires (a
|
||||
// preview stepped past it, a split closed it) — the frame must not focus a
|
||||
// hidden textarea out from under whatever the operator moved to since.
|
||||
// `mountToken` is bumped on every mount/unmount so a stale frame can tell
|
||||
// it is no longer the most recent one.
|
||||
let mountToken = 0;
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
el,
|
||||
onMount(mountOpts?: { focus?: boolean }) {
|
||||
const focusOnMount = mountOpts?.focus !== false;
|
||||
const token = ++mountToken;
|
||||
requestAnimationFrame(() => {
|
||||
if (token !== mountToken) return; // parked/remounted since this was scheduled
|
||||
if (focusOnMount && !input.disabled) input.focus();
|
||||
});
|
||||
},
|
||||
onUnmount() {
|
||||
// Nothing to release: the composer needs no GPU context or size fit.
|
||||
mountToken++;
|
||||
},
|
||||
focusTerminal() {
|
||||
if (!input.disabled) input.focus();
|
||||
|
||||
Reference in New Issue
Block a user