Alt-click focus behavior (#3872)

* fix(web-shared): keep Alt+hover working after selecting a span

Chrome's Alt menu-bar handling blurred the page and cleared the overlay
flag, which also killed span hover until the window was refocused.

* test(web-shared): drop useAltHeld unit tests

* refactor(web-shared): let useAltHeld own Alt tracking

Drop the event union, reducer, and timeline mouse-move graft. The hook
listens for keys, blur, and window pointermove itself and returns only
altHeld.

* style(web-shared): trim useAltHeld comment

* fix(web-shared): keep Alt gap overlay when nothing is selected

pointermove was copying e.altKey, so Chrome's post-preventDefault
pointermove with altKey false cleared the flag on Alt press.

* Revert "fix(web-shared): keep Alt gap overlay when nothing is selected"

This reverts commit ba142906f7.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Mitul Shah
2026-08-28 14:36:11 -04:00
committed by GitHub
parent 88f5d214d4
commit 4d5852b901
3 changed files with 62 additions and 15 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@workflow/web-shared': patch
---
Keep the trace viewer Alt+hover delta overlay working after a span click by stopping Chrome from stealing Alt.
@@ -0,0 +1,54 @@
'use client';
import { useEffect, useState } from 'react';
function isAltModifierKey(key: string): boolean {
return key === 'Alt' || key === 'AltGraph';
}
function isTypingTarget(target: EventTarget | null): boolean {
return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
);
}
/**
* Whether Alt/Option is held, for the timeline delta overlay.
*
* preventDefault stops Chrome from taking the menu bar (which kills hover).
* Capture-phase keys; pointermove recovers if keydown was missed; blur
* clears after Alt+Tab.
*/
export function useAltHeld(): { altHeld: boolean } {
const [altHeld, setAltHeld] = useState(false);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent): void => {
if (!isAltModifierKey(e.key)) return;
if (!isTypingTarget(e.target)) {
e.preventDefault();
}
setAltHeld(true);
};
const onKeyUp = (e: KeyboardEvent): void => {
if (isAltModifierKey(e.key)) setAltHeld(false);
};
const onBlur = (): void => setAltHeld(false);
const onPointerMove = (e: PointerEvent): void => setAltHeld(e.altKey);
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('keyup', onKeyUp, true);
window.addEventListener('blur', onBlur);
window.addEventListener('pointermove', onPointerMove, true);
return () => {
window.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('keyup', onKeyUp, true);
window.removeEventListener('blur', onBlur);
window.removeEventListener('pointermove', onPointerMove, true);
};
}, []);
return { altHeld };
}
@@ -27,6 +27,7 @@ import {
type TimelineHover,
} from './components/timeline';
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
import { useAltHeld } from './components/use-alt-held';
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
import { ActiveSpanProvider, useActiveSpan } from './context';
import { searchSpans } from './search';
@@ -370,29 +371,16 @@ function TraceViewerContent({
[setActiveSpan, scrollSpanIntoView, cancelPendingZoom, focusViewportOnSpan]
);
const [altHeld, setAltHeld] = useState(false);
const { altHeld } = useAltHeld();
useEffect(() => {
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
handleClearActiveSpan();
} else if (e.key === 'Alt') {
setAltHeld(true);
}
};
const onKeyUp = (e: KeyboardEvent): void => {
if (e.key === 'Alt') setAltHeld(false);
};
const onBlur = (): void => setAltHeld(false);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
};
return () => window.removeEventListener('keydown', onKeyDown);
}, [handleClearActiveSpan]);
const timelineRef = useRef<HTMLDivElement>(null);