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,8 @@
import assert from 'node:assert/strict'
import { appendProgressEntry } from './progressLog'
import type { ProgressEntry } from '../types/kanban'
import { mapBackendSession } from './collabSync'
import { progressEntryKey } from './progressEntryKey'
import { appendProgressEntry, normalizeProgressLog } from './progressLog'
let log = appendProgressEntry([], {
timestamp: 1,
@@ -149,3 +152,144 @@ assert.equal(assistantLog.length, 2)
assert.equal(assistantLog[0]?.detail, '文件已成功写入(278 行)。')
assert.equal(assistantLog[0]?.summary, '文件已成功写入(278 行)。')
assert.equal(assistantLog[1]?.detail, '采集完成报告')
// A live client receives these as individual deltas. A reconnect receives the
// same rows as a full snake_case snapshot. Both paths must produce identical
// row identities: otherwise React remounts progress rows and browser anchoring
// sees a false remove/insert pair during every full sync.
const snapshotSeconds = 1_700_000_000
const snapshotRows = [
{
timestamp: snapshotSeconds,
type: 'status_change',
summary: 'Running',
detail: 'phase=running',
},
{
timestamp: snapshotSeconds + 0.1,
type: 'status_change',
summary: 'Running',
detail: 'phase=running',
},
{
timestamp: snapshotSeconds + 1,
type: 'thinking',
summary: 'Thinking',
detail: 'Need ',
},
{
timestamp: snapshotSeconds + 1.1,
type: 'thinking',
summary: 'Thinking',
detail: 'context',
},
{
timestamp: snapshotSeconds + 2,
type: 'assistant',
summary: 'Answer',
detail: 'Answer ',
},
{
timestamp: snapshotSeconds + 2.1,
type: 'assistant',
summary: 'ready',
detail: 'ready',
},
{
timestamp: snapshotSeconds + 3,
type: 'tool_call',
summary: 'file_read',
detail: '{"path":',
},
{
timestamp: snapshotSeconds + 3.1,
type: 'tool_call',
summary: 'file_read',
detail: '"README.md"}',
},
] as const
const liveDeltas: ProgressEntry[] = snapshotRows.map(entry => ({
timestamp: entry.timestamp * 1000,
type: entry.type,
summary: entry.summary,
detail: entry.detail,
}))
const liveSnapshot = liveDeltas.reduce<ProgressEntry[]>(
(entries, entry) => appendProgressEntry(entries, entry),
[],
)
const normalizedSnapshot = normalizeProgressLog(liveDeltas)
const mappedSnapshot = mapBackendSession({
task_id: 'progress-snapshot',
channel_id: 'session:progress-snapshot',
progress_log: snapshotRows,
}).progressLog
assert.equal(liveSnapshot.length, 4)
assert.deepEqual(
liveSnapshot.map(entry => entry.type),
['status_change', 'thinking', 'assistant', 'tool_call'],
)
assert.deepEqual(
normalizedSnapshot.map(entry => ({ key: progressEntryKey(entry), timestamp: entry.timestamp })),
liveSnapshot.map(entry => ({ key: progressEntryKey(entry), timestamp: entry.timestamp })),
)
assert.deepEqual(
mappedSnapshot.map(entry => ({ key: progressEntryKey(entry), timestamp: entry.timestamp })),
liveSnapshot.map(entry => ({ key: progressEntryKey(entry), timestamp: entry.timestamp })),
)
assert.deepEqual(
liveSnapshot.map(entry => entry.timestamp),
[
snapshotSeconds * 1000,
(snapshotSeconds + 1) * 1000,
(snapshotSeconds + 2) * 1000,
(snapshotSeconds + 3) * 1000,
],
)
assert.deepEqual(
liveSnapshot.map(progressEntryKey),
[
'status_change::1700000000000:Running:phase=running',
'thinking:stream:1700000001000',
'assistant:stream:1700000002000',
'tool:turnless:file_read:1700000003000',
],
)
assert.ok(liveSnapshot.every(entry => (
!entry.itemId
&& !entry.streamId
&& !entry.toolCallId
&& !entry.permissionGroupKey
)))
// Persisted snake_case identifiers must survive the full-sync bridge. These
// identifiers take precedence over mutable summaries and timestamps when the
// UI derives a row key.
const mappedStableIds = mapBackendSession({
task_id: 'progress-stable-ids',
channel_id: 'session:progress-stable-ids',
progress_log: [
{
timestamp: snapshotSeconds + 10,
type: 'tool_call',
summary: 'shell_exec',
tool_call_id: 'call-42',
},
{
timestamp: snapshotSeconds + 11,
type: 'autonomy',
summary: 'shell_exec: ask',
permission_group_key: 'tool:shell_exec/python:domain:example.com',
},
],
}).progressLog
assert.equal(mappedStableIds[0]?.toolCallId, 'call-42')
assert.equal(mappedStableIds[1]?.permissionGroupKey, 'tool:shell_exec/python:domain:example.com')
assert.equal(progressEntryKey(mappedStableIds[0]!), 'tool_call::call-42')
assert.equal(
progressEntryKey(mappedStableIds[1]!),
'autonomy::tool:shell_exec/python:domain:example.com',
)