From df67cb8a460fcc12f32b92b537039f151f462807 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 24 Apr 2026 10:35:11 -0400 Subject: [PATCH] =?UTF-8?q?fix(web/session):=20don't=20stopPropagation=20o?= =?UTF-8?q?n=20drawer=20panel=20=E2=80=94=20breaks=20player=20clicks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drawer used onClick={onClose} on the backdrop + onClick={e => e.stopPropagation()} on the panel to stop inside-clicks from closing the drawer. That pattern is fine for most React trees, but React's stopPropagation() also aborts the NATIVE DOM event — and asciinema- player wires its click-to-play handler via document-level event delegation. So every click inside the drawer (including the big play button) died at the panel boundary and never reached the player's dispatcher. Confirmed end-to-end by calling window.__ap. play() directly from DevTools: playback started, cast rendered in full, ended event fired. Swap to the idiomatic target===currentTarget guard on the backdrop so only genuine backdrop clicks close the drawer; everything inside (including native-delegated handlers) gets its events untouched. All the debug instrumentation from b5c6b8a, 4424138, 6d031ae, and f032ece (cast logging, lifecycle listeners, window.__ap) is reverted here — symptom root-cause is known, it was event delegation not the parser or the cast. --- decnet_web/src/components/SessionDrawer.tsx | 62 ++++----------------- 1 file changed, 10 insertions(+), 52 deletions(-) diff --git a/decnet_web/src/components/SessionDrawer.tsx b/decnet_web/src/components/SessionDrawer.tsx index 615bc43e..e0bdc56d 100644 --- a/decnet_web/src/components/SessionDrawer.tsx +++ b/decnet_web/src/components/SessionDrawer.tsx @@ -136,61 +136,14 @@ const SessionDrawer: React.FC = ({ decky, sid, fields, onClo playerInstance.current = null; } const cast = buildCastBlob(header, playable); - // One-time diagnostic: when the player silently refuses to play, the - // cast text itself is usually the culprit. Log the first chunk so - // "yes, the header renders correctly" is a one-F12 check. - console.debug( - 'asciinema cast (first 400 chars):', - cast.slice(0, 400), - `| events=${playable.length} | cols=${header.width} rows=${header.height}`, - ); try { - const p = AsciinemaPlayer.create( + playerInstance.current = AsciinemaPlayer.create( { data: cast }, playerContainer.current, - { - fit: 'width', - terminalFontSize: '12px', - // Force parse up front. Without this the recording is only - // parsed on the user's click-to-play, and any parse failure - // there is invisible to our lifecycle instrumentation above. - preload: true, - }, + { fit: 'width', terminalFontSize: '12px' }, ); - playerInstance.current = p; - // The player's init() is async; any failure there bypasses the - // sync try/catch above and lands as an unhandled rejection. - // Hook every lifecycle event so we can see which state it - // actually ends up in ("loading" / "ended" / "errored" / etc). - // metadata is the one that carries the parsed duration, fires - // AFTER _initializeDriver (which does the actual cast parse). - // playing / idle tell us whether the timer ever advanced. - const events_to_hook = [ - 'ready', 'metadata', 'play', 'playing', 'pause', 'idle', - 'ended', 'error', 'errored', 'loading', 'reset', 'seeked', - ]; - for (const evt of events_to_hook) { - try { - p.addEventListener?.(evt, (...args: unknown[]) => - console.debug(`asciinema-player event: ${evt}`, ...args), - ); - } catch { /* addEventListener may not support this event name */ } - } - // getDuration() resolves once the recording is parsed. If it - // resolves to 0 or NaN we know the parser produced an empty - // events stream despite the cast looking well-formed. - p.getDuration?.().then( - (d: number) => console.debug('asciinema-player duration:', d), - (err: unknown) => console.error('asciinema-player getDuration failed:', err), - ); - // DEBUG: expose the live instance on window so the operator can - // poke it from DevTools — window.__ap.play() bypasses the UI - // click-handler chain entirely and tells us whether playback - // would advance if the button click actually reached core.play. - (window as unknown as { __ap: unknown }).__ap = p; - console.debug('asciinema-player instance → window.__ap'); } catch (err) { - console.error('asciinema-player failed to mount (sync):', err); + console.error('asciinema-player failed to mount', err); } return () => { if (playerInstance.current) { @@ -207,7 +160,13 @@ const SessionDrawer: React.FC = ({ decky, sid, fields, onClo return (
{ if (e.target === e.currentTarget) onClose(); }} style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.6)', @@ -219,7 +178,6 @@ const SessionDrawer: React.FC = ({ decky, sid, fields, onClo ref={panelRef} role="dialog" aria-modal="true" - onClick={(e) => e.stopPropagation()} style={{ width: 'min(920px, 100%)', height: '100%', backgroundColor: 'var(--bg-color, #0d1117)',