mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
fix(sessions): keep chat composer focus when a preview iframe loads
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
61f2d8dc6a
commit
70de39ed35
@@ -90,6 +90,7 @@
|
||||
// each editor view's onDeploy → runtime.syncPreviewWithDeployed. So only the
|
||||
// iframe fallback (a separate page) has to be told to refresh.
|
||||
if (slot.kind === 'editor') return
|
||||
armFocusReclaim()
|
||||
try {
|
||||
const win = frame?.contentWindow
|
||||
if (!win) return
|
||||
@@ -112,6 +113,88 @@
|
||||
}
|
||||
}
|
||||
|
||||
const iframeSrc = $derived(withMenuHidden(tab.url, workspaceId || undefined))
|
||||
|
||||
// A preview frame boots the whole app, and SvelteKit's router focuses the
|
||||
// frame's document body on every navigation — which drags focus off whatever
|
||||
// the user is typing in (the chat composer, mid-message, while the assistant
|
||||
// opens or reloads previews). Hand it back, bounded to the load the session
|
||||
// itself triggered: a click into the frame is the user's own move to make.
|
||||
const FOCUS_RECLAIM_WINDOW_MS = 10_000
|
||||
let focusReclaim: { target: HTMLElement; until: number } | undefined
|
||||
let frameInteracted = false
|
||||
|
||||
function armFocusReclaim() {
|
||||
const active = document.activeElement
|
||||
const isTextEntry =
|
||||
active instanceof HTMLTextAreaElement ||
|
||||
active instanceof HTMLInputElement ||
|
||||
(active instanceof HTMLElement && active.isContentEditable)
|
||||
frameInteracted = false
|
||||
focusReclaim = isTextEntry
|
||||
? { target: active as HTMLElement, until: Date.now() + FOCUS_RECLAIM_WINDOW_MS }
|
||||
: undefined
|
||||
}
|
||||
|
||||
// A click or keypress inside the frame reaches no listener out here, so read it
|
||||
// off the frame's own document (same-origin) — it fires ahead of the focus move
|
||||
// it causes, which is what separates a user's click from the router's reset.
|
||||
function trackFrameInteraction(target: HTMLIFrameElement) {
|
||||
try {
|
||||
const doc = target.contentDocument
|
||||
if (!doc) return
|
||||
const mark = () => (frameInteracted = true)
|
||||
doc.addEventListener('pointerdown', mark, true)
|
||||
doc.addEventListener('keydown', mark, true)
|
||||
} catch {
|
||||
// Defensively cross-origin; the reclaim window expiring is the fallback.
|
||||
}
|
||||
}
|
||||
|
||||
// The router parks focus on the frame's body, so anything else focused in there
|
||||
// was clicked. Second line of defence: the listeners above only exist from the
|
||||
// load event on, and a slow frame hydrates (and resets focus) well before that.
|
||||
function frameFocusIsRouterReset(): boolean {
|
||||
try {
|
||||
const doc = frame?.contentDocument
|
||||
const inner = doc?.activeElement
|
||||
return !inner || inner === doc?.body
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Arms on mount and on every src change (a tab opened or re-pointed by the
|
||||
// assistant); reload() arms for the in-frame navigations it drives.
|
||||
$effect(() => {
|
||||
void iframeSrc
|
||||
if (!frame) return
|
||||
armFocusReclaim()
|
||||
})
|
||||
|
||||
// Focus entering the frame blurs the top window with the frame as
|
||||
// document.activeElement — no event reaches the parent once it is inside.
|
||||
$effect(() => {
|
||||
function onWindowBlur() {
|
||||
const reclaim = focusReclaim
|
||||
if (!reclaim || !frame || document.activeElement !== frame) return
|
||||
if (frameInteracted || Date.now() > reclaim.until || !reclaim.target.isConnected) {
|
||||
focusReclaim = undefined
|
||||
return
|
||||
}
|
||||
if (!frameFocusIsRouterReset()) return
|
||||
// Deferred: focusing from inside the blur handler moves activeElement but
|
||||
// not the keyboard — the transfer into the frame is still in flight, and
|
||||
// it lands last, leaving the caret drawn here and the keys going nowhere.
|
||||
setTimeout(() => {
|
||||
if (frameInteracted || !reclaim.target.isConnected) return
|
||||
reclaim.target.focus({ preventScroll: true })
|
||||
}, 0)
|
||||
}
|
||||
window.addEventListener('blur', onWindowBlur)
|
||||
return () => window.removeEventListener('blur', onWindowBlur)
|
||||
})
|
||||
|
||||
const visibility = $derived(
|
||||
active ? 'z-10 opacity-100 pointer-events-auto' : 'z-0 opacity-0 pointer-events-none'
|
||||
)
|
||||
@@ -223,12 +306,13 @@
|
||||
{:else if mounted}
|
||||
<iframe
|
||||
bind:this={frame}
|
||||
src={withMenuHidden(tab.url, workspaceId || undefined)}
|
||||
src={iframeSrc}
|
||||
onload={(e) => {
|
||||
const f = e.currentTarget as HTMLIFrameElement
|
||||
// Re-apply after load so a toggle that happened while the frame was
|
||||
// loading (its layout read the pre-toggle preference) isn't lost.
|
||||
applyPageIframeTheme(darkMode, f)
|
||||
trackFrameInteraction(f)
|
||||
onLoad(f)
|
||||
}}
|
||||
title="Session preview: {label}"
|
||||
|
||||
Reference in New Issue
Block a user