Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/renderer/canvas/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { isWorktreePanelType, type WorktreePanelType } from '../../shared/panels
import { openFileAsPanel } from '../lib/fs/fileRouting'
import { setPendingReveal } from '../lib/editor/editorReveal'
import { CanvasTopOverlayContext } from './CanvasTopOverlayContext'
import { syncBrowserSurfaces } from '../panels/browserSurfaceRegistry'

// Module-level style injection — shared across all Canvas instances
let canvasStyleInjected = false
Expand Down Expand Up @@ -230,6 +231,10 @@ const Canvas: React.FC<CanvasProps> = ({ children, overlayChildren, onCreateAtPo
el.style.transform = transform
el.style.setProperty('--zoom', String(zoom))
}
// Browser and T3 guests live in a fixed host outside the transformed
// world. Realign them in this task so they paint with their canvas nodes;
// MutationObserver + rAF would leave them one frame behind while panning.
syncBrowserSurfaces()

// Promote the world to its own GPU layer for the duration of the gesture so
// pan/zoom stays smooth, then de-promote once it settles. While promoted,
Expand Down Expand Up @@ -687,6 +692,7 @@ const Canvas: React.FC<CanvasProps> = ({ children, overlayChildren, onCreateAtPo
{/* World div: transformed to implement pan/zoom */}
<div
ref={worldRef}
data-canvas-world
style={{
position: 'absolute',
top: 0,
Expand Down Expand Up @@ -721,6 +727,7 @@ const Canvas: React.FC<CanvasProps> = ({ children, overlayChildren, onCreateAtPo
>
<div
ref={setTopOverlayWorldRef}
data-canvas-top-overlay-world
style={{
position: 'absolute',
top: 0,
Expand Down
1 change: 1 addition & 0 deletions src/renderer/canvas/CanvasGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const CanvasGrid: React.FC<CanvasGridProps> = ({
return (
<div
ref={divRef}
data-canvas-grid
style={{
position: 'absolute',
left: 0,
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/panels/BackgroundBrowserHost.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ vi.mock('./BrowserPanel', () => ({
}))

import BackgroundBrowserHost from './BackgroundBrowserHost'
import { BrowserPanelSurfaceSlot, PersistentBrowserHostContext } from './browserSurfaceRegistry'
import { BrowserPanelSurfaceSlot, PersistentBrowserHostContext, syncBrowserSurfaces } from './browserSurfaceRegistry'
import { useAppStore } from '../stores/appStore'
import { useUIStore } from '../stores/uiStore'

Expand Down Expand Up @@ -200,6 +200,7 @@ describe('BackgroundBrowserHost', () => {

act(() => {
world.style.transform = 'scale(1.5) translate(20px, 10px)'
syncBrowserSurfaces()
})

await vi.waitFor(() => {
Expand Down
21 changes: 20 additions & 1 deletion src/renderer/panels/browserSurfaceRegistry.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BrowserPanelSurfaceSlot, registerBrowserSurface } from './browserSurfaceRegistry'
import { BrowserPanelSurfaceSlot, registerBrowserSurface, syncBrowserSurfaces } from './browserSurfaceRegistry'

;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true

Expand Down Expand Up @@ -109,6 +109,25 @@ describe('browser surface layout tracking', () => {
expect(surface.style.width).toBe('300px')
})

it('can align a canvas-driven surface before the next animation frame', async () => {
await frame()
rect = new DOMRect(180, 70, 240, 160)
const transformed = host.querySelector('main')!
transformed.dataset.canvasWorld = ''
transformed.style.transform = 'translate(80px, 30px)'
const grid = host.querySelector('aside')!
grid.dataset.canvasGrid = ''
grid.style.backgroundPosition = '80px 30px'

syncBrowserSurfaces()
await Promise.resolve()

expect(surface.style.left).toBe('180px')
expect(surface.style.top).toBe('70px')
expect(surface.style.transform).toBe('scale(0.8, 0.8)')
expect(frames.size).toBe(0)
})

it('follows sibling layout changes and ancestor child insertion', async () => {
await frame()
rect = new DOMRect(60, 70, 300, 200)
Expand Down
69 changes: 44 additions & 25 deletions src/renderer/panels/browserSurfaceRegistry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -291,33 +291,43 @@ function schedule(): void {
if (frame !== null || surfaces.size === 0) return
frame = requestAnimationFrame(() => {
frame = null
const started = PERF_ENABLED ? performance.now() : 0
if (rebuildTracking) watchSurfaces()
refreshAnimations()
// Background-workspace guests deliberately stay mounted so CLI/agent
// control keeps their exact webContents and page state. They have no live
// geometry slot, though, and are already parked by unregisterSlot. Exclude
// them from the per-frame layout pass without changing their lifecycle.
const activeIds = [...slots.entries()]
.filter(([id, slot]) => surfaces.has(id) && slot.isConnected)
.map(([id]) => id)
if (activeIds.length === 0) {
geometryAnimations.clear()
return
}
perfCount('browserGeometryFrame')
const geometry = frameGeometry()
const writes = activeIds.map((id) => measureSurface(id, geometry))
for (const write of writes) write()
// Animated transforms produce no further mutations or resize notifications.
for (const [element, animations] of geometryAnimations) {
if (!animations.some((animation) => animation.playState === 'running')) geometryAnimations.delete(element)
}
if (PERF_ENABLED) perfCount('browserGeometryMicros', Math.round((performance.now() - started) * 1000))
syncBrowserSurfaces()
if (geometryAnimations.size) schedule()
})
}

/**
* Align persistent webview surfaces immediately after an imperative canvas
* transform. Waiting for MutationObserver -> requestAnimationFrame puts the
* fixed guest host one paint behind the canvas node that owns its slot.
*/
export function syncBrowserSurfaces(): void {
if (surfaces.size === 0) return
const started = PERF_ENABLED ? performance.now() : 0
if (rebuildTracking) watchSurfaces()
refreshAnimations()
// Background-workspace guests deliberately stay mounted so CLI/agent
// control keeps their exact webContents and page state. They have no live
// geometry slot, though, and are already parked by unregisterSlot. Exclude
// them from the layout pass without changing their lifecycle.
const activeIds = [...slots.entries()]
.filter(([id, slot]) => surfaces.has(id) && slot.isConnected)
.map(([id]) => id)
if (activeIds.length === 0) {
geometryAnimations.clear()
return
}
perfCount('browserGeometryFrame')
const geometry = frameGeometry()
const writes = activeIds.map((id) => measureSurface(id, geometry))
for (const write of writes) write()
// Animated transforms produce no further mutations or resize notifications.
for (const [element, animations] of geometryAnimations) {
if (!animations.some((animation) => animation.playState === 'running')) geometryAnimations.delete(element)
}
if (PERF_ENABLED) perfCount('browserGeometryMicros', Math.round((performance.now() - started) * 1000))
}

function watchSurfaces(): void {
cleanupTracking?.()
cleanupTracking = null
Expand All @@ -332,8 +342,17 @@ function watchSurfaces(): void {
}
const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule)
const mutation = typeof MutationObserver === 'undefined' ? null : new MutationObserver((records) => {
if (records.some((record) => record.type === 'childList')) rebuildTracking = true
for (const record of records) animatedElements.add(record.target as HTMLElement)
// Canvas owns these transforms and synchronously aligns the fixed guests.
// Observing their style/class writes would repeat that work next frame.
// Child changes still rebuild the tracked node set.
const pending = records.filter((record) => record.type === 'childList' || !(
(record.target as HTMLElement).hasAttribute('data-canvas-world')
|| (record.target as HTMLElement).hasAttribute('data-canvas-top-overlay-world')
|| (record.target as HTMLElement).hasAttribute('data-canvas-grid')
))
if (pending.length === 0) return
if (pending.some((record) => record.type === 'childList')) rebuildTracking = true
for (const record of pending) animatedElements.add(record.target as HTMLElement)
schedule()
})
const ancestors = new Set<HTMLElement>()
Expand Down