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:
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./api.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./api.js")>("./api.js");
|
||||
@@ -12,6 +12,7 @@ vi.mock("./api.js", async () => {
|
||||
});
|
||||
|
||||
import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt } from "./api.js";
|
||||
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import { createStructuredConnection, type StructuredSocketFactory } from "./structured.js";
|
||||
import type { ReducedState, Session, StructuredEvent, TranscriptRow } from "./types.js";
|
||||
@@ -349,6 +350,22 @@ describe("structured connection: pane lifecycle", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("carries the same off-canvas sessions button as the terminal attach header", () => {
|
||||
const conn = mount({ sockets: [new FakeSocket()] });
|
||||
const btn = conn.el.querySelector(".sessions-btn") as HTMLButtonElement;
|
||||
expect(btn).not.toBeNull();
|
||||
expect(btn.getAttribute("aria-label")).toBe("Show sessions");
|
||||
expect(btn.type).toBe("button");
|
||||
|
||||
let toggled = 0;
|
||||
const onToggle = () => toggled++;
|
||||
document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle);
|
||||
btn.click();
|
||||
document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle);
|
||||
expect(toggled).toBe(1);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("focusTerminal focuses the composer only when it is enabled", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
@@ -400,3 +417,72 @@ describe("structured connection: pane lifecycle", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("structured connection: deferred mount focus", () => {
|
||||
let frames: FrameRequestCallback[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
frames = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||
frames.push(cb);
|
||||
return frames.length;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function runFrames(): void {
|
||||
const pending = frames;
|
||||
frames = [];
|
||||
for (const cb of pending) cb(0);
|
||||
}
|
||||
|
||||
it("does not focus the composer if the pane was parked before its deferred frame ran", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
|
||||
conn.onMount({ focus: true });
|
||||
conn.onUnmount(); // parked again before the frame below runs
|
||||
runFrames();
|
||||
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("does not focus the composer if it was re-mounted into a different pane before the stale frame ran", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
|
||||
conn.onMount({ focus: true }); // stale frame #1
|
||||
conn.onUnmount();
|
||||
conn.onMount({ focus: false }); // the pane that actually won focus, without it
|
||||
runFrames(); // both frames fire; only the latest should have any say
|
||||
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("still focuses normally when nothing raced the frame", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
|
||||
conn.onMount({ focus: true });
|
||||
runFrames();
|
||||
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
expect(document.activeElement).toBe(input);
|
||||
conn.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user