fix(ui): stabilize workplace chat scrolling

This commit is contained in:
LZH-YS1998
2026-07-13 23:02:39 +08:00
parent 4e7aa75ba5
commit 4bc18dcd27
46 changed files with 5937 additions and 1258 deletions
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useReducer, useState } from 'react'
import { useCallback, useMemo, useReducer, useRef, useState } from 'react'
import type { ChatChannel, ChatMessage } from '../types/chat'
import { stableMessageTimelineKey } from '../lib/messageTimelineIdentity'
function uid(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
@@ -73,6 +74,12 @@ function messageIdentityKeys(message: ChatMessage): Set<string> {
return keys
}
function scopedMessageIdentityKeys(message: ChatMessage): Set<string> {
return new Set(
[...messageIdentityKeys(message)].map(key => `${message.channelId}\u0000${key}`),
)
}
function isDerivedIdentityKey(value: string): boolean {
return value.startsWith('checkpoint:')
}
@@ -81,6 +88,52 @@ function messageTimestamp(message: ChatMessage): number {
return typeof message.timestamp === 'number' ? message.timestamp : 0
}
function isOptimisticMessage(message: ChatMessage): boolean {
return String(message.id ?? '').startsWith('msg-')
}
function isPersistentMessage(message: ChatMessage): boolean {
// sendMessage creates client-only optimistic rows with this prefix. Once the
// backend acknowledges one, message deduplication replaces its identity with
// the persistent ui_message_id / backend message id.
return !isOptimisticMessage(message)
}
function latestPersistentMessageTimestamps(messages: ChatMessage[]): Record<string, number> {
const latest: Record<string, number> = {}
for (const message of messages) {
if (!isPersistentMessage(message)) continue
const timestamp = messageTimestamp(message)
if (timestamp > (latest[message.channelId] ?? 0)) {
latest[message.channelId] = timestamp
}
}
return latest
}
function advanceReadTimestamp(
state: Record<string, number>,
channelId: string,
timestamp: number,
): Record<string, number> {
if (timestamp <= (state[channelId] ?? 0)) return state
return { ...state, [channelId]: timestamp }
}
function unreadMessageCounts(
messages: ChatMessage[],
readTimestamps: Record<string, number>,
): Record<string, number> {
const counts: Record<string, number> = {}
for (const message of messages) {
if (!isPersistentMessage(message) || message.sender === 'user') continue
const lastRead = readTimestamps[message.channelId] ?? 0
if (messageTimestamp(message) <= lastRead) continue
counts[message.channelId] = (counts[message.channelId] ?? 0) + 1
}
return counts
}
function messageRoleBucket(message: ChatMessage): 'user' | 'assistant' {
const sender = String(message.sender ?? '').trim().toLowerCase()
const metadata = messageMetadata(message)
@@ -154,7 +207,12 @@ function mergeDuplicateMessages(
let preferred = existing
let secondary = candidate
if (preferCandidate) {
const existingOptimistic = isOptimisticMessage(existing)
const candidateOptimistic = isOptimisticMessage(candidate)
if (existingOptimistic !== candidateOptimistic) {
preferred = existingOptimistic ? candidate : existing
secondary = existingOptimistic ? existing : candidate
} else if (preferCandidate) {
preferred = candidate
secondary = existing
} else if (messagePreferenceScore(candidate) > messagePreferenceScore(existing)) {
@@ -184,14 +242,30 @@ function mergeDuplicateMessages(
? normalizedContent
: preferred.content
const existingCheckpointId = String(messageMetadata(existing).checkpoint_id ?? '').trim()
const candidateCheckpointId = String(messageMetadata(candidate).checkpoint_id ?? '').trim()
const preservesCheckpointPosition = !!existingCheckpointId && existingCheckpointId === candidateCheckpointId
const replacesResultSurface = isResultSurface(existing) && isResultSurface(candidate)
const retainedTimelineId = replacesResultSurface
? String(
messageMetadata(existing).ui_timeline_id
?? messageMetadata(candidate).ui_timeline_id
?? stableMessageTimelineKey(existing),
).trim()
: ''
const mergedMetadata = { ...messageMetadata(secondary), ...messageMetadata(preferred) }
if (retainedTimelineId) mergedMetadata.ui_timeline_id = retainedTimelineId
return {
...secondary,
...preferred,
...(canonicalId ? { id: canonicalId } : {}),
content,
metadata: { ...messageMetadata(secondary), ...messageMetadata(preferred) },
metadata: mergedMetadata,
mentions,
timestamp: messageTimestamp(preferred) || messageTimestamp(secondary),
timestamp: preservesCheckpointPosition || replacesResultSurface
? messageTimestamp(existing) || messageTimestamp(candidate)
: messageTimestamp(preferred) || messageTimestamp(secondary),
}
}
@@ -201,7 +275,7 @@ function dedupeMessages(messages: ChatMessage[]): ChatMessage[] {
const identityKeyToIdx = new Map<string, number>()
for (const message of [...messages].sort((a, b) => messageTimestamp(a) - messageTimestamp(b))) {
const candidateIds = messageIdentityKeys(message)
const candidateIds = scopedMessageIdentityKeys(message)
let matchIndex = -1
let preferCandidate = false
@@ -242,7 +316,7 @@ function dedupeMessages(messages: ChatMessage[]): ChatMessage[] {
}
// Register all identity keys for the merged/inserted message for fast future lookups
for (const id of messageIdentityKeys(deduped[insertIdx])) {
for (const id of scopedMessageIdentityKeys(deduped[insertIdx])) {
if (!identityKeyToIdx.has(id)) identityKeyToIdx.set(id, insertIdx)
}
}
@@ -250,8 +324,63 @@ function dedupeMessages(messages: ChatMessage[]): ChatMessage[] {
return deduped
}
function mergeMessagesIntoExisting(
state: ChatMessage[],
incoming: ChatMessage[],
): ChatMessage[] {
const merged = [...state]
const identityKeyToIdx = new Map<string, number>()
merged.forEach((message, index) => {
for (const key of scopedMessageIdentityKeys(message)) identityKeyToIdx.set(key, index)
})
for (const candidate of [...incoming].sort((a, b) => messageTimestamp(a) - messageTimestamp(b))) {
let matchIndex = -1
for (const key of scopedMessageIdentityKeys(candidate)) {
const index = identityKeyToIdx.get(key)
if (index !== undefined) {
matchIndex = index
break
}
}
if (matchIndex < 0) {
for (let index = merged.length - 1; index >= 0; index -= 1) {
if (messagesSemanticallyMatch(merged[index], candidate)) {
matchIndex = index
break
}
}
}
if (matchIndex < 0) {
matchIndex = merged.length
merged.push(candidate)
} else {
const mounted = merged[matchIndex]
merged[matchIndex] = mergeDuplicateMessages(
mounted,
candidate,
mounted.id === candidate.id,
)
}
for (const key of scopedMessageIdentityKeys(candidate)) identityKeyToIdx.set(key, matchIndex)
for (const key of scopedMessageIdentityKeys(merged[matchIndex])) identityKeyToIdx.set(key, matchIndex)
}
return merged.sort((a, b) => (
messageTimestamp(a) === messageTimestamp(b)
? a.id.localeCompare(b.id)
: messageTimestamp(a) - messageTimestamp(b)
))
}
export const __chatStoreTestUtils = {
advanceReadTimestamp,
dedupeMessages,
latestPersistentMessageTimestamps,
mergeMessagesIntoExisting,
unreadMessageCounts,
}
type ChannelAction =
@@ -335,7 +464,7 @@ function messageReducer(state: ChatMessage[], action: MessageAction): ChatMessag
}
case 'MERGE': {
if (action.messages.length === 0) return state
return dedupeMessages([...state, ...action.messages])
return mergeMessagesIntoExisting(state, action.messages)
}
case 'MARK_SENDER_DELETED': return state.map(m =>
m.sender === action.senderId ? { ...m, senderDeleted: true, senderName: '[已删除的 Agent]' } : m
@@ -372,6 +501,14 @@ export function useChatStore(): ChatStoreState {
const [messages, dispatchMsg] = useReducer(messageReducer, [])
const [readTimestamps, setReadTimestamps] = useState<Record<string, number>>({})
const [scopeProjectId, setScopeProjectId] = useState<string>('default')
const readBaselineProjectRef = useRef<string | null>(null)
const latestPersistentTimestamps = useMemo(
() => latestPersistentMessageTimestamps(messages),
[messages],
)
const latestPersistentTimestampsRef = useRef(latestPersistentTimestamps)
latestPersistentTimestampsRef.current = latestPersistentTimestamps
const messagesByChannel = useMemo<Record<string, ChatMessage[]>>(() => {
const buckets: Record<string, ChatMessage[]> = {}
@@ -382,16 +519,10 @@ export function useChatStore(): ChatStoreState {
return buckets
}, [messages])
const unreadCounts = useMemo<Record<string, number>>(() => {
const counts: Record<string, number> = {}
for (const message of messages) {
if (message.sender === 'user') continue
const lastRead = readTimestamps[message.channelId] ?? 0
if (message.timestamp <= lastRead) continue
counts[message.channelId] = (counts[message.channelId] ?? 0) + 1
}
return counts
}, [messages, readTimestamps])
const unreadCounts = useMemo(
() => unreadMessageCounts(messages, readTimestamps),
[messages, readTimestamps],
)
const sendMessage = useCallback((opts: {
channelId: string; sender: string; senderName: string; content: string;
@@ -421,7 +552,9 @@ export function useChatStore(): ChatStoreState {
}, [unreadCounts])
const markRead = useCallback((channelId: string) => {
setReadTimestamps(prev => ({ ...prev, [channelId]: Date.now() }))
const latestTimestamp = latestPersistentTimestampsRef.current[channelId] ?? 0
if (latestTimestamp <= 0) return
setReadTimestamps(prev => advanceReadTimestamp(prev, channelId, latestTimestamp))
}, [])
const markSenderDeleted = useCallback((agentId: string) => {
@@ -440,12 +573,15 @@ export function useChatStore(): ChatStoreState {
const clear = useCallback(() => {
dispatchCh({ type: 'CLEAR' })
dispatchMsg({ type: 'CLEAR' })
readBaselineProjectRef.current = null
setReadTimestamps({})
}, [])
const initFromBackend = useCallback((projectId: string, chs: ChatChannel[], msgs: ChatMessage[]) => {
const nextProjectId = projectId || 'default'
const projectChanged = nextProjectId !== scopeProjectId
const shouldResetReadBaseline = readBaselineProjectRef.current !== nextProjectId
readBaselineProjectRef.current = nextProjectId
setScopeProjectId(nextProjectId)
dispatchCh({ type: 'SET', channels: chs })
// Backend `collab_sync` / `collab_sync_push` payloads carry the
@@ -462,14 +598,11 @@ export function useChatStore(): ChatStoreState {
} else {
dispatchMsg({ type: 'MERGE', messages: msgs })
}
// Mark all loaded messages as read so they don't show as unread (#17)
const latest: Record<string, number> = {}
for (const m of msgs) {
if (!latest[m.channelId] || m.timestamp > latest[m.channelId]) {
latest[m.channelId] = m.timestamp
}
// Establish one read baseline when entering a project. Repeated full-sync
// payloads must not advance it behind the viewport controller's back.
if (shouldResetReadBaseline) {
setReadTimestamps(latestPersistentMessageTimestamps(msgs))
}
setReadTimestamps(prev => projectChanged ? latest : ({ ...prev, ...latest }))
}, [scopeProjectId])
const addMessageFromBackend = useCallback((msg: ChatMessage) => {