Skip to content

Commit c9e6787

Browse files
committed
fixes
1 parent 857f985 commit c9e6787

28 files changed

Lines changed: 2365 additions & 189 deletions

File tree

apps/desktop/src/main/browser-agent/panel.test.ts

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,14 +119,157 @@ describe('panel chat scope', () => {
119119
})
120120
})
121121

122-
it('does not hide the native page behind a replacement captured at stale bounds', async () => {
123-
const { win } = showPanel(panel)
122+
it('force-hides the native page when its replacement was captured at stale bounds', async () => {
123+
const { win, view } = showPanel(panel)
124124
const scopeId = panel.getActivePanelScopeId()
125125

126126
await expect(panel.capturePanelSnapshot(win, scopeId)).resolves.not.toBeNull()
127127
panel.setPanelBounds({ x: 399, y: 64, width: 601, height: 786 }, win)
128+
vi.mocked(view.setVisible).mockClear()
129+
130+
expect(panel.setPanelOccluded(true, win, scopeId)).toBe(false)
131+
expect(view.setVisible).not.toHaveBeenCalled()
132+
133+
expect(panel.setPanelOccluded(true, win, scopeId, true)).toBe(true)
134+
expect(view.setVisible).toHaveBeenLastCalledWith(false)
135+
})
136+
137+
it('force-hides the native page without a captured replacement frame', () => {
138+
const { win, view } = showPanel(panel)
139+
const scopeId = panel.getActivePanelScopeId()
140+
vi.mocked(view.setVisible).mockClear()
128141

129142
expect(panel.setPanelOccluded(true, win, scopeId)).toBe(false)
143+
expect(view.setVisible).not.toHaveBeenCalled()
144+
145+
expect(panel.setPanelOccluded(true, win, scopeId, true)).toBe(true)
146+
expect(view.setVisible).toHaveBeenLastCalledWith(false)
147+
})
148+
149+
it('applies a forced hide before the panel reports its first bounds', () => {
150+
const win = new BrowserWindow()
151+
const view = new WebContentsView()
152+
const active = { id: 'tab-1', scopeId: 'chat-test', view, pinned: false }
153+
panel.initPanel({
154+
getMainWindow: () => win,
155+
activeTab: () => active,
156+
backgroundColor: () => '#0c0c0c',
157+
ensureInitialTab: () => {},
158+
onViewDetached: () => {},
159+
})
160+
panel.activatePanelScope('chat-test')
161+
162+
expect(panel.setPanelOccluded(true, win, 'chat-test')).toBe(false)
163+
expect(panel.setPanelOccluded(true, win, 'chat-test', true)).toBe(true)
164+
165+
panel.setPanelBounds(PANEL_RECT, win)
166+
expect(view.setVisible).toHaveBeenLastCalledWith(false)
167+
})
168+
169+
it('transfers a hidden lease to a focused window before its bounds arrive', () => {
170+
const { win, view } = showPanel(panel)
171+
const other = new BrowserWindow()
172+
const scopeId = panel.getActivePanelScopeId()
173+
vi.mocked(other.isFocused).mockReturnValue(true)
174+
vi.mocked(view.setVisible).mockClear()
175+
176+
expect(panel.setPanelOccluded(true, other, scopeId)).toBe(false)
177+
expect(panel.setPanelOccluded(true, other, scopeId, true)).toBe(true)
178+
expect(win.contentView.removeChildView).toHaveBeenCalledWith(view)
179+
180+
panel.setPanelBounds(PANEL_RECT, other)
181+
expect(view.setVisible).toHaveBeenLastCalledWith(false)
182+
expect(panel.setPanelOccluded(false, other, scopeId)).toBe(true)
183+
expect(view.setVisible).toHaveBeenLastCalledWith(true)
184+
expect(panel.setPanelOccluded(false, win, scopeId)).toBe(true)
185+
})
186+
187+
it('acknowledges forced occlusion in an unfocused window that has no local native view', () => {
188+
const { win, view } = showPanel(panel)
189+
const other = new BrowserWindow()
190+
const scopeId = panel.getActivePanelScopeId()
191+
vi.mocked(other.isFocused).mockReturnValue(false)
192+
vi.mocked(view.setVisible).mockClear()
193+
194+
expect(panel.setPanelOccluded(true, other, scopeId, true)).toBe(true)
195+
expect(view.setVisible).not.toHaveBeenCalled()
196+
expect(panel.setPanelOccluded(false, other, scopeId)).toBe(true)
197+
expect(view.setVisible).not.toHaveBeenCalled()
198+
expect(panel.setPanelOccluded(false, win, scopeId)).toBe(true)
199+
})
200+
201+
it('acknowledges cross-scope modal leases only for a window without the singleton view', () => {
202+
const { win, view } = showPanel(panel)
203+
const other = new BrowserWindow()
204+
vi.mocked(other.isFocused).mockReturnValue(false)
205+
vi.mocked(view.setVisible).mockClear()
206+
207+
expect(panel.setPanelOccluded(true, other, 'chat-in-other-window', true)).toBe(true)
208+
expect(panel.setPanelOccluded(false, other, 'chat-in-other-window')).toBe(true)
209+
expect(view.setVisible).not.toHaveBeenCalled()
210+
211+
vi.mocked(other.isFocused).mockReturnValue(true)
212+
expect(panel.setPanelOccluded(true, other, 'focused-other-chat', true)).toBe(false)
213+
214+
// A stale scope from the real owner must never mutate or falsely
215+
// acknowledge the currently hosted native surface.
216+
expect(panel.setPanelOccluded(true, win, 'stale-owner-chat', true)).toBe(false)
217+
expect(panel.setPanelOccluded(false, win, 'stale-owner-chat')).toBe(false)
218+
expect(view.setVisible).not.toHaveBeenCalled()
219+
})
220+
221+
it('releases the old window occlusion lease when panel ownership moves', async () => {
222+
const { win, view } = showPanel(panel)
223+
const other = new BrowserWindow()
224+
const scopeId = panel.getActivePanelScopeId()
225+
226+
await expect(panel.capturePanelSnapshot(win, scopeId)).resolves.not.toBeNull()
227+
expect(panel.setPanelOccluded(true, win, scopeId)).toBe(true)
228+
expect(view.setVisible).toHaveBeenLastCalledWith(false)
229+
230+
panel.setPanelBounds(PANEL_RECT, other)
231+
expect(view.setVisible).toHaveBeenLastCalledWith(true)
232+
// The displaced renderer can retire its stale local snapshot without
233+
// changing the new owner's already-visible native view.
234+
expect(panel.setPanelOccluded(false, win, scopeId)).toBe(true)
235+
expect(view.setVisible).toHaveBeenLastCalledWith(true)
236+
})
237+
238+
it('lets a displaced window retire its snapshot without revealing the current modal lease', async () => {
239+
const { win, view } = showPanel(panel)
240+
const other = new BrowserWindow()
241+
const scopeId = panel.getActivePanelScopeId()
242+
243+
await expect(panel.capturePanelSnapshot(win, scopeId)).resolves.not.toBeNull()
244+
expect(panel.setPanelOccluded(true, win, scopeId)).toBe(true)
245+
246+
vi.mocked(other.isFocused).mockReturnValue(true)
247+
expect(panel.setPanelOccluded(true, other, scopeId, true)).toBe(true)
248+
panel.setPanelBounds(PANEL_RECT, other)
249+
expect(view.setVisible).toHaveBeenLastCalledWith(false)
250+
251+
expect(panel.setPanelOccluded(false, win, scopeId)).toBe(true)
252+
expect(view.setVisible).toHaveBeenLastCalledWith(false)
253+
expect(panel.setPanelOccluded(false, other, scopeId)).toBe(true)
254+
expect(view.setVisible).toHaveBeenLastCalledWith(true)
255+
})
256+
257+
it('lets the prior scope retire after a focused window establishes the next hidden lease', () => {
258+
const { win, view } = showPanel(panel)
259+
const other = new BrowserWindow()
260+
const previousScope = panel.getActivePanelScopeId()
261+
const nextScope = 'chat-in-focused-window'
262+
vi.mocked(other.isFocused).mockReturnValue(true)
263+
264+
panel.activatePanelScope(nextScope)
265+
expect(panel.setPanelOccluded(true, other, nextScope, true)).toBe(true)
266+
panel.setPanelBounds(PANEL_RECT, other, undefined, nextScope)
267+
expect(view.setVisible).toHaveBeenLastCalledWith(false)
268+
269+
expect(panel.setPanelOccluded(false, win, previousScope)).toBe(true)
270+
expect(view.setVisible).toHaveBeenLastCalledWith(false)
271+
expect(panel.setPanelOccluded(false, other, nextScope)).toBe(true)
272+
expect(view.setVisible).toHaveBeenLastCalledWith(true)
130273
})
131274

132275
it('treats an unpainted blank tab as a valid backdrop snapshot', async () => {
@@ -152,6 +295,7 @@ describe('panel chat scope', () => {
152295
vi.mocked(view.setVisible).mockClear()
153296

154297
expect(panel.setPanelOccluded(true, win, 'some-other-chat')).toBe(false)
298+
expect(panel.setPanelOccluded(true, win, 'some-other-chat', true)).toBe(false)
155299
expect(view.setVisible).not.toHaveBeenCalled()
156300
})
157301
})

apps/desktop/src/main/browser-agent/panel.ts

Lines changed: 108 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ let panelBounds: BrowserPanelBounds | null = null
6262
let panelAnchor: BrowserPanelAnchor | null = null
6363
/** True only after a replacement frame has painted in Sim's renderer. */
6464
let panelOccluded = false
65+
/** Window whose renderer currently owns the native-surface replacement lease. */
66+
let occlusionOwnerWindow: BrowserWindow | null = null
6567
/** Invalidates captures when ownership, scope, or panel visibility changes. */
6668
let panelCaptureGeneration = 0
6769
let panelLeaseAt = 0
@@ -293,6 +295,7 @@ function detachAttachedView(): void {
293295
/** Reveals the native view and invalidates every frame captured for its old state. */
294296
function resetOcclusion(): void {
295297
panelOccluded = false
298+
occlusionOwnerWindow = null
296299
occludableFrame = null
297300
panelCaptureGeneration++
298301
}
@@ -553,16 +556,108 @@ export async function capturePanelSnapshot(
553556
export function setPanelOccluded(
554557
occluded: boolean,
555558
ownerWindow?: BrowserWindow,
556-
scopeId = activePanelScopeId
559+
scopeId = activePanelScopeId,
560+
force = false
557561
): boolean {
558-
if (!panelUpdateAllowed(ownerWindow, scopeId)) return false
559-
if (occluded && (panelBounds === null || host.activeTab() === null)) return false
560-
if (panelOccluded === occluded) return true
561-
if (occluded) {
562+
if (!scopeId) return false
563+
564+
if (scopeId !== activePanelScopeId) {
565+
const currentOwner = occlusionOwnerWindow ?? panelOwner() ?? host.getMainWindow()
566+
const requesterOwnsNativeSurface =
567+
!ownerWindow || currentOwner === null || ownerWindow === currentOwner
568+
569+
// Every app window has its own renderer modal state, but the Browser is a
570+
// singleton native surface hosted by only one of them. A background
571+
// renderer on another chat therefore has nothing local to hide or reveal.
572+
// Acknowledge its forced modal lease as a scoped no-op so its strict
573+
// pre-paint gate can proceed, while still rejecting stale requests from
574+
// the window that actually owns the native surface.
575+
if (!requesterOwnsNativeSurface && !occluded) return true
576+
if (
577+
!requesterOwnsNativeSurface &&
578+
force &&
579+
ownerWindow &&
580+
!ownerWindow.isDestroyed() &&
581+
!ownerWindow.isFocused()
582+
) {
583+
return true
584+
}
585+
return false
586+
}
587+
588+
// Once ownership has transferred, an old renderer still needs to retire its
589+
// local replacement when its modal closes. The old lease was released by
590+
// the transfer, so revealing an already-visible panel is a scoped no-op.
591+
if (!occluded && !panelOccluded) return true
592+
// Another focused window may have replaced this renderer's lease with its
593+
// own modal lease. Retiring the displaced renderer's local snapshot is also
594+
// a no-op: it must not reveal the CURRENT owner's still-occluded view.
595+
if (!occluded && ownerWindow && occlusionOwnerWindow && ownerWindow !== occlusionOwnerWindow) {
596+
return true
597+
}
598+
599+
const panelAllowed = panelUpdateAllowed(ownerWindow, scopeId)
600+
const focusedForceTransfer =
601+
occluded &&
602+
force &&
603+
!panelAllowed &&
604+
Boolean(ownerWindow && !ownerWindow.isDestroyed() && ownerWindow.isFocused())
605+
const forceWithoutLocalSurface =
606+
occluded &&
607+
force &&
608+
!panelAllowed &&
609+
Boolean(ownerWindow && !ownerWindow.isDestroyed() && !ownerWindow.isFocused())
610+
// An unfocused non-owner window has no native view in its compositor. It can
611+
// safely open its own renderer modal without mutating the focused/owning
612+
// window's lease. If it later gains focus while the marker remains, the
613+
// bounds-report guard establishes a real hidden lease before transfer.
614+
if (forceWithoutLocalSurface) return true
615+
if (!panelAllowed && !focusedForceTransfer) return false
616+
617+
// A focused second window can open a modal before its next rAF reports new
618+
// panel bounds. Transfer a HIDDEN, bounds-less lease atomically: the old
619+
// window stops painting now and the new window's first bounds attach the
620+
// singleton already hidden. Clearing the old rect also prevents a reveal at
621+
// another window's geometry if the modal closes unusually quickly.
622+
if (focusedForceTransfer && ownerWindow) {
623+
panelOwnerWindow = ownerWindow
624+
panelBounds = null
625+
panelAnchor = null
626+
panelLeaseAt = 0
627+
panelOccluded = true
628+
occlusionOwnerWindow = ownerWindow
629+
occludableFrame = null
630+
panelCaptureGeneration++
631+
layout()
632+
return true
633+
}
634+
635+
if (!occluded) {
636+
if (ownerWindow && occlusionOwnerWindow && ownerWindow !== occlusionOwnerWindow) return false
637+
panelOccluded = false
638+
occlusionOwnerWindow = null
639+
occludableFrame = null
562640
layout()
563-
if (!occludableFrame || !frameGeometryIsCurrent(occludableFrame)) return false
641+
return true
642+
}
643+
644+
// A pre-paint modal handshake can arrive one React commit before the panel
645+
// reports its first bounds. A forced lease must still stick in that state so
646+
// a view attached later in the same frame starts hidden, rather than briefly
647+
// punching through the already-visible renderer effect.
648+
if (occluded && (panelBounds === null || host.activeTab() === null) && !force) return false
649+
if (panelOccluded) {
650+
return !ownerWindow || !occlusionOwnerWindow || ownerWindow === occlusionOwnerWindow
564651
}
565-
panelOccluded = occluded
652+
layout()
653+
// The lossless frame is the normal path. A full-screen renderer effect
654+
// may explicitly force the final fallback after capture/geometry retries:
655+
// a temporarily blank/blurred host is preferable to a native rectangle
656+
// punching above a modal or global takeover. Ordinary popovers never force
657+
// this path because they must remain pixel-neutral.
658+
if ((!occludableFrame || !frameGeometryIsCurrent(occludableFrame)) && !force) return false
659+
panelOccluded = true
660+
occlusionOwnerWindow = ownerWindow ?? panelWindow()
566661
occludableFrame = null
567662
layout()
568663
return true
@@ -590,7 +685,12 @@ export function setPanelBounds(
590685
// must not pull the browser out from under the window displaying it.
591686
if (bounds === null && !panelUpdateAllowed(ownerWindow)) return
592687
if (bounds !== null) {
593-
panelOwnerWindow = ownerWindow ?? host.getMainWindow()
688+
const nextOwner = ownerWindow ?? host.getMainWindow()
689+
// Occlusion belongs to a renderer window, not to the mutable singleton.
690+
// Moving the native view to another window releases the previous window's
691+
// lease; the new owner must establish its own if it also has a modal.
692+
if (occlusionOwnerWindow && nextOwner !== occlusionOwnerWindow) resetOcclusion()
693+
panelOwnerWindow = nextOwner
594694
} else {
595695
panelOwnerWindow = null
596696
}

apps/desktop/src/main/browser-agent/session.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,23 @@ describe('browser-agent session', () => {
565565
expect(restoredTab?.pendingRestoreUrl).toBe('https://retained.example/')
566566
})
567567

568+
it('closes live pages even when the suspend descriptor cannot be saved', () => {
569+
const { persistence } = memoryBrowserPersistence()
570+
vi.mocked(persistence.save).mockReturnValue(false)
571+
session = freshSession(win, {}, persistence)
572+
const tab = session.withBrowserScope('chat-deleted', () => session.ensureTab())
573+
574+
// Suspension accompanies chat deletion: a failed descriptor save must
575+
// never leave the deleted chat's pages loaded invisibly.
576+
expect(session.suspendBrowserScope('chat-deleted')).toBe(true)
577+
578+
expect((tab.view as unknown as MockView).webContents.close).toHaveBeenCalledOnce()
579+
expect(session.withBrowserScope('chat-deleted', () => session.peekTabsState())).toMatchObject({
580+
tabs: [],
581+
activeTabId: null,
582+
})
583+
})
584+
568585
it('normalizes browser shortcuts to Command on macOS and Control elsewhere', () => {
569586
const input = {
570587
type: 'keyDown',

apps/desktop/src/main/browser-agent/session.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,11 @@ export function disposeBrowserScope(scopeId: string): void {
666666
* No empty-strip/session-closed events are published: soft deletion removes
667667
* the resource's UI separately, and those events would overwrite its retained
668668
* renderer descriptor before the chat can be restored.
669+
*
670+
* The persist is best-effort: suspension accompanies chat deletion, and a
671+
* descriptor that could not be saved must never leave the deleted chat's
672+
* pages loaded invisibly. A restore after a failed save falls back to the
673+
* last successfully saved descriptor.
669674
*/
670675
export function suspendBrowserScope(scopeId: string): boolean {
671676
const resolved = resolveBrowserScopeId(scopeId)
@@ -675,12 +680,10 @@ export function suspendBrowserScope(scopeId: string): boolean {
675680
return true
676681
}
677682

678-
let persisted = true
679683
withBrowserScope(resolved, () => {
680-
if (hasSession()) persisted = persistBrowserSession()
681-
if (persisted) closeLiveTabs()
684+
if (hasSession()) persistBrowserSession()
685+
closeLiveTabs()
682686
})
683-
if (!persisted) return false
684687

685688
suspendedBrowserScopes.add(resolved)
686689
browserScopeStates.delete(resolved)

apps/desktop/src/main/index.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -629,9 +629,23 @@ function main(): void {
629629
const win = windowForContents(sender)
630630
return win ? captureBrowserAgentPanelSnapshot(win, scopeId) : Promise.resolve(null)
631631
},
632-
setOccluded: (sender, occluded, scopeId) => {
632+
setOccluded: (sender, occluded, scopeId, force) => {
633633
const win = windowForContents(sender)
634-
return win ? setBrowserAgentPanelOccluded(occluded, win, scopeId) : false
634+
if (!win) return false
635+
// A modal in a stale renderer must not resurrect a soft-deleted
636+
// Browser scope. There is no local native surface for that scope;
637+
// acknowledge only its forced hide/any reveal as scoped no-ops.
638+
if (isBrowserScopeSuspended(scopeId)) return !occluded || force === true
639+
// The focused window may open a modal before its next bounds frame
640+
// has transferred the singleton Browser from another app window.
641+
// Move the session scope first so the forced hide establishes the
642+
// new owner's hidden lease, rather than acknowledging a background
643+
// no-op and then attaching the view visibly on the bounds report.
644+
const resolvedScopeId =
645+
occluded && force && focusedAppWindow() === win
646+
? activateAgentBrowserScope(scopeId)
647+
: scopeId
648+
return setBrowserAgentPanelOccluded(occluded, win, resolvedScopeId, force)
635649
},
636650
},
637651
beginOAuthConnect: (providerId, scope) => connectFlow.beginConnectHandoff(providerId, scope),

0 commit comments

Comments
 (0)