Fix pane focus sync without terminal auto-focus#211
Fix pane focus sync without terminal auto-focus#211yaroslavyaroslav wants to merge 9 commits intosupabitapp:mainfrom
Conversation
28ee1af to
d92d966
Compare
|
Hey 👋 could you get a bit more into details about the issue? |
|
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. |
sbertix
left a comment
There was a problem hiding this comment.
Nice fix 💪 just a few comments
| 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 | ||
| ) | ||
| ) |
There was a problem hiding this comment.
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.
| 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) | ||
| } |
There was a problem hiding this comment.
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 | ||
| } |
There was a problem hiding this comment.
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? | ||
| } |
There was a problem hiding this comment.
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.focusedSurfaceID → renderContext.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)
|
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? 🙇♂️ |
|
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. |
|
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 |
|
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
|
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 |
CleanShot.2026-04-08.at.10.56.15.mp4I think the last commit might have made it worse 🤔 |
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
Verification