Skip to content

Fix pane focus sync without terminal auto-focus#211

Open
yaroslavyaroslav wants to merge 9 commits intosupabitapp:mainfrom
yaroslavyaroslav:even-more-mouse-focus-fix
Open

Fix pane focus sync without terminal auto-focus#211
yaroslavyaroslav wants to merge 9 commits intosupabitapp:mainfrom
yaroslavyaroslav:even-more-mouse-focus-fix

Conversation

@yaroslavyaroslav
Copy link
Copy Markdown
Contributor

@yaroslavyaroslav yaroslavyaroslav commented Apr 2, 2026

So the issue was that on repo/worktree switch follow mouse to focus were still having issues sometimes (i.e. it were falling into undefined state with few panes being focused).

Summary

  • prevent panes from becoming Ghostty-focused unless they are also the real AppKit first responder
  • keep the focused pane id as intent and reconcile when focus callbacks fire
  • add coverage for the no-auto-focus policy and the responder-driven pane switch flow

Verification

  • xcodebuild test -quiet -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" -only-testing:supacodeTests/TerminalRenderingPolicyTests -only-testing:supacodeTests/WorktreeTerminalManagerTests CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation
  • make build-app

@yaroslavyaroslav yaroslavyaroslav force-pushed the even-more-mouse-focus-fix branch from 28ee1af to d92d966 Compare April 2, 2026 16:35
@sbertix
Copy link
Copy Markdown
Collaborator

sbertix commented Apr 2, 2026

Hey 👋 could you get a bit more into details about the issue?

@yaroslavyaroslav
Copy link
Copy Markdown
Contributor Author

yaroslavyaroslav commented Apr 2, 2026

Yep, focus still had buggy behavior when switching to the new worktree/repo by mouse.

This way first responder still stays the section panel yet some terminal pane already became active, so when mouse were moved to different pane directly — it happened to be active as well yet the initially active never were marked as non active.

So you have to click on few panes to resolve that collision.

I think it could be quite stable reproduced in to have three vertical splits and move mouse to the bottom one after clicking that repo from a repo list.

Often happens to me when I select a repo down the list and then moving my pointer to through the bottom panel up to the top automatically (I mean unintentionally)

UPD: this is the core idea of PR some struct refactoring happened after linter failed on too many attributes on a function.

Copy link
Copy Markdown
Collaborator

@sbertix sbertix left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice fix 💪 just a few comments

Comment on lines +279 to +303
let windowRenderContext = SurfaceRenderContext(
isSelectedTab: false,
windowIsVisible: lastWindowIsVisible == true,
windowIsKey: lastWindowIsKey == true,
focus: SurfaceFocusContext(
focusedSurfaceID: nil,
firstResponderSurfaceID: firstResponderSurfaceID
)
)
let baseFocusContext = SurfaceFocusContext(
focusedSurfaceID: nil,
firstResponderSurfaceID: firstResponderSurfaceID
)
for (tabId, tree) in trees {
let focusedId = focusedSurfaceIdByTab[tabId]
let isSelectedTab = (tabId == selectedTabId)
let visibleSurfaceIDs = Set(tree.visibleLeaves().map(\.id))
var renderContext = windowRenderContext
renderContext = SurfaceRenderContext(
isSelectedTab: tabId == selectedTabId,
windowIsVisible: renderContext.windowIsVisible,
windowIsKey: renderContext.windowIsKey,
focus: SurfaceFocusContext(
focusedSurfaceID: focusedSurfaceIdByTab[tabId],
firstResponderSurfaceID: baseFocusContext.firstResponderSurfaceID
)
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like windowRenderContext and baseFocusContext are constructed but never really consumed — renderContext gets immediately overwritten on the next line, and baseFocusContext is only used to read back .firstResponderSurfaceID which is already a local 🤔

Could we simplify the loop body to something like this? Cause the current flow is a bit hard to follow with the intermediate structs that don't add anything, imho.

Suggested change
let windowRenderContext = SurfaceRenderContext(
isSelectedTab: false,
windowIsVisible: lastWindowIsVisible == true,
windowIsKey: lastWindowIsKey == true,
focus: SurfaceFocusContext(
focusedSurfaceID: nil,
firstResponderSurfaceID: firstResponderSurfaceID
)
)
let baseFocusContext = SurfaceFocusContext(
focusedSurfaceID: nil,
firstResponderSurfaceID: firstResponderSurfaceID
)
for (tabId, tree) in trees {
let focusedId = focusedSurfaceIdByTab[tabId]
let isSelectedTab = (tabId == selectedTabId)
let visibleSurfaceIDs = Set(tree.visibleLeaves().map(\.id))
var renderContext = windowRenderContext
renderContext = SurfaceRenderContext(
isSelectedTab: tabId == selectedTabId,
windowIsVisible: renderContext.windowIsVisible,
windowIsKey: renderContext.windowIsKey,
focus: SurfaceFocusContext(
focusedSurfaceID: focusedSurfaceIdByTab[tabId],
firstResponderSurfaceID: baseFocusContext.firstResponderSurfaceID
)
)
for (tabId, tree) in trees {
let visibleSurfaceIDs = Set(tree.visibleLeaves().map(\.id))
let renderContext = SurfaceRenderContext(
isSelectedTab: tabId == selectedTabId,
windowIsVisible: lastWindowIsVisible == true,
windowIsKey: lastWindowIsKey == true,
focus: SurfaceFocusContext(
focusedSurfaceID: focusedSurfaceIdByTab[tabId],
firstResponderSurfaceID: firstResponderSurfaceID
)
)


#expect(activity.isVisible)
#expect(!activity.isFocused)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test covers the case where firstResponderSurfaceID is nil, which is great — but I think we're missing the split-pane scenario where the first responder points to a different surface than the focused one (e.g., user clicks into a sibling pane). That's arguably the most common real-world case this PR fixes, right?

Something like focusedSurfaceID: paneA, firstResponderSurfaceID: paneB, surfaceID: paneA → should NOT be focused.

Wdyt? Cause without it, someone could accidentally change the AND to an OR and the tests would still pass 😅

}
let window = selectedWindow ?? surfaces.values.compactMap(\.window).first
return (window?.firstResponder as? GhosttySurfaceView)?.id
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT — this chains a few optional lookups and any of them returning nil means no surface gets focused in the entire worktree. Totally fine for the firstResponder as? GhosttySurfaceView cast (expected when focus is on a non-terminal view), but the case where trees[selectedTabId] is nil would mean a data inconsistency that'd be super hard to debug with no logging.

Could we add a SupaLogger.warning just for that one case? We already log for layout and blocking scripts, so it'd be consistent. Let me know if you think it's overkill tbh.

struct SurfaceFocusContext: Equatable {
let focusedSurfaceID: UUID?
let firstResponderSurfaceID: UUID?
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

100% nitpicking, but SurfaceFocusContext is never used independently from SurfaceRenderContext — it's always nested inside it. We could flatten the two UUID fields directly into SurfaceRenderContext and save a level of indirection (renderContext.focus.focusedSurfaceIDrenderContext.focusedSurfaceID).

I'm good with either tbh, the nesting does hint at the "intent vs reality" story which is nice. (But this improvement is negligible)

@sbertix
Copy link
Copy Markdown
Collaborator

sbertix commented Apr 2, 2026

Also, I'm not sure this fixes it entirely when switching quickly between two worktrees: I've been able to replicate the issue (two terminals active side by side). Could you double check, please? 🙇‍♂️

@yaroslavyaroslav
Copy link
Copy Markdown
Contributor Author

About the last bit: can't reproduce this issue anymore locally.

On the current main when I'm switching to worktree with three panes presented bug is quite stable, active state is broken and two of three panes are becoming active.

On this branch instance running I try the very same approach and it works quite stable wi no bug appearing.

@sbertix
Copy link
Copy Markdown
Collaborator

sbertix commented Apr 3, 2026

This last commit now works with the fast switching, but has the "opposite" issue: we now get some worktrees that get a focused terminal on switch, and some that don't at all until you actively tap/type

@yaroslavyaroslav
Copy link
Copy Markdown
Contributor Author

Fixed this one, I believe. I mean last few bits is kinda complicated to me to resolve since I unable to reproduce them locally, so would love if you'll be a part of this feedback loop

…-fix

# Conflicts:
#	supacode/Features/Terminal/Models/WorktreeTerminalState.swift
#	supacodeTests/WorktreeTerminalManagerTests.swift
@yaroslavyaroslav
Copy link
Copy Markdown
Contributor Author

I've faced the issue that you've been talking about.

Yep, focus state was inconsistent between project/worktrees tableview and terminal panes, so yep, mouse could have been moved to terminal already, while tableview still were first responder and to receive keyboard events.

Per my tests last commit fixes this as well

@sbertix
Copy link
Copy Markdown
Collaborator

sbertix commented Apr 8, 2026

CleanShot.2026-04-08.at.10.56.15.mp4

I think the last commit might have made it worse 🤔

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