diff --git a/packages/web/e2e/outline.spec.mjs b/packages/web/e2e/outline.spec.mjs index 56a6340..d1d0b6e 100644 --- a/packages/web/e2e/outline.spec.mjs +++ b/packages/web/e2e/outline.spec.mjs @@ -4,10 +4,15 @@ * tick rail (it measures the free margin live and hides itself when a docked panel eats * the room — also asserted here). * - * Flow: three exchanges in one session (the mock answers the first with + * 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), - * then a fresh session whose FIRST message is "slow stream test" for a 40-line tool output - * long enough to scroll inside. + * 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"; @@ -22,9 +27,11 @@ test.use({ viewport: { width: 1440, height: 860 } }); /** Reply completion marker: every mock turn-2 ends with this exact sentence. */ const REPLY = "Command finished"; -test("minimap ticks + hover preview + jump, sticky group header, ArrowUp history recall", async ({ - page, -}) => { +/** + * 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; @@ -43,8 +50,7 @@ test("minimap ticks + hover preview + jump, sticky group header, ArrowUp history }, }); expect(put.ok(), "put models").toBeTruthy(); - - const newSession = async () => { + 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" }, @@ -52,31 +58,62 @@ test("minimap ticks + hover preview + jump, sticky group header, ArrowUp history ).json(); return res.session.sessionId; }; - const ta = page.getByPlaceholder(/输入消息/); - const send = 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 }, - ); - }; +} - // --- session 1: three exchanges -> outline entries, jump, scrollspy, history --- +/** 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); - // 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. + // 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(3); + 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(() => { @@ -85,7 +122,7 @@ test("minimap ticks + hover preview + jump, sticky group header, ArrowUp history }); await expect(page.locator("[data-outline-tick][aria-current]")).toHaveAttribute( "aria-label", - /第 3 轮/, + /第 5 轮/, ); await expect(card).toHaveCount(0); @@ -110,19 +147,19 @@ test("minimap ticks + hover preview + jump, sticky group header, ArrowUp history 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); closing the - // panel restores the rail (live measurement, not a breakpoint). + // 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); - const menuButton = page.getByRole("button", { name: "对话索引" }); await menuButton.click(); const menuEntries = page.locator("[data-outline-menu-entry]"); - await expect(menuEntries).toHaveCount(3); + 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(3); + await expect(ticks).toHaveCount(5); await expect(menuButton).toHaveCount(0); // Phone-narrow: no rail either, the toolbar index instead; the agents-panel button @@ -132,14 +169,26 @@ test("minimap ticks + hover preview + jump, sticky group header, ArrowUp history await expect(menuButton).toBeVisible(); await expect(page.getByText("智能体面板")).toBeHidden(); await page.setViewportSize({ width: 1440, height: 860 }); - await expect(ticks).toHaveCount(3); + 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 expect(ta).toHaveValue("第五问:回顾结论"); await page.keyboard.press("ArrowUp"); - await expect(ta).toHaveValue("第二问:运行检查"); + await expect(ta).toHaveValue("第四问:整理清单"); // ↓ walks forward and past the newest restores the (empty) draft. await page.keyboard.press("ArrowDown"); await page.keyboard.press("ArrowDown"); @@ -149,7 +198,7 @@ test("minimap ticks + hover preview + jump, sticky group header, ArrowUp history await ta.press("End"); await page.keyboard.type(",补充"); await page.keyboard.press("ArrowUp"); - await expect(ta).toHaveValue("第三问:总结结果,补充"); + await expect(ta).toHaveValue("第五问:回顾结论,补充"); await ta.fill(""); // --- session 2: long tool output -> sticky header --- @@ -198,3 +247,58 @@ test("minimap ticks + hover preview + jump, sticky group header, ArrowUp history 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); +}); diff --git a/packages/web/src/features/chat/conversation-outline.tsx b/packages/web/src/features/chat/conversation-outline.tsx index 4002514..aafebf0 100644 --- a/packages/web/src/features/chat/conversation-outline.tsx +++ b/packages/web/src/features/chat/conversation-outline.tsx @@ -10,15 +10,22 @@ * hit-transparent (pointer events only on the ticks), so wheel scrolling anywhere in * the gutter keeps scrolling the stream. Rendered into MessageStream's relative wrapper * via its `outline` slot, so the rail spans exactly the stream area — never the composer. + * Long conversations don't stack every turn: the rail renders a sliding window of ticks + * around the reading position (`windowOutline`, its width sized to the measured rail + * height so the stack can never spill over the toolbar or composer), tiny edge dots + * standing in for the turns hidden past it, and labels keep their global turn numbers. * * - `OutlineMenuButton` — the fallback for when the rail cannot show: a toolbar icon * button (top right) opening a dropdown index of the same entries, tap to jump. Phones * are the primary case (no hover pointer, no gutter), but it also covers a desktop * window whose gutter a docked panel has eaten — navigation stays reachable either way. + * The dropdown list scrolls, so it stays complete — no windowing here. * * Which shape shows is the owner's call via `useOutlineRailFit`: the rail needs a * hover-capable pointer and a live-measured gutter (ResizeObserver — window resizes and * panel drags both count), and the menu button renders exactly when the rail cannot. + * Below OUTLINE_MIN_TURNS turns neither shape renders at all — that short a conversation + * needs no index. * * Jump targets are the [data-outline-anchor] wrappers MessageItems stamps at the top * level only, queried scoped to the stream's scroll container (item ids repeat across @@ -33,7 +40,13 @@ import { S } from "../../lib/strings"; import { Dropdown } from "../../components/ui/dropdown"; import { GlyphIcon } from "../../components/ui/glyph-icon"; import type { OutlineEntry } from "./outline-model"; -import { previewText } from "./outline-model"; +import { + OUTLINE_MIN_TURNS, + previewText, + railTickPitch, + railWindowHalf, + windowOutline, +} from "./outline-model"; /** Panel-with-list glyph (24×24 line path) for the toolbar menu button. */ const OUTLINE_ICON = @@ -52,16 +65,12 @@ const FLASH_MS = 1000; */ const GUTTER_MIN_PX = 56; -/** The stream column cap the gutter derives from (Tailwind max-w-3xl). */ -const COLUMN_MAX_PX = 768; +/** The stream column cap the gutter derives from (Tailwind max-w-3xl = 48rem). */ +const COLUMN_MAX_REM = 48; /** The rail's hover-preview interaction needs a pointer that can hover. */ const HOVER_QUERY = "(hover: hover) and (pointer: fine)"; -/** Tick pitch bounds (px): compress toward MIN as turns outgrow the rail, never past hoverability. */ -const TICK_PITCH_MAX = 12; -const TICK_PITCH_MIN = 5; - export interface OutlineRailFit { /** Whether the rail can show: hover-capable pointer AND a wide-enough measured gutter. */ shown: boolean; @@ -96,7 +105,11 @@ export function useOutlineRailFit( const el = scrollRef.current; if (!el) return; const measure = () => { - const gutter = (el.clientWidth - COLUMN_MAX_PX) / 2; + // The column cap is rem-based: resolve it against the live root font size, so + // browser font scaling (which widens the real column without touching the + // container) can't leave the rail sitting on top of the prose. + const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + const gutter = (el.clientWidth - COLUMN_MAX_REM * rem) / 2; setFit({ gutterOk: gutter >= GUTTER_MIN_PX, height: el.clientHeight }); }; measure(); @@ -156,6 +169,26 @@ function jumpToAnchor(container: HTMLElement | null, id: number): void { flashTimer = window.setTimeout(() => anchor.classList.remove("outline-flash"), FLASH_MS); } +/** + * Edge hint for the rail's sliding window: a tiny vertical ellipsis of dots where turns + * are hidden past the window. Purely decorative and non-interactive — it never re-enables + * pointer events inside the hit-transparent overlay, and it hides from assistive tech + * (the ticks' global turn numbers already tell that story). + */ +function RailOverflowMark({ edge }: { edge: "above" | "below" }) { + return ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ); +} + /** The turn's reply preview for a card/menu row: text, an "answering" pulse for the newest running turn, or "". */ function answerPreview( entry: OutlineEntry, @@ -194,7 +227,7 @@ export function ConversationOutline({ // cheap, and keying on version also re-binds after the stream remounts on a session switch. useEffect(() => { const el = scrollRef.current; - if (!fit.shown || !el || entries.length === 0) return; + if (!fit.shown || !el || entries.length < OUTLINE_MIN_TURNS) return; const ids = new Set(entries.map((entry) => entry.anchorId)); let raf: number | null = null; const compute = () => { @@ -215,7 +248,7 @@ export function ConversationOutline({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [fit.shown, scrollRef, entries.length, version]); - if (!fit.shown || entries.length === 0) return null; + if (!fit.shown || entries.length < OUTLINE_MIN_TURNS) return null; /** Tick center Y relative to the rail overlay (the preview card anchors to it, clamped in render). */ const tickTop = (e: MouseEvent | FocusEvent) => { @@ -223,14 +256,26 @@ export function ConversationOutline({ return rect.top + rect.height / 2 - (navRef.current?.getBoundingClientRect().top ?? 0); }; - // Compress the pitch as turns outgrow the rail (~32px breathing room); past ~140 turns - // at minimum pitch the stack simply clips — a conversation that long stopped being - // scannable by any other means well before the map does. - const pitch = Math.max( - TICK_PITCH_MIN, - Math.min(TICK_PITCH_MAX, Math.floor((fit.height - 32) / entries.length)), + // The rail renders a sliding window around the reading position, not every turn: an + // unbounded stack would outgrow the rail (and creep over the toolbar and composer) no + // matter how hard the pitch compresses. The half-width adapts to the measured rail + // height so the stack always fits; `start` is the windowed ticks' global offset — + // labels and active tracking stay in global turn numbers. + const half = railWindowHalf(fit.height); + const { start, end } = windowOutline( + entries.length, + entries.findIndex((en) => en.anchorId === activeId), + half, + half, ); - const hovered = hover === null ? null : (entries.find((en) => en.anchorId === hover.id) ?? null); + const visible = entries.slice(start, end); + + // Pitch compresses toward MIN as the window outgrows the rail; railWindowHalf already + // sized the window so the stack (plus edge dots) fits fit.height at minimum pitch. + const pitch = railTickPitch(fit.height, visible.length); + // Looked up in the windowed slice: a tick the window slid away from mid-hover unmounts + // without a mouseleave, and its card must not linger. + const hovered = hover === null ? null : (visible.find((en) => en.anchorId === hover.id) ?? null); const cardAnswer = hovered === null ? "" : answerPreview(hovered, entries, running, 160); return ( @@ -242,8 +287,12 @@ export function ConversationOutline({ aria-label={S.chat.outlineTitle} className="pointer-events-none absolute inset-y-0 left-0 z-10 flex flex-col items-start justify-center" > -
- {entries.map((entry, i) => { + {/* max-h + clip is a safety net only: the height-adaptive window keeps the stack + inside the rail by construction, and this guarantees a mismeasure still can't + push ticks (which take pointer events) out over the toolbar or composer. */} +
+ {start > 0 && } + {visible.map((entry, i) => { const active = entry.anchorId === activeId; return ( ); })} + {end < entries.length && }
{/* Preview card for the hovered/focused tick only — never mounted at rest. Purely a tooltip: hit-transparent (moving the mouse toward it can't trap hover) and hidden @@ -327,7 +382,8 @@ export function OutlineMenuButton({ }) { const [open, setOpen] = useState(false); const [activeId, setActiveId] = useState(null); - if (entries.length === 0) return null; + // Same gate as the rail: with this few turns neither shape earns its place. + if (entries.length < OUTLINE_MIN_TURNS) return null; const setOpenComputing = (next: boolean) => { if (next && scrollRef.current) { diff --git a/packages/web/src/features/chat/outline-model.ts b/packages/web/src/features/chat/outline-model.ts index 601983f..08e2512 100644 --- a/packages/web/src/features/chat/outline-model.ts +++ b/packages/web/src/features/chat/outline-model.ts @@ -1,7 +1,8 @@ /** * Conversation outline data (pure logic, unit-testable): reduces the stream items to one * entry per exchange — the user's question plus a truncated plain-text preview of the - * assistant's reply — for the left quick-jump index. + * assistant's reply — for the left quick-jump index. Also owns the outline's display + * math: the minimum-turns gate both shapes share and the tick rail's sliding window. * * Entry boundaries: a turn opens at a user prompt (user_text / user_image) and collects * every assistant_text that follows until the next prompt. Consecutive user items merge @@ -28,6 +29,67 @@ export interface OutlineEntry { /** Answer accumulation cap: enough for any preview length while keeping rebuilds O(entries) cheap. */ const ANSWER_CAP = 500; +/** + * Visibility gate shared by both outline shapes (tick rail and toolbar dropdown): below + * this many turns the whole conversation is a flick of the wheel away, and an index would + * be chrome without navigation value. + */ +export const OUTLINE_MIN_TURNS = 5; + +/** Rail window half-widths: at most this many ticks render before/after the active one. */ +export const OUTLINE_WINDOW_BEFORE = 20; +export const OUTLINE_WINDOW_AFTER = 20; + +/** + * The slice of entries the tick rail renders: a sliding window of at most + * `before + 1 + after` ticks kept centered on the active entry, shifted — never shrunk — + * at the edges (at the bottom of a long conversation the window is simply the last + * `before + 1 + after` turns). No active entry yet (null / -1) parks the window at the + * END, where a conversation opens and where new turns appear. Bounds are indices into the + * full entries array (`end` exclusive): callers slice with them and must keep labeling + * ticks by GLOBAL index — the window moves which ticks exist, never what they are. + */ +export function windowOutline( + entryCount: number, + activeIndex: number | null, + before: number = OUTLINE_WINDOW_BEFORE, + after: number = OUTLINE_WINDOW_AFTER, +): { start: number; end: number } { + const size = before + 1 + after; + if (entryCount <= size) return { start: 0, end: entryCount }; + if (activeIndex === null || activeIndex < 0 || activeIndex >= entryCount) { + return { start: entryCount - size, end: entryCount }; + } + const start = Math.min(Math.max(0, activeIndex - before), entryCount - size); + return { start, end: start + size }; +} + +/** Tick pitch bounds (px): compress toward MIN as the window outgrows the rail, never past hoverability. */ +export const TICK_PITCH_MAX = 12; +export const TICK_PITCH_MIN = 5; + +/** Vertical allowance (px) the tick stack keeps free inside the rail: the edge dots plus breathing room. */ +const RAIL_STACK_ALLOWANCE = 48; + +/** Tick pitch (px) for `count` ticks in a `height`-px rail: MAX, compressed toward MIN as the stack outgrows it. */ +export function railTickPitch(height: number, count: number): number { + return Math.max( + TICK_PITCH_MIN, + Math.min(TICK_PITCH_MAX, Math.floor((height - RAIL_STACK_ALLOWANCE) / Math.max(1, count))), + ); +} + +/** + * Height-adaptive window half-width: at most OUTLINE_WINDOW_BEFORE/AFTER, shrunk until + * the whole window fits the measured rail at minimum pitch. A short rail (small window, + * split screen) thus shows fewer turns instead of letting the tick stack spill over the + * toolbar and composer. One symmetric value, since the default half-widths are equal. + */ +export function railWindowHalf(height: number): number { + const fits = Math.floor((height - RAIL_STACK_ALLOWANCE) / TICK_PITCH_MIN); + return Math.min(OUTLINE_WINDOW_BEFORE, Math.max(0, Math.floor((fits - 1) / 2))); +} + export function buildOutline(items: readonly ChatItem[]): OutlineEntry[] { const out: OutlineEntry[] = []; let current: OutlineEntry | null = null; diff --git a/packages/web/test/outline-model.test.ts b/packages/web/test/outline-model.test.ts index 7ae6387..0cab3cd 100644 --- a/packages/web/test/outline-model.test.ts +++ b/packages/web/test/outline-model.test.ts @@ -1,13 +1,23 @@ /** * outline-model.ts unit tests: turn segmentation into outline entries (merge of adjacent - * user items, banner/goal-round handling, answer accumulation) and the plain-text preview - * reduction. + * user items, banner/goal-round handling, answer accumulation), the plain-text preview + * reduction, and the tick rail's sliding-window bounds. */ import { describe, expect, it } from "vitest"; import type { ChatItem } from "../src/lib/omni/stream-model"; import { handoffMessage } from "../src/features/chat/agent-handoff"; import { buildScheduledMessage } from "@prismshadow/penguin-core/markers"; -import { buildOutline, previewText } from "../src/features/chat/outline-model"; +import { + OUTLINE_WINDOW_AFTER, + OUTLINE_WINDOW_BEFORE, + TICK_PITCH_MAX, + TICK_PITCH_MIN, + buildOutline, + previewText, + railTickPitch, + railWindowHalf, + windowOutline, +} from "../src/features/chat/outline-model"; let nextId = 0; const user = (text: string): ChatItem => ({ kind: "user_text", id: nextId++, text }); @@ -90,6 +100,68 @@ describe("buildOutline", () => { }); }); +describe("windowOutline", () => { + /** The default window size the components render at most (20 + active + 20). */ + const SIZE = OUTLINE_WINDOW_BEFORE + 1 + OUTLINE_WINDOW_AFTER; + + it("covers everything while the window isn't outgrown", () => { + expect(windowOutline(SIZE, 3)).toEqual({ start: 0, end: SIZE }); + expect(windowOutline(5, null)).toEqual({ start: 0, end: 5 }); + expect(windowOutline(0, null)).toEqual({ start: 0, end: 0 }); + }); + + it("parks at the end without an active entry (null or -1): the newest turns show first", () => { + expect(windowOutline(100, null)).toEqual({ start: 100 - SIZE, end: 100 }); + expect(windowOutline(100, -1)).toEqual({ start: 100 - SIZE, end: 100 }); + }); + + it("centers on the active entry and recenters as it moves", () => { + expect(windowOutline(100, 50)).toEqual({ start: 30, end: 71 }); + expect(windowOutline(100, 51)).toEqual({ start: 31, end: 72 }); + }); + + it("shifts (never shrinks) at both edges, keeping the full window", () => { + // Near the start: still SIZE entries from index 0. + expect(windowOutline(100, 0)).toEqual({ start: 0, end: SIZE }); + expect(windowOutline(100, OUTLINE_WINDOW_BEFORE)).toEqual({ start: 0, end: SIZE }); + expect(windowOutline(100, OUTLINE_WINDOW_BEFORE + 1)).toEqual({ start: 1, end: SIZE + 1 }); + // Near the end: the last SIZE entries (the active one included). + expect(windowOutline(100, 99)).toEqual({ start: 100 - SIZE, end: 100 }); + expect(windowOutline(100, 99 - OUTLINE_WINDOW_AFTER)).toEqual({ start: 100 - SIZE, end: 100 }); + expect(windowOutline(100, 98 - OUTLINE_WINDOW_AFTER)).toEqual({ start: 99 - SIZE, end: 99 }); + }); + + it("honors custom half-widths", () => { + expect(windowOutline(10, 5, 1, 2)).toEqual({ start: 4, end: 8 }); + expect(windowOutline(10, 0, 1, 2)).toEqual({ start: 0, end: 4 }); + expect(windowOutline(10, 9, 1, 2)).toEqual({ start: 6, end: 10 }); + }); +}); + +describe("rail fit (pitch and height-adaptive window half-width)", () => { + it("keeps the full half-width on a normal-height rail and shrinks it on short ones", () => { + expect(railWindowHalf(800)).toBe(OUTLINE_WINDOW_BEFORE); + expect(railWindowHalf(200)).toBe(14); // floor(((200-48)/5 - 1) / 2) + expect(railWindowHalf(60)).toBe(0); + expect(railWindowHalf(0)).toBe(0); + }); + + it("compresses the pitch toward MIN as ticks outgrow the rail, never past it", () => { + expect(railTickPitch(860, 41)).toBe(TICK_PITCH_MAX); + expect(railTickPitch(500, 5)).toBe(TICK_PITCH_MAX); + expect(railTickPitch(253, 41)).toBe(TICK_PITCH_MIN); + expect(railTickPitch(100, 41)).toBe(TICK_PITCH_MIN); // clamped, not floor's 1 + }); + + it("the windowed stack always fits inside the rail (the overlap-with-composer guard)", () => { + for (let height = 60; height <= 1200; height += 7) { + const count = 2 * railWindowHalf(height) + 1; + // Ticks plus the two edge-dot marks (~36px worst case) stay within the rail. + expect(count * railTickPitch(height, count) + 36).toBeLessThanOrEqual(height); + } + }); +}); + describe("previewText", () => { it("flattens markdown to one plain line", () => { const md =