Skip to content

Commit 5a72181

Browse files
authored
Merge pull request #59 from softpudding/feat/recording-dnd-slider
Bundle keyframes into event_detail; recover stalled compiler agent
2 parents bb93236 + a06ca82 commit 5a72181

File tree

14 files changed

+634
-128
lines changed

14 files changed

+634
-128
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,7 @@ reference/
7676
eval/.server_port
7777
eval/.locks/
7878
eval/output
79+
80+
# Claude Code local state
81+
.claude/
82+
CLAUDE.local.md

extension/src/__tests__/recording-keyframe-policy.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ describe('recording keyframe policy', () => {
4646
const options = getRecordingKeyframeCaptureOptions();
4747

4848
expect(options.preferredFormat).toBe('jpeg');
49-
expect(options.maxOutputWidth).toBe(960);
50-
expect(options.maxOutputHeight).toBe(540);
49+
expect(options.maxOutputWidth).toBe(1920);
50+
expect(options.maxOutputHeight).toBe(1080);
5151
expect(options.warmupBeforeCapture).toBe(true);
5252
expect(options.settleBeforeCapture).toBe(true);
5353
});
@@ -57,8 +57,8 @@ describe('recording keyframe policy', () => {
5757

5858
expect(getRecordingPreActionWaitForRender()).toBe(0);
5959
expect(options.preferredFormat).toBe('jpeg');
60-
expect(options.maxOutputWidth).toBe(960);
61-
expect(options.maxOutputHeight).toBe(540);
60+
expect(options.maxOutputWidth).toBe(1920);
61+
expect(options.maxOutputHeight).toBe(1080);
6262
expect(options.warmupBeforeCapture).toBe(false);
6363
expect(options.settleBeforeCapture).toBe(false);
6464
});

extension/src/content/index.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ let scrollTimeoutId: number | null = null;
1919
const CONTAINER_TEXT_MAX_LENGTH = 280;
2020
const ELEMENT_HTML_MAX_LENGTH = 1500;
2121

22+
// Drag-and-drop recording state
23+
const DRAG_DISTANCE_THRESHOLD = 30;
24+
let pendingDrag: {
25+
sourceElement: Element;
26+
startX: number;
27+
startY: number;
28+
} | null = null;
29+
let dragConfirmedByHtml5 = false;
30+
let suppressNextClickRecording = false;
31+
2232
function isOpenBrowserUiPage(): boolean {
2333
if (
2434
window.location.protocol !== 'http:' &&
@@ -537,6 +547,14 @@ function findOwningForm(element: Element): HTMLFormElement | null {
537547
return element instanceof HTMLFormElement ? element : element.closest('form');
538548
}
539549

550+
function isSliderLikeElement(el: Element): boolean {
551+
if (el instanceof HTMLInputElement && el.type === 'range') {
552+
return true;
553+
}
554+
const role = el.getAttribute('role');
555+
return role === 'slider' && el.hasAttribute('aria-valuemin');
556+
}
557+
540558
function installRecordingListeners(): void {
541559
document.addEventListener(
542560
'pointerdown',
@@ -553,6 +571,13 @@ function installRecordingListeners(): void {
553571
return;
554572
}
555573

574+
pendingDrag = {
575+
sourceElement: event.target,
576+
startX: pointerEvent.clientX,
577+
startY: pointerEvent.clientY,
578+
};
579+
dragConfirmedByHtml5 = false;
580+
556581
const form = findOwningForm(event.target);
557582
sendRecordingPreAction('click', {
558583
element: serializeElement(event.target),
@@ -564,9 +589,97 @@ function installRecordingListeners(): void {
564589
true,
565590
);
566591

592+
// HTML5 drag-and-drop detection
593+
document.addEventListener(
594+
'dragstart',
595+
(event) => {
596+
if (!shouldRecordTrustedEvent(event) || !pendingDrag) {
597+
return;
598+
}
599+
dragConfirmedByHtml5 = true;
600+
},
601+
true,
602+
);
603+
604+
document.addEventListener(
605+
'drop',
606+
(event) => {
607+
if (
608+
!shouldRecordTrustedEvent(event) ||
609+
!isElement(event.target) ||
610+
!pendingDrag ||
611+
!dragConfirmedByHtml5
612+
) {
613+
return;
614+
}
615+
616+
sendRecordingEvent('drag_and_drop', {
617+
sourceElement: serializeElement(pendingDrag.sourceElement),
618+
targetElement: serializeElement(event.target),
619+
startX: pendingDrag.startX,
620+
startY: pendingDrag.startY,
621+
endX: (event as DragEvent).clientX,
622+
endY: (event as DragEvent).clientY,
623+
});
624+
suppressNextClickRecording = true;
625+
pendingDrag = null;
626+
dragConfirmedByHtml5 = false;
627+
},
628+
true,
629+
);
630+
631+
document.addEventListener(
632+
'dragend',
633+
() => {
634+
// Cleanup for cancelled HTML5 drags where drop never fired
635+
pendingDrag = null;
636+
dragConfirmedByHtml5 = false;
637+
},
638+
true,
639+
);
640+
641+
// Pointer-based drag detection (for non-HTML5 DnD libraries)
642+
document.addEventListener(
643+
'pointerup',
644+
(event) => {
645+
if (!pendingDrag || dragConfirmedByHtml5) {
646+
pendingDrag = null;
647+
return;
648+
}
649+
650+
if (!shouldRecordTrustedEvent(event) || !isElement(event.target)) {
651+
pendingDrag = null;
652+
return;
653+
}
654+
655+
const dx = (event as PointerEvent).clientX - pendingDrag.startX;
656+
const dy = (event as PointerEvent).clientY - pendingDrag.startY;
657+
const distance = Math.sqrt(dx * dx + dy * dy);
658+
659+
if (distance > DRAG_DISTANCE_THRESHOLD) {
660+
sendRecordingEvent('drag_and_drop', {
661+
sourceElement: serializeElement(pendingDrag.sourceElement),
662+
targetElement: serializeElement(event.target),
663+
startX: pendingDrag.startX,
664+
startY: pendingDrag.startY,
665+
endX: (event as PointerEvent).clientX,
666+
endY: (event as PointerEvent).clientY,
667+
});
668+
suppressNextClickRecording = true;
669+
}
670+
671+
pendingDrag = null;
672+
},
673+
true,
674+
);
675+
567676
document.addEventListener(
568677
'click',
569678
(event) => {
679+
if (suppressNextClickRecording) {
680+
suppressNextClickRecording = false;
681+
return;
682+
}
570683
if (!shouldRecordTrustedEvent(event) || !isElement(event.target)) {
571684
return;
572685
}
@@ -598,6 +711,15 @@ function installRecordingListeners(): void {
598711
return;
599712
}
600713

714+
// Suppress per-frame input events from range sliders — the final
715+
// value is captured by the change handler as a set_slider event.
716+
if (
717+
event.target instanceof HTMLInputElement &&
718+
event.target.type === 'range'
719+
) {
720+
return;
721+
}
722+
601723
sendRecordingEvent('input', {
602724
element: serializeElement(event.target),
603725
});
@@ -612,6 +734,26 @@ function installRecordingListeners(): void {
612734
return;
613735
}
614736

737+
if (isSliderLikeElement(event.target)) {
738+
const el = event.target;
739+
sendRecordingEvent('set_slider', {
740+
element: serializeElement(el),
741+
value:
742+
el instanceof HTMLInputElement
743+
? el.value
744+
: el.getAttribute('aria-valuenow'),
745+
min:
746+
el instanceof HTMLInputElement
747+
? el.min
748+
: el.getAttribute('aria-valuemin'),
749+
max:
750+
el instanceof HTMLInputElement
751+
? el.max
752+
: el.getAttribute('aria-valuemax'),
753+
});
754+
return;
755+
}
756+
615757
sendRecordingEvent('change', {
616758
element: serializeElement(event.target),
617759
});

extension/src/recording/keyframe-annotation.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { calculateConfirmationBannerLayout } from '../commands/single-highlight';
22

3-
type AnnotatableRecordingEventType = 'click' | 'focus' | 'change' | 'submit';
3+
type AnnotatableRecordingEventType =
4+
| 'click'
5+
| 'focus'
6+
| 'change'
7+
| 'submit'
8+
| 'drag_and_drop'
9+
| 'set_slider';
410

511
interface RecordingEventBBox {
612
x: number;
@@ -11,7 +17,7 @@ interface RecordingEventBBox {
1117

1218
interface RecordingKeyframeAnnotationTarget {
1319
bbox: RecordingEventBBox;
14-
intendedAction: 'click' | 'keyboard_input';
20+
intendedAction: 'click' | 'keyboard_input' | 'drag_and_drop' | 'set_slider';
1521
message: string;
1622
}
1723

@@ -57,7 +63,14 @@ function getEventTargetRecord(
5763
eventType: AnnotatableRecordingEventType,
5864
eventData: Record<string, unknown>,
5965
): Record<string, unknown> | null {
60-
const candidate = eventType === 'submit' ? eventData.form : eventData.element;
66+
let candidate: unknown;
67+
if (eventType === 'submit') {
68+
candidate = eventData.form;
69+
} else if (eventType === 'drag_and_drop') {
70+
candidate = eventData.targetElement;
71+
} else {
72+
candidate = eventData.element;
73+
}
6174

6275
return isObjectRecord(candidate) ? candidate : null;
6376
}
@@ -77,6 +90,14 @@ export function getRecordingAnnotationMessage(
7790
return 'This is the input the user just focused.';
7891
}
7992

93+
if (eventType === 'drag_and_drop') {
94+
return 'This is where the user dropped the dragged element.';
95+
}
96+
97+
if (eventType === 'set_slider') {
98+
return 'This is the slider the user adjusted.';
99+
}
100+
80101
return 'This is the element the user just typed into.';
81102
}
82103

@@ -88,7 +109,9 @@ export function resolveRecordingKeyframeAnnotationTarget(
88109
eventType !== 'click' &&
89110
eventType !== 'focus' &&
90111
eventType !== 'change' &&
91-
eventType !== 'submit'
112+
eventType !== 'submit' &&
113+
eventType !== 'drag_and_drop' &&
114+
eventType !== 'set_slider'
92115
) {
93116
return null;
94117
}
@@ -103,9 +126,20 @@ export function resolveRecordingKeyframeAnnotationTarget(
103126
return null;
104127
}
105128

129+
let intendedAction: RecordingKeyframeAnnotationTarget['intendedAction'];
130+
if (eventType === 'click') {
131+
intendedAction = 'click';
132+
} else if (eventType === 'drag_and_drop') {
133+
intendedAction = 'drag_and_drop';
134+
} else if (eventType === 'set_slider') {
135+
intendedAction = 'set_slider';
136+
} else {
137+
intendedAction = 'keyboard_input';
138+
}
139+
106140
return {
107141
bbox,
108-
intendedAction: eventType === 'click' ? 'click' : 'keyboard_input',
142+
intendedAction,
109143
message: getRecordingAnnotationMessage(eventType),
110144
};
111145
}

extension/src/recording/keyframe-policy.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import type { ScreenshotCaptureOptions } from '../utils/highlight-screenshot';
22

3-
const ACTION_KEYFRAME_EVENT_TYPES = new Set(['click', 'change', 'submit']);
3+
const ACTION_KEYFRAME_EVENT_TYPES = new Set([
4+
'click',
5+
'change',
6+
'submit',
7+
'drag_and_drop',
8+
'set_slider',
9+
]);
410
const DEFAULT_RECORDING_KEYFRAME_WAIT_MS = 180;
511
const ACTION_RECORDING_KEYFRAME_WAIT_MS = 60;
612
const PRE_ACTION_RECORDING_KEYFRAME_WAIT_MS = 0;
13+
const AFTER_ACTION_RECORDING_KEYFRAME_WAIT_MS = 400;
714
const RECORDING_KEYFRAME_CAPTURE_OPTIONS: ScreenshotCaptureOptions = {
815
preferredFormat: 'jpeg',
9-
maxOutputWidth: 960,
10-
maxOutputHeight: 540,
16+
maxOutputWidth: 1920,
17+
maxOutputHeight: 1080,
1118
warmupBeforeCapture: true,
1219
warmupMaxAttempts: 1,
1320
settleBeforeCapture: true,
@@ -17,8 +24,8 @@ const RECORDING_KEYFRAME_CAPTURE_OPTIONS: ScreenshotCaptureOptions = {
1724
const PRE_ACTION_RECORDING_KEYFRAME_CAPTURE_OPTIONS: ScreenshotCaptureOptions =
1825
{
1926
preferredFormat: 'jpeg',
20-
maxOutputWidth: 960,
21-
maxOutputHeight: 540,
27+
maxOutputWidth: 1920,
28+
maxOutputHeight: 1080,
2229
warmupBeforeCapture: false,
2330
settleBeforeCapture: false,
2431
};
@@ -106,6 +113,14 @@ export function getRecordingPreActionCaptureOptions(): ScreenshotCaptureOptions
106113
return { ...PRE_ACTION_RECORDING_KEYFRAME_CAPTURE_OPTIONS };
107114
}
108115

116+
export function shouldCaptureAfterKeyframe(eventType: string): boolean {
117+
return ACTION_KEYFRAME_EVENT_TYPES.has(eventType);
118+
}
119+
120+
export function getRecordingAfterKeyframeWaitForRender(): number {
121+
return AFTER_ACTION_RECORDING_KEYFRAME_WAIT_MS;
122+
}
123+
109124
export function shouldDiscardPostCaptureRecordingKeyframe(
110125
eventType: string,
111126
eventData: Record<string, unknown>,

0 commit comments

Comments
 (0)