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
@@ -156,7 +156,7 @@ export function AgentProgressBlock({ entries, agentStatus, currentTool, toolElap
const cfg = ENTRY_CONFIG[entry.type] || ENTRY_CONFIG.status_change
return (
<div key={progressEntryKey(entry, hiddenCount + i)} className={`ptl-entry${isLast ? ' ptl-entry-last' : ''}`}>
<div key={progressEntryKey(entry)} className={`ptl-entry${isLast ? ' ptl-entry-last' : ''}`}>
<div className="ptl-connector">
<div className="ptl-dot" style={{ color: cfg.color }}>
{cfg.icon}
@@ -5,6 +5,58 @@ import { mapBackendMessage } from '../lib/collabSync'
import { analyzeCheckpointMessages } from './checkpointUtils'
import { __chatStoreTestUtils } from './ChatStore'
const persistentTimestamps = __chatStoreTestUtils.latestPersistentMessageTimestamps([
{
id: 'db-assistant-1',
channelId: 'session:read-test',
sender: 'assistant',
senderName: 'OPC',
content: 'First persisted reply',
timestamp: 10,
mentions: [],
},
{
id: 'msg-local-only',
channelId: 'session:read-test',
sender: 'user',
senderName: 'You',
content: 'Optimistic message',
timestamp: 30,
mentions: [],
metadata: { ui_message_id: 'ui-local-only' },
},
{
id: 'db-assistant-2',
channelId: 'session:read-test',
sender: 'assistant',
senderName: 'OPC',
content: 'Latest persisted reply',
timestamp: 20,
mentions: [],
},
])
assert.equal(persistentTimestamps['session:read-test'], 20)
const unreadState = { 'session:read-test': 10 }
const advancedReadState = __chatStoreTestUtils.advanceReadTimestamp(
unreadState,
'session:read-test',
persistentTimestamps['session:read-test'],
)
assert.notEqual(advancedReadState, unreadState)
assert.equal(advancedReadState['session:read-test'], 20)
assert.equal(
__chatStoreTestUtils.advanceReadTimestamp(advancedReadState, 'session:read-test', 20),
advancedReadState,
'marking an already-read channel must preserve state identity',
)
assert.equal(
__chatStoreTestUtils.advanceReadTimestamp(advancedReadState, 'session:read-test', 15),
advancedReadState,
'an older snapshot must not move the read cursor backwards',
)
const syntheticCheckpoint: ChatMessage = {
id: 'checkpoint::cp-delivery',
channelId: 'session:task-1',
@@ -43,6 +95,7 @@ const mergedCheckpoint = __chatStoreTestUtils.dedupeMessages([
assert.equal(mergedCheckpoint.length, 1)
assert.equal(mergedCheckpoint[0].id, 'db-message-1')
assert.equal(mergedCheckpoint[0].timestamp, 1, 'checkpoint status updates must keep their original timeline position')
assert.equal(mergedCheckpoint[0].metadata?.checkpoint_status, 'ignored')
assert.deepEqual([...analyzeCheckpointMessages(mergedCheckpoint).pendingMessageIds], [])
assert.deepEqual([...analyzeCheckpointMessages(mergedCheckpoint).respondedMessageIds], ['db-message-1'])
@@ -100,6 +153,42 @@ const mergedUserMessage = __chatStoreTestUtils.dedupeMessages([
assert.equal(mergedUserMessage.length, 1)
assert.equal(mergedUserMessage[0].metadata?.ui_message_id, 'ui-1')
assert.equal(mergedUserMessage[0].timestamp, 4, 'backend acknowledgement must replace the optimistic client clock')
const mirroredUserMessages = __chatStoreTestUtils.dedupeMessages([
backendUserMessage,
{ ...backendUserMessage, id: 'db-user-mirror', channelId: 'session:child-task' },
])
assert.equal(
mirroredUserMessages.length,
2,
'ui_message_id mirrors in different channels must remain available to each channel projection',
)
assert.deepEqual(
__chatStoreTestUtils.unreadMessageCounts([
{
id: 'msg-local-system',
channelId: 'session:task-1',
sender: 'system',
senderName: 'System',
content: 'Local task assignment notice',
timestamp: 100,
mentions: [],
},
{
id: 'db-assistant-unread',
channelId: 'session:task-1',
sender: 'assistant',
senderName: 'OPC',
content: 'Persisted reply',
timestamp: 90,
mentions: [],
},
], {}),
{ 'session:task-1': 1 },
'local-only system rows must not become unread entries that markRead can never cover',
)
const nativeCompanyRawTurn: ChatMessage = {
id: 'native-raw-1',
@@ -137,6 +226,44 @@ const mergedNativeCompanyDuplicate = __chatStoreTestUtils.dedupeMessages([
assert.equal(mergedNativeCompanyDuplicate.length, 1)
assert.equal(mergedNativeCompanyDuplicate[0].id, 'role-result-1')
assert.equal(mergedNativeCompanyDuplicate[0].senderName, 'Chao')
assert.equal(mergedNativeCompanyDuplicate[0].timestamp, 5, 'semantic result replacement must retain its original timeline position')
assert.equal(
mergedNativeCompanyDuplicate[0].metadata?.ui_timeline_id,
'message:native-raw-1',
'semantic result replacement must retain the already-mounted row identity',
)
const repeatedNativeCompanySync = __chatStoreTestUtils.dedupeMessages([
...mergedNativeCompanyDuplicate,
nativeCompanyRawTurn,
companyRoleResult,
])
assert.equal(repeatedNativeCompanySync.length, 1)
assert.equal(repeatedNativeCompanySync[0].metadata?.ui_timeline_id, 'message:native-raw-1')
assert.equal(repeatedNativeCompanySync[0].timestamp, 5)
const mountedHighPriorityResult: ChatMessage = {
...companyRoleResult,
id: 'mounted-high-result',
timestamp: 10,
metadata: { source: 'engine', transcript_kind: 'child_task_result' },
}
const olderLowPrioritySurface: ChatMessage = {
...nativeCompanyRawTurn,
id: 'older-low-result',
timestamp: 4,
}
const historyBackfillMerge = __chatStoreTestUtils.mergeMessagesIntoExisting(
[mountedHighPriorityResult],
[olderLowPrioritySurface],
)
assert.equal(historyBackfillMerge.length, 1)
assert.equal(historyBackfillMerge[0].id, 'mounted-high-result')
assert.equal(historyBackfillMerge[0].timestamp, 10, 'history backfill must not move an already-mounted result row')
assert.equal(
historyBackfillMerge[0].metadata?.ui_timeline_id,
'message:mounted-high-result',
'history backfill must retain the mounted high-priority result key',
)
const mappedTaskGeneralistMessage = mapBackendMessage({
message_id: 'legacy-task-generalist',
@@ -152,4 +279,4 @@ const mappedTaskGeneralistMessage = mapBackendMessage({
assert.equal(mappedTaskGeneralistMessage.senderName, 'OPC')
console.log('ChatStore.test.ts: OK (optimistic, checkpoint, and company result identity merging)')
console.log('ChatStore.test.ts: OK (read cursors, optimistic, checkpoint, and company result identity merging)')
@@ -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) => {
@@ -1,54 +1,38 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { buildNarrativeMessageItems, copyTextToClipboard, parseProjectUpdatePayload, shouldReleaseStickToBottomOnScroll } from './MessageList'
import { buildNarrativeMessageItems, copyTextToClipboard, messageTimelineKey, parseProjectUpdatePayload } from './MessageList'
import type { MessageScrollPolicy } from './MessageList'
import type { ChatMessage } from '../types/chat'
import { progressEntryKey } from '../lib/progressEntryKey'
assert.equal(
shouldReleaseStickToBottomOnScroll({
previousScrollTop: 1200,
nextScrollTop: 900,
atBottom: false,
userScrolling: false,
programmaticScroll: false,
}),
true,
'scrollbar drag upward should release stick-to-bottom even without wheel/pointer events',
const messageListSource = readFileSync(new URL('./MessageList.tsx', import.meta.url), 'utf8')
const supportedScrollPolicies: MessageScrollPolicy[] = ['follow', 'initial-bottom', 'manual']
assert.deepEqual(supportedScrollPolicies, ['follow', 'initial-bottom', 'manual'])
assert.match(
messageListSource,
/export type MessageScrollPolicy = 'follow' \| 'initial-bottom' \| 'manual'/,
'MessageList must expose one unambiguous three-state scroll policy',
)
assert.match(messageListSource, /scrollPolicy = 'follow'/, 'main transcript behavior should default to follow mode')
assert.doesNotMatch(messageListSource, /useVirtualizer/, 'chat transcript must use stable normal DOM rows')
assert.doesNotMatch(messageListSource, /PROGRAMMATIC_SCROLL_GRACE_MS/, 'scroll behavior must not regress to timer-based intent guessing')
const progressWithoutServerId = {
type: 'status_change' as const,
summary: 'Waiting for reviewer',
detail: 'Gate entered',
timestamp: 1234,
}
assert.equal(
shouldReleaseStickToBottomOnScroll({
previousScrollTop: 1200,
nextScrollTop: 900,
atBottom: false,
userScrolling: false,
programmaticScroll: true,
}),
false,
'programmatic scrolls should not release stick-to-bottom',
progressEntryKey(progressWithoutServerId),
progressEntryKey({ ...progressWithoutServerId }),
'progress identity must derive from stable event fields rather than its array position',
)
assert.equal(
shouldReleaseStickToBottomOnScroll({
previousScrollTop: 900,
nextScrollTop: 900,
atBottom: false,
userScrolling: true,
programmaticScroll: false,
}),
true,
'explicit user scroll state should release stick-to-bottom while away from bottom',
)
assert.equal(
shouldReleaseStickToBottomOnScroll({
previousScrollTop: 900,
nextScrollTop: 1200,
atBottom: true,
userScrolling: true,
programmaticScroll: false,
}),
false,
'scrolling back to bottom should keep follow mode available',
assert.doesNotMatch(
progressEntryKey(progressWithoutServerId),
/:0$/,
'progress fallback identity must not carry a shifting array index',
)
const parsedUpdate = parseProjectUpdatePayload(JSON.stringify({
@@ -90,6 +74,81 @@ const baseMessage = (id: string, content: string, timestamp: number, sender = 's
metadata: {},
})
assert.equal(
messageTimelineKey({
...baseMessage('checkpoint-message', 'Approval needed', 10),
metadata: {
checkpoint_id: 'checkpoint-42',
canonical_turn_id: 'turn-ignored',
ui_message_id: 'ui-ignored',
},
}),
'checkpoint:checkpoint-42',
'checkpoint identity must win so pending/resolved updates reuse one row',
)
assert.equal(
messageTimelineKey({
...baseMessage('assistant-final', 'Final answer', 20, 'assistant'),
metadata: { canonical_turn_id: 'turn-7', transcript_kind: 'runtime_v2_assistant' },
}),
'turn:assistant:turn-7',
'assistant draft and final surfaces must share the canonical turn key',
)
assert.equal(
messageTimelineKey({
...baseMessage('user-message', 'Question', 30, 'user'),
metadata: { canonical_turn_id: 'turn-8', ui_message_id: 'ui-8' },
}),
'ui:ui-8',
'a persisted user turn must retain the optimistic ui_message_id key',
)
assert.equal(
messageTimelineKey({
...baseMessage('optimistic-message', 'Local echo', 40, 'user'),
metadata: { ui_message_id: 'ui-9' },
}),
'ui:ui-9',
'optimistic and persisted user echoes must share ui_message_id identity',
)
assert.equal(
messageTimelineKey(baseMessage('persistent-message', 'Stored message', 50, 'assistant')),
'message:persistent-message',
'messages without stronger runtime identity must fall back to the persistent id',
)
assert.equal(
messageTimelineKey({
...baseMessage('higher-priority-result', 'Final answer', 55, 'assistant'),
metadata: {
canonical_turn_id: 'turn-7',
transcript_kind: 'child_task_result',
ui_timeline_id: 'turn:assistant:turn-7',
},
}),
'turn:assistant:turn-7',
'a semantic result replacement must keep the mounted native-final/draft slot',
)
const sharedCompanyTurn = 'company-turn-1'
const companyTurnKeys = [
messageTimelineKey({
...baseMessage('runtime-context', 'Execution context', 60),
metadata: { kind: 'runtime_v2_user_turn', canonical_turn_id: sharedCompanyTurn },
}),
messageTimelineKey({
...baseMessage('company-stream-1', 'First company surface', 61, 'assistant'),
metadata: { kind: 'runtime_v2_company_assistant', canonical_turn_id: sharedCompanyTurn },
}),
messageTimelineKey({
...baseMessage('company-stream-2', 'Second company surface', 62, 'assistant'),
metadata: { kind: 'runtime_v2_company_assistant', canonical_turn_id: sharedCompanyTurn },
}),
messageTimelineKey({
...baseMessage('role-result', 'Role result', 63, 'assistant'),
metadata: { kind: 'company_role_result', canonical_turn_id: sharedCompanyTurn },
}),
]
assert.equal(new Set(companyTurnKeys).size, companyTurnKeys.length, 'distinct company rows sharing a turn need unique DOM keys')
const narrativeItems = buildNarrativeMessageItems([
baseMessage('m1', '[Company:cto::execute::abc] starting Research source reliability', 1000),
baseMessage('m2', '[Delegating to codex] task=Research source reliability | cmd=codex exec ...', 1100),
@@ -194,4 +253,4 @@ if (originalDocument) {
delete (globalThis as any).document
}
console.log('MessageList.test.tsx: OK (scroll + narrative timeline helpers)')
console.log('MessageList.test.tsx: OK (scroll contract + stable timeline identity + narrative helpers)')
File diff suppressed because it is too large Load Diff
@@ -85,11 +85,16 @@ assert.doesNotMatch(src, /localResponded|setLocalResponded/, 'panel must wait fo
assert.match(src, /user_input_answers/, 'structured answers must be forwarded to the backend')
const messageListSrc = readFileSync(join(here, 'MessageList.tsx'), 'utf8')
const progressIndex = messageListSrc.indexOf("items.push({ kind: 'progress-block' })")
const pendingIndex = messageListSrc.indexOf("items.push({ kind: 'pending-section' })")
const endIndex = messageListSrc.indexOf("items.push({ kind: 'end-anchor' })")
assert.ok(progressIndex !== -1 && pendingIndex !== -1 && endIndex !== -1)
assert.ok(progressIndex < pendingIndex, 'pending checkpoint cards should render after the progress block')
assert.ok(pendingIndex < endIndex, 'pending checkpoint cards should render before the end anchor')
const timelineIndex = messageListSrc.indexOf('{processed.map(row => (')
const progressIndex = messageListSrc.indexOf('{showProgressBlock && (')
const endIndex = messageListSrc.indexOf('<div className="msg-end-anchor" />')
assert.ok(timelineIndex !== -1 && progressIndex !== -1 && endIndex !== -1)
assert.ok(timelineIndex < progressIndex && progressIndex < endIndex)
assert.doesNotMatch(messageListSrc, /kind: 'pending-section'/, 'pending cards must not be moved into a second tail section')
assert.match(
messageListSrc,
/Checkpoint cards never leave the chronological transcript/,
'task-user-input cards must remain at their creation position',
)
console.log('TaskUserInputPanel.test.tsx: OK (markdown and choice checkpoint panel)')
@@ -81,4 +81,15 @@ const fallbackMarkup = renderToStaticMarkup(
assert.match(fallbackMarkup, /CTO/)
assert.doesNotMatch(fallbackMarkup, /Engineer/)
const preparingMarkup = renderToStaticMarkup(
React.createElement(WorkItemProgressCard, {
workItemLog: [],
isCompanyRuntime: true,
}),
)
assert.match(preparingMarkup, /Execution Progress/)
assert.match(preparingMarkup, /Preparing company roles/)
assert.match(preparingMarkup, /role="status"/)
console.log('WorkItemProgressCard.test.tsx: OK (executor rollup preferred with current-owner fallback)')
@@ -542,8 +542,8 @@ export function WorkItemProgressCard({
return isCompanyRuntime ? [] : workItemLogWorkItems
}, [isCompanyRuntime, roleSummaries, workItemLogWorkItems])
if (isCompanyRuntime && roleSummaries.length === 0) return null
if (workItemLog.length === 0 && workItems.length === 0 && roleSummaries.length === 0) return null
const isPreparingCompanyRuntime = isCompanyRuntime && roleSummaries.length === 0
if (!isCompanyRuntime && workItemLog.length === 0 && workItems.length === 0 && roleSummaries.length === 0) return null
return (
<div className="wi-progress-card">
@@ -571,6 +571,12 @@ export function WorkItemProgressCard({
</div>
)}
{isPreparingCompanyRuntime && (
<div className="wi-progress-pipeline wi-progress-pipeline-empty" role="status">
Preparing company roles
</div>
)}
</div>
)
}
@@ -794,6 +794,16 @@
/* ══════════════════════════════════════════════════════════════════════════
Message List
══════════════════════════════════════════════════════════════════════════ */
.msg-list-shell {
flex: 1;
min-height: 0;
min-width: 0;
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
}
.msg-list {
flex: 1;
min-height: 0;
@@ -803,16 +813,82 @@
scrollbar-gutter: stable;
scrollbar-color: var(--border) transparent;
overscroll-behavior-y: contain;
overflow-anchor: none;
position: relative;
}
.msg-list:focus {
outline: none;
}
.msg-list-following {
overflow-anchor: none;
}
.msg-list-browsing {
overflow-anchor: auto;
}
.msg-list-content {
width: 100%;
min-height: 100%;
}
.msg-timeline-row {
overflow-anchor: auto;
}
.msg-list-following .msg-timeline-row {
overflow-anchor: none;
}
.msg-end-anchor {
height: 1px;
flex-shrink: 0;
overflow-anchor: none;
}
.msg-list-floating-actions {
position: absolute;
left: 50%;
bottom: 12px;
z-index: 8;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
max-width: calc(100% - 24px);
pointer-events: none;
}
.msg-list-float-btn {
pointer-events: auto;
min-width: 0;
max-width: min(320px, 70vw);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid color-mix(in srgb, var(--border) 72%, var(--accent) 28%);
border-radius: 999px;
padding: 7px 12px;
background: color-mix(in srgb, var(--bg-elevated) 92%, var(--accent) 8%);
color: var(--text);
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.28);
font: inherit;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.msg-list-float-btn:hover {
border-color: var(--accent);
background: color-mix(in srgb, var(--bg-elevated) 84%, var(--accent) 16%);
}
.msg-list-pending-btn {
border-color: color-mix(in srgb, var(--yellow) 55%, var(--border));
color: var(--yellow);
}
.msg-history-hint {
display: flex;
align-items: center;
@@ -849,33 +925,6 @@
color: var(--text-secondary);
}
.msg-pending-section {
margin-top: 10px;
padding: 12px 16px 0;
border-top: 1px solid var(--border);
background: linear-gradient(180deg, transparent 0%, var(--bg-elevated) 28px);
}
.msg-pending-header {
font-size: 10px;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 8px 10px;
}
.msg-pending-stack {
display: flex;
flex-direction: column;
gap: 10px;
padding-bottom: 8px;
}
.msg-row-pending {
animation: msg-enter 200ms ease-out;
}
/* ── Welcome ──────────────────────────────────────────────────────────── */
.msg-welcome {
display: flex;