fix(ui): stabilize workplace chat scrolling
This commit is contained in:
@@ -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[] = []
|
||||
|
||||
Reference in New Issue
Block a user