feat(chat): replace the history scrollbar with a minimap

Loaded history is reachable without 80-message batches; DOM windowing stays at ~60 rows. The minimap draws a stable content-weighted overview, expands to 80 px on hover, and mutes cancelled replies like their status line.
This commit is contained in:
mesalogo
2026-10-12 12:51:59 +08:00
parent bcb1ebbe62
commit 6694aa141e
12 changed files with 748 additions and 99 deletions
+8 -62
View File
@@ -4608,7 +4608,7 @@ describe("App", () => {
expect(scrollTo).not.toHaveBeenCalled();
});
it("windows the latest 80 messages and preserves scroll when revealing earlier messages", async () => {
it("exposes the full loaded history while keeping the DOM windowed", async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: "00000000-0000-4000-8000-000000000421",
@@ -4627,62 +4627,17 @@ describe("App", () => {
const { container } = render(<App />);
expect(await screen.findByText("历史消息 160")).toBeInTheDocument();
// Only the rows near the bottom are mounted (PERF-14); a spacer stands in
// for the rest of the 80-message batch.
// Only the rows near the bottom are mounted (PERF-14); spacers stand in
// for the rest of the loaded history.
const mounted = container.querySelectorAll(".message").length;
expect(mounted).toBeGreaterThan(0);
expect(mounted).toBeLessThan(80);
expect(
screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /加载更早的消息/u })).not.toBeInTheDocument();
expect(screen.queryByText("历史消息 080")).not.toBeInTheDocument();
const chat = container.querySelector<HTMLElement>(".chat");
if (!chat) {
throw new Error("Missing chat scroll container");
}
const topSpacerHeight = (): number =>
Number.parseFloat(
container.querySelector<HTMLElement>('[data-window-spacer="top"]')?.style.height ?? "0",
) || 0;
Object.defineProperties(chat, {
clientHeight: { configurable: true, value: 400 },
scrollHeight: {
configurable: true,
get: () => container.querySelectorAll(".message").length * 10 + topSpacerHeight(),
},
scrollTop: {
configurable: true,
writable: true,
value: 125,
},
});
fireEvent.click(
screen.getByRole("button", {
name: "加载更早的消息(还剩 81 条)",
}),
);
expect(
screen.getByRole("button", { name: "加载更早的消息(还剩 1 条)" }),
).toBeInTheDocument();
expect(container.querySelectorAll(".message")).toHaveLength(mounted);
// The 80 earlier messages land above the reader, who keeps their place.
expect(chat.scrollTop).toBe(125 + 80 * 160);
fireEvent.click(
screen.getByRole("button", {
name: "加载更早的消息(还剩 1 条)",
}),
);
expect(screen.getByText("历史消息 000")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /加载更早的消息/u }),
).not.toBeInTheDocument();
expect(screen.getByText("历史消息 000").closest("article")).toHaveFocus();
expect(container.querySelectorAll(".message").length).toBeLessThanOrEqual(mounted + 1);
expect(container.querySelector('[data-window-spacer="top"]')).toBeInTheDocument();
});
it("opens a long conversation in one bounded commit without catch-up steps", async () => {
it("opens a long conversation in one bounded commit without a history gate", async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: "00000000-0000-4000-8000-000000000422",
@@ -4720,9 +4675,8 @@ describe("App", () => {
expect(counts).toHaveLength(1);
expect(counts[0]).toBeLessThan(80);
expect(
screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /加载更早的消息/u })).not.toBeInTheDocument();
expect(container.querySelector('[data-window-spacer="top"]')).toBeInTheDocument();
});
it("keeps the reader position while a response continues below", async () => {
@@ -5201,17 +5155,9 @@ describe("App", () => {
const { container } = render(<App />);
expect(await screen.findByText("第一段历史 160")).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", {
name: "加载更早的消息(还剩 81 条)",
}),
);
const firstPane = container.querySelector<HTMLElement>(
`[data-conversation-id="${firstConversationId}"]`,
);
expect(
screen.getByRole("button", { name: "加载更早的消息(还剩 1 条)" }),
).toBeInTheDocument();
// Windowed (PERF-14): the mounted rows stay bounded and keep their nodes.
const mountedRows = firstPane?.querySelectorAll(".message").length ?? 0;
expect(mountedRows).toBeGreaterThan(0);
+1 -29
View File
@@ -131,10 +131,7 @@ import type {
ImageViewerItem,
Message,
} from "./ChatTimeline";
import {
messageRenderBatchSize,
type ChatScrollSnapshot,
} from "./ChatHistoryPane";
import type { ChatScrollSnapshot } from "./ChatHistoryPane";
import { usePaneOrder } from "./pane-order";
import { getConversationDisplayTitle, isUnusedConversation, type Conversation } from "./chat-conversation";
import {
@@ -1806,9 +1803,6 @@ function App(): React.JSX.Element {
),
[],
);
const [visibleMessageCounts, setVisibleMessageCounts] = useState<
Record<string, number>
>({});
const sidebarRef = useRef<HTMLElement>(null);
const sidebarToggleRef = useRef<HTMLButtonElement>(null);
const livePrimarySidebarWidthRef = useRef(primarySidebarWidth);
@@ -1863,15 +1857,6 @@ function App(): React.JSX.Element {
},
[],
);
const handleVisibleMessageCountChange = useCallback(
(conversationId: string, count: number): void => {
setVisibleMessageCounts((current) => ({
...current,
[conversationId]: count,
}));
},
[],
);
const closeNarrowSidebar = useCallback((): void => {
setSidebarOpen(false);
requestAnimationFrame(() => sidebarToggleRef.current?.focus());
@@ -4608,11 +4593,6 @@ function App(): React.JSX.Element {
delete next[conversationId];
return next;
});
setVisibleMessageCounts((current) => {
const next = { ...current };
delete next[conversationId];
return next;
});
setConversationActivity(conversationId, false);
const remaining = conversationStore.getState().filter(
(conversation) => conversation.id !== conversationId,
@@ -5978,7 +5958,6 @@ function App(): React.JSX.Element {
const index = conversation.messages.findIndex(item => item.id === targetId);
if (index < 0 || !snapshot.messages.some(message => message.id === targetId)) notify({ tone: 'info', message: t('magicNotes:capture.missingMessage') });
else {
setVisibleMessageCounts(current => ({ ...current, [source.conversationId]: Math.max(current[source.conversationId] ?? messageRenderBatchSize, conversation.messages.length - index) }));
setNoteMessageNavigation({ conversationId: source.conversationId, messageId: targetId, requestId: Date.now() });
}
}
@@ -7112,19 +7091,12 @@ function App(): React.JSX.Element {
onSelectTask={setSelectedAssistantTaskId}
onSetInput={setQuickActionInput}
onSetScheduleEnabled={setAssistantScheduleEnabled}
onVisibleMessageCountChange={
handleVisibleMessageCountChange
}
quickActions={quickActions}
schedules={assistantSchedules}
scrollSnapshot={chatScrollSnapshots[conversationId]}
selectedAssistantTaskId={selectedAssistantTaskId}
store={conversationStore}
tasks={tasksByConversation.get(conversationId) ?? emptyConversationTasks}
visibleMessageCount={
visibleMessageCounts[conversationId] ??
messageRenderBatchSize
}
/>
))}
{activeProject?.kind === "channel" &&
+8
View File
@@ -16,6 +16,7 @@ import type {
KnowledgeSearchReference,
} from "../../shared/contracts";
import type { ImageOperation } from "../../shared/image-generation-contracts";
import { ChatMinimap } from "./ChatMinimap";
import { ChatTimeline, type ImageViewerItem, type Message } from "./ChatTimeline";
import { isUnusedConversation, type Conversation } from "./chat-conversation";
import { MessageExpansionProvider } from "./message-expansion";
@@ -1141,6 +1142,13 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
/>
</div>
</section>
{active && (
<ChatMinimap
messages={messages}
rowHeights={heights}
scrollContainerRef={scrollRef}
/>
)}
{active && showScrollToBottom && (
<button
aria-controls="chat-message-list"
+172
View File
@@ -0,0 +1,172 @@
import { act, cleanup, fireEvent, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatMinimap, minimapLines, minimapMessageColor, minimapScrollTop } from "./ChatMinimap";
import { MessageRowHeights } from "./chat-message-window";
import type { Message } from "./ChatTimeline";
function message(role: Message["role"], id: string, extra: Partial<Message> = {}): Message {
return { id, role, content: id, createdAt: 1, state: "complete", ...extra } as Message;
}
function scroller(scrollHeight = 1_000, clientHeight = 200, scrollTop = 0): HTMLElement {
const element = document.createElement("section");
let top = scrollTop;
Object.defineProperties(element, {
clientHeight: { configurable: true, value: clientHeight },
scrollHeight: { configurable: true, value: scrollHeight },
offsetTop: { configurable: true, value: 0 },
scrollTop: {
configurable: true,
get: () => top,
set: (value: number) => { top = Math.max(0, Math.min(scrollHeight - clientHeight, value)); },
},
});
element.scrollTo = vi.fn(({ top: next }: ScrollToOptions) => {
element.scrollTop = next ?? 0;
element.dispatchEvent(new Event("scroll"));
}) as typeof element.scrollTo;
return element;
}
const messages = Array.from({ length: 20 }, (_, index) => message(index % 2 ? "assistant" : "user", `m${index}`));
describe("ChatMinimap", () => {
let fillRect: ReturnType<typeof vi.fn>;
beforeEach(() => {
fillRect = vi.fn();
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
clearRect: vi.fn(),
fillRect,
setTransform: vi.fn(),
set fillStyle(_value: string) {},
} as unknown as CanvasRenderingContext2D);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("maps message roles and states to stable overview colors", () => {
expect(minimapMessageColor(message("user", "user"))).toBe("#6d9ee8");
expect(minimapMessageColor(message("assistant", "assistant"))).toBe("#8f98a8");
expect(minimapMessageColor(message("assistant", "tool", { tools: [{ callId: "1" }] as never }))).toBe("#d9a441");
expect(minimapMessageColor(message("assistant", "error", { state: "error" }))).toBe("#e06b75");
expect(minimapMessageColor(message("assistant", "failed", { state: "error", terminalStatus: "failed" }))).toBe("#e06b75");
expect(minimapMessageColor(message("assistant", "cancelled", { state: "error", terminalStatus: "cancelled" }))).toBe("#8f98a8");
});
it("clamps minimap positions to the scrollable range", () => {
expect(minimapScrollTop(-1, 1_000, 200)).toBe(0);
expect(minimapScrollTop(0.5, 1_000, 200)).toBe(400);
expect(minimapScrollTop(2, 1_000, 200)).toBe(800);
});
it("profiles messages as wrapped text lines with blank lines and code", () => {
const lines = minimapLines(message("assistant", "a", {
content: `${"x".repeat(100)}\n\n\`\`\`ts\n code\n\`\`\`\n中文`,
}));
expect(lines).toEqual([
{ indent: 0, width: 72 / 72, code: false },
{ indent: 0, width: 28 / 72, code: false },
{ indent: 0, width: 0, code: false },
{ indent: 4 / 72, width: 4 / 72, code: true },
{ indent: 0, width: 4 / 72, code: false },
]);
expect(minimapLines(message("assistant", "empty", { content: "" }))).toHaveLength(1);
});
it("draws one stroke per text line in the expanded overview", () => {
const long = [message("assistant", "long", { content: Array.from({ length: 30 }, () => "line").join("\n") }), message("user", "u")];
const heights = new MessageRowHeights();
heights.set("long", 900);
heights.set("u", 100);
const view = render(<ChatMinimap messages={long} rowHeights={heights} scrollContainerRef={{ current: scroller() }} />);
fillRect.mockClear();
fireEvent.pointerEnter(view.getByRole("scrollbar"));
// 180 px block at a 3 px pitch fits all 30 lines; the user turn draws one.
expect(fillRect).toHaveBeenCalledTimes(31);
});
it("spans the scroller height and moves the slider synchronously on scroll", () => {
const scroll = scroller();
const view = render(<ChatMinimap messages={messages} rowHeights={new MessageRowHeights()} scrollContainerRef={{ current: scroll }} />);
const rail = view.getByRole("scrollbar");
expect(rail.style.display).toBe("");
expect(fillRect).toHaveBeenCalledTimes(messages.length);
const slider = rail.querySelector<HTMLElement>(".chat-minimap__viewport")!;
expect(slider.style.height).toBe("40px");
scroll.scrollTop = 800;
scroll.dispatchEvent(new Event("scroll"));
// No frame or React commit in between: the slider is already at the end.
expect(slider.style.transform).toBe("translateY(160px)");
expect(rail).toHaveAttribute("aria-valuenow", "100");
});
it("keeps the overview drawing stable while rows are measured during scrolling", () => {
const scroll = scroller();
const heights = new MessageRowHeights();
const view = render(<ChatMinimap messages={messages} rowHeights={heights} scrollContainerRef={{ current: scroll }} />);
fillRect.mockClear();
// Scrolling mounts and measures rows: heights change, messages do not.
heights.set("m0", 900);
heights.set("m1", 30);
view.rerender(<ChatMinimap messages={messages} rowHeights={heights} scrollContainerRef={{ current: scroll }} />);
scroll.dispatchEvent(new Event("scroll"));
expect(fillRect).not.toHaveBeenCalled();
});
it("places the slider on the overview span of the rows in view", () => {
// Row 0 is tall in pixels but short in text: the overview gives it 1/20.
const scroll = scroller(1_000, 200);
const heights = new MessageRowHeights();
heights.set("m0", 810);
for (let index = 1; index < messages.length; index += 1) heights.set(`m${index}`, 10);
const view = render(<ChatMinimap messages={messages} rowHeights={heights} scrollContainerRef={{ current: scroll }} />);
const slider = view.getByRole("scrollbar").querySelector<HTMLElement>(".chat-minimap__viewport")!;
// Viewport [0, 200] lies inside row 0, i.e. inside its 10 px overview block.
expect(slider.style.transform).toBe("translateY(0px)");
expect(slider.style.height).toBe("16px");
});
it("hides itself when the conversation does not scroll", () => {
const view = render(<ChatMinimap messages={messages} rowHeights={new MessageRowHeights()} scrollContainerRef={{ current: scroller(200, 200) }} />);
expect(view.getByRole("scrollbar", { hidden: true }).style.display).toBe("none");
});
it("expands on hover and redraws the wider overview", () => {
const view = render(<ChatMinimap messages={messages} rowHeights={new MessageRowHeights()} scrollContainerRef={{ current: scroller() }} />);
const rail = view.getByRole("scrollbar");
fillRect.mockClear();
fireEvent.pointerEnter(rail);
expect(rail).toHaveAttribute("data-expanded", "true");
expect(fillRect).toHaveBeenCalledTimes(messages.length);
fireEvent.pointerLeave(rail);
expect(rail).toHaveAttribute("data-expanded", "false");
});
it("jumps on track clicks, drags the slider, and pages by keyboard", () => {
const scroll = scroller();
const view = render(<ChatMinimap messages={messages} rowHeights={new MessageRowHeights()} scrollContainerRef={{ current: scroll }} />);
const rail = view.getByRole("scrollbar");
const slider = rail.querySelector<HTMLElement>(".chat-minimap__viewport")!;
vi.spyOn(rail, "getBoundingClientRect").mockReturnValue({ top: 0, height: 200, left: 0, right: 14 } as DOMRect);
vi.spyOn(slider, "getBoundingClientRect").mockImplementation(() => {
const top = Number(/translateY\((.+)px\)/.exec(slider.style.transform)?.[1] ?? 0);
return { top, bottom: top + 40, height: 40 } as DOMRect;
});
// Track click centers the slider under the pointer: (100 - 20) / 160.
fireEvent.pointerDown(rail, { button: 0, clientY: 100, pointerId: 1 });
expect(scroll.scrollTop).toBe(400);
fireEvent.pointerMove(rail, { clientY: 180, pointerId: 1 });
expect(scroll.scrollTop).toBe(800);
fireEvent.pointerUp(rail, { clientY: 180, pointerId: 1 });
fireEvent.pointerMove(rail, { clientY: 0, pointerId: 1 });
expect(scroll.scrollTop).toBe(800);
act(() => fireEvent.keyDown(rail, { key: "Home" }));
expect(scroll.scrollTo).toHaveBeenLastCalledWith({ top: 0, behavior: "auto" });
act(() => fireEvent.keyDown(rail, { key: "End" }));
expect(scroll.scrollTo).toHaveBeenLastCalledWith({ top: 1_000, behavior: "auto" });
});
});
+402
View File
@@ -0,0 +1,402 @@
import { useCallback, useLayoutEffect, useRef, type KeyboardEvent, type PointerEvent, type RefObject, type WheelEvent } from "react";
import { useTranslation } from "react-i18next";
import type { Message } from "./ChatTimeline";
import type { MessageRowHeights } from "./chat-message-window";
import { isCancelledMessage } from "./message-terminal-status";
/** Rail width when idle and when hovered (expanded overview), in CSS pixels. */
export const minimapCollapsedWidth = 14;
export const minimapExpandedWidth = 80;
export function minimapMessageColor(message: Message): string {
// Cancelled replies are muted like their status line; only failures are red.
if (message.state === "error") return isCancelledMessage(message) ? "#8f98a8" : "#e06b75";
if (message.tools?.length || message.pendingQuestions?.length) return "#d9a441";
return message.role === "user" ? "#6d9ee8" : "#8f98a8";
}
export function minimapScrollTop(
pointerRatio: number,
scrollHeight: number,
viewportHeight: number,
): number {
const maxScrollTop = Math.max(0, scrollHeight - viewportHeight);
return Math.min(1, Math.max(0, pointerRatio)) * maxScrollTop;
}
/**
* Stable overview weight of a message, in text-line units: its wrapped lines
* plus fixed room for the header/padding and each tool row. It depends only on
* the message content, never on measured DOM heights, so the overview keeps
* its density while the timeline measures rows during scrolling.
*/
export function minimapMessageWeight(message: Message): number {
return minimapLines(message).length + 3 + 2 * (message.tools?.length ?? 0);
}
/** Largest index i in [0, prefix.length - 2] with prefix[i] <= value. */
function locate(prefix: Float64Array, value: number): number {
let low = 0;
let high = prefix.length - 2;
while (low < high) {
const middle = (low + high + 1) >> 1;
if (prefix[middle]! <= value) low = middle;
else high = middle - 1;
}
return Math.max(0, low);
}
/** Maps a position on one prefix-sum axis to the other, row by row. */
function mapAxis(from: Float64Array, to: Float64Array, value: number): number {
const count = from.length - 1;
if (count <= 0) return 0;
const clamped = Math.min(from[count]!, Math.max(0, value));
const index = locate(from, clamped);
const span = from[index + 1]! - from[index]!;
const fraction = span > 0 ? (clamped - from[index]!) / span : 0;
return to[index]! + fraction * (to[index + 1]! - to[index]!);
}
function prefixSums(messages: readonly Message[], size: (message: Message) => number): Float64Array {
const prefix = new Float64Array(messages.length + 1);
for (let index = 0; index < messages.length; index += 1) {
prefix[index + 1] = prefix[index]! + Math.max(0, size(messages[index]!));
}
return prefix;
}
/** Characters per minimap text line, roughly one reading-column line. */
const minimapCharsPerLine = 72;
/** Upper bound of text lines kept per message; very long replies are sampled. */
const minimapMaxLines = 600;
/**
* Coarse text-line profile of a message: one entry per wrapped visual line,
* holding its indent and length as fractions of a full line. Empty entries
* (`width === 0`) are blank lines. Code fences become indented strokes.
*/
export type MinimapLine = { indent: number; width: number; code: boolean };
const lineCache = new WeakMap<Message, MinimapLine[]>();
export function minimapLines(message: Message): MinimapLine[] {
const cached = lineCache.get(message);
if (cached) return cached;
const lines: MinimapLine[] = [];
let code = false;
const source = message.content ?? "";
for (const raw of source.split("\n")) {
if (lines.length >= minimapMaxLines) break;
if (/^\s*(```|~~~)/.test(raw)) {
code = !code;
continue;
}
const text = raw.replace(/\t/g, " ");
const trimmed = text.trimStart();
if (!trimmed) {
lines.push({ indent: 0, width: 0, code });
continue;
}
const indent = Math.min(0.5, (text.length - trimmed.length) / minimapCharsPerLine);
// Wide (CJK) characters take about two columns.
let columns = 0;
for (const char of trimmed) columns += char.charCodeAt(0) > 0x2e7f ? 2 : 1;
const span = Math.max(1, Math.round(minimapCharsPerLine * (1 - indent)));
while (columns > 0 && lines.length < minimapMaxLines) {
const take = Math.min(span, columns);
lines.push({ indent, width: take / minimapCharsPerLine, code });
columns -= take;
}
}
// Tool calls or attachments without text still show as one stroke.
if (lines.length === 0) lines.push({ indent: 0, width: 0.35, code: false });
lineCache.set(message, lines);
return lines;
}
type ChatMinimapProps = {
messages: readonly Message[];
rowHeights: MessageRowHeights;
scrollContainerRef: RefObject<HTMLElement | null>;
};
type Drag = { pointerId: number; grabOffset: number };
/**
* Replaces the native scrollbar of the chat scroller with a canvas overview.
* Geometry follows the scroller on every scroll/resize event by writing styles
* directly, so the viewport indicator never waits for a React commit.
*/
export function ChatMinimap({ messages, rowHeights, scrollContainerRef }: ChatMinimapProps): React.JSX.Element {
const { t } = useTranslation("app");
const railRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const sliderRef = useRef<HTMLSpanElement>(null);
const dragRef = useRef<Drag | undefined>(undefined);
const expandedRef = useRef(false);
const drawnRef = useRef<{ width: number; height: number; messages: readonly Message[] | undefined }>({ width: 0, height: 0, messages: undefined });
const layoutRef = useRef({ scrollable: false, railHeight: -1, percent: -1, scrollHeight: -1 });
const dataRef = useRef({ messages, rowHeights });
// Overview axis: stable content weights. Scroll axis: current row heights
// (measured or estimated). The slider maps between them row by row, so the
// drawing never changes when rows are measured during scrolling.
const axesRef = useRef<{ messages: readonly Message[] | undefined; weights: Float64Array; pixels: Float64Array }>({
messages: undefined, weights: new Float64Array(1), pixels: new Float64Array(1),
});
const refreshAxes = useCallback((): void => {
const { messages: current, rowHeights: heights } = dataRef.current;
const axes = axesRef.current;
const weights = axes.messages === current ? axes.weights : prefixSums(current, minimapMessageWeight);
axesRef.current = { messages: current, weights, pixels: prefixSums(current, (message) => heights.size(message.id)) };
}, []);
const draw = useCallback((force: boolean): void => {
const canvas = canvasRef.current;
const rail = railRef.current;
const container = scrollContainerRef.current;
if (!canvas || !rail || !container) return;
const width = rail.clientWidth ||
(expandedRef.current ? minimapExpandedWidth : minimapCollapsedWidth);
const height = Math.max(1, rail.clientHeight || container.clientHeight);
const { messages: current } = dataRef.current;
const drawn = drawnRef.current;
if (!force && drawn.width === width && drawn.height === height && drawn.messages === current) return;
drawnRef.current = { width, height, messages: current };
if (axesRef.current.messages !== current) refreshAxes();
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
const context = canvas.getContext("2d");
if (!context) return;
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(0, 0, width, height);
const { weights } = axesRef.current;
const totalWeight = weights[current.length] ?? 0;
if (totalWeight <= 0) return;
const expanded = expandedRef.current;
const inset = expanded ? 6 : 2;
const lineSpan = width - inset * 2;
// Expanded rows: thin strokes on a device-pixel-friendly pitch.
const pitch = ratio >= 2 ? 2 : 3;
const stroke = ratio >= 2 ? 1.5 : 2;
for (let index = 0; index < current.length; index += 1) {
const message = current[index]!;
const top = (weights[index]! / totalWeight) * height;
const blockHeight = Math.max(1, ((weights[index + 1]! - weights[index]!) / totalWeight) * height);
context.fillStyle = minimapMessageColor(message);
if (!expanded) {
context.fillRect(inset, top, lineSpan, blockHeight);
continue;
}
// Expanded: one stroke per wrapped text line, like an editor minimap.
// User turns are right-aligned and narrower, echoing the chat layout.
const user = message.role === "user";
const span = user ? lineSpan * 0.7 : lineSpan;
const base = user ? width - inset - span : inset;
const lines = minimapLines(message);
const rows = Math.max(1, Math.min(lines.length, Math.floor(blockHeight / pitch)));
const rowStep = rows > 1 ? Math.min(pitch * 1.6, blockHeight / rows) : 0;
for (let row = 0; row < rows; row += 1) {
const line = lines[Math.floor((row * lines.length) / rows)];
if (!line || line.width <= 0) continue;
const lineWidth = Math.max(2, Math.min(1, line.width) * span);
const left = user ? base + span - lineWidth : base + line.indent * span;
context.globalAlpha = line.code ? 0.6 : 1;
context.fillRect(left, top + row * rowStep, Math.min(lineWidth, base + span - left), Math.min(stroke, blockHeight));
}
context.globalAlpha = 1;
}
}, [refreshAxes, scrollContainerRef]);
/** Overview position (0..railHeight) of a scroll-content offset. */
const toRail = (offset: number, scrollHeight: number, railHeight: number): number => {
const { weights, pixels } = axesRef.current;
const count = pixels.length - 1;
const totalPixels = pixels[count] ?? 0;
const totalWeight = weights[count] ?? 0;
if (totalPixels <= 0 || totalWeight <= 0 || scrollHeight <= 0) return (offset / Math.max(1, scrollHeight)) * railHeight;
// Scroll content and the row model differ only by padding; scale linearly.
const pixel = (offset / scrollHeight) * totalPixels;
return (mapAxis(pixels, weights, pixel) / totalWeight) * railHeight;
};
/** Scroll-content offset of an overview position (inverse of toRail). */
const fromRail = (y: number, scrollHeight: number, railHeight: number): number => {
const { weights, pixels } = axesRef.current;
const count = pixels.length - 1;
const totalPixels = pixels[count] ?? 0;
const totalWeight = weights[count] ?? 0;
if (totalPixels <= 0 || totalWeight <= 0 || railHeight <= 0) return (y / Math.max(1, railHeight)) * scrollHeight;
const pixel = mapAxis(weights, pixels, (y / railHeight) * totalWeight);
return (pixel / totalPixels) * scrollHeight;
};
/** Syncs the slider with the scroller; runs synchronously in the event. */
const sync = useCallback((): void => {
const rail = railRef.current;
const slider = sliderRef.current;
const container = scrollContainerRef.current;
if (!rail || !slider || !container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const scrollable = scrollHeight > clientHeight + 1 && dataRef.current.messages.length > 0;
const last = layoutRef.current;
if (last.scrollable !== scrollable) rail.style.display = scrollable ? "" : "none";
if (!scrollable) {
layoutRef.current = { ...last, scrollable };
return;
}
// The rail spans the reading area below the title bar and context strip
// (CSS). Its height is read only when it may have changed, so a plain
// scroll event does no layout reads.
let { railHeight } = last;
const shown = !last.scrollable;
if (shown || railHeight < 0) railHeight = rail.clientHeight || clientHeight;
// Row measurements change scrollHeight; only then rebuild the pixel axis.
if (shown || last.scrollHeight !== scrollHeight || axesRef.current.messages !== dataRef.current.messages) refreshAxes();
// The slider covers the overview span of the rows in view, so it sits
// exactly on the strokes of the visible messages.
let top = toRail(scrollTop, scrollHeight, railHeight);
let bottom = toRail(scrollTop + clientHeight, scrollHeight, railHeight);
if (bottom - top < 16) {
const center = (top + bottom) / 2;
top = Math.max(0, Math.min(railHeight - 16, center - 8));
bottom = top + 16;
}
slider.style.height = `${bottom - top}px`;
slider.style.transform = `translateY(${top}px)`;
const maxScrollTop = scrollHeight - clientHeight;
const percent = Math.round(maxScrollTop > 0 ? (scrollTop / maxScrollTop) * 100 : 0);
if (percent !== last.percent) rail.setAttribute("aria-valuenow", String(percent));
layoutRef.current = { scrollable, railHeight, percent, scrollHeight };
if (shown) draw(false);
}, [draw, refreshAxes, scrollContainerRef]);
useLayoutEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
container.addEventListener("scroll", sync, { passive: true });
// Content growth (streaming, measurement, resize) changes scrollHeight
// without a scroll event.
const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(sync);
observer?.observe(container);
if (container.firstElementChild) observer?.observe(container.firstElementChild);
// The rail itself shrinks when the context strip above the reading area
// grows, independently of the scroller size.
const rail = railRef.current;
const railObserver = typeof ResizeObserver === "undefined" || !rail
? undefined
: new ResizeObserver(() => {
if (rail.clientHeight === layoutRef.current.railHeight) return;
layoutRef.current = { ...layoutRef.current, railHeight: -1 };
sync();
draw(false);
});
if (rail) railObserver?.observe(rail);
// The per-commit effect below performs the initial sync.
return () => {
container.removeEventListener("scroll", sync);
observer?.disconnect();
railObserver?.disconnect();
};
}, [draw, scrollContainerRef, sync]);
// Pane commits keep the slider in sync (row measurements move scrollHeight).
// The canvas redraws only when the message list itself changes; draw()
// compares identities, so measurement-only commits never repaint it.
useLayoutEffect(() => {
dataRef.current = { messages, rowHeights };
sync();
if (layoutRef.current.scrollable) draw(false);
});
const scrollToSliderTop = (clientY: number, grabOffset: number): void => {
const rail = railRef.current;
const container = scrollContainerRef.current;
if (!rail || !container) return;
const bounds = rail.getBoundingClientRect();
const target = fromRail(clientY - bounds.top - grabOffset, container.scrollHeight, bounds.height);
const maxScrollTop = container.scrollHeight - container.clientHeight;
container.scrollTop = minimapScrollTop(maxScrollTop > 0 ? target / maxScrollTop : 0, container.scrollHeight, container.clientHeight);
sync();
};
const onPointerDown = (event: PointerEvent<HTMLDivElement>): void => {
if (event.button !== 0) return;
const slider = sliderRef.current;
if (!slider) return;
const sliderBounds = slider.getBoundingClientRect();
const onSlider = event.clientY >= sliderBounds.top && event.clientY <= sliderBounds.bottom;
// Grabbing the slider keeps its offset; clicking the track centers it.
const grabOffset = onSlider ? event.clientY - sliderBounds.top : sliderBounds.height / 2;
dragRef.current = { pointerId: event.pointerId, grabOffset };
event.currentTarget.setPointerCapture?.(event.pointerId);
event.preventDefault();
if (!onSlider) scrollToSliderTop(event.clientY, grabOffset);
};
const onPointerMove = (event: PointerEvent<HTMLDivElement>): void => {
const drag = dragRef.current;
if (drag && drag.pointerId === event.pointerId) scrollToSliderTop(event.clientY, drag.grabOffset);
};
const endDrag = (event: PointerEvent<HTMLDivElement>): void => {
if (dragRef.current?.pointerId !== event.pointerId) return;
dragRef.current = undefined;
event.currentTarget.releasePointerCapture?.(event.pointerId);
};
const setExpanded = (expanded: boolean): void => {
if (expandedRef.current === expanded) return;
expandedRef.current = expanded;
const rail = railRef.current;
if (rail) rail.dataset.expanded = expanded ? "true" : "false";
draw(true);
};
const onWheel = (event: WheelEvent<HTMLDivElement>): void => {
const container = scrollContainerRef.current;
if (!container) return;
container.scrollTop += event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
const container = scrollContainerRef.current;
if (!container) return;
const step = Math.max(1, container.clientHeight * 0.8);
if (event.key === "Home") container.scrollTo({ top: 0, behavior: "auto" });
else if (event.key === "End") container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
else if (event.key === "PageUp") container.scrollTo({ top: container.scrollTop - step, behavior: "auto" });
else if (event.key === "PageDown") container.scrollTo({ top: container.scrollTop + step, behavior: "auto" });
else return;
event.preventDefault();
};
return (
<div
aria-controls="chat-message-list"
aria-label={t("chat.minimap")}
aria-orientation="vertical"
aria-valuemax={100}
aria-valuemin={0}
className="chat-minimap"
data-expanded="false"
onKeyDown={onKeyDown}
onLostPointerCapture={() => { dragRef.current = undefined; }}
onPointerCancel={endDrag}
onPointerDown={onPointerDown}
onPointerEnter={() => setExpanded(true)}
onPointerLeave={() => { if (!dragRef.current) setExpanded(false); }}
onPointerMove={onPointerMove}
onPointerUp={(event) => {
endDrag(event);
const bounds = railRef.current?.getBoundingClientRect();
if (bounds && (event.clientX < bounds.left || event.clientX > bounds.right)) setExpanded(false);
}}
onWheel={onWheel}
ref={railRef}
// Shown by sync() once the scroller overflows; React never rewrites it.
style={{ display: "none" }}
role="scrollbar"
tabIndex={0}
>
<canvas aria-hidden="true" className="chat-minimap__canvas" ref={canvasRef} />
<span aria-hidden="true" className="chat-minimap__viewport" ref={sliderRef} />
</div>
);
}
+11 -2
View File
@@ -37,7 +37,9 @@ function ConversationHistoryLoader({ conversationId, active, load }: {
</section>;
}
type PaneProps = Omit<ComponentProps<typeof ChatHistoryPane>, "conversation" | "taskStrip" | "artifactById">;
type PaneProps = Omit<ComponentProps<typeof ChatHistoryPane>, "conversation" | "taskStrip" | "artifactById" | "onVisibleMessageCountChange" | "visibleMessageCount">;
const ignoreVisibleMessageCountChange = (): void => undefined;
export type ConversationHistorySlotProps = PaneProps & {
conversationId: string;
@@ -110,5 +112,12 @@ export const ConversationHistorySlot = memo(function ConversationHistorySlot({
/>
);
}
return <ChatHistoryPane {...paneProps} artifactById={artifactById} conversation={conversation} taskStrip={taskStrip} />;
return <ChatHistoryPane
{...paneProps}
artifactById={artifactById}
conversation={conversation}
onVisibleMessageCountChange={ignoreVisibleMessageCountChange}
taskStrip={taskStrip}
visibleMessageCount={conversation.messages.length}
/>;
});
@@ -439,6 +439,7 @@ export const app = {
copyMessage: 'Copy this response',
loadEarlierMessages: 'Load earlier messages ({{count}} remaining)',
scrollToBottom: 'Scroll to bottom',
minimap: 'Conversation minimap',
status: {
preparingRequest: 'Preparing request',
waitingForProgress: 'Waiting for further progress',
@@ -424,6 +424,7 @@ export const app = {
copyMessage: '复制此回复',
loadEarlierMessages: '加载更早的消息(还剩 {{count}} 条)',
scrollToBottom: '到底部',
minimap: '对话缩略图',
status: {
preparingRequest: '正在准备请求',
waitingForProgress: '等待后续进展',
+78
View File
@@ -7343,6 +7343,84 @@ button > svg {
color: var(--danger);
}
/* The minimap replaces the native scrollbar of the message scroller. */
.chat-history-pane > .chat {
scrollbar-width: none;
}
.chat-history-pane > .chat::-webkit-scrollbar {
display: none;
}
/* The rail covers only the reading area: below the context strip and, with
frosted glass, below the title bar the pane extends under. */
.chat-minimap {
position: absolute;
z-index: 3;
top: var(--chat-context-height, 0px);
right: 0;
bottom: 0;
width: 14px;
overflow: hidden;
background: transparent;
cursor: default;
touch-action: none;
user-select: none;
}
.app-shell[data-frosted-glass='true'] .chat-minimap {
top: calc(58px + var(--chat-context-height, 0px));
}
.chat-minimap[data-expanded='true'] {
width: 80px;
border-left: 1px solid var(--border-default);
background: var(--surface-glass-panel);
box-shadow: var(--shadow-card);
backdrop-filter: blur(8px);
}
.chat-minimap:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.chat-minimap__canvas {
display: block;
width: 100%;
height: 100%;
opacity: 0.55;
}
.chat-minimap[data-expanded='true'] .chat-minimap__canvas {
opacity: 0.85;
}
.chat-minimap__viewport {
position: absolute;
top: 0;
right: 0;
left: 0;
background: color-mix(in srgb, var(--text-muted, #8f98a8) 22%, transparent);
pointer-events: none;
will-change: transform;
}
.chat-minimap:hover .chat-minimap__viewport,
.chat-minimap:active .chat-minimap__viewport {
background: color-mix(in srgb, var(--accent) 24%, transparent);
}
@media (max-width: 700px) {
.chat-minimap {
width: 10px;
}
.chat-minimap[data-expanded='true'] {
width: 64px;
}
}
.message__status--active {
color: var(--text-muted);
}