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
@@ -1,6 +1,6 @@
# 聊天历史导航
聊天历史导航负责长会话的连续滚动、窗口化渲染,以及后续的对话 minimap。第一阶段先取消向上滚动时每次增加 80 条消息的产品限制,保留已有的消息窗口化机制;第二阶段再评估 minimap。
聊天历史导航负责长会话的连续滚动、窗口化渲染和对话 minimap。当前实现已经取消向上滚动时每次增加 80 条消息的产品限制,保留已有的消息窗口化机制,并用轻量 canvas 提供消息分布和快速定位。
## 文档导航
@@ -14,4 +14,4 @@
- **历史窗口**:允许滚动访问的消息范围。第一阶段扩大为当前已加载会话的全部消息。
- **DOM 窗口**:实际挂载消息组件的范围。继续由消息高度估算和最多约 60 条挂载消息控制。
- **minimap**:对话右侧的轻量纵向全局导航概览,不等于完整渲染所有消息。
- **minimap**:替代消息区原生滚动条的 canvas 全局概览,高度与滚动区一致,悬停时展开为较宽的缩略长图;不等于完整渲染所有消息。
@@ -14,4 +14,62 @@
- App 测试通过 341/349;剩余 8 个失败集中在既有图片附件异步场景,未出现聊天历史窗口相关失败。
- 全局 `npm run typecheck` 仍被既有 `src/main/desktop-storage-electron-fixture.ts:215-217` 类型错误阻断;全局 lint 仍包含生成的 VitePress cache 错误。
- A 组临时恢复后连续两次在 composer 初始化阶段超时,未进入性能场景,因此没有有效 A 组数据,不能据此推导 A/B 数值差异。B 组代码已恢复并重新完成 bundle、定向类型检查、定向 lint 和 58/58 聊天窗口测试。
- 下一步实现 minimap 前仍需先修复性能驱动在 A 组接线下的 composer 初始化问题,或建立不依赖 composer 的长会话专用 benchmark,再进行严格 A/B 测量。
## 2026-10-11
- 已确认工作区干净,生产代码保持 `47917094` 的 A 组:向上滚动仍按 80 条揭示,DOM 窗口化仍保留。
- 当前聊天窗口和时间线定向测试:59/59 通过,包含 2,000 条消息连续滚动、实际挂载数量上限、锚点稳定、状态恢复和 80 条历史揭示。
- App 单元测试:342/350 通过;8 个失败均为等待状态圆点的既有异步断言,失败位置不涉及历史窗口逻辑。
- `npm run build:bundle`:通过。
- 当前 A 组首次可重复的 `npm run perf:app`:通过。长会话打开交互延迟 54.4 ms;长会话滚动无长任务,帧间隔 p95 8.5 ms;流式输入延迟 p95 10.0 ms;流式输入场景无长任务。
- 性能基准内存回收:24 次切换后 mounted panes 保持 6,DOM 从 24,746 降至约 2,446,堆从 50.5 MB 回收至约 22 MB,后半段增长约 0.1 MB。
- 之前记录的“A 组 composer 初始化超时”未在当前回退后的基线复现;现在已经取得有效 A 组数据,后续可在同一 bundle 和环境下重新接线 B 组做严格交替 A/B。
- minimap 尚未实现;在 B 组完成交替测量前不进入 minimap 性能对比。
- B 组已重新接线:`ConversationHistorySlot` 对完整会话传入 `conversation.messages.length`,App 层不再维护 80 条可见数量状态;摘要会话仍走原有历史加载流程。
- B 组 App 历史回归:4/4 通过,覆盖无历史闸门、长会话有界提交、暖切换位置和展开状态、超过 500 条历史保留。
- B 组 `npm run build:bundle`:通过;改动文件 web 类型检查和 lint:通过。
- B 组 `npm run perf:app`:通过。长会话打开交互延迟 46.8 ms,长会话滚动无长任务、帧间隔 p95 8.5 ms;流式输入延迟 p95 9.5 ms,无长任务。
- A/B 基准对比:B 组 DOM 在长会话滚动后约 2,644、24 次切换回收后约 2,446;A 组对应约 2,632 和 2,446。B 组 24 次切换后的堆约 22.9 MB,后半段增长约 0.1 MB;A 组约 22.0 MB,后半段增长约 0.1 MB。当前没有观察到明显滚动、流式或内存回收回归。
- 全量 App 测试:B 组 341/350 通过;剩余 9 个中 1 个为已迁移历史测试前的旧断言结果,8 个仍为既有等待状态圆点异步断言。迁移后的历史相关测试已单独通过。
- B 组尚未提交;下一步继续做多轮交替 A/B 和低端机模拟,再决定是否进入 minimap。
## 2026-10-12
- C 组已实现 minimap:使用单个 canvas 绘制用户、助手、工具/待处理和错误消息色带,不为每条消息增加 DOM 节点。
- minimap 显示当前滚动视口,支持点击、指针拖动、Home、End、PageUp 和 PageDown;使用现有滚动容器和消息高度估算定位。
- 新增 `ChatMinimap.test.tsx`,覆盖颜色分类、坐标边界、跳转和键盘操作;minimap、历史窗口和时间线定向测试共 62/62 通过。
- C 组 App 历史相关回归 4/4 通过;改动文件 lint、web 类型检查和 `npm run build:bundle` 通过。
- C 组 `npm run perf:app`:通过。长会话打开交互延迟 46.4 ms;长会话滚动帧间隔 p95 8.5 ms、无长任务;流式输入延迟 p95 9.6 ms、无长任务。
- B/C 单次对比:滚动 p95 均为 8.5 ms 且无长任务;B 组流式输入 p95 9.5 ms,C 组 9.6 ms;B 组 24 次切换后 DOM/堆约 2,446/22.9 MB,C 组约 2,449/23.1 MB,后半段堆增长均约 0.1 MB。
- C 组尚未做多轮交替和低端机模拟;当前结果支持继续保留实现并进入更广泛验证,不能单凭单次数据宣称最终性能结论。
### 2026-10-12 交互修订
评审发现初版 minimap 是悬浮在中部的 220 px 小条,视口指示经 `requestAnimationFrame` + React 状态更新而滞后于滚动,且没有悬停放大。已修订:
- minimap 替代消息滚动区的原生滚动条:`.chat-history-pane > .chat` 隐藏原生滚动条,minimap 高度与滚动区 `clientHeight` 一致并贴合右侧。
- 视口滑块在 `scroll` 事件和 `ResizeObserver` 回调中同步写入 `transform`,不经过 React 提交或下一帧;滚动区尺寸不变时只写 `transform`,不读布局。
- canvas 仅在消息变化、行高测量变化、尺寸变化或展开/收起时重绘;普通滚动不重绘。
- 悬停时展开为 96 px(窄屏 64 px)的缩略长图:用户消息靠右、助手消息靠左并按内容长度给出线宽;拖动中离开不收起。
- 点击轨道将滑块中心移到指针处,拖动滑块保持抓取偏移;滚轮转发给消息区;保留 Home/End/PageUp/PageDown;无障碍角色为 `scrollbar`,文案使用 i18n `chat.minimap`。
- 验证:minimap、历史窗口和时间线定向测试 65/65;web 类型检查和改动文件 lint 通过;`App.test.tsx` 342/350,8 个失败与此前相同(等待状态圆点异步断言);`npm run build:bundle` 通过。
- `npm run perf:app` 通过:长会话打开帧 p95 8.6 ms、无长任务;长会话滚动帧 p95 8.6 ms、无长任务;`stream-seeded`/`stream-and-type` 无长任务、帧 p95 8.5 ms;24 次切换后 DOM 2,449、堆 23 MB,后半段增长 0.1 MB。
- 未验证:真实鼠标悬停/拖动的视觉效果仍需在桌面端人工确认;多轮交替与低端机模拟仍未做。
### 2026-10-12 第二次交互修订
- minimap 只覆盖阅读区:顶部从 `--chat-context-height`(任务条)开始,毛玻璃模式下再让出 58 px 标题栏;滑块轨道高度取 rail 实际高度,rail 自身尺寸变化由独立 `ResizeObserver` 触发重算。
- 展开宽度调为 120 px(后续按反馈改为 80 px)。展开视图改为逐行描线:`minimapLines` 按约 72 列折行(CJK 按两列计)、保留空行、代码块缩进并降低不透明度,每条消息最多取 600 行;消息块高度装不下时按行均匀抽样。行剖面按消息对象缓存在 `WeakMap`,流式更新产生新对象时才重算。
- 验证:定向测试 67/67(新增折行剖面和逐行绘制用例);web 类型检查与 lint 通过;`npm run build:bundle` 通过。
- `npm run perf:app` 通过,各场景帧 p95 8.5–8.6 ms;长会话打开、长会话滚动、两个流式场景均无长任务;内存回收与上一轮一致。运行中出现的 renderer error 为网络更新检查 `AbortError`,与本功能无关。
### 2026-10-12 第三次交互修订
- 展开宽度改为 80 px(窄屏仍为 64 px)。
- 已取消的回复在缩略图中改为助手灰色,与正文状态行一致;只有失败的回复显示红色(复用 `isCancelledMessage`)。
- 修正滚动时缩略图密度变化:此前每条消息的色块高度取 `MessageRowHeights.size()`,未测量行使用“已测行平均高度”估算;滚动挂载新行后估算值和实测值变化,每次提交都会按新高度重画整张图。现改为双轴:
- 绘制轴:`minimapMessageWeight`(折行数 + 3 行头部 + 每个工具 2 行),只依赖消息内容;画布只在消息数组变化(新消息、流式输出)或尺寸、展开状态变化时重画,行测量不再触发重画。
- 滚动轴:行高前缀和,仅在 `scrollHeight` 变化时重建。滑块位置和高度按行在两轴之间分段线性映射,覆盖视口内消息在缩略图上的实际区段(最小 16 px);点击和拖动走反向映射。
- 验证:minimap、历史窗口、时间线定向测试 69/69(新增“行测量不��发重画”和“滑块对齐可见行的概览区段”用例);web 类型检查与 lint 通过;`npm run build:bundle` 通过。
- `npm run perf:app` 连续两次通过。第二次结果:长会话打开、长会话滚动、`stream-seeded`、`stream-and-type` 均无长任务,帧 p95 8.5 ms;24 次切换后 DOM 2,449、堆 23.2 MB,后半段增长 0.2 MB。第一次运行中长会话滚动帧 p95 为 16.6 ms,`stream-seeded` 有 1 个 62 ms 长任务,第二次未复现,按单次噪声记录。
- 未验证:真实桌面端滚动时的视觉稳定性需人工确认。
@@ -2,7 +2,9 @@
## 当前边界
`ChatHistoryPane` 当前有两层机制:`messageRenderBatchSize = 80` 控制向上滚动时可访问的历史窗口;`maxRenderedMessageCount = 60` 控制实际挂载的消息组件。第一阶段只移除前者在生产接线中的作用,保留后者以及消息高度占位和锚点恢复。
`ChatHistoryPane` 当前有两层机制:`messageRenderBatchSize = 80` 控制向上滚动时可访问的历史窗口;`maxRenderedMessageCount = 60` 控制实际挂载的消息组件。当前工作区处于 C 组实验:生产接线直接使用已加载消息总数,并用基于高度估算的 canvas minimap 替代消息区原生滚动条;DOM 窗口化、消息高度占位和锚点恢复保持不变。
minimap(`ChatMinimap.tsx`)的更新路径不经过 React:`scroll` 事件和 `ResizeObserver` 回调同步写入滑块 `transform`,只有滚动区尺寸、消息或行高测量变化时才重绘 canvas;悬停展开改写 `data-expanded` 后重绘一次。React 只在 `ChatHistoryPane` 自身提交时刷新消息数据。
已加载历史的消息数组仍由会话 store 提供。带 `messageSummary` 的会话继续先显示加载状态,由现有 `loadHistory` 路径取得完整历史。这样本实验测量的是滚动历史窗口的变化,不混入存储或 IPC 改造。
@@ -14,7 +16,7 @@
| B 本阶段 | 当前已加载消息全部可访问 | 保留 | 无 |
| C 下一阶段 | 当前已加载消息全部可访问 | 保留 | 有 |
A 组保留在组件级回归测试中;B 组是当前生产实验;C 组待 B 组稳定后实现。
A 组是已记录的生产基线;B 组是无 minimap 的历史导航实验;C 组是当前工作区实验,待多轮对比和低端机模拟完成后再决定发布范围。
## 功能测试计划
@@ -38,7 +40,7 @@ A 组保留在组件级回归测试中;B 组是当前生产实验;C 组待 B
- 实际挂载消息数量;
- 流式输出时输入延迟。
通过条件:B 组功能测试全部通过,实际挂载消息数量仍受窗口上限控制;相对 A 组没有明显的输入、流式输出或滚动回归。若 B 组稳定,再以同一批数据实现 C 组并做增量对比。
通过条件:B 组功能测试全部通过,实际挂载消息数量仍受窗口上限控制;相对 A 组没有明显的输入、流式输出或滚动回归。C 组还需在同一批数据上确认 minimap 的定位行为和增量性能成本。当前 A/B/C 数据记录在 `progress.md`。
## 验证命令
+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);
}