* feat(app): add chat outline navigation Long agent sessions need prompt navigation without coupling viewport movement to disposable server windows. Keep fetched timeline pages in client state so repeated jumps stay local, and settle prompt jumps against virtualized row measurement changes. * fix(app): preserve chat outline timeline coverage * fix(app): reconcile outline window submissions * fix(app): harden outline timeline navigation
242 lines
8.4 KiB
TypeScript
242 lines
8.4 KiB
TypeScript
import { expect, test } from "../support/fixtures";
|
|
import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate";
|
|
import {
|
|
clickChatOutlineRowEdge,
|
|
disableChatOutlineFromAppearance,
|
|
expectActiveChatOutlinePrompt,
|
|
expectChatOutlinePreview,
|
|
expectChatOutlinePrompts,
|
|
expectChatOutlineAlignedWithActiveTabGlyph,
|
|
expectChatOutlinePromptToRemainBare,
|
|
expectLiveTurnPromptAboveFoldAndActive,
|
|
expectNoChatOutlinePreviewWhileCrossingToSidebar,
|
|
expectNoChatOutline,
|
|
expectNoChatOutlinePreview,
|
|
expectOneActiveChatOutlinePrompt,
|
|
focusChatOutlinePrompt,
|
|
hoverChatOutlinePrompt,
|
|
chatOutlineRail,
|
|
movePointerOffChatOutline,
|
|
pointAtChatOutlineRowEdge,
|
|
pressEnterOnFocusedPrompt,
|
|
splitCurrentPanelRight,
|
|
} from "../support/helpers/chat-outline";
|
|
import {
|
|
expectTimelineAtMaximumScrollWithPromptVisible,
|
|
expectTimelinePromptNotMounted,
|
|
expectTimelinePromptLandedBelowTop,
|
|
expectTimelinePromptVisible,
|
|
holdOlderHistoryPages,
|
|
openAgentTimeline,
|
|
scrollThroughOlderHistoryPages,
|
|
scrollTimelineToNewestLoadedEdge,
|
|
scrollTimelineToOldestLoadedEdge,
|
|
seedLongMockAgentTimeline,
|
|
type LongTimelineAgent,
|
|
} from "../support/helpers/timeline-pagination";
|
|
|
|
const WIDE_VIEWPORT = { width: 1280, height: 900 };
|
|
const LOADED_TURNS = 16;
|
|
|
|
test.describe("desktop chat outline", () => {
|
|
test("indexes unloaded prompts and jumps with one bounded merged page", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
|
try {
|
|
const jumps = await trackPromptJumpRequests(page, agent.agentId);
|
|
await page.setViewportSize(WIDE_VIEWPORT);
|
|
await openAgentTimeline(page, agent);
|
|
|
|
const rail = chatOutlineRail(page);
|
|
await expectChatOutlinePrompts(page, 80);
|
|
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
|
|
|
await rail.getByRole("tab").nth(39).click();
|
|
await expectTimelinePromptLandedBelowTop(page, agent.prompts[39]);
|
|
await expect.poll(() => jumps.requests()).toHaveLength(1);
|
|
expect(jumps.requests()[0]).toMatchObject({ limit: 40, mergeWindow: true });
|
|
|
|
await page.getByTestId("scroll-to-bottom-button").click();
|
|
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
|
|
|
const requestsBeforeLoadedJump = jumps.requests().length;
|
|
await rail.getByRole("tab").last().click();
|
|
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
|
expect(jumps.requests()).toHaveLength(requestsBeforeLoadedJump);
|
|
|
|
await rail.getByRole("tab").first().click();
|
|
await expectTimelinePromptVisible(page, agent.oldestPrompt);
|
|
await agent.client.sendAgentMessage(agent.agentId, "live prompt while viewing old history");
|
|
await agent.client.waitForFinish(agent.agentId, 15_000);
|
|
await expectTimelinePromptVisible(page, agent.oldestPrompt);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test.describe("with the whole conversation loaded", () => {
|
|
let agent: LongTimelineAgent;
|
|
|
|
test.beforeAll(async () => {
|
|
test.setTimeout(120_000);
|
|
agent = await seedLongMockAgentTimeline({ turns: LOADED_TURNS });
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await agent.cleanup();
|
|
});
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.setViewportSize(WIDE_VIEWPORT);
|
|
await openAgentTimeline(page, agent);
|
|
await expectChatOutlinePrompts(page, LOADED_TURNS);
|
|
});
|
|
|
|
test("previews the prompt under the pointer, anywhere across its row", async ({ page }) => {
|
|
await hoverChatOutlinePrompt(page, 2);
|
|
await expectChatOutlinePreview(page, agent.prompts[1]);
|
|
|
|
await pointAtChatOutlineRowEdge(page, 7);
|
|
await expectChatOutlinePreview(page, agent.prompts[6]);
|
|
|
|
await movePointerOffChatOutline(page);
|
|
await expectNoChatOutlinePreview(page);
|
|
});
|
|
|
|
test("does not preview while crossing the rail toward the sidebar", async ({ page }) => {
|
|
await expectNoChatOutlinePreviewWhileCrossingToSidebar(page);
|
|
});
|
|
|
|
test("closes the preview when the pointer leaves after a jump", async ({ page }) => {
|
|
await clickChatOutlineRowEdge(page, 4);
|
|
await movePointerOffChatOutline(page);
|
|
|
|
await expectNoChatOutlinePreview(page);
|
|
});
|
|
|
|
test("previews and jumps to the focused prompt from the keyboard", async ({ page }) => {
|
|
await focusChatOutlinePrompt(page, 1);
|
|
await expectChatOutlinePreview(page, agent.prompts[0]);
|
|
|
|
await pressEnterOnFocusedPrompt(page);
|
|
await expectTimelinePromptLandedBelowTop(page, agent.oldestPrompt);
|
|
await expectActiveChatOutlinePrompt(page, 1);
|
|
});
|
|
|
|
test("moves the active mark as the reader jumps and scrolls", async ({ page }) => {
|
|
await expectOneActiveChatOutlinePrompt(page);
|
|
|
|
await clickChatOutlineRowEdge(page, 4);
|
|
await expectTimelinePromptVisible(page, agent.prompts[3]);
|
|
await expectActiveChatOutlinePrompt(page, 4);
|
|
|
|
await scrollTimelineToOldestLoadedEdge(page);
|
|
await expectActiveChatOutlinePrompt(page, 1);
|
|
});
|
|
|
|
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
|
|
await clickChatOutlineRowEdge(page, 4);
|
|
|
|
await expectChatOutlinePromptToRemainBare(page, 4);
|
|
});
|
|
|
|
test("aligns the ticks with the active tab glyph rail", async ({ page }) => {
|
|
await expectChatOutlineAlignedWithActiveTabGlyph(page);
|
|
});
|
|
|
|
test("clamps the newest prompt at maximum scroll while keeping it visible", async ({
|
|
page,
|
|
}) => {
|
|
await clickChatOutlineRowEdge(page, LOADED_TURNS);
|
|
|
|
await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.newestPrompt);
|
|
});
|
|
|
|
test("keeps the current prompt active throughout a long live assistant turn", async ({
|
|
page,
|
|
}) => {
|
|
const prompt = "chat outline live turn reading position";
|
|
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
|
try {
|
|
await expectLiveTurnPromptAboveFoldAndActive(page, prompt, LOADED_TURNS + 1);
|
|
} finally {
|
|
await agent.client.waitForFinish(agent.agentId, 15_000);
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe("with virtualized loaded history", () => {
|
|
let agent: LongTimelineAgent;
|
|
|
|
test.beforeAll(async () => {
|
|
test.setTimeout(120_000);
|
|
agent = await seedLongMockAgentTimeline({ turns: 80 });
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await agent.cleanup();
|
|
});
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.setViewportSize(WIDE_VIEWPORT);
|
|
});
|
|
|
|
test("pins a loaded prompt inside the virtualized block without fetching", async ({ page }) => {
|
|
const history = await holdOlderHistoryPages(page, agent);
|
|
await openAgentTimeline(page, agent);
|
|
await expectChatOutlinePrompts(page, 80);
|
|
await scrollThroughOlderHistoryPages(page, 2, history);
|
|
await scrollTimelineToNewestLoadedEdge(page);
|
|
const requestsBeforeJump = history.requestCount();
|
|
|
|
await clickChatOutlineRowEdge(page, 30);
|
|
await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]);
|
|
|
|
expect(history.requestCount()).toBe(requestsBeforeJump);
|
|
});
|
|
|
|
test("pins a newer loaded prompt when jumping downward from old history", async ({ page }) => {
|
|
const history = await holdOlderHistoryPages(page, agent);
|
|
await openAgentTimeline(page, agent);
|
|
await expectChatOutlinePrompts(page, 80);
|
|
await scrollThroughOlderHistoryPages(page, 2, history);
|
|
await scrollTimelineToNewestLoadedEdge(page);
|
|
|
|
await clickChatOutlineRowEdge(page, 30);
|
|
await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]);
|
|
|
|
await clickChatOutlineRowEdge(page, 60);
|
|
|
|
await expectTimelinePromptLandedBelowTop(page, agent.prompts[59]);
|
|
});
|
|
});
|
|
|
|
test("hides the rail when a split makes its panel narrow", async ({ page }) => {
|
|
const agent = await seedLongMockAgentTimeline({ turns: 2 });
|
|
try {
|
|
await page.setViewportSize(WIDE_VIEWPORT);
|
|
await openAgentTimeline(page, agent);
|
|
|
|
await splitCurrentPanelRight(page);
|
|
|
|
await expectNoChatOutline(page);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("can be disabled from Appearance settings", async ({ page }) => {
|
|
const agent = await seedLongMockAgentTimeline({ turns: 2 });
|
|
try {
|
|
await page.setViewportSize(WIDE_VIEWPORT);
|
|
await openAgentTimeline(page, agent);
|
|
|
|
await disableChatOutlineFromAppearance(page);
|
|
|
|
await expectNoChatOutline(page);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
});
|