Merge pull request #2025 from manaflow-ai/issue-1357-ghost-terminal-sidebar-bleed

Fix #1357: hide stale terminal portal after restore churn
This commit is contained in:
Lawrence Chen 2026-03-24 18:32:31 -07:00 committed by GitHub
commit 9b3a6ba28b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 89 additions and 9 deletions

View file

@ -1211,18 +1211,33 @@ final class WindowTerminalPortal: NSObject {
return
}
guard let anchorView = entry.anchorView, let window else {
// Only hide if the entry is not marked visibleInUI. When a workspace is
// remounting, updateNSView sets visibleInUI=true before the deferred bind
// provides an anchor hiding here would race with that and cause a flash.
if !entry.visibleInUI {
if entry.visibleInUI {
let shouldPreserveVisibleOnTransient = !hostedView.isHidden &&
scheduleTransientRecoveryRetryIfNeeded(
forHostedId: hostedId,
entry: &entry,
hostedView: hostedView,
reason: "missingAnchorOrWindow"
)
if shouldPreserveVisibleOnTransient {
#if DEBUG
if !hostedView.isHidden {
dlog("portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 reason=missingAnchorOrWindow")
}
dlog(
"portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " +
"reason=missingAnchorOrWindow frame=\(portalDebugFrame(hostedView.frame))"
)
#endif
hostedView.isHidden = true
resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry)
return
}
} else {
resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry)
}
#if DEBUG
if !hostedView.isHidden {
dlog("portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 reason=missingAnchorOrWindow")
}
#endif
hostedView.isHidden = true
if entry.visibleInUI {
_ = scheduleTransientRecoveryRetryIfNeeded(
forHostedId: hostedId,
entry: &entry,

View file

@ -2596,6 +2596,71 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase {
XCTAssertEqual(portal.debugHostedSubviewCount(), 1, "Stale anchorless hosted views should be detached from hostView")
}
func testDeferredSyncHidesVisibleHostedViewAfterAnchorDisappears() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
let portal = WindowTerminalPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
var retiredAnchor: NSView? = NSView(frame: NSRect(x: 24, y: 28, width: 96, height: 180))
contentView.addSubview(retiredAnchor!)
let retiredTerminal = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 96, height: 180))
let retiredHosted = GhosttySurfaceScrollView(surfaceView: retiredTerminal)
portal.bind(hostedView: retiredHosted, to: retiredAnchor!, visibleInUI: true)
portal.synchronizeHostedViewForAnchor(retiredAnchor!)
let retiredWindowPoint = retiredAnchor!.convert(
NSPoint(x: retiredAnchor!.bounds.midX, y: retiredAnchor!.bounds.midY),
to: nil
)
XCTAssertTrue(
portal.terminalViewAtWindowPoint(retiredWindowPoint) === retiredTerminal,
"Initial hit-testing should resolve the first hosted terminal at its anchor"
)
retiredAnchor?.removeFromSuperview()
retiredAnchor = nil
let activeAnchor = NSView(frame: NSRect(x: 184, y: 28, width: 280, height: 180))
contentView.addSubview(activeAnchor)
let activeTerminal = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 280, height: 180))
let activeHosted = GhosttySurfaceScrollView(surfaceView: activeTerminal)
portal.bind(hostedView: activeHosted, to: activeAnchor, visibleInUI: true)
portal.synchronizeHostedViewForAnchor(activeAnchor)
XCTAssertTrue(
retiredHosted.isHidden,
"A visible hosted terminal whose anchor vanished should hide as soon as the replacement anchor sync runs"
)
// Drain the queued full-sync turn so the portal clears any stale hit-test region left by the rebind.
drainMainQueue()
let activeWindowPoint = activeAnchor.convert(
NSPoint(x: activeAnchor.bounds.midX, y: activeAnchor.bounds.midY),
to: nil
)
XCTAssertNil(
portal.terminalViewAtWindowPoint(retiredWindowPoint),
"Restore-like rebinds should clear stale portal hit regions on the queued portal resync"
)
XCTAssertTrue(
portal.terminalViewAtWindowPoint(activeWindowPoint) === activeTerminal,
"The active terminal should remain visible after the stale hosted view is hidden"
)
}
func testSynchronizeReusesInstalledTargetWithoutRepeatedContentViewLookup() {
let window = ContentViewCountingWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),