You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Drop only the restore event after a focus-preserving pane swap #1950
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.
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):
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}.
Sent the same swap with focus: false. herdr 0.9.5 accepts the request but ignores the parameter, so focus still moves to B.
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";constW=process.argv[2]!;const{ herdrRpc, ping, sessionSnapshot, workspaceCreate, paneSplit, paneFocus, paneSwapWith, subscribeEvents }=awaitimport(join(W,"server/herdr/client.ts"));constSESSION="repro1950";constxdg=process.env.XDG_CONFIG_HOME!;mkdirSync(xdg,{recursive: true});constsocket=join(xdg,"herdr","sessions",SESSION,"herdr.sock");constenv=Object.fromEntries(Object.entries(process.env).filter(([k])=>!/^(HERDR|OMO|SENPI|PI)_/.test(k)))asRecord<string,string>;constanswers=async()=>{try{awaitping(socket);returntrue;}catch{returnfalse;}};if(awaitanswers())thrownewError("isolated socket already answering; refusing to reuse");constserver=Bun.spawn(["herdr","--session",SESSION,"server"],{ env,stdout: "ignore",stderr: "ignore"});for(constdeadline=Date.now()+15_000;!(existsSync(socket)&&(awaitanswers()));){if(Date.now()>deadline)thrownewError("isolated herdr did not start");awaitBun.sleep(100);// startup gate only, mirrors scripts/test-herdr.ts}constid=awaitping(socket);console.log("isolated herdr:",JSON.stringify(id),"socket:",socket);constws=awaitworkspaceCreate({cwd: xdg,label: "repro-1950"},socket);constA=ws.root_pane.pane_id;constB=(awaitpaneSplit(A,"right",false,socket)).pane_id;constC=(awaitpaneSplit(B,"down",false,socket)).pane_id;awaitpaneFocus(A,socket);console.log("panes",{ A, B, C },"focused before:",(awaitsessionSnapshot(socket)).focused_pane_id);constframes: unknown[]=[];letwaiter: {n: number;resolve: ()=>void}|null=null;conststarted=Promise.withResolvers<void>();constsub=subscribeEvents([{type: "pane.focused"}],{onEvent: (f: unknown)=>{frames.push(f);if(waiter&&frames.length>=waiter.n)waiter.resolve();},onStarted: ()=>started.resolve(),},socket);awaitstarted.promise;constframesUntil=(n: number)=>frames.length>=n ? Promise.resolve() : Promise.race([newPromise<void>((resolve)=>{waiter={ n, resolve };}),Bun.sleep(5000).then(()=>{thrownewError(`timed out waiting for ${n} focus frames, have ${frames.length}`);}),]);constdrain=()=>frames.splice(0);letfails=0;constcheck=(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?awaitframesUntil(0);drain();consts1=awaitherdrRpc("pane.swap",{source_pane_id: B,target_pane_id: C},socket);awaitframesUntil(1);constf1=(awaitsessionSnapshot(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)awaitpaneFocus(A,socket);awaitframesUntil(1);drain();lets2: unknown;try{s2=awaitherdrRpc("pane.swap",{source_pane_id: B,target_pane_id: C,focus: false},socket);}catch(e){s2={error: String(e)};}constf2=(awaitsessionSnapshot(socket)).focused_pane_id;awaitPromise.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.awaitpaneFocus(A,socket);awaitframesUntil(1);drain();awaitpaneSwapWith(B,C,socket);awaitframesUntil(2);constf3=(awaitsessionSnapshot(socket)).focused_pane_id;constev3=drain()as{event?: string;data?: Record<string,unknown>}[];constids=ev3.map((f)=>f.data?.pane_id);constkeys=[...newSet(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 });awaitserver.exited;console.log(`server exited ${server.exitCode}; socket still answering: ${awaitanswers()}`);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)
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.
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.
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.
Activity
devswha commentedon Oct 29, 2026
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_HOMEin a mkdtemp dir and its own session, never the live one):pane.swap {source_pane_id: B, target_pane_id: C}. herdr moves its focus to the source (A → B) and emitspane_focused {pane_id: B}.focus: false. herdr 0.9.5 accepts the request but ignores the parameter, so focus still moves to B.paneSwapWith(B, C). The restore works (herdr ends on A), but the subscription sees two frames,pane_focused Bthenpane_focused A. Each frame carries onlytype,pane_idandworkspace_id, with no request or correlation id. These are the framesserver/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 onlypane_id,direction,source_pane_idandtarget_pane_id.handle_pane_swap(src/app/api/panes.rs) callstab.layout.focus_pane(source_pane_id)andrecord_pane_focus_changeunconditionally on every swap. ThePaneFocusedevent is still{pane_id, workspace_id}. By contrast,pane.movealready has afocusflag. I found no upstream issue asking for a no-focuspane.swapor 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 themovingHerdrFocusguard are as DESIGN.md describes.Repro script (run from an Ovis checkout:
XDG_CONFIG_HOME=$(mktemp -d) bun repro-1950.ts "$PWD")Output
devswha commentedon Oct 30, 2026
Filed upstream as herdrdev/herdr#5677:
pane.swapignoresfocus: false, and the restore emits apane_focusedwith no correlation id. It includes a standalone repro (re-run today on herdr 0.9.5). This stays blocked on that.devswha commentedon Nov 4, 2026
Re-checked on
origin/main@ 7a923ab against herdr 0.9.5 (still the latest stable; upstreammasteris still ee5bfb5). Same isolated repro as above (ownXDG_CONFIG_HOMEin a mkdtemp dir, own session):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.
devswha commentedon Nov 4, 2026
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.