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:
@@ -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`。
|
||||
|
||||
## 验证命令
|
||||
|
||||
|
||||
@@ -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