重构会话页面的消息渲染,支持块级虚拟化,提升长会话性能,减少卡断

This commit is contained in:
xintaofei
2026-03-24 18:52:25 +08:00
parent b7df63c5f8
commit 284e45fbdf
11 changed files with 386 additions and 223 deletions

View File

@@ -18,7 +18,7 @@
"@streamdown/code": "^1.0.2",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@tanstack/react-virtual": "^3.13.18",
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2",

482
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -863,11 +863,9 @@ fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
let turn_model = msg.model.clone();
i += 1;
// Absorb consecutive assistant msgs AND tool-result-only user msgs
while i < messages.len()
&& (matches!(messages[i].role, MessageRole::Assistant)
|| is_tool_result_only(&messages[i]))
{
// Only absorb immediately following tool-result-only user msgs
// (stop at the next assistant message to keep turns small for virtualization)
while i < messages.len() && is_tool_result_only(&messages[i]) {
blocks.extend(messages[i].content.clone());
i += 1;
}

View File

@@ -1192,9 +1192,10 @@ fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
let timestamp = msg.timestamp;
i += 1;
// Only absorb immediately following Tool messages
// (stop at the next assistant message to keep turns small for virtualization)
while i < messages.len()
&& (matches!(messages[i].role, MessageRole::Assistant)
|| matches!(messages[i].role, MessageRole::Tool))
&& matches!(messages[i].role, MessageRole::Tool)
{
blocks.extend(messages[i].content.clone());
if usage.is_none() {

View File

@@ -681,9 +681,10 @@ fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
let timestamp = msg.timestamp;
i += 1;
// Only absorb immediately following Tool messages
// (stop at the next assistant message to keep turns small for virtualization)
while i < messages.len()
&& (matches!(messages[i].role, MessageRole::Assistant)
|| matches!(messages[i].role, MessageRole::Tool))
&& matches!(messages[i].role, MessageRole::Tool)
{
blocks.extend(messages[i].content.clone());
if usage.is_none() {

View File

@@ -1105,9 +1105,10 @@ fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
let timestamp = msg.timestamp;
i += 1;
// Only absorb immediately following Tool messages
// (stop at the next assistant message to keep turns small for virtualization)
while i < messages.len()
&& (matches!(messages[i].role, MessageRole::Assistant)
|| matches!(messages[i].role, MessageRole::Tool))
&& matches!(messages[i].role, MessageRole::Tool)
{
blocks.extend(messages[i].content.clone());
if usage.is_none() {

View File

@@ -667,9 +667,10 @@ fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
let timestamp = msg.timestamp;
i += 1;
// Only absorb immediately following Tool messages
// (stop at the next assistant message to keep turns small for virtualization)
while i < messages.len()
&& (matches!(messages[i].role, MessageRole::Assistant)
|| matches!(messages[i].role, MessageRole::Tool))
&& matches!(messages[i].role, MessageRole::Tool)
{
blocks.extend(messages[i].content.clone());
if usage.is_none() {

View File

@@ -139,7 +139,7 @@ export const Reasoning = memo(
return (
<ReasoningContext.Provider value={contextValue}>
<Collapsible
className={cn("not-prose mb-4", className)}
className={cn("not-prose", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}

View File

@@ -28,7 +28,7 @@ export type ToolProps = ComponentProps<typeof Collapsible>
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("group mb-4 w-full rounded-md border", className)}
className={cn("group w-full rounded-md border", className)}
{...props}
/>
)

View File

@@ -60,6 +60,8 @@ type ThreadRenderItem =
kind: "turn"
group: ResolvedMessageGroup
phase: "persisted" | "optimistic" | "streaming"
showStats: boolean
isRoleTransition: boolean
}
| {
key: string
@@ -69,9 +71,11 @@ type ThreadRenderItem =
const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
group,
dimmed = false,
showStats = true,
}: {
group: ResolvedMessageGroup
dimmed?: boolean
showStats?: boolean
}) {
return (
<div className={dimmed ? "opacity-70" : undefined}>
@@ -86,7 +90,7 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
<UserResourceLinks resources={group.resources} className="self-end" />
) : null}
</Message>
{group.role === "assistant" && (
{showStats && group.role === "assistant" && (
<TurnStats
usage={group.usage}
duration_ms={group.duration_ms}
@@ -214,9 +218,40 @@ export function MessageListView({
model: msg.model,
},
phase,
showStats: false,
isRoleTransition: false,
}
})
// Compute showStats and isRoleTransition for each turn item
for (let idx = 0; idx < items.length; idx++) {
const item = items[idx]
if (item.kind !== "turn") continue
// isRoleTransition: role differs from previous turn item
if (idx > 0) {
const prev = items[idx - 1]
if (
prev.kind === "turn" &&
prev.group.role !== item.group.role
) {
item.isRoleTransition = true
}
}
// showStats: only on the last assistant turn before a non-assistant or end
if (item.group.role === "assistant") {
const next = items[idx + 1]
if (
!next ||
next.kind !== "turn" ||
next.group.role !== "assistant"
) {
item.showStats = true
}
}
}
const lastPhase = timelineTurns[timelineTurns.length - 1]?.phase ?? null
if (
lastPhase === "optimistic" &&
@@ -237,21 +272,29 @@ export function MessageListView({
[historicalPlanEntries]
)
const renderThreadItem = useCallback((item: ThreadRenderItem) => {
switch (item.kind) {
case "turn":
return (
<HistoricalMessageGroup
group={item.group}
dimmed={item.phase === "optimistic"}
/>
)
case "typing":
return <PendingTypingIndicator />
default:
return null
}
}, [])
const renderThreadItem = useCallback(
(item: ThreadRenderItem) => {
switch (item.kind) {
case "turn": {
const pt = item.isRoleTransition ? 16 : 0
return (
<div style={pt > 0 ? { paddingTop: pt } : undefined}>
<HistoricalMessageGroup
group={item.group}
dimmed={item.phase === "optimistic"}
showStats={item.showStats}
/>
</div>
)
}
case "typing":
return <PendingTypingIndicator />
default:
return null
}
},
[]
)
const emptyState = useMemo(
() =>
@@ -304,7 +347,7 @@ export function MessageListView({
getItemKey={(item) => item.key}
renderItem={renderThreadItem}
emptyState={emptyState}
estimateSize={180}
estimateSize={100}
overscan={10}
/>
<MessageThreadScrollButton />

View File

@@ -1,6 +1,6 @@
"use client"
import { useCallback } from "react"
import { useCallback, useEffect } from "react"
import type { ReactNode } from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import { useStickToBottomContext } from "use-stick-to-bottom"
@@ -17,6 +17,7 @@ interface VirtualizedMessageThreadProps<T> {
emptyState?: ReactNode
estimateSize?: number
overscan?: number
gap?: number
className?: string
contentClassName?: string
contentProps?: Omit<MessageThreadContentProps, "children" | "className">
@@ -29,6 +30,7 @@ export function VirtualizedMessageThread<T>({
emptyState,
estimateSize = 160,
overscan = 8,
gap = 16,
className,
contentClassName,
contentProps,
@@ -45,13 +47,23 @@ export function VirtualizedMessageThread<T>({
isScrollingResetDelay: 100,
paddingStart: 16,
paddingEnd: 16,
gap: 32,
gap,
getItemKey: (index) => {
const item = items[index]
return item ? getItemKey(item, index) : index
},
})
// Adjust scroll position when items above the viewport are measured
// differently from their estimates — prevents blank gaps when scrolling up
useEffect(() => {
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (
item,
_delta,
instance
) => item.start < (instance.scrollOffset ?? 0)
}, [virtualizer])
const renderVirtualRow = useCallback(
(virtualItem: ReturnType<typeof virtualizer.getVirtualItems>[number]) => {
const item = items[virtualItem.index]