Skip to content

Drop only the restore event after a focus-preserving pane swap #1950

Closed as not planned

Description

@devswha

Follow-up to #1818.

Current behavior

An explicit pane swap preserves herdr's focused pane by snapshotting focus, running the native swap, then restoring only after a real change if focus still belongs to the swapped source. Ovis serializes its focus-changing calls per socket, so a newer Ovis focus choice runs after restoration. Restore failures are logged without failing an already committed swap.

The browser retains its request-only movingHerdrFocus guard. With Follow herdr focus enabled:

  • A focus event arriving while a swap is pending can be ignored, including a legitimate same-PC choice.
  • A restore event arriving after the reply can still move the browser to the restored pane.

These limitations are deliberately left visible rather than risking the loss of genuine focus events through an exact-event filter that cannot identify its target.

Why a client-only filter is insufficient

Six ordering failure cases were found while attempting held-event replay, pane-name restore tokens, expiry bounds, overlapping requests, and per-request event boundaries. A server-stamped sequence also cannot identify the restore reliably: native herdr focus events carry no RPC correlation, and an older unread source/restore pair can look like the current swap's events.

Root fix

Prefer upstream support for an explicit pane.swap with focus:false, so the swap never changes focus and needs no restoration. Alternatively, expose a correlated event identifier or request identifier upstream that proves which focus event a restore produced.

Do not infer identity from pane names, arrival timing, sleeps, or a presumed-empty event backlog.

Acceptance

  • Swapping preserves native and browser pane/tab/workspace selection without a temporary focus move, or suppresses only a provably correlated restore event.
  • Genuine same-PC focus events are delivered in order, including during overlapping swaps and after an HTTP reply.
  • Both event-before-answer and event-after-answer orderings are covered deterministically.
  • A missed event, disconnect, or delayed reply never causes a later genuine same-pane event to be dropped.
  • The native TUI's own focus changes are not overwritten by an Ovis check/restore window; a no-focus native swap should eliminate that window entirely.

Activity

  1. devswha commented on Oct 29, 2026

    @devswha
    OwnerAuthor

    Re-checked on origin/main @ cbe3436 against herdr 0.9.5 (latest stable, protocol 22; newest preview is 2026-10-14). Still blocked on upstream, so this stays open.

    What I tried (isolated herdr: its own XDG_CONFIG_HOME in a mkdtemp dir and its own session, never the live one):

    1. Focused pane A, then sent a native explicit pane.swap {source_pane_id: B, target_pane_id: C}. herdr moves its focus to the source (A → B) and emits pane_focused {pane_id: B}.
    2. Sent the same swap with focus: false. herdr 0.9.5 accepts the request but ignores the parameter, so focus still moves to B.
    3. Ran main's paneSwapWith(B, C). The restore works (herdr ends on A), but the subscription sees two frames, pane_focused B then pane_focused A. Each frame carries only type, pane_id and workspace_id, with no request or correlation id. These are the frames server/collector.ts (parseFocusFrame → onFocus) relays to the browser, so the restore event still cannot be told apart from a genuine focus change.

    Upstream source (herdrdev/herdr master @ ee5bfb5, 2026-10-29): PaneSwapParams (src/api/schema/panes.rs) still has only pane_id, direction, source_pane_id and target_pane_id. handle_pane_swap (src/app/api/panes.rs) calls tab.layout.focus_pane(source_pane_id) and record_pane_focus_change unconditionally on every swap. The PaneFocused event is still {pane_id, workspace_id}. By contrast, pane.move already has a focus flag. I found no upstream issue asking for a no-focus pane.swap or for correlated focus events.

    So the root fix described above (native pane.swap focus:false, or a correlated event id) is still unavailable. Nothing changed on the Ovis side either: the restore path and the movingHerdrFocus guard are as DESIGN.md describes.

    Repro script (run from an Ovis checkout: XDG_CONFIG_HOME=$(mktemp -d) bun repro-1950.ts "$PWD")
    // Repro for ovis#1950 on an isolated herdr (own XDG_CONFIG_HOME + own session name).
    // Run: XDG_CONFIG_HOME=$RUN_TMP/xdg bun $RUN_TMP/repro-1950.ts <worktree>
    import { join } from "node:path";
    import { existsSync, mkdirSync } from "node:fs";
    
    const W = process.argv[2]!;
    const { herdrRpc, ping, sessionSnapshot, workspaceCreate, paneSplit, paneFocus, paneSwapWith, subscribeEvents } =
      await import(join(W, "server/herdr/client.ts"));
    
    const SESSION = "repro1950";
    const xdg = process.env.XDG_CONFIG_HOME!;
    mkdirSync(xdg, { recursive: true });
    const socket = join(xdg, "herdr", "sessions", SESSION, "herdr.sock");
    const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(HERDR|OMO|SENPI|PI)_/.test(k))) as Record<string, string>;
    
    const answers = async () => { try { await ping(socket); return true; } catch { return false; } };
    if (await answers()) throw new Error("isolated socket already answering; refusing to reuse");
    const server = Bun.spawn(["herdr", "--session", SESSION, "server"], { env, stdout: "ignore", stderr: "ignore" });
    for (const deadline = Date.now() + 15_000; !(existsSync(socket) && (await answers()));) {
      if (Date.now() > deadline) throw new Error("isolated herdr did not start");
      await Bun.sleep(100); // startup gate only, mirrors scripts/test-herdr.ts
    }
    const id = await ping(socket);
    console.log("isolated herdr:", JSON.stringify(id), "socket:", socket);
    
    const ws = await workspaceCreate({ cwd: xdg, label: "repro-1950" }, socket);
    const A = ws.root_pane.pane_id;
    const B = (await paneSplit(A, "right", false, socket)).pane_id;
    const C = (await paneSplit(B, "down", false, socket)).pane_id;
    await paneFocus(A, socket);
    console.log("panes", { A, B, C }, "focused before:", (await sessionSnapshot(socket)).focused_pane_id);
    
    const frames: unknown[] = [];
    let waiter: { n: number; resolve: () => void } | null = null;
    const started = Promise.withResolvers<void>();
    const sub = subscribeEvents([{ type: "pane.focused" }], {
      onEvent: (f: unknown) => { frames.push(f); if (waiter && frames.length >= waiter.n) waiter.resolve(); },
      onStarted: () => started.resolve(),
    }, socket);
    await started.promise;
    const framesUntil = (n: number) => frames.length >= n ? Promise.resolve() : Promise.race([
      new Promise<void>((resolve) => { waiter = { n, resolve }; }),
      Bun.sleep(5000).then(() => { throw new Error(`timed out waiting for ${n} focus frames, have ${frames.length}`); }),
    ]);
    const drain = () => frames.splice(0);
    
    let fails = 0;
    const check = (name: string, ok: boolean, detail: unknown) => { console.log(`${ok ? "OBSERVED" : "NOT-OBSERVED"} ${name}:`, JSON.stringify(detail)); if (!ok) fails++; };
    
    // 1) Native explicit swap with herdr focused on A (not the source): does herdr move focus to the source?
    await framesUntil(0); drain();
    const s1 = await herdrRpc("pane.swap", { source_pane_id: B, target_pane_id: C }, socket);
    await framesUntil(1);
    const f1 = (await sessionSnapshot(socket)).focused_pane_id;
    check("1 native pane.swap moves herdr focus to the source (A -> B)", f1 === B, { focused_after: f1, swap_focused_pane_id: s1.swap.focused_pane_id, events: drain() });
    
    // 2) Does herdr 0.9.5 accept focus:false on pane.swap? (the upstream fix the issue asks for)
    await paneFocus(A, socket); await framesUntil(1); drain();
    let s2: unknown;
    try { s2 = await herdrRpc("pane.swap", { source_pane_id: B, target_pane_id: C, focus: false }, socket); } catch (e) { s2 = { error: String(e) }; }
    const f2 = (await sessionSnapshot(socket)).focused_pane_id;
    await Promise.race([framesUntil(1), Bun.sleep(1000)]);
    check("2 pane.swap focus:false is not honored (focus still moves or the param is refused)", f2 !== A, { focused_after: f2, reply: s2, events: drain() });
    
    // 3) Ovis main's paneSwapWith: the restore produces a second pane_focused frame that carries no correlation.
    await paneFocus(A, socket); await framesUntil(1); drain();
    await paneSwapWith(B, C, socket);
    await framesUntil(2);
    const f3 = (await sessionSnapshot(socket)).focused_pane_id;
    const ev3 = drain() as { event?: string; data?: Record<string, unknown> }[];
    const ids = ev3.map((f) => f.data?.pane_id);
    const keys = [...new Set(ev3.flatMap((f) => Object.keys(f.data ?? {})))].sort();
    check("3 Ovis swap restores herdr focus to A", f3 === A, { focused_after: f3 });
    check("3 browser-visible focus frames are [source B, restore A]", ids.length === 2 && ids[0] === B && ids[1] === A, ev3);
    check("3 focus frames carry no request/correlation id", keys.every((k) => ["type", "pane_id", "workspace_id"].includes(k)), { data_keys: keys });
    
    sub.close();
    Bun.spawnSync(["herdr", "--session", SESSION, "server", "stop"], { env });
    await server.exited;
    console.log(`server exited ${server.exitCode}; socket still answering: ${await answers()}`);
    console.log(fails === 0 ? "RESULT: REPRODUCED (all limitation observations hold)" : `RESULT: ${fails} observation(s) did not hold`);
    process.exit(fails === 0 ? 0 : 1);
    Output
    isolated herdr: {"version":"0.9.5","protocol":22,"terminal_attach":true} socket: /tmp/tmp.gApL8C9obT/xdg/herdr/sessions/repro1950/herdr.sock
    panes {
      A: "w1:p1",
      B: "w1:p2",
      C: "w1:p3",
    } focused before: w1:p1
    OBSERVED 1 native pane.swap moves herdr focus to the source (A -> B): {"focused_after":"w1:p2","swap_focused_pane_id":"w1:p2","events":[{"data":{"pane_id":"w1:p2","type":"pane_focused","workspace_id":"w1"},"event":"pane_focused"}]}
    OBSERVED 2 pane.swap focus:false is not honored (focus still moves or the param is refused): {"focused_after":"w1:p2","reply":{"type":"pane_swap","swap":{"changed":true,"source_pane_id":"w1:p2","target_pane_id":"w1:p3","focused_pane_id":"w1:p2","layout":{"workspace_id":"w1","tab_id":"w1:t1","zoomed":false,"area":{"x":0,"y":0,"width":120,"height":40},"focused_pane_id":"w1:p2","panes":[{"pane_id":"w1:p1","focused":false,"rect":{"x":0,"y":0,"width":60,"height":40}},{"pane_id":"w1:p2","focused":true,"rect":{"x":60,"y":0,"width":60,"height":20}},{"pane_id":"w1:p3","focused":false,"rect":{"x":60,"y" …
    OBSERVED 3 Ovis swap restores herdr focus to A: {"focused_after":"w1:p1"}
    OBSERVED 3 browser-visible focus frames are [source B, restore A]: [{"data":{"pane_id":"w1:p2","type":"pane_focused","workspace_id":"w1"},"event":"pane_focused"},{"data":{"pane_id":"w1:p1","type":"pane_focused","workspace_id":"w1"},"event":"pane_focused"}]
    OBSERVED 3 focus frames carry no request/correlation id: {"data_keys":["pane_id","type","workspace_id"]}
    server exited 0; socket still answering: false
    RESULT: REPRODUCED (all limitation observations hold)
    
  2. devswha commented on Oct 30, 2026

    @devswha
    OwnerAuthor

    Filed upstream as herdrdev/herdr#5677: pane.swap ignores focus: false, and the restore emits a pane_focused with no correlation id. It includes a standalone repro (re-run today on herdr 0.9.5). This stays blocked on that.

  3. devswha commented on Nov 4, 2026

    @devswha
    OwnerAuthor

    Re-checked on origin/main @ 7a923ab against herdr 0.9.5 (still the latest stable; upstream master is still ee5bfb5). Same isolated repro as above (own XDG_CONFIG_HOME in a mkdtemp dir, own session):

    OBSERVED 1 native pane.swap moves herdr focus to the source (A -> B)
    OBSERVED 2 pane.swap focus:false is not honored (focus still moves or the param is refused)
    OBSERVED 3 Ovis swap restores herdr focus to A
    OBSERVED 3 browser-visible focus frames are [source B, restore A]
    OBSERVED 3 focus frames carry no request/correlation id: {"data_keys":["pane_id","type","workspace_id"]}
    RESULT: REPRODUCED (all limitation observations hold)
    

    Upstream closed herdrdev/herdr#5677 on 2026-10-30 as a feature request (a focus-preserving swap or correlated focus events would extend the API). So neither root fix in this issue is available, and the issue itself rules out a client-only filter. No Ovis change is made for now.

  4. devswha commented on Nov 4, 2026

    @devswha
    OwnerAuthor

    Closing as a known limit: herdr reports no request id on the two focus events of a focus-preserving swap, so the restore event can't be told apart without guessing, which this issue rules out. Repro is in the comment above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions