fix(web): patch assistant-ui tap scheduler for bulk history prepends

Loading an older page prepends hundreds of messages in one flush. tap's
scheduler aborts after 50 dirty resources and drops the overflow, so the
thread never applied the merged page: the scroll-restore gate never
passed and the top sentinel kept re-triggering (loads everything at
once). Raise MAX_FLUSH_LIMIT 50->2000 via bun patchedDependencies.

Adds a Playwright regression spec driving the real message-window store
and HappyThread against a fake paginated API: one page per top
approach, scroll restored, no idle reloads.
This commit is contained in:
weishu
2026-07-28 19:39:32 +08:00
parent fcb56989d9
commit a469d66bc4
7 changed files with 316 additions and 0 deletions
+4
View File
@@ -60,6 +60,10 @@ Bun workspaces; `shared` consumed by cli, hub, web.
- Prefer 4-space indentation - Prefer 4-space indentation
- Zod for runtime validation (schemas in `shared/src/schemas.ts`) - Zod for runtime validation (schemas in `shared/src/schemas.ts`)
## Patched dependencies
- `@assistant-ui/tap` (`patches/`): raises tap scheduler `MAX_FLUSH_LIMIT` 50→2000. Bulk message prepends (older-history pages) create hundreds of tap resources in one flush; the default limit throws and drops the overflow, leaving the thread stale. Re-check the patch when upgrading `@assistant-ui/react`.
## Common commands (repo root) ## Common commands (repo root)
```bash ```bash
+3
View File
@@ -242,6 +242,9 @@
}, },
}, },
}, },
"patchedDependencies": {
"@assistant-ui/tap@0.3.5": "patches/@assistant-ui%2Ftap@0.3.5.patch",
},
"packages": { "packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
+56
View File
@@ -0,0 +1,56 @@
import { expect, test } from '@playwright/test'
// Regression: loading an older page prepends hundreds of messages at once.
// assistant-ui's tap scheduler aborts a flush with more than 50 dirty
// resources and drops the rest, so the thread never reflected the merged
// page (see patches/@assistant-ui%2Ftap@0.3.5.patch). This spec drives the
// real message-window store + HappyThread against a fake paginated API and
// pins the contract: one page per top approach, scroll position restored.
test('scroll-to-top loads one page per approach with correct scroll restore', async ({ page }) => {
await page.goto('/e2e-fixtures/history-load-fixture.html')
const viewport = page.locator('.app-scroll-y')
await expect(viewport).toBeVisible()
// Wait for the initial tail sync plus the initial scroll-settling window.
await page.waitForTimeout(3500)
await page.evaluate(() => {
(document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 0
})
await page.waitForTimeout(2000)
const afterFirst = await page.evaluate(() => {
const el = document.querySelector('.app-scroll-y') as HTMLElement
return {
scrollTop: Math.round(el.scrollTop),
childCount: document.querySelector('.happy-thread-messages')?.childElementCount ?? 0,
beforeReqs: window.__probe.requests.filter((r) => r.direction === 'before').length
}
})
// Exactly one page loaded, the DOM shows it, scroll restored away from top.
expect(afterFirst.beforeReqs).toBe(1)
expect(afterFirst.childCount).toBe(400)
expect(afterFirst.scrollTop).toBeGreaterThan(1000)
// Idle watch: no further loads may happen without another scroll.
await page.waitForTimeout(2500)
const idleReqs = await page.evaluate(() => window.__probe.requests.filter((r) => r.direction === 'before').length)
expect(idleReqs).toBe(1)
// Second approach to the top loads exactly one more page.
await page.evaluate(() => {
(document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 0
})
await page.waitForTimeout(2000)
const afterSecond = await page.evaluate(() => {
const el = document.querySelector('.app-scroll-y') as HTMLElement
return {
scrollTop: Math.round(el.scrollTop),
childCount: document.querySelector('.happy-thread-messages')?.childElementCount ?? 0,
beforeReqs: window.__probe.requests.filter((r) => r.direction === 'before').length
}
})
expect(afterSecond.beforeReqs).toBe(2)
expect(afterSecond.childCount).toBe(600)
expect(afterSecond.scrollTop).toBeGreaterThan(1000)
})
+3
View File
@@ -37,5 +37,8 @@
"playwright": "1.60.0", "playwright": "1.60.0",
"react-devtools-core": "^7.0.1", "react-devtools-core": "^7.0.1",
"vite-plugin-pwa": "^1.2.0" "vite-plugin-pwa": "^1.2.0"
},
"patchedDependencies": {
"@assistant-ui/tap@0.3.5": "patches/@assistant-ui%2Ftap@0.3.5.patch"
} }
} }
+10
View File
@@ -0,0 +1,10 @@
diff --git a/dist/core/scheduler.js b/dist/core/scheduler.js
index a61a05417fca038339e8e80c2dee5ee2ea4ef211..05e10177ce4d69bac63d02fee06cd151ea0f62ba 100644
--- a/dist/core/scheduler.js
+++ b/dist/core/scheduler.js
@@ -1,4 +1,4 @@
-const MAX_FLUSH_LIMIT = 50;
+const MAX_FLUSH_LIMIT = 2000;
let flushState = {
schedulers: new Set([]),
isScheduled: false,
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HAPI history load e2e fixture</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./history-load-fixture.tsx"></script>
</body>
</html>
+228
View File
@@ -0,0 +1,228 @@
import { useMemo, useRef } from 'react'
import ReactDOM from 'react-dom/client'
import { AssistantRuntimeProvider } from '@assistant-ui/react'
import '../src/index.css'
import type { ApiClient } from '../src/api/client'
import type { DecryptedMessage, MessagesResponse, Session } from '../src/types/api'
import { I18nProvider } from '../src/lib/i18n-context'
import { useMessages } from '../src/hooks/queries/useMessages'
import { normalizeDecryptedMessage } from '../src/chat/normalize'
import { reduceChatBlocks } from '../src/chat/reducer'
import { reconcileChatBlocks } from '../src/chat/reconcile'
import { buildVisibleChatBlocks } from '../src/chat/toolGroups'
import { isQueuedForInvocation } from '../src/lib/messages'
import { useHappyRuntime } from '../src/lib/assistant-runtime'
import { HappyThread } from '../src/components/AssistantChat/HappyThread'
import type { ChatBlock } from '../src/chat/types'
// Drives the real message-window store + chat pipeline + HappyThread against a
// fake paginated message API, so e2e tests can exercise older-history loading
// without a hub. `window.__probe.requests` records every API call for
// assertions (e.g. "exactly one older page per top-approach").
const SESSION_ID = 'history-load-fixture'
const TOTAL_MESSAGES = 1200
const BASE_AT = 1_700_000_000_000
type Probe = {
requests: { direction: string; beforeSeq: number | null; limit: number | undefined; at: number }[]
}
declare global {
interface Window {
__probe: Probe
}
}
window.__probe = { requests: [] }
const allMessages: DecryptedMessage[] = Array.from({ length: TOTAL_MESSAGES }, (_, index) => {
const seq = index + 1
return {
id: `m-${seq}`,
seq,
localId: null,
content: {
role: 'user',
content: { type: 'text', text: `Fixture message ${seq}` }
},
createdAt: BASE_AT + seq,
invokedAt: BASE_AT + seq
} as DecryptedMessage
})
function positionOf(message: DecryptedMessage): { at: number; seq: number } {
return { at: message.invokedAt ?? message.createdAt, seq: message.seq ?? 0 }
}
function pageFrom(messages: DecryptedMessage[], overrides: Partial<MessagesResponse['page']>): MessagesResponse['page'] {
const oldest = messages[0] ?? null
const newest = messages[messages.length - 1] ?? null
return {
direction: 'latest',
limit: 200,
epoch: 1,
reset: false,
nextBeforeSeq: oldest?.seq ?? null,
nextBeforeAt: oldest ? positionOf(oldest).at : null,
nextAfterSeq: newest?.seq ?? null,
nextAfterAt: newest ? positionOf(newest).at : null,
snapshotHeadSeq: newest?.seq ?? null,
snapshotHeadAt: newest ? positionOf(newest).at : null,
hasMore: false,
...overrides
}
}
const fakeApi = {
getMessages: async (_sessionId: string, query: {
limit?: number
beforeAt?: number | null
beforeSeq?: number | null
afterAt?: number | null
afterSeq?: number | null
}): Promise<MessagesResponse> => {
const limit = query.limit ?? 200
let direction = 'latest'
if (query.beforeSeq != null || query.beforeAt != null) direction = 'before'
else if (query.afterSeq != null || query.afterAt != null) direction = 'after'
window.__probe.requests.push({
direction,
beforeSeq: query.beforeSeq ?? null,
limit: query.limit,
at: Date.now()
})
// Small async delay to mimic latency.
await new Promise((resolve) => setTimeout(resolve, 50))
if (direction === 'before') {
const cursorAt = query.beforeAt ?? Number.POSITIVE_INFINITY
const cursorSeq = query.beforeSeq ?? Number.POSITIVE_INFINITY
const older = allMessages.filter((message) => {
const position = positionOf(message)
return position.at < cursorAt || (position.at === cursorAt && position.seq < cursorSeq)
})
const pageMessages = older.slice(-limit)
const oldest = pageMessages[0] ?? null
return {
messages: pageMessages,
page: pageFrom(pageMessages, {
direction: 'before',
limit,
hasMore: older.length > pageMessages.length,
nextBeforeSeq: oldest?.seq ?? null,
nextBeforeAt: oldest ? positionOf(oldest).at : null,
nextAfterSeq: null,
nextAfterAt: null,
snapshotHeadSeq: null,
snapshotHeadAt: null
})
}
}
const pageMessages = allMessages.slice(-limit)
return {
messages: pageMessages,
page: pageFrom(pageMessages, {
direction: 'latest',
limit,
reset: true,
hasMore: allMessages.length > pageMessages.length
})
}
}
} as unknown as ApiClient
const fakeSession = {
id: SESSION_ID,
active: true,
thinking: false,
agentState: null,
metadata: { path: '/tmp/fixture', host: 'fixture' }
} as unknown as Session
const noopSend = () => {}
const noopAbort = async () => {}
function FixtureThread() {
const {
messages,
warning,
isSyncingTail,
isLoadingMore,
hasMore,
unseenCount,
messagesVersion,
historyVersion,
loadMore,
setViewMode
} = useMessages(fakeApi, SESSION_ID)
const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map())
const normalizedMessages = useMemo(() => {
const normalized = []
for (const message of messages) {
if (isQueuedForInvocation(message)) continue
const next = normalizeDecryptedMessage(message)
if (next) normalized.push(next)
}
return normalized
}, [messages])
const reduced = useMemo(() => reduceChatBlocks(normalizedMessages, null, {}), [normalizedMessages])
const reconciled = useMemo(
() => reconcileChatBlocks(reduced.blocks, blocksByIdRef.current),
[reduced.blocks]
)
blocksByIdRef.current = reconciled.byId
const visibleBlocks = useMemo(
() => buildVisibleChatBlocks(reconciled.blocks, { hasMoreMessages: hasMore }),
[reconciled.blocks, hasMore]
)
const runtime = useHappyRuntime({
session: fakeSession,
blocks: visibleBlocks,
messagesVersion,
historyVersion,
isSending: false,
onSendMessage: noopSend,
onAbort: noopAbort
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<div className="flex h-screen min-h-0 flex-col">
<HappyThread
api={fakeApi}
sessionId={SESSION_ID}
metadata={null}
disabled={false}
onRefresh={() => {}}
onViewModeChange={setViewMode}
isSyncingTail={isSyncingTail}
messagesWarning={warning}
hasMoreMessages={hasMore}
isLoadingMoreMessages={isLoadingMore}
onLoadMore={loadMore}
unseenCount={unseenCount}
rawMessagesCount={messages.length}
normalizedMessagesCount={normalizedMessages.length}
messagesVersion={messagesVersion}
historyVersion={historyVersion}
forceScrollToken={0}
outlineOpen={false}
outlineItems={[]}
onOutlineOpenChange={() => {}}
/>
</div>
</AssistantRuntimeProvider>
)
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<I18nProvider>
<FixtureThread />
</I18nProvider>
)