重构会话页面的消息渲染,支持块级虚拟化,提升长会话性能,减少卡断
This commit is contained in:
@@ -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
482
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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) => {
|
||||
const renderThreadItem = useCallback(
|
||||
(item: ThreadRenderItem) => {
|
||||
switch (item.kind) {
|
||||
case "turn":
|
||||
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 />
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user