feat(web): window the conversation outline rail and gate it to 5+ turns (#190)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0f58744432
commit
23a39dab15
4 files changed
+353
-59
No files matched your search
@@ -4,10 +4,15 @@
|
|||||||
* tick rail (it measures the free margin live and hides itself when a docked panel eats
|
* tick rail (it measures the free margin live and hides itself when a docked panel eats
|
||||||
* the room — also asserted here).
|
* 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),
|
* 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
|
* crossing the outline's five-turn visibility gate on the way — at four turns neither the
|
||||||
* long enough to scroll inside.
|
* 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 { test, expect } from "@playwright/test";
|
||||||
import { provisionAndLogin } from "./auth.mjs";
|
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. */
|
/** Reply completion marker: every mock turn-2 ends with this exact sentence. */
|
||||||
const REPLY = "Command finished";
|
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);
|
await provisionAndLogin(page.request, U, P);
|
||||||
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
|
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
|
||||||
const projectId = projects.projects[0].projectId;
|
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();
|
expect(put.ok(), "put models").toBeTruthy();
|
||||||
|
return async () => {
|
||||||
const newSession = async () => {
|
|
||||||
const res = await (
|
const res = await (
|
||||||
await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, {
|
await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, {
|
||||||
data: { provider: "custom", modelId: "claude-4-8", approvalMode: "allow-all" },
|
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();
|
).json();
|
||||||
return res.session.sessionId;
|
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 page.goto(`${BASE}/chat/${await newSession()}`);
|
||||||
await ta.waitFor();
|
await ta.waitFor();
|
||||||
await send("第一问:项目结构", 1);
|
await send("第一问:项目结构", 1);
|
||||||
await send("第二问:运行检查", 2);
|
await send("第二问:运行检查", 2);
|
||||||
await send("第三问:总结结果", 3);
|
await send("第三问:总结结果", 3);
|
||||||
|
await send("第四问:整理清单", 4);
|
||||||
|
|
||||||
// One tick per exchange in the gutter minimap; auto-follow parked the stream at the
|
// Below five turns neither outline shape renders: no rail ticks even though the gutter
|
||||||
// bottom, so the newest exchange is the active tick. Message text is NOT duplicated
|
// fits them…
|
||||||
// into the DOM at rest — the preview card exists only while hovering.
|
|
||||||
const ticks = page.locator("[data-outline-tick]");
|
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]");
|
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":
|
// 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.
|
// that mapping is the semantic under test, not auto-follow's timing under load.
|
||||||
await page.evaluate(() => {
|
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(
|
await expect(page.locator("[data-outline-tick][aria-current]")).toHaveAttribute(
|
||||||
"aria-label",
|
"aria-label",
|
||||||
/第 3 轮/,
|
/第 5 轮/,
|
||||||
);
|
);
|
||||||
await expect(card).toHaveCount(0);
|
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);
|
expect(Math.abs(jumpDelta)).toBeLessThan(40);
|
||||||
|
|
||||||
// The rail lives in the free gutter: a docked panel that eats the slack hides it and
|
// 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
|
// the index moves to the toolbar dropdown (navigation stays reachable, and the dropdown
|
||||||
// panel restores the rail (live measurement, not a breakpoint).
|
// lists ALL entries — no windowing there); closing the panel restores the rail (live
|
||||||
|
// measurement, not a breakpoint).
|
||||||
await page.getByRole("button", { name: "打开工作区" }).click();
|
await page.getByRole("button", { name: "打开工作区" }).click();
|
||||||
await expect(ticks).toHaveCount(0);
|
await expect(ticks).toHaveCount(0);
|
||||||
const menuButton = page.getByRole("button", { name: "对话索引" });
|
|
||||||
await menuButton.click();
|
await menuButton.click();
|
||||||
const menuEntries = page.locator("[data-outline-menu-entry]");
|
const menuEntries = page.locator("[data-outline-menu-entry]");
|
||||||
await expect(menuEntries).toHaveCount(3);
|
await expect(menuEntries).toHaveCount(5);
|
||||||
await expect(menuEntries.first()).toContainText("第一问:项目结构");
|
await expect(menuEntries.first()).toContainText("第一问:项目结构");
|
||||||
await menuEntries.nth(2).click(); // jump and close
|
await menuEntries.nth(2).click(); // jump and close
|
||||||
await expect(menuEntries).toHaveCount(0);
|
await expect(menuEntries).toHaveCount(0);
|
||||||
await page.getByRole("button", { name: "打开工作区" }).click();
|
await page.getByRole("button", { name: "打开工作区" }).click();
|
||||||
await expect(ticks).toHaveCount(3);
|
await expect(ticks).toHaveCount(5);
|
||||||
await expect(menuButton).toHaveCount(0);
|
await expect(menuButton).toHaveCount(0);
|
||||||
|
|
||||||
// Phone-narrow: no rail either, the toolbar index instead; the agents-panel button
|
// 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(menuButton).toBeVisible();
|
||||||
await expect(page.getByText("智能体面板")).toBeHidden();
|
await expect(page.getByText("智能体面板")).toBeHidden();
|
||||||
await page.setViewportSize({ width: 1440, height: 860 });
|
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.
|
// ↑ walks back through this session's inputs, newest first; a second ↑ goes older.
|
||||||
await ta.click();
|
await ta.click();
|
||||||
await page.keyboard.press("ArrowUp");
|
await page.keyboard.press("ArrowUp");
|
||||||
await expect(ta).toHaveValue("第三问:总结结果");
|
await expect(ta).toHaveValue("第五问:回顾结论");
|
||||||
await page.keyboard.press("ArrowUp");
|
await page.keyboard.press("ArrowUp");
|
||||||
await expect(ta).toHaveValue("第二问:运行检查");
|
await expect(ta).toHaveValue("第四问:整理清单");
|
||||||
// ↓ walks forward and past the newest restores the (empty) draft.
|
// ↓ walks forward and past the newest restores the (empty) draft.
|
||||||
await page.keyboard.press("ArrowDown");
|
await page.keyboard.press("ArrowDown");
|
||||||
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 ta.press("End");
|
||||||
await page.keyboard.type(",补充");
|
await page.keyboard.type(",补充");
|
||||||
await page.keyboard.press("ArrowUp");
|
await page.keyboard.press("ArrowUp");
|
||||||
await expect(ta).toHaveValue("第三问:总结结果,补充");
|
await expect(ta).toHaveValue("第五问:回顾结论,补充");
|
||||||
await ta.fill("");
|
await ta.fill("");
|
||||||
|
|
||||||
// --- session 2: long tool output -> sticky header ---
|
// --- 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).toBeGreaterThan(-5);
|
||||||
expect(landed).toBeLessThan(300);
|
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);
|
||||||
|
});
|
||||||
@@ -10,15 +10,22 @@
|
|||||||
* hit-transparent (pointer events only on the ticks), so wheel scrolling anywhere in
|
* 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
|
* 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.
|
* 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
|
* - `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
|
* 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
|
* 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.
|
* 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
|
* 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
|
* 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.
|
* 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
|
* 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
|
* 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 { Dropdown } from "../../components/ui/dropdown";
|
||||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||||
import type { OutlineEntry } from "./outline-model";
|
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. */
|
/** Panel-with-list glyph (24×24 line path) for the toolbar menu button. */
|
||||||
const OUTLINE_ICON =
|
const OUTLINE_ICON =
|
||||||
@@ -52,16 +65,12 @@ const FLASH_MS = 1000;
|
|||||||
*/
|
*/
|
||||||
const GUTTER_MIN_PX = 56;
|
const GUTTER_MIN_PX = 56;
|
||||||
|
|
||||||
/** The stream column cap the gutter derives from (Tailwind max-w-3xl). */
|
/** The stream column cap the gutter derives from (Tailwind max-w-3xl = 48rem). */
|
||||||
const COLUMN_MAX_PX = 768;
|
const COLUMN_MAX_REM = 48;
|
||||||
|
|
||||||
/** The rail's hover-preview interaction needs a pointer that can hover. */
|
/** The rail's hover-preview interaction needs a pointer that can hover. */
|
||||||
const HOVER_QUERY = "(hover: hover) and (pointer: fine)";
|
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 {
|
export interface OutlineRailFit {
|
||||||
/** Whether the rail can show: hover-capable pointer AND a wide-enough measured gutter. */
|
/** Whether the rail can show: hover-capable pointer AND a wide-enough measured gutter. */
|
||||||
shown: boolean;
|
shown: boolean;
|
||||||
@@ -96,7 +105,11 @@ export function useOutlineRailFit(
|
|||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const measure = () => {
|
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 });
|
setFit({ gutterOk: gutter >= GUTTER_MIN_PX, height: el.clientHeight });
|
||||||
};
|
};
|
||||||
measure();
|
measure();
|
||||||
@@ -156,6 +169,26 @@ function jumpToAnchor(container: HTMLElement | null, id: number): void {
|
|||||||
flashTimer = window.setTimeout(() => anchor.classList.remove("outline-flash"), FLASH_MS);
|
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 (
|
||||||
|
<div
|
||||||
|
data-outline-overflow={edge}
|
||||||
|
aria-hidden
|
||||||
|
className={`flex w-10 flex-col items-start gap-[3px] pl-4 ${edge === "above" ? "pb-1.5" : "pt-1.5"}`}
|
||||||
|
>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<span key={i} className="h-[2px] w-[2px] rounded-full bg-gray-300 dark:bg-gray-700" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The turn's reply preview for a card/menu row: text, an "answering" pulse for the newest running turn, or "". */
|
/** The turn's reply preview for a card/menu row: text, an "answering" pulse for the newest running turn, or "". */
|
||||||
function answerPreview(
|
function answerPreview(
|
||||||
entry: OutlineEntry,
|
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.
|
// cheap, and keying on version also re-binds after the stream remounts on a session switch.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
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));
|
const ids = new Set(entries.map((entry) => entry.anchorId));
|
||||||
let raf: number | null = null;
|
let raf: number | null = null;
|
||||||
const compute = () => {
|
const compute = () => {
|
||||||
@@ -215,7 +248,7 @@ export function ConversationOutline({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [fit.shown, scrollRef, entries.length, version]);
|
}, [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). */
|
/** Tick center Y relative to the rail overlay (the preview card anchors to it, clamped in render). */
|
||||||
const tickTop = (e: MouseEvent<HTMLElement> | FocusEvent<HTMLElement>) => {
|
const tickTop = (e: MouseEvent<HTMLElement> | FocusEvent<HTMLElement>) => {
|
||||||
@@ -223,14 +256,26 @@ export function ConversationOutline({
|
|||||||
return rect.top + rect.height / 2 - (navRef.current?.getBoundingClientRect().top ?? 0);
|
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
|
// The rail renders a sliding window around the reading position, not every turn: an
|
||||||
// at minimum pitch the stack simply clips — a conversation that long stopped being
|
// unbounded stack would outgrow the rail (and creep over the toolbar and composer) no
|
||||||
// scannable by any other means well before the map does.
|
// matter how hard the pitch compresses. The half-width adapts to the measured rail
|
||||||
const pitch = Math.max(
|
// height so the stack always fits; `start` is the windowed ticks' global offset —
|
||||||
TICK_PITCH_MIN,
|
// labels and active tracking stay in global turn numbers.
|
||||||
Math.min(TICK_PITCH_MAX, Math.floor((fit.height - 32) / entries.length)),
|
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);
|
const cardAnswer = hovered === null ? "" : answerPreview(hovered, entries, running, 160);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -242,8 +287,12 @@ export function ConversationOutline({
|
|||||||
aria-label={S.chat.outlineTitle}
|
aria-label={S.chat.outlineTitle}
|
||||||
className="pointer-events-none absolute inset-y-0 left-0 z-10 flex flex-col items-start justify-center"
|
className="pointer-events-none absolute inset-y-0 left-0 z-10 flex flex-col items-start justify-center"
|
||||||
>
|
>
|
||||||
<div>
|
{/* max-h + clip is a safety net only: the height-adaptive window keeps the stack
|
||||||
{entries.map((entry, i) => {
|
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. */}
|
||||||
|
<div className="max-h-full overflow-hidden">
|
||||||
|
{start > 0 && <RailOverflowMark edge="above" />}
|
||||||
|
{visible.map((entry, i) => {
|
||||||
const active = entry.anchorId === activeId;
|
const active = entry.anchorId === activeId;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -251,7 +300,12 @@ export function ConversationOutline({
|
|||||||
type="button"
|
type="button"
|
||||||
data-outline-tick={entry.anchorId}
|
data-outline-tick={entry.anchorId}
|
||||||
aria-current={active || undefined}
|
aria-current={active || undefined}
|
||||||
aria-label={S.chat.outlineTickLabel(i + 1, entry.question || S.chat.outlineNoText)}
|
// Global turn number (start + i): the window changes which ticks render,
|
||||||
|
// never how a turn is numbered.
|
||||||
|
aria-label={S.chat.outlineTickLabel(
|
||||||
|
start + i + 1,
|
||||||
|
entry.question || S.chat.outlineNoText,
|
||||||
|
)}
|
||||||
style={{ height: pitch }}
|
style={{ height: pitch }}
|
||||||
onMouseEnter={(e) => setHover({ id: entry.anchorId, top: tickTop(e) })}
|
onMouseEnter={(e) => setHover({ id: entry.anchorId, top: tickTop(e) })}
|
||||||
onMouseLeave={() => setHover(null)}
|
onMouseLeave={() => setHover(null)}
|
||||||
@@ -275,6 +329,7 @@ export function ConversationOutline({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{end < entries.length && <RailOverflowMark edge="below" />}
|
||||||
</div>
|
</div>
|
||||||
{/* Preview card for the hovered/focused tick only — never mounted at rest. Purely a
|
{/* 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
|
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 [open, setOpen] = useState(false);
|
||||||
const [activeId, setActiveId] = useState<number | null>(null);
|
const [activeId, setActiveId] = useState<number | null>(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) => {
|
const setOpenComputing = (next: boolean) => {
|
||||||
if (next && scrollRef.current) {
|
if (next && scrollRef.current) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Conversation outline data (pure logic, unit-testable): reduces the stream items to one
|
* 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
|
* 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
|
* 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
|
* 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. */
|
/** Answer accumulation cap: enough for any preview length while keeping rebuilds O(entries) cheap. */
|
||||||
const ANSWER_CAP = 500;
|
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[] {
|
export function buildOutline(items: readonly ChatItem[]): OutlineEntry[] {
|
||||||
const out: OutlineEntry[] = [];
|
const out: OutlineEntry[] = [];
|
||||||
let current: OutlineEntry | null = null;
|
let current: OutlineEntry | null = null;
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* outline-model.ts unit tests: turn segmentation into outline entries (merge of adjacent
|
* 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
|
* user items, banner/goal-round handling, answer accumulation), the plain-text preview
|
||||||
* reduction.
|
* reduction, and the tick rail's sliding-window bounds.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { ChatItem } from "../src/lib/omni/stream-model";
|
import type { ChatItem } from "../src/lib/omni/stream-model";
|
||||||
import { handoffMessage } from "../src/features/chat/agent-handoff";
|
import { handoffMessage } from "../src/features/chat/agent-handoff";
|
||||||
import { buildScheduledMessage } from "@prismshadow/penguin-core/markers";
|
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;
|
let nextId = 0;
|
||||||
const user = (text: string): ChatItem => ({ kind: "user_text", id: nextId++, text });
|
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", () => {
|
describe("previewText", () => {
|
||||||
it("flattens markdown to one plain line", () => {
|
it("flattens markdown to one plain line", () => {
|
||||||
const md =
|
const md =
|
||||||
|
|||||||
Reference in new issue
Block a user