Skip to content

fix(herdr): show the pane's session when pane.focus focuses it - #1546

Merged
Gaurav-Gosain merged 4 commits into
Gaurav-Gosain:mainfrom
jonasws:fix/herdr-focus-switches-session
Nov 4, 2026
Merged

Gaurav-Gosain merged 4 commits into
Gaurav-Gosain:mainfrom
jonasws:fix/herdr-focus-switches-session

Conversation

@jonasws

@jonasws jonasws commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

What does this pull request do?

herdr's pane.focus and agent.focus now show the pane's session on the attached client. Until now they focused the pane inside its own session and left the client where it was, so a pane in another session was focused where nobody could see it.

The herdr event stream also sends workspace_focused, tab_focused and pane_focused when a client moves to another session, so herdr clients that re-read their state on those events see the new focus at once.

Follows #1493, which made pane.focus mark the pane seen.

Motivation / Context

tuios follows herdr's API at 0.9.5. There, focusing a pane makes the pane's workspace the active one:

All links are at the v0.9.5 tag's commit.

A herdr workspace is a tuios session (docs/AGENT_STATE.md, "How tuios maps onto herdr"). tuios already shows the session for workspace.focus, by moving the attached client the way the session switcher does. pane.focus ran only focus-window, which focuses a pane within its session and never moves a client to another session.

What has been changed

  • pane.focus, agent.focus: after focus-window, the attached client moves to the pane's session when it isn't showing it already. With no client attached, the pane is still focused in its session, as before.
  • Focus events: the herdr translator reads client-session-changed. A client that moves from one session to another sends workspace_focused, tab_focused and pane_focused for the new session, its current tab and its focused pane, once each and in that order. A client's first attach sends nothing, since herdr has no attach, only a switch. A rename, which re-announces a client in the same session, sends nothing either. A window-focused in a session no client is in sends no pane_focused, because herdr sends focus events only for the active workspace. Without that, a pane.focus on a pane that wasn't the focused one in its session read pane_focused, workspace_focused, tab_focused, pane_focused. With no client attached, pane_focused is sent as before.
  • Shared helper: the client move is herdrShowSession, taken out of herdrWorkspaceFocus, so both methods pick the client the same way (the one showing the caller's session, else the one showing the active workspace) and need the same select-workspace grant. workspace.focus behaves as before.
  • Docs: docs/AGENT_STATE.md (the method and event tables) and docs/protocol.md ("Changes to existing verbs").
  • Test: e2e/tui/herdr_focus_session_test.go, recorded in e2e/tui/NEGATIVE_CONTROLS.md.

Notes on compatibility

  • A herdr client calling pane.focus or agent.focus on a pane in another session now moves the attached tuios client to that session, as herdr moves its own UI.
  • A focus within the session the client shows, and a focus with no client attached, behave as before.
  • Subscribers to workspace.focused, tab.focused or pane.focused now get those events when a client switches sessions, through pane.focus, workspace.focus or the session switcher. Attaching a client sends none of them.
  • Subscribers to pane.focused no longer get it for a window focused in a session no client shows, while a client is attached elsewhere. It comes when a client moves there.

Follow-up, for a later PR: event-hook plugins see three events per switch

Plugin event hooks are fed by the same translator, so a plugin subscribed to all three focus events now runs three times, at once, on a session switch. plugins/pr-status is one: its three runs check the saved text before any of them has written it, so a switch after the minute-long cache has gone stale makes up to three gh pr view calls instead of one. The cell still ends up right; the cost is the extra calls.

This matches herdr, which sends the same three events, so this PR leaves it as is. We may want to handle it differently down the road, for instance with a lock in pr-status.sh so only one run calls gh, or by having the plugin listen to pane.focused alone. That belongs in its own PR.

Binary size cost

Measured with go1.27.3, against the base c606b6b:

  • linux/amd64: +4,096 bytes (46,944,416 → 46,948,512)
  • darwin/arm64: +16,560 bytes (43,724,354 → 43,740,914)

Negative controls

  • The control deletes the call site, not the function.
  • Every negative test has its positive half (a focus within the shown session leaves the client where it is).

Control run (TestHerdrFocusShowsThePanesSession, recorded in e2e/tui/NEGATIVE_CONTROLS.md):

  • main (c606b6b): "pane.focus on a pane in another session: the stream ended before workspace_focused and pane_focused" (the client never moves, so the subscription times out)
  • Wiring cut: herdrShowSession call in herdrFocusPane: same message
  • Wiring cut: client-session-changed case in the herdr translator: "the stream ended before workspace_focused and pane_focused"
  • Wiring cut: the translator check that drops pane_focused for a session no client is in (b97aa14): "the focus events read pane_focused workspace_focused tab_focused pane_focused, want workspace_focused tab_focused pane_focused"

TestPluginPRStatus guards the attach case: with focus events on attach, its three hook runs each called gh (CI run 41798073377, shard 2: "gh ran 3 times for two focuses in a minute, want 1").

@Gaurav-Gosain

Copy link
Copy Markdown
Owner

Thanks Jonas, this is a really nice catch, and the source links made it easy to check. I read herdr 0.9.5 alongside it and you're right: pane.focus goes through switch_workspace_tab, so the pane's workspace becomes the active one. The test passes here, fails on main, and fails again with the client move taken out. Sharing herdrShowSession with workspace.focus is a nice cleanup too.

One thing on the event order. When the pane isn't already the focused one in its session, focus-window sends a pane_focused before the client moves. The stream then reads pane_focused, workspace_focused, tab_focused, pane_focused. herdr sends the three once, workspace first. The test doesn't hit this, because "far" is already focused in its session. Could you make the test focus another window there first, and drop or delay the early pane_focused for a pane whose session no client shows? Happy to land it as is and take that as a follow-up if you'd rather. Thanks again!

@jonasws
jonasws force-pushed the fix/herdr-focus-switches-session branch from a671c16 to 2dd3b44 Compare November 4, 2026 10:37
@jonasws

jonasws commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for reading it against herdr, and for catching the order. You're right, and I've done it in this PR rather than a follow-up: ff1b6dc.

  • Test: TestHerdrFocusShowsThePanesSession now focuses a second window ("farther") in the other session first, so "far" isn't the focused one there. It then checks that the focus events come once each, in the order workspace_focused, tab_focused, pane_focused. On b97aa14 it fails with the sequence you described: "the focus events read pane_focused workspace_focused tab_focused pane_focused, want workspace_focused tab_focused pane_focused".
  • Fix: I went with dropping the event rather than delaying it. The herdr translator now tracks the session each client is in, and sends no pane_focused for a window-focused in a session no client is in. The move's own pane_focused then names the pane. Doing it in the translator rather than in herdrFocusPane also covers a focus-window from anywhere else into a hidden session, which herdr wouldn't report either, since that workspace isn't the active one. With no client attached at all, nothing is active to compare with, so pane_focused is sent as before.
  • Control: added to e2e/tui/NEGATIVE_CONTROLS.md. docs/AGENT_STATE.md and docs/protocol.md say the same.

I've also rebased on main (c606b6b) and re-run the controls there. Since the test now reads the focus events before it checks the client, main and the herdrShowSession cut fail at the event stream rather than at the client check, and 7d653bc records that. The description matches.

@jonasws
jonasws force-pushed the fix/herdr-focus-switches-session branch from 1b609f5 to 7d653bc Compare November 4, 2026 11:15
  herdr 0.9.5's pane.focus and agent.focus make the pane's workspace the
  active one (handle_pane_focus and focus_pane_in_workspace in herdr's
  src/app). A herdr workspace is a tuios session, but herdrFocusPane only
  ran focus-window, which focuses the pane inside its own session. A pane
  in a session no client showed was focused where nobody could see it,
  and the client stayed on the session it was on.

  herdrFocusPane now moves the attached client to the pane's session after
  focus-window, the way workspace.focus already did. That move is
  herdrShowSession, taken out of herdrWorkspaceFocus so both share it, with
  the same choice of client and the same select-workspace grant. With no
  client attached the pane is still focused in its session, as before.

  TestHerdrFocusShowsThePanesSession covers a focus in the shown session,
  a pane.focus across sessions and an agent.focus back. Its negative
  controls are in e2e/tui/NEGATIVE_CONTROLS.md.
…sion

  herdr sends workspace_focused, tab_focused and pane_focused whenever the
  active workspace or pane changes (emit_focus_api_events in herdr's
  src/app/api.rs). tuios accepted subscriptions to those events but never
  sent them for a move between sessions, so a herdr client that re-reads
  its state on a focus event kept showing the old focus until its next
  refresh, after a pane.focus had moved the attached client.

  The herdr translator now reads client-session-changed. A client that
  enters a session it did not show sends the three events, for that
  session, its current tab and its focused pane. The translator remembers
  the session each client shows, so a rename, which re-announces the
  client in the same session, sends nothing.

  TestHerdrFocusShowsThePanesSession now subscribes to the focus events
  and checks them for the move. The control is in
  e2e/tui/NEGATIVE_CONTROLS.md.
  herdr sends focus events only for the active workspace. A pane.focus on a
  pane its session did not have focused ran focus-window before the client
  moved, and the translator turned that window-focused into a pane_focused
  for a session nobody showed. The stream then read pane_focused,
  workspace_focused, tab_focused, pane_focused, where herdr sends the three
  once, workspace first.

  The translator now keeps the session each client is in, and drops
  window-focused for a session no client is in. With no client attached it
  sends pane_focused as before, since there is no active workspace to
  compare with. The move's own pane_focused names the pane.

  TestHerdrFocusShowsThePanesSession now focuses another window in the far
  session first, and checks the focus events come once each, in herdr's
  order. The control is in e2e/tui/NEGATIVE_CONTROLS.md.
  The control table for TestHerdrFocusShowsThePanesSession named main as
  57150e1 and quoted the client check for it. Since the test reads the
  focus events before it checks the client, a build without the client
  move fails at the event stream instead. Main at c606b6b and the build
  with the herdrShowSession call cut were run again against the test as it
  stands, and the table quotes what they print. The translator control
  names its commit after the rebase.
@Gaurav-Gosain
Gaurav-Gosain force-pushed the fix/herdr-focus-switches-session branch from 7d653bc to b1d5a51 Compare November 4, 2026 11:59
@Gaurav-Gosain
Gaurav-Gosain merged commit b1d5a51 into Gaurav-Gosain:main Nov 4, 2026
35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants