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
@@ -2,11 +2,20 @@ import type { ChatMessage } from '../types/chat'
import type { ProgressEntry, Session } from '../types/kanban'
import { getContextUsageMetrics } from './contextUsage'
import { isSessionWorking } from './sessionRuntime'
import { stableMessageTimelineKey } from './messageTimelineIdentity'
const CONTEXT_TOKENS_RE = /(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s+tokens/i
const USED_PCT_RE = /(\d{1,3})%\s*used/i
const REMAINING_PCT_RE = /(\d{1,3})%\s*remaining/i
export function isMessageVisibleAtDetailLevel(
message: ChatMessage,
detailLevel: 'summary' | 'full',
): boolean {
if (detailLevel === 'full') return true
return String(message.metadata?.detail_visibility ?? 'summary').trim() !== 'full'
}
function compactWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim()
}
@@ -58,7 +67,7 @@ function resultSurfacePriority(message: ChatMessage): number {
return 0
}
function resultSurfaceDedupeKey(message: ChatMessage): string {
export function resultSurfaceDedupeKey(message: ChatMessage): string {
if (resultSurfacePriority(message) <= 0) return ''
const content = compactWhitespace(stripNarrativeTitlePrefix(message.content)).slice(0, 2000)
return content ? `result:${content}` : ''
@@ -454,10 +463,29 @@ export function getConversationHeaderSession(
export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatMessage[] {
const seen = new Set<string>()
const resultKeyIndex = new Map<string, number>()
const checkpointIndex = new Map<string, number>()
const merged: ChatMessage[] = []
for (const group of messageGroups) {
for (const message of group) {
const metadata = (message.metadata ?? {}) as Record<string, unknown>
const checkpointId = String(metadata.checkpoint_id ?? '').trim()
if (checkpointId) {
const existingIndex = checkpointIndex.get(checkpointId)
if (existingIndex !== undefined) {
const existing = merged[existingIndex]
const latest = message.timestamp >= existing.timestamp ? message : existing
const earliest = message.timestamp < existing.timestamp ? message : existing
merged[existingIndex] = {
...existing,
...latest,
id: existing.id,
channelId: existing.channelId,
timestamp: earliest.timestamp,
metadata: { ...(existing.metadata ?? {}), ...(latest.metadata ?? {}) },
}
continue
}
}
const uiMessageId = typeof metadata.ui_message_id === 'string'
? metadata.ui_message_id.trim()
: ''
@@ -465,16 +493,32 @@ export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatM
if (resultKey) {
const existingIndex = resultKeyIndex.get(resultKey)
if (existingIndex !== undefined) {
if (resultSurfacePriority(message) > resultSurfacePriority(merged[existingIndex])) {
merged[existingIndex] = message
const existing = merged[existingIndex]
const candidateWins = resultSurfacePriority(message) > resultSurfacePriority(existing)
const preferred = candidateWins ? message : existing
const secondary = candidateWins ? existing : message
merged[existingIndex] = {
...secondary,
...preferred,
// A result surface keeps the chronology of the first underlying
// delivery, independent of which related channel happened to be
// traversed first for this render.
timestamp: Math.min(existing.timestamp, message.timestamp),
metadata: {
...(secondary.metadata ?? {}),
...(preferred.metadata ?? {}),
ui_timeline_id: stableMessageTimelineKey(existing),
},
}
continue
}
resultKeyIndex.set(resultKey, merged.length)
}
const dedupeKey = resultKey || uiMessageId || `${message.sender}:${message.replyToId ?? ''}:${message.timestamp}:${message.content.trim()}`
const checkpointKey = checkpointId ? `checkpoint:${checkpointId}` : ''
const dedupeKey = resultKey || checkpointKey || uiMessageId || `${message.sender}:${message.replyToId ?? ''}:${message.timestamp}:${message.content.trim()}`
if (seen.has(dedupeKey)) continue
seen.add(dedupeKey)
if (checkpointId) checkpointIndex.set(checkpointId, merged.length)
merged.push(message)
}
}
@@ -485,6 +529,98 @@ export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatM
))
}
/**
* Build the durable transcript shown by a company/org parent session.
*
* A parent conversation may observe every related runtime channel so that
* canonical role deliveries can be surfaced in one place. Those channels
* also contain transient child turns, however, and rendering all of them in
* the parent transcript makes the visible timeline change whenever the
* runtime projection selects a different child. Keep the parent's committed
* messages and only admit the canonical cross-channel result surfaces.
*/
export function selectCompanySummaryMessages(
messages: ChatMessage[],
parentChannelId: string,
): ChatMessage[] {
const terminalAssistantTurn = (message: ChatMessage): string => {
const metadata = (message.metadata ?? {}) as Record<string, unknown>
const kind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
if (kind !== 'runtime_v2_assistant' && kind !== 'runtime_v2_company_assistant') return ''
return String(metadata.canonical_turn_id ?? metadata.turn_id ?? '').trim()
}
const parentTerminalTurns = new Set(
messages
.filter(message => (
message.channelId === parentChannelId
&& isMessageVisibleAtDetailLevel(message, 'summary')
))
.map(terminalAssistantTurn)
.filter(Boolean),
)
const childTerminalByTurn = new Map<string, ChatMessage>()
const durableMessages: ChatMessage[] = []
for (const message of messages) {
if (message.channelId === parentChannelId) {
if (isMessageVisibleAtDetailLevel(message, 'summary')) {
durableMessages.push(message)
}
continue
}
const metadata = (message.metadata ?? {}) as Record<string, unknown>
const kind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
const checkpointId = String(metadata.checkpoint_id ?? '').trim()
const checkpointType = String(metadata.checkpoint_type ?? '').trim()
const checkpointResponseId = String(metadata.response_to_checkpoint_id ?? '').trim()
const escalationResponseId = String(metadata.response_to_escalation_id ?? '').trim()
if ((checkpointId && checkpointType) || checkpointResponseId || escalationResponseId) {
durableMessages.push(message)
continue
}
if ([
'company_role_result',
'company_role_result_retry',
'child_task_result',
'child_task_result_retry',
'child_result',
'top_level_reply',
].includes(kind)) {
durableMessages.push(message)
continue
}
// Snapshot builder deliberately marks the final runtime surface as
// summary-visible. Preserve that durable contract instead of reusing the
// result-dedupe priority table as a visibility threshold.
const isSummaryTerminal = String(metadata.detail_visibility ?? '').trim() === 'summary'
&& (kind === 'runtime_v2_assistant' || kind === 'runtime_v2_company_assistant')
if (!isSummaryTerminal) continue
const turnId = terminalAssistantTurn(message)
if (!turnId) {
durableMessages.push(message)
continue
}
if (parentTerminalTurns.has(turnId)) continue
const existing = childTerminalByTurn.get(turnId)
if (!existing) {
childTerminalByTurn.set(turnId, message)
continue
}
const existingPriority = resultSurfacePriority(existing)
const candidatePriority = resultSurfacePriority(message)
if (candidatePriority > existingPriority) {
childTerminalByTurn.set(turnId, message)
} else if (
candidatePriority === existingPriority
&& (message.timestamp > existing.timestamp
|| (message.timestamp === existing.timestamp && message.id.localeCompare(existing.id) > 0))
) {
childTerminalByTurn.set(turnId, message)
}
}
durableMessages.push(...childTerminalByTurn.values())
return mergeConversationMessages([durableMessages])
}
export function mergeConversationProgressLog(timelineSessions: Session[]): ProgressEntry[] {
const seen = new Set<string>()
const merged: ProgressEntry[] = []