Files
oh-my-pi/packages/coding-agent/test/modes/components/session-selector-scroll-stability.test.ts
T
roboomp e266782604 fix(session-selector): swap delete dialog into SessionList slot
Earlier rounds shrank the SessionList by the dialog's row count to keep
the picker inside the viewport, but the SessionList could only claw back
whole session rows and bottomed out at zero entries. On a narrow
terminal with a long session title the dialog still wrapped past what
the SessionList could free, the picker overflowed the viewport, and the
TUI committed the header into native scrollback.

The picker now hosts the SessionList inside a single contentSlot
Container. Opening the delete confirmation swaps the dialog INTO that
slot (replacing the SessionList); closing it swaps the SessionList back.
The dialog therefore competes only with the SessionList's rendered
budget, not with the SessionList AND the picker chrome, so the picker
frame stays bounded by terminalRows even when the dialog wraps to many
rows. SessionList's external-reserve plumbing is no longer needed and is
removed.

Addresses PR #3285 second-round review feedback.
2026-06-23 00:05:53 +00:00

167 lines
6.0 KiB
TypeScript

import { beforeAll, describe, expect, it } from "bun:test";
import { SessionSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/session-selector";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { SessionInfo } from "@oh-my-pi/pi-coding-agent/session/session-listing";
import { TUI } from "@oh-my-pi/pi-tui";
import { StressRenderScheduler } from "../../../../tui/test/render-stress-scheduler";
import { VirtualTerminal } from "../../../../tui/test/virtual-terminal";
beforeAll(() => {
initTheme();
});
function makeSessions(count: number): SessionInfo[] {
return Array.from({ length: count }, (_, i) => ({
path: `/work/SESSION_${i}.jsonl`,
id: `id-${i}`,
cwd: "/work",
title: `SESSION_${i}`,
created: new Date("2024-01-01T00:00:00Z"),
modified: new Date("2024-01-02T00:00:00Z"),
messageCount: 1,
size: 1024,
firstMessage: `body content ${i}`,
allMessagesText: `body content ${i}`,
}));
}
describe("issue #3283: /resume picker scrolls down after deleting a session", () => {
it("keeps the picker header pinned at the same viewport row before and after a delete", async () => {
const term = new VirtualTerminal(80, 24, 4096);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const selector = new SessionSelectorComponent(
makeSessions(20),
() => {},
() => {},
() => {},
{
getTerminalRows: () => term.rows,
onDelete: async () => true,
},
);
selector.setOnRequestRender(() => tui.requestRender());
tui.addChild(selector);
tui.setFocus(selector);
try {
tui.start();
await scheduler.drain(term);
const headerRowBefore = term.getViewport().findIndex(row => Bun.stripANSI(row).includes("Resume Session"));
expect(headerRowBefore).toBeGreaterThanOrEqual(0);
// Press Delete (CSI 3 ~) to open the confirmation dialog, then
// Enter to accept "Yes".
selector.handleInput("\x1b[3~");
tui.requestRender();
await scheduler.drain(term);
selector.handleInput("\n");
// onDelete is async; let its microtasks flush before draining renders.
for (let i = 0; i < 8; i++) await Promise.resolve();
await scheduler.drain(term);
const viewport = term.getViewport().map(row => Bun.stripANSI(row).trimEnd());
const headerRowAfter = viewport.findIndex(row => row.includes("Resume Session"));
// Regression: dialog growing the frame and then shrinking must
// not push the picker header further down into the viewport
// (committed scrollback rows from the dialog frame).
expect(headerRowAfter).toBeGreaterThanOrEqual(0);
expect(headerRowAfter).toBe(headerRowBefore);
} finally {
tui.stop();
await term.flush();
}
});
it("keeps the picker header pinned even when the delete dialog is canceled", async () => {
const term = new VirtualTerminal(80, 24, 4096);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const selector = new SessionSelectorComponent(
makeSessions(20),
() => {},
() => {},
() => {},
{
getTerminalRows: () => term.rows,
onDelete: async () => true,
},
);
selector.setOnRequestRender(() => tui.requestRender());
tui.addChild(selector);
tui.setFocus(selector);
try {
tui.start();
await scheduler.drain(term);
const headerRowBefore = term.getViewport().findIndex(row => Bun.stripANSI(row).includes("Resume Session"));
expect(headerRowBefore).toBeGreaterThanOrEqual(0);
// Open dialog, then Esc to cancel without deleting.
selector.handleInput("\x1b[3~");
tui.requestRender();
await scheduler.drain(term);
selector.handleInput("\x1b");
await scheduler.drain(term);
const viewport = term.getViewport().map(row => Bun.stripANSI(row).trimEnd());
const headerRowAfter = viewport.findIndex(row => row.includes("Resume Session"));
expect(headerRowAfter).toBe(headerRowBefore);
// Dialog gone, no scroll-down artefact.
expect(viewport.some(row => row.includes("Delete session?"))).toBe(false);
} finally {
tui.stop();
await term.flush();
}
});
it("keeps the picker frame bounded on a narrow terminal where the dialog title wraps", () => {
// Direct structural contract: even on a small terminal with a
// session name that wraps the dialog past any plausible fixed
// reserve, the picker's total rendered output stays within the
// terminal height. The dialog REPLACES the SessionList inside
// the picker frame, so its rows compete only with the
// SessionList's rendered budget — not with both the SessionList
// AND picker chrome (PR #3285 second review round). Without the
// structural fix the dialog could push the picker past the
// viewport once the SessionList reserve could not shrink any
// further, and the TUI committed the header into scrollback.
const longName = "a-very-very-very-very-long-session-title-that-must-wrap-on-a-narrow-terminal";
const sessions: SessionInfo[] = [
{
path: `/work/${longName}.jsonl`,
id: "id-long",
cwd: "/work",
title: longName,
created: new Date("2024-01-01T00:00:00Z"),
modified: new Date("2024-01-02T00:00:00Z"),
messageCount: 1,
size: 1024,
firstMessage: longName,
allMessagesText: longName,
},
...makeSessions(10),
];
const NARROW_WIDTH = 30;
const TERMINAL_ROWS = 24;
const selector = new SessionSelectorComponent(
sessions,
() => {},
() => {},
() => {},
{ getTerminalRows: () => TERMINAL_ROWS, onDelete: async () => true },
);
// Baseline: picker fits the viewport before the dialog opens.
expect(selector.render(NARROW_WIDTH).length).toBeLessThanOrEqual(TERMINAL_ROWS);
// Open the delete confirmation; the dialog takes the SessionList
// slot inside the picker frame. The picker's rendered total must
// STILL fit the viewport even though the dialog wraps past the
// previous 12-row reserve guess.
selector.handleInput("\x1b[3~");
expect(selector.render(NARROW_WIDTH).length).toBeLessThanOrEqual(TERMINAL_ROWS);
});
});