Skip to content

Commit 3e1d81c

Browse files
committed
Drop the idle-notify wrapper and abort reservations on dispose
Esc with a visible list was notifying twice (abort microtask plus close), and occupancy comments still described overlayList instead of idle.
1 parent 72602ba commit 3e1d81c

3 files changed

Lines changed: 49 additions & 51 deletions

File tree

src/tui/product-host.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -531,11 +531,11 @@ export async function mountProductHost(config: ProductHostConfig): Promise<Produ
531531

532532
// Alt+A from the model picker: close it and open a fresh selector over
533533
// every first-class provider kind, no already-connected filtering. This
534-
// gets its own PrimaryOverlayKind opened through the same close-then-open
535-
// path openModels itself uses, rather than the palette's priorOverlay
536-
// stack — that stack exists so the palette can float over a permission or
537-
// operator question without dropping the awaited promise underneath it,
538-
// which does not apply here.
534+
// gets its own PrimaryOverlayKind opened through the same reserved
535+
// close-then-open path openModels itself uses, rather than the palette's
536+
// priorOverlay stack — that stack exists so the palette can float over a
537+
// permission or operator question without dropping the awaited promise
538+
// underneath it, which does not apply here.
539539
openAddProvider =
540540
addProviderChoices !== undefined && onConnect !== undefined
541541
? (opts?: { returnToModels?: boolean }): void => {

src/tui/shell.ts

Lines changed: 22 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1964,7 +1964,7 @@ interface ShellInternals {
19641964
overlayRawBodyText: string;
19651965
/** Snapshot when palette stacks over another primary overlay. */
19661966
priorOverlay: PriorOverlaySnapshot | null;
1967-
/** Advances whenever a new overlay takes ownership of the shared host. */
1967+
/** Advances on a new overlay taking the host, and when the host empties. */
19681968
overlayGeneration: number;
19691969
/** Optional stable ids aligned with overlayItems for the open primary. */
19701970
overlayItemIds: readonly string[];
@@ -2013,13 +2013,13 @@ interface ShellInternals {
20132013
overlayOnDispose: (() => void) | null;
20142014
/** True while the open primary is a decision gate that must not be replaced. */
20152015
overlayIsGate: boolean;
2016-
/** Fired once the shell has no overlay open, so queued gates can re-open. */
2016+
/** Fired once the overlay host is idle, so queued gates can re-open. */
20172017
overlayClosedListeners: Set<() => void>;
20182018
/**
20192019
* Command-surface open while a live overlay still holds the host. One slot;
20202020
* a newer command replaces an older one. Flushed only after that overlay
2021-
* has actually closed and the host is idle — never from notifyOverlayClosed,
2022-
* which would let wireGates drain a queued gate onto the same host.
2021+
* has actually closed and the host is idle — never from idle-notify, which
2022+
* would let wireGates drain a queued gate onto the same host.
20232023
*/
20242024
deferredCommandOverlay: OpenListOverlayOpts | null;
20252025
/** True while a microtask to flush deferredCommandOverlay is queued. */
@@ -4313,11 +4313,6 @@ function notifyOverlayClosed(shell: AppShell): void {
43134313
for (const listener of [...bag.overlayClosedListeners]) listener();
43144314
}
43154315

4316-
function notifyIfHostIdle(shell: AppShell): void {
4317-
if (!isOverlayHostIdle(shell)) return;
4318-
notifyOverlayClosed(shell);
4319-
}
4320-
43214316
/**
43224317
* Hold the overlay host idle-notify while an async command surface is still
43234318
* claiming it (permissions.list() before settings/permissions paint). Release
@@ -4337,7 +4332,7 @@ export function reserveOverlayHost(shell: AppShell): () => void {
43374332
if (!current || current.overlayReservationEpoch !== epoch) return;
43384333
if (current.overlayHostReservations > 0) current.overlayHostReservations -= 1;
43394334
scheduleDeferredCommandFlush(shell);
4340-
notifyIfHostIdle(shell);
4335+
notifyOverlayClosed(shell);
43414336
};
43424337
}
43434338

@@ -4349,8 +4344,6 @@ function abortOverlayHostReservations(shell: AppShell): void {
43494344
bag.overlayHostReservations = 0;
43504345
bag.overlayGeneration += 1;
43514346
scheduleDeferredCommandFlush(shell);
4352-
// Next tick so the same Esc cannot also dismiss a gate that this abort drains.
4353-
queueMicrotask(() => notifyIfHostIdle(shell));
43544347
}
43554348

43564349
/** One deferred command-surface slot while the host is busy. */
@@ -4387,7 +4380,7 @@ function flushDeferredCommandOverlay(shell: AppShell): void {
43874380
if (shell.overlayList !== null) return;
43884381
const opts = bag.deferredCommandOverlay;
43894382
if (opts === null) {
4390-
notifyIfHostIdle(shell);
4383+
notifyOverlayClosed(shell);
43914384
return;
43924385
}
43934386
bag.deferredCommandOverlay = null;
@@ -4482,7 +4475,7 @@ export interface OverlayContinuationToken {
44824475
readonly generation: number;
44834476
}
44844477

4485-
/** Capture overlay generation for an async continuation. Stale after a newer open or Esc during a reserved in-flight open. */
4478+
/** Capture overlay generation for an async continuation. Stale after a newer open, a full close, or Esc abort. */
44864479
export function captureOverlayContinuation(shell: AppShell): OverlayContinuationToken {
44874480
return { generation: internals.get(shell)?.overlayGeneration ?? -1 };
44884481
}
@@ -4495,7 +4488,7 @@ export function isOverlayContinuationCurrent(
44954488
return isOverlayGenerationCurrent(shell, token) && shell.overlayList === null;
44964489
}
44974490

4498-
/** True while the shell is live and no newer overlay has advanced the generation. */
4491+
/** True while the shell is live and generation has not advanced. */
44994492
export function isOverlayGenerationCurrent(
45004493
shell: AppShell,
45014494
token: OverlayContinuationToken,
@@ -5453,19 +5446,19 @@ export function openSlashCommands(shell: AppShell): boolean {
54535446

54545447
// Every keystroke lands here while the popup is already open. Closing and
54555448
// reopening released the overlay host between the two calls (closeSlashPopup
5456-
// routes through closeInsetOverlay, which fires notifyOverlayClosed) — long
5457-
// enough for a queued permission/operator gate to drain onto it. Refreshing
5458-
// the open palette in place never releases the host, so a queued gate has
5459-
// nothing to drain into. priorOverlay stacking is untouched here (it is only
5460-
// ever written by openListOverlay's stack-on-open path), so a palette
5461-
// stacked over a prior overlay keeps that snapshot across the refresh.
5449+
// routes through closeInsetOverlay, which idle-notifies) — long enough for a
5450+
// queued permission/operator gate to drain onto it. Refreshing the open
5451+
// palette in place never releases the host, so a queued gate has nothing to
5452+
// drain into. priorOverlay stacking is untouched here (it is only ever
5453+
// written by openListOverlay's stack-on-open path), so a palette stacked
5454+
// over a prior overlay keeps that snapshot across the refresh.
54625455
//
54635456
// A typo that zeroes the matches must not fall through to closeSlashPopup
5464-
// while the popup is already open — that closes through the same
5465-
// notifyOverlayClosed path and drains a queued gate mid-filter. Instead
5466-
// this refreshes in place to a "(no matches)" row, same as the general
5467-
// palette does, and holds the host until a real dismiss (deleting the `/`,
5468-
// Esc, accept) or a backspace that restores matches.
5457+
// while the popup is already open — that closes through the same idle-notify
5458+
// path and drains a queued gate mid-filter. Instead this refreshes in place
5459+
// to a "(no matches)" row, same as the general palette does, and holds the
5460+
// host until a real dismiss (deleting the `/`, Esc, accept) or a backspace
5461+
// that restores matches.
54695462
if (isSlashPopupOpen(shell) && shell.overlayKind === "palette") {
54705463
refreshSlashPopupInPlace(shell, matches);
54715464
return true;
@@ -5930,6 +5923,8 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption
59305923
if (internals.get(shell)?.overlayHostReservations) {
59315924
abortOverlayHostReservations(shell);
59325925
key.preventDefault();
5926+
// Next tick so the same Esc cannot also dismiss a gate this abort drains.
5927+
queueMicrotask(() => notifyOverlayClosed(shell));
59335928
return;
59345929
}
59355930
if (shell.observe) {
@@ -6480,11 +6475,7 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption
64806475
// release subscriptions or settle awaited cancellation exactly once.
64816476
let overlayGuard = 4;
64826477
while (shell.overlayList !== null && overlayGuard-- > 0) closeInsetOverlay(shell);
6483-
const bag = internals.get(shell);
6484-
if (bag) {
6485-
bag.overlayReservationEpoch += 1;
6486-
bag.overlayHostReservations = 0;
6487-
}
6478+
abortOverlayHostReservations(shell);
64886479
disposed = true;
64896480
shell.disposed = true;
64906481
if (wireKeys) {

src/tui/slash-popup-gate.test.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
/**
22
* CL-6699: a queued permission/operator gate must not open onto the host in
33
* the middle of a `/` command filter session. The old close-then-reopen
4-
* refresh (closeSlashPopup -> closeInsetOverlay -> notifyOverlayClosed)
5-
* released the host between the two calls, and a gate queued behind the
6-
* popup drained into that gap.
4+
* refresh released the host between the two calls (idle-notify via
5+
* onOverlayClosed), and a gate queued behind the popup drained into that gap.
76
*
87
* CL-6711: accepting a slash/palette command must not drain that same queue
98
* onto the host before dispatch has claimed it. A live gate already on the
@@ -14,11 +13,7 @@ import { describe, expect, test } from "bun:test";
1413

1514
import { withTestRenderer } from "./harness";
1615
import type { PaletteCommand } from "./command-catalog";
17-
import {
18-
openCommandSurface,
19-
type CommandSurfaceDeps,
20-
type PluginsSurfaceDeps,
21-
} from "./command-surfaces";
16+
import { openCommandSurface, type CommandSurfaceDeps } from "./command-surfaces";
2217
import { wireGates } from "./gate-wire";
2318
import { openPermissionsOverlay } from "./overlays";
2419
import {
@@ -27,6 +22,7 @@ import {
2722
closeReplaceableOverlay,
2823
createAppShell,
2924
cycleOverlaySelection,
25+
isOverlayHostIdle,
3026
isSlashPopupOpen,
3127
moveOverlaySelection,
3228
onOverlayClosed,
@@ -160,14 +156,13 @@ describe("/ popup keeps a queued gate queued across a filter refresh", () => {
160156
await withShell(async ({ shell, press }) => {
161157
const emitter = new EventEmitter();
162158
const dispose = wireGates(emitter, shell);
163-
// The host closing (onOverlayClosed) is what the queued gate waits
159+
// The host going idle (onOverlayClosed) is what the queued gate waits
164160
// on to drain — see gate-wire.ts's onOverlayClosed/pending. Under the
165161
// old close-then-reopen refresh this fires on every filter keystroke
166-
// (closeSlashPopup -> closeInsetOverlay -> notifyOverlayClosed) even
167-
// though the palette immediately re-stacks on top and every assertion
168-
// on shell.overlayKind alone sees only "palette" again by the time it
169-
// runs. Counting this call directly is what actually distinguishes
170-
// the in-place refresh from the old close+reopen one.
162+
// even though the palette immediately re-stacks on top and every
163+
// assertion on shell.overlayKind alone sees only "palette" again by
164+
// the time it runs. Counting this call directly is what actually
165+
// distinguishes the in-place refresh from the old close+reopen one.
171166
let closedCount = 0;
172167
const disposeClosedSpy = onOverlayClosed(shell, () => {
173168
closedCount++;
@@ -802,7 +797,13 @@ describe("overlay host occupancy and opt-in deferral", () => {
802797
plugins: {
803798
list: () => [],
804799
setEnabled: () => Promise.resolve(undefined),
805-
} as unknown as PluginsSurfaceDeps,
800+
saveCredentials: async () => undefined,
801+
verify: async () => ({ ok: true, message: "" }),
802+
addPath: async () => ({ ok: true, message: "" }),
803+
webProviders: () => [],
804+
currentWebProvider: () => undefined,
805+
setWebProvider: async () => undefined,
806+
},
806807
});
807808
hanging.resolve();
808809
await Promise.resolve();
@@ -913,10 +914,13 @@ describe("overlay host occupancy and opt-in deferral", () => {
913914
press("Escape");
914915
await render();
915916
await Bun.sleep(60);
917+
expect(shell.overlayKind).not.toBe("settings");
918+
expect(isOverlayHostIdle(shell)).toBe(true);
916919
resolveSecond();
917920
await Promise.resolve();
918921
await Promise.resolve();
919922
expect(shell.overlayKind).not.toBe("settings");
923+
expect(isOverlayHostIdle(shell)).toBe(true);
920924
},
921925
{
922926
onCommand: (name, shell) => {
@@ -956,16 +960,19 @@ describe("overlay host occupancy and opt-in deferral", () => {
956960
try {
957961
openHelpOverlay(shell);
958962
expect(shell.overlayKind).toBe("help");
963+
expect(isOverlayHostIdle(shell)).toBe(false);
959964

960965
let resolved: unknown;
961966
emitPermissionGate(emitter, (outcome) => {
962967
resolved = outcome;
963968
});
964969
expect(shell.overlayKind).toBe("help");
970+
expect(isOverlayHostIdle(shell)).toBe(false);
965971
expect(resolved).toBeUndefined();
966972

967973
openHelpOverlay(shell);
968974
expect(shell.overlayKind).toBe("help");
975+
expect(isOverlayHostIdle(shell)).toBe(false);
969976
expect(resolved).toBeUndefined();
970977
} finally {
971978
dispose();

0 commit comments

Comments
 (0)