feat(app): add chat outline navigation (#2792)

* 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
This commit is contained in:
Mohamed Boudra
2026-08-02 20:48:23 +08:00
committed by GitHub
parent 0ad51e26a1
commit 553ac53643
49 changed files with 3385 additions and 84 deletions
@@ -17,6 +17,8 @@ interface LongTimelineAgentOptions {
}
export interface LongTimelineAgent extends MockAgentWorkspace {
/** Every seeded prompt, oldest first, so a test can name the nth turn. */
prompts: string[];
firstOlderPagePrompt: string;
initialTailAnchorPrompt: string;
initialTailOldestPrompt: string;
@@ -60,6 +62,7 @@ interface OlderHistoryPages {
expectRepeatedEntries(): void;
expectOwnedTextRendered(text: string): Promise<void>;
releasePage(pageNumber: number): void;
requestCount(): number;
}
function promptForTurn(index: number): string {
@@ -82,6 +85,7 @@ export async function seedLongMockAgentTimeline(
return {
...agent,
prompts: Array.from({ length: options.turns }, (_unused, index) => promptForTurn(index)),
firstOlderPagePrompt: promptForTurn(Math.max(0, options.turns - 40)),
initialTailAnchorPrompt: promptForTurn(Math.max(0, options.turns - 5)),
initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)),
@@ -148,6 +152,25 @@ export async function rememberTimelinePromptPosition(
return { prompt, top: box.y };
}
export async function expectTimelinePromptLandedBelowTop(
page: Page,
prompt: string,
): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt });
await expect(promptRow).toBeVisible();
await expect
.poll(async () => {
const [timelineBox, promptBox] = await Promise.all([
timeline.boundingBox(),
promptRow.boundingBox(),
]);
if (!timelineBox || !promptBox) return Number.POSITIVE_INFINITY;
return Math.abs(promptBox.y - timelineBox.y - 8);
})
.toBeLessThanOrEqual(2);
}
export async function expectTimelinePromptPositionPreserved(
page: Page,
before: TimelinePromptPositionSnapshot,
@@ -163,6 +186,39 @@ export async function expectTimelinePromptPositionPreserved(
.toBeLessThanOrEqual(2);
}
export async function expectTimelineAtMaximumScrollWithPromptVisible(
page: Page,
prompt: string,
): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const item = timeline.getByText(prompt, { exact: true });
await expect
.poll(async () => {
const [metrics, timelineBox, promptBox] = await Promise.all([
timeline.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
return {
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
scrollTop: element.scrollTop,
};
}),
timeline.boundingBox(),
item.boundingBox(),
]);
if (!timelineBox || !promptBox) return false;
const isAtMaximumScroll =
Math.abs(metrics.scrollTop + metrics.clientHeight - metrics.scrollHeight) <= 2;
const isPromptFullyVisible =
promptBox.y >= timelineBox.y &&
promptBox.y + promptBox.height <= timelineBox.y + timelineBox.height;
return isAtMaximumScroll && isPromptFullyVisible;
})
.toBe(true);
}
export async function openAgentTimeline(
page: Page,
agent: MockAgentWorkspace,
@@ -299,6 +355,9 @@ export async function holdOlderHistoryPages(
releasePage(pageNumber) {
gate.releasePage(pageNumber);
},
requestCount() {
return gate.getRequestCount();
},
};
}