Skip to content

Commit 5c145dd

Browse files
committed
fix(hover-preview): wait for visible frames before timeout
1 parent 7131e64 commit 5c145dd

3 files changed

Lines changed: 71 additions & 19 deletions

File tree

AGENTS.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -906,10 +906,12 @@ capability result and falls back to the current resized pane rectangle, so the
906906
refresh loop does not repeat a known-failing probe.
907907

908908
The feature degrades cleanly when no SGR mouse report arrives within the
909-
bounded support window: it logs once, disables its mouse-mode lease, and leaves
910-
tracking and lifecycle observers running. Pi imports for the controller and
911-
overlay stay under `src/host/pi/hover-preview/**`; the renderer, lease, config
912-
resolver, and tmux plumbing remain host-neutral.
909+
bounded support window after the first visible frame exists: it logs once,
910+
disables its mouse-mode lease, and leaves tracking and lifecycle observers
911+
running. A slow spawn wave therefore does not spend the support window before
912+
there is anything to hover. Pi imports for the controller and overlay stay
913+
under `src/host/pi/hover-preview/**`; the renderer, lease, config resolver, and
914+
tmux plumbing remain host-neutral.
913915

914916
## Window Targeting: Stable IDs First, Names as Identity Check, Indices Never
915917

src/host/pi/hover-preview/controller.ts

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -107,26 +107,21 @@ export class HoverPreviewController {
107107
this.unsubscribeInput = this.deps.registerInputListener(handler);
108108
this.transition({ kind: "armed-on-mount" }, "mount");
109109

110-
// There is no portable positive acknowledgement for terminal mouse mode.
111-
// Keep a bounded, unref'd fallback: hosts that never deliver an SGR report
112-
// get one warning and the controller relinquishes its mode lease.
113-
this.mouseSupportTimer = setTimeout(
114-
() => {
115-
if (this.mounted && !this.disposed) this.abandonMouseSupport();
116-
},
117-
Math.max(1_000, this.cfg.leaseTtlMs),
118-
);
119-
const maybeUnref = this.mouseSupportTimer as ReturnType<
120-
typeof setTimeout
121-
> & {
122-
unref?: () => void;
123-
};
124-
maybeUnref.unref?.();
110+
// A widget can mount before the first subagent frame exists. Do not start
111+
// the bounded mouse-support fallback until there is something the pointer
112+
// can actually hover; a slow spawn wave must not consume the whole grace
113+
// period.
114+
if (this.hitMap.length > 0) this.armMouseSupportTimer();
125115
this.deps.onDebug(`[hover-preview] mounted gen=${mountedGeneration}`);
126116
}
127117

128118
updateHitMap(regions: ReadonlyArray<HitRegion>): void {
129119
this.hitMap = regions.map((region) => ({ ...region }));
120+
if (this.hitMap.length === 0) {
121+
this.clearMouseSupportTimer();
122+
} else {
123+
this.armMouseSupportTimer();
124+
}
130125
}
131126

132127
cprForFirstLine(line: string, lineIndex: number): string {
@@ -402,6 +397,34 @@ export class HoverPreviewController {
402397
}
403398
}
404399

400+
private armMouseSupportTimer(): void {
401+
if (
402+
!this.mounted ||
403+
this.disposed ||
404+
this.mouseUnsupported ||
405+
this.mouseSupportTimer !== null
406+
) {
407+
return;
408+
}
409+
410+
// There is no portable positive acknowledgement for terminal mouse mode.
411+
// Keep a bounded, unref'd fallback: hosts that never deliver an SGR report
412+
// get one warning and the controller relinquishes its mode lease. This
413+
// timer starts only once a visible frame exists (see updateHitMap()).
414+
this.mouseSupportTimer = setTimeout(
415+
() => {
416+
if (this.mounted && !this.disposed) this.abandonMouseSupport();
417+
},
418+
Math.max(1_000, this.cfg.leaseTtlMs),
419+
);
420+
const maybeUnref = this.mouseSupportTimer as ReturnType<
421+
typeof setTimeout
422+
> & {
423+
unref?: () => void;
424+
};
425+
maybeUnref.unref?.();
426+
}
427+
405428
private clearHoverTimers(): void {
406429
this.clearHoverIntentTimer();
407430
this.clearStickyTimer();

test/hover-preview-controller.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,34 @@ describe("HoverPreviewController — mount/dispose", () => {
137137
const shortCfg = { ...CFG, leaseTtlMs: 1_000 };
138138
const ctrl = new HoverPreviewController(shortCfg, deps);
139139
ctrl.mount();
140+
ctrl.updateHitMap([
141+
{ agentId: "a", topRow: 0, bottomRow: 3, leftCol: 0, rightCol: 23 },
142+
]);
143+
await vi.advanceTimersByTimeAsync(1_000);
144+
expect(deps.notifyCalls).toHaveLength(1);
145+
expect(deps.listener.handlers).toHaveLength(0);
146+
await ctrl.dispose();
147+
vi.useRealTimers();
148+
});
149+
150+
it("does not spend the mouse-support grace period before frames exist", async () => {
151+
vi.useFakeTimers();
152+
const shortCfg = { ...CFG, leaseTtlMs: 1_000 };
153+
const ctrl = new HoverPreviewController(shortCfg, deps);
154+
ctrl.mount();
155+
140156
await vi.advanceTimersByTimeAsync(1_000);
157+
expect(deps.notifyCalls).toHaveLength(0);
158+
expect(deps.listener.handlers).toHaveLength(1);
159+
160+
ctrl.updateHitMap([
161+
{ agentId: "a", topRow: 0, bottomRow: 3, leftCol: 0, rightCol: 23 },
162+
]);
163+
await vi.advanceTimersByTimeAsync(999);
164+
expect(deps.notifyCalls).toHaveLength(0);
165+
expect(deps.listener.handlers).toHaveLength(1);
166+
167+
await vi.advanceTimersByTimeAsync(1);
141168
expect(deps.notifyCalls).toHaveLength(1);
142169
expect(deps.listener.handlers).toHaveLength(0);
143170
await ctrl.dispose();

0 commit comments

Comments
 (0)