fix(web/session): don't stopPropagation on drawer panel — breaks player clicks

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.
This commit is contained in:
2026-04-24 10:35:11 -04:00
parent 6d031ae18c
commit df67cb8a46

View File

@@ -136,61 +136,14 @@ const SessionDrawer: React.FC<SessionDrawerProps> = ({ 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<SessionDrawerProps> = ({ decky, sid, fields, onClo
return (
<div
onClick={onClose}
// Close only on actual backdrop clicks. The previous design put
// onClick={onClose} here + onClick={stopPropagation} on the
// panel — but React's stopPropagation also aborts the NATIVE
// event, which broke asciinema-player's click-to-play because
// the player attaches its click handler via document-level
// delegation (the event never reached it).
onClick={(e) => { 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<SessionDrawerProps> = ({ 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)',