/** * Conversation minimap + sticky work-group header + composer input history (the three * chat-navigation features), on a viewport whose stream gutter is wide enough for the * tick rail (it measures the free margin live and hides itself when a docked panel eats * the room — also asserted here). * * Flow (test 1): five exchanges in one session (the mock answers the first with * thinking + exec_command and later ones with plain text — hasToolResult is history-wide), * crossing the outline's five-turn visibility gate on the way — at four turns neither the * rail nor the toolbar fallback renders — then a fresh session whose FIRST message is * "slow stream test" for a 40-line tool output long enough to scroll inside. * * Test 2 drives one session to 45 exchanges to watch the rail's sliding window: at most * 20 ticks either side of the reading position, global turn numbers intact, ellipsis dots * standing in for the turns hidden past an edge. */ import { test, expect } from "@playwright/test"; import { provisionAndLogin } from "./auth.mjs"; const BASE = process.env.BASE_URL; const MOCK = process.env.MOCK_URL; const U = "outlineuser"; const P = "password123"; test.use({ viewport: { width: 1440, height: 860 } }); /** Reply completion marker: every mock turn-2 ends with this exact sentence. */ const REPLY = "Command finished"; /** * Provision the shared e2e user, point the project's default model at the mock LLM, and * return a factory for fresh sessions in that project (idempotent — both tests call it). */ async function setup(page) { await provisionAndLogin(page.request, U, P); const projects = await (await page.request.get(`${BASE}/api/projects`)).json(); const projectId = projects.projects[0].projectId; const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, { data: { defaultModel: { provider: "custom", modelId: "claude-4-8" }, models: [ { provider: "custom", modelId: "claude-4-8", apiKey: "sk-mock", baseUrl: MOCK, contextWindow: 200000, }, ], }, }); expect(put.ok(), "put models").toBeTruthy(); return async () => { const res = await ( await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, { data: { provider: "custom", modelId: "claude-4-8", approvalMode: "allow-all" }, }) ).json(); return res.session.sessionId; }; } /** Send a message and wait until the body carries `replies` completed mock replies. */ const sender = (page, ta) => async (text, replies) => { await ta.click(); await ta.fill(text); await page.keyboard.press("Enter"); await page.waitForFunction( ([marker, want]) => document.body.innerText.split(marker).length - 1 >= want, [REPLY, replies], { timeout: 60000 }, ); }; test("minimap ticks + hover preview + jump, five-turn gate, sticky group header, ArrowUp history recall", async ({ page, }) => { const newSession = await setup(page); const ta = page.getByPlaceholder(/输入消息/); const send = sender(page, ta); // --- session 1: five exchanges -> visibility gate, outline entries, jump, scrollspy, history --- await page.goto(`${BASE}/chat/${await newSession()}`); await ta.waitFor(); await send("第一问:项目结构", 1); await send("第二问:运行检查", 2); await send("第三问:总结结果", 3); await send("第四问:整理清单", 4); // Below five turns neither outline shape renders: no rail ticks even though the gutter // fits them… const ticks = page.locator("[data-outline-tick]"); const menuButton = page.getByRole("button", { name: "对话索引" }); await expect(ticks).toHaveCount(0); // …and no toolbar fallback either while a docked panel eats the gutter. Wait until the // stream really is too narrow for the rail (the exact condition the fallback keys on) // plus a paint, so the button's absence proves the gate — not a panel still opening. await page.getByRole("button", { name: "打开工作区" }).click(); await page.waitForFunction(() => { const c = document.querySelector("[data-outline-anchor]")?.closest(".overflow-y-auto"); return c ? (c.clientWidth - 768) / 2 < 56 : false; }); await page.evaluate( () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))), ); await expect(menuButton).toHaveCount(0); await page.getByRole("button", { name: "打开工作区" }).click(); // The fifth exchange crosses the gate: one tick per exchange in the gutter minimap; // auto-follow parked the stream at the bottom, so the newest exchange is the active // tick. Message text is NOT duplicated into the DOM at rest — the preview card exists // only while hovering. Five entries don't outgrow the rail window: no overflow dots. await send("第五问:回顾结论", 5); const card = page.locator("[data-outline-card]"); await expect(ticks).toHaveCount(5); await expect(page.locator("[data-outline-overflow]")).toHaveCount(0); // Park at the live bottom explicitly before asserting "bottom → newest tick active": // that mapping is the semantic under test, not auto-follow's timing under load. await page.evaluate(() => { const c = document.querySelector("[data-outline-anchor]").closest(".overflow-y-auto"); c.scrollTop = c.scrollHeight; }); await expect(page.locator("[data-outline-tick][aria-current]")).toHaveAttribute( "aria-label", /第 5 轮/, ); await expect(card).toHaveCount(0); // Hovering a tick pops the preview card (question bold + truncated reply); leaving unmounts it. await ticks.first().hover(); await expect(card).toContainText("第一问:项目结构"); await expect(card).toContainText(REPLY); await ta.hover(); await expect(card).toHaveCount(0); // Clicking a tick jumps the stream to that turn and moves the active tick. await ticks.first().click(); await expect(page.locator("[data-outline-tick][aria-current]")).toHaveAttribute( "aria-label", /第 1 轮/, ); const jumpDelta = await page.evaluate(() => { const container = document.querySelector("[data-outline-anchor]").closest(".overflow-y-auto"); const first = document.querySelector("[data-outline-anchor]"); return first.getBoundingClientRect().top - container.getBoundingClientRect().top; }); expect(Math.abs(jumpDelta)).toBeLessThan(40); // The rail lives in the free gutter: a docked panel that eats the slack hides it and // the index moves to the toolbar dropdown (navigation stays reachable, and the dropdown // lists ALL entries — no windowing there); closing the panel restores the rail (live // measurement, not a breakpoint). await page.getByRole("button", { name: "打开工作区" }).click(); await expect(ticks).toHaveCount(0); await menuButton.click(); const menuEntries = page.locator("[data-outline-menu-entry]"); await expect(menuEntries).toHaveCount(5); await expect(menuEntries.first()).toContainText("第一问:项目结构"); await menuEntries.nth(2).click(); // jump and close await expect(menuEntries).toHaveCount(0); await page.getByRole("button", { name: "打开工作区" }).click(); await expect(ticks).toHaveCount(5); await expect(menuButton).toHaveCount(0); // Phone-narrow: no rail either, the toolbar index instead; the agents-panel button // drops to icon-only below sm (same rule as the workspace button). await page.setViewportSize({ width: 420, height: 820 }); await expect(ticks).toHaveCount(0); await expect(menuButton).toBeVisible(); await expect(page.getByText("智能体面板")).toBeHidden(); await page.setViewportSize({ width: 1440, height: 860 }); await expect(ticks).toHaveCount(5); // Scaled root font (browser font-size preference): the max-w-3xl column is rem-based, // so at 20px root font it becomes 960px — on a 1000px stream a fixed 768px assumption // would still call the gutter wide enough and leave the ticks ON the prose. The fit // must track the real column width: rail hidden, toolbar fallback in its place. await page.evaluate(() => (document.documentElement.style.fontSize = "20px")); await page.setViewportSize({ width: 1000, height: 860 }); await expect(ticks).toHaveCount(0); await expect(menuButton).toBeVisible(); await page.evaluate(() => (document.documentElement.style.fontSize = "")); await page.setViewportSize({ width: 1440, height: 860 }); await expect(ticks).toHaveCount(5); // ↑ walks back through this session's inputs, newest first; a second ↑ goes older. await ta.click(); await page.keyboard.press("ArrowUp"); await expect(ta).toHaveValue("第五问:回顾结论"); await page.keyboard.press("ArrowUp"); await expect(ta).toHaveValue("第四问:整理清单"); // ↓ walks forward and past the newest restores the (empty) draft. await page.keyboard.press("ArrowDown"); await page.keyboard.press("ArrowDown"); await expect(ta).toHaveValue(""); // Editing a recalled entry ends navigation: ↑ then goes back to caret movement. await page.keyboard.press("ArrowUp"); await ta.press("End"); await page.keyboard.type(",补充"); await page.keyboard.press("ArrowUp"); await expect(ta).toHaveValue("第五问:回顾结论,补充"); await ta.fill(""); // --- session 2: long tool output -> sticky header --- await page.goto(`${BASE}/chat/${await newSession()}`); await ta.waitFor(); await send("slow stream test", 1); // Expand the settled group, then the tool card with the 40-line output. const header = page.locator("[data-group-header]"); await expect(header).toHaveCount(1); await header.click(); await page.locator("button[aria-expanded]").filter({ hasText: "exec_command" }).first().click(); await expect(page.getByText("line 40")).toBeVisible(); // Scrolled into the middle of the tool output, the two sticky levels stack: the group // header flush at the scrollport top (-top-4 cancels the container's own py-4), and the // tool row pinned right below it (top-4 = the header's offset plus its height) — the bar // directly above the content is the section being read, never a skipped level. const stuck = await page.evaluate(() => { const container = document.querySelector("[data-outline-anchor]").closest(".overflow-y-auto"); const head = document.querySelector("[data-group-header]"); const card = head.parentElement; container.scrollTop = card.offsetTop + 400; const ct = container.getBoundingClientRect().top; const row = [...document.querySelectorAll("button[aria-expanded]")].find((b) => b.textContent.includes("exec_command"), ); return { delta: Math.abs(head.getBoundingClientRect().top - ct), rowDelta: row.getBoundingClientRect().top - ct, headerHeight: head.getBoundingClientRect().height, cardAboveFold: card.getBoundingClientRect().top < ct, }; }); expect(stuck.delta).toBeLessThan(2); expect(Math.abs(stuck.rowDelta - stuck.headerHeight)).toBeLessThan(2); // stacked right below expect(stuck.cardAboveFold).toBeTruthy(); // Collapsing from the stuck header lands the view back on the group, not on unrelated content. await header.click(); const landed = await page.evaluate(() => { const container = document.querySelector("[data-outline-anchor]").closest(".overflow-y-auto"); const card = document.querySelector("[data-group-header]").parentElement; return card.getBoundingClientRect().top - container.getBoundingClientRect().top; }); expect(landed).toBeGreaterThan(-5); expect(landed).toBeLessThan(300); }); test("the tick rail windows to the turns around the reading position on long conversations", async ({ page, }) => { // 45 sequential exchanges against a 90s default budget: give the loop room. Only the // first send takes the mock's two-round tool path (hasToolResult is history-wide), so // the other 44 are single-round text replies. test.setTimeout(300_000); const TURNS = 45; const WINDOW = 41; // 20 ticks before + the active one + 20 after const newSession = await setup(page); await page.goto(`${BASE}/chat/${await newSession()}`); const ta = page.getByPlaceholder(/输入消息/); const send = sender(page, ta); await ta.waitFor(); for (let n = 1; n <= TURNS; n++) await send(`第 ${n} 问`, n); // Parked at the live bottom the active turn is the newest, and the rail shows the LAST // 41 turns: labels keep their GLOBAL numbers (the first visible tick is turn 5, not a // renumbered turn 1) and the ellipsis dots mark the turns hidden above — none below. const ticks = page.locator("[data-outline-tick]"); await page.evaluate(() => { const c = document.querySelector("[data-outline-anchor]").closest(".overflow-y-auto"); c.scrollTop = c.scrollHeight; }); await expect(ticks).toHaveCount(WINDOW); await expect(page.locator("[data-outline-tick][aria-current]")).toHaveAttribute( "aria-label", /第 45 轮/, ); await expect(ticks.first()).toHaveAttribute("aria-label", /第 5 轮/); await expect(ticks.last()).toHaveAttribute("aria-label", /第 45 轮/); await expect(page.locator('[data-outline-overflow="above"]')).toHaveCount(1); await expect(page.locator('[data-outline-overflow="below"]')).toHaveCount(0); // Jumping to the earliest visible tick (turn 5) recenters the window at the start: // turns 1–41 render and the hidden turns move below the window. await ticks.first().click(); await expect(page.locator("[data-outline-tick][aria-current]")).toHaveAttribute( "aria-label", /第 5 轮/, ); await expect(ticks.first()).toHaveAttribute("aria-label", /第 1 轮/); await expect(ticks.last()).toHaveAttribute("aria-label", /第 41 轮/); await expect(page.locator('[data-outline-overflow="above"]')).toHaveCount(0); await expect(page.locator('[data-outline-overflow="below"]')).toHaveCount(1); // The toolbar dropdown (shown once the docked panel hides the rail) still lists EVERY // turn — the window is rail-only; the dropdown list scrolls instead. await page.getByRole("button", { name: "打开工作区" }).click(); await expect(ticks).toHaveCount(0); await page.getByRole("button", { name: "对话索引" }).click(); await expect(page.locator("[data-outline-menu-entry]")).toHaveCount(TURNS); });