From 53fdf493539aeb441b0979ec736c51f46221bdab Mon Sep 17 00:00:00 2001 From: Serhii Khvala Date: Sat, 7 Nov 2026 13:01:52 +0200 Subject: [PATCH 1/5] fix(desktop): the browser panel keeps the theme background while a page loads A hosted browser tab that mounts with its URL already set, such as a tab restored at launch, shows a white panel until the page's first frame. The guest has no document yet, so it is transparent and the host webview element's white backing shows through for as long as the server takes to respond. The host element now paints the app background until the guest commits its first real document, then returns to white, which stays the browser canvas behind transparent pages once content exists. Made with Claude Fable 5.1 in Claude Code, running in T3 Code. --- apps/web/src/browser/HostedBrowserWebview.tsx | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 34c7978b7b40b..9263738ee60dc 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -135,6 +135,8 @@ export function HostedBrowserWebview(props: { const [webviewGeneration, setWebviewGeneration] = useState(0); const [recoverySrc, setRecoverySrc] = useState(initialSrc); + // A guest with no document is transparent, so the host paints the panel until the first commit. + const [committedGeneration, setCommittedGeneration] = useState(null); const active = presentation.visible && presentation.rect !== null; const shown = active || pictureInPicture; const reportedShown = useEffectEvent(() => serverDriven && shown); @@ -186,8 +188,13 @@ export function HostedBrowserWebview(props: { new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }), ); }; + const markCommitted = (event: Event) => { + if ((event as Event & { url?: string }).url === "about:blank") return; + setCommittedGeneration(webviewGeneration); + }; webview.addEventListener("did-attach", register); webview.addEventListener("dom-ready", register); + webview.addEventListener("did-navigate", markCommitted); webview.addEventListener("render-process-gone", recoverGuest); webview.addEventListener("focus", dismissHostPopups); register(); @@ -196,16 +203,11 @@ export function HostedBrowserWebview(props: { if (recoveryTimeout !== null) clearTimeout(recoveryTimeout); webview.removeEventListener("did-attach", register); webview.removeEventListener("dom-ready", register); + webview.removeEventListener("did-navigate", markCommitted); webview.removeEventListener("render-process-gone", recoverGuest); webview.removeEventListener("focus", dismissHostPopups); }; - }, [ - clientSettingsHydrated, - config, - runtimeTabId, - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A crash recovery remounts the webview under a new key, so the listeners attach to the new element. - webviewGeneration, - ]); + }, [clientSettingsHydrated, config, runtimeTabId, webviewGeneration]); const lastRect = presentation.rect; const layoutZoomFactor = useBrowserLayoutZoomFactor(zoomFactor); @@ -362,7 +364,9 @@ export function HostedBrowserWebview(props: { } aria-hidden={active ? undefined : true} className={cn( - "absolute flex overflow-hidden bg-white", + "absolute flex overflow-hidden", + // White is the browser canvas behind transparent pages once a document exists. + committedGeneration === webviewGeneration ? "bg-white" : "bg-background", active && !layout.fillsPanel && "ring-1 ring-border/70 shadow-sm", )} style={{ From 2d7a742e039570c2c508f792ab680b5e35c10386 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:31:51 +0000 Subject: [PATCH 2/5] fix(desktop): wait for the page's styles before restoring the white canvas AI-assisted; verified with the tests and tools listed above. --- apps/web/src/browser/HostedBrowserWebview.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 9263738ee60dc..8fdf049af1236 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -37,6 +37,7 @@ interface ElectronWebview extends HTMLElement { preload?: string; webpreferences?: string; getWebContentsId: () => number; + getURL: () => string; executeJavaScript: (code: string, userGesture?: boolean) => Promise; } @@ -135,8 +136,8 @@ export function HostedBrowserWebview(props: { const [webviewGeneration, setWebviewGeneration] = useState(0); const [recoverySrc, setRecoverySrc] = useState(initialSrc); - // A guest with no document is transparent, so the host paints the panel until the first commit. - const [committedGeneration, setCommittedGeneration] = useState(null); + // The guest is transparent until a page's styles apply, so the host paints the theme until then. + const [readyGeneration, setReadyGeneration] = useState(null); const active = presentation.visible && presentation.rect !== null; const shown = active || pictureInPicture; const reportedShown = useEffectEvent(() => serverDriven && shown); @@ -188,13 +189,13 @@ export function HostedBrowserWebview(props: { new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }), ); }; - const markCommitted = (event: Event) => { - if ((event as Event & { url?: string }).url === "about:blank") return; - setCommittedGeneration(webviewGeneration); + const markDocumentReady = () => { + if (webview.getURL() === "about:blank") return; + setReadyGeneration(webviewGeneration); }; webview.addEventListener("did-attach", register); webview.addEventListener("dom-ready", register); - webview.addEventListener("did-navigate", markCommitted); + webview.addEventListener("dom-ready", markDocumentReady); webview.addEventListener("render-process-gone", recoverGuest); webview.addEventListener("focus", dismissHostPopups); register(); @@ -203,7 +204,7 @@ export function HostedBrowserWebview(props: { if (recoveryTimeout !== null) clearTimeout(recoveryTimeout); webview.removeEventListener("did-attach", register); webview.removeEventListener("dom-ready", register); - webview.removeEventListener("did-navigate", markCommitted); + webview.removeEventListener("dom-ready", markDocumentReady); webview.removeEventListener("render-process-gone", recoverGuest); webview.removeEventListener("focus", dismissHostPopups); }; @@ -366,7 +367,7 @@ export function HostedBrowserWebview(props: { className={cn( "absolute flex overflow-hidden", // White is the browser canvas behind transparent pages once a document exists. - committedGeneration === webviewGeneration ? "bg-white" : "bg-background", + readyGeneration === webviewGeneration ? "bg-white" : "bg-background", active && !layout.fillsPanel && "ring-1 ring-border/70 shadow-sm", )} style={{ From 836e22afd0ef43d0533e1876524e90004d3c6aca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:51:19 +0000 Subject: [PATCH 3/5] fix(desktop): keep the theme background until the page and its stylesheets load AI-assisted; verified with the tests and tools listed above. --- apps/web/src/browser/HostedBrowserWebview.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 8fdf049af1236..0afb11e7d82c2 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -136,7 +136,7 @@ export function HostedBrowserWebview(props: { const [webviewGeneration, setWebviewGeneration] = useState(0); const [recoverySrc, setRecoverySrc] = useState(initialSrc); - // The guest is transparent until a page's styles apply, so the host paints the theme until then. + // The guest stays transparent until its page and stylesheets load; paint the theme until then. const [readyGeneration, setReadyGeneration] = useState(null); const active = presentation.visible && presentation.rect !== null; const shown = active || pictureInPicture; @@ -195,7 +195,7 @@ export function HostedBrowserWebview(props: { }; webview.addEventListener("did-attach", register); webview.addEventListener("dom-ready", register); - webview.addEventListener("dom-ready", markDocumentReady); + webview.addEventListener("did-finish-load", markDocumentReady); webview.addEventListener("render-process-gone", recoverGuest); webview.addEventListener("focus", dismissHostPopups); register(); @@ -204,7 +204,7 @@ export function HostedBrowserWebview(props: { if (recoveryTimeout !== null) clearTimeout(recoveryTimeout); webview.removeEventListener("did-attach", register); webview.removeEventListener("dom-ready", register); - webview.removeEventListener("dom-ready", markDocumentReady); + webview.removeEventListener("did-finish-load", markDocumentReady); webview.removeEventListener("render-process-gone", recoverGuest); webview.removeEventListener("focus", dismissHostPopups); }; From 8d4ffcad06fcf421857ddc3ed4ed5dc4dbd4572c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 04:08:39 +0000 Subject: [PATCH 4/5] fix(desktop): restore the white canvas once the guest paints its first frame AI-assisted; verified with the tests and tools listed above. --- apps/desktop/src/preview/GuestProtocol.ts | 2 ++ apps/desktop/src/preview/PickPreload.ts | 8 +++++++ apps/web/src/browser/HostedBrowserWebview.tsx | 22 +++++++++++++------ 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index a4fd88c475bca..7a0f39da5032b 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -16,3 +16,5 @@ export const PASSKEY_GET_CHANNEL = "preview:passkey-get"; export const PASSKEY_BRIDGE_ARGUMENT = "--t3code-preview-passkey-bridge"; export const ANNOTATION_VOICE_EVENT_CHANNEL = "preview:annotation-voice-event"; export const ANNOTATION_VOICE_STATE_CHANNEL = "preview:annotation-voice-state"; +// HostedBrowserWebview listens for this on the webview's ipc-message event. +export const GUEST_PAINTED_CHANNEL = "preview:guest-painted"; diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index dcb16b851dc23..8e3661824545a 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -31,6 +31,7 @@ import { ANNOTATION_VOICE_STATE_CHANNEL, CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, + GUEST_PAINTED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, RECORDING_CURSOR_CHANNEL, @@ -1809,3 +1810,10 @@ ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotatio activeSession?.applyTheme(theme); }); ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); + +// Rendering, and with it rAF, waits for render-blocking stylesheets, so this marks the first real frame. +window.addEventListener("DOMContentLoaded", () => { + requestAnimationFrame(() => + requestAnimationFrame(() => ipcRenderer.sendToHost(GUEST_PAINTED_CHANNEL)), + ); +}); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 0afb11e7d82c2..0e00025647daf 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -31,6 +31,9 @@ import { type WebviewCrashRecoveryState, } from "./webviewCrashRecovery"; +// Sent by the desktop guest preload (apps/desktop/src/preview/GuestProtocol.ts). +const GUEST_PAINTED_CHANNEL = "preview:guest-painted"; + interface ElectronWebview extends HTMLElement { src: string; partition: string; @@ -136,8 +139,8 @@ export function HostedBrowserWebview(props: { const [webviewGeneration, setWebviewGeneration] = useState(0); const [recoverySrc, setRecoverySrc] = useState(initialSrc); - // The guest stays transparent until its page and stylesheets load; paint the theme until then. - const [readyGeneration, setReadyGeneration] = useState(null); + // The guest is transparent until a page paints, so the host shows the theme until the first frame. + const [paintedGeneration, setPaintedGeneration] = useState(null); const active = presentation.visible && presentation.rect !== null; const shown = active || pictureInPicture; const reportedShown = useEffectEvent(() => serverDriven && shown); @@ -189,13 +192,17 @@ export function HostedBrowserWebview(props: { new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }), ); }; - const markDocumentReady = () => { + const markPainted = () => { if (webview.getURL() === "about:blank") return; - setReadyGeneration(webviewGeneration); + setPaintedGeneration(webviewGeneration); + }; + const onGuestMessage = (event: Event) => { + if ((event as Event & { channel?: string }).channel === GUEST_PAINTED_CHANNEL) markPainted(); }; webview.addEventListener("did-attach", register); webview.addEventListener("dom-ready", register); - webview.addEventListener("did-finish-load", markDocumentReady); + webview.addEventListener("ipc-message", onGuestMessage); + webview.addEventListener("did-finish-load", markPainted); webview.addEventListener("render-process-gone", recoverGuest); webview.addEventListener("focus", dismissHostPopups); register(); @@ -204,7 +211,8 @@ export function HostedBrowserWebview(props: { if (recoveryTimeout !== null) clearTimeout(recoveryTimeout); webview.removeEventListener("did-attach", register); webview.removeEventListener("dom-ready", register); - webview.removeEventListener("did-finish-load", markDocumentReady); + webview.removeEventListener("ipc-message", onGuestMessage); + webview.removeEventListener("did-finish-load", markPainted); webview.removeEventListener("render-process-gone", recoverGuest); webview.removeEventListener("focus", dismissHostPopups); }; @@ -367,7 +375,7 @@ export function HostedBrowserWebview(props: { className={cn( "absolute flex overflow-hidden", // White is the browser canvas behind transparent pages once a document exists. - readyGeneration === webviewGeneration ? "bg-white" : "bg-background", + paintedGeneration === webviewGeneration ? "bg-white" : "bg-background", active && !layout.fillsPanel && "ring-1 ring-border/70 shadow-sm", )} style={{ From 6cf325fd315eda0ccc79a149f44f8340d150db27 Mon Sep 17 00:00:00 2001 From: Serhii Khvala Date: Wed, 11 Nov 2026 11:55:14 +0200 Subject: [PATCH 5/5] fix(desktop): report the guest's first frame without waiting for DOMContentLoaded A deferred or module script holds DOMContentLoaded while the page already paints, so a transparent page showed dark text on the theme backing until the script finished. The preload now schedules its frame callbacks at document start; Chromium runs no frame before the body exists and render-blocking stylesheets load, so the callback still marks the first paint. The did-finish-load fallback is gone: the preload is always attached, and load can fire before the first frame. The guest skips about:blank itself, so the host no longer checks the URL. --- apps/desktop/src/preview/PickPreload.ts | 6 +++--- apps/web/src/browser/HostedBrowserWebview.tsx | 10 ++-------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 8e3661824545a..4283b5f4984c7 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -1811,9 +1811,9 @@ ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotatio }); ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); -// Rendering, and with it rAF, waits for render-blocking stylesheets, so this marks the first real frame. -window.addEventListener("DOMContentLoaded", () => { +// Chromium runs no frame until the body exists and render-blocking stylesheets load, so this is the first paint. +if (location.href !== "about:blank") { requestAnimationFrame(() => requestAnimationFrame(() => ipcRenderer.sendToHost(GUEST_PAINTED_CHANNEL)), ); -}); +} diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 0e00025647daf..ff4f449e5a0d3 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -40,7 +40,6 @@ interface ElectronWebview extends HTMLElement { preload?: string; webpreferences?: string; getWebContentsId: () => number; - getURL: () => string; executeJavaScript: (code: string, userGesture?: boolean) => Promise; } @@ -192,17 +191,13 @@ export function HostedBrowserWebview(props: { new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }), ); }; - const markPainted = () => { - if (webview.getURL() === "about:blank") return; - setPaintedGeneration(webviewGeneration); - }; const onGuestMessage = (event: Event) => { - if ((event as Event & { channel?: string }).channel === GUEST_PAINTED_CHANNEL) markPainted(); + if ((event as Event & { channel?: string }).channel !== GUEST_PAINTED_CHANNEL) return; + setPaintedGeneration(webviewGeneration); }; webview.addEventListener("did-attach", register); webview.addEventListener("dom-ready", register); webview.addEventListener("ipc-message", onGuestMessage); - webview.addEventListener("did-finish-load", markPainted); webview.addEventListener("render-process-gone", recoverGuest); webview.addEventListener("focus", dismissHostPopups); register(); @@ -212,7 +207,6 @@ export function HostedBrowserWebview(props: { webview.removeEventListener("did-attach", register); webview.removeEventListener("dom-ready", register); webview.removeEventListener("ipc-message", onGuestMessage); - webview.removeEventListener("did-finish-load", markPainted); webview.removeEventListener("render-process-gone", recoverGuest); webview.removeEventListener("focus", dismissHostPopups); };