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:
@@ -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);
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: '等待后续进展',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user