diff --git a/AGENTS.md b/AGENTS.md
index dd7eb586..e8b86be3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -60,6 +60,10 @@ Bun workspaces; `shared` consumed by cli, hub, web.
- Prefer 4-space indentation
- 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)
```bash
diff --git a/bun.lock b/bun.lock
index 48a9b488..ac408885 100644
--- a/bun.lock
+++ b/bun.lock
@@ -242,6 +242,9 @@
},
},
},
+ "patchedDependencies": {
+ "@assistant-ui/tap@0.3.5": "patches/@assistant-ui%2Ftap@0.3.5.patch",
+ },
"packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
diff --git a/e2e/history-load.spec.ts b/e2e/history-load.spec.ts
new file mode 100644
index 00000000..4c5fd7c9
--- /dev/null
+++ b/e2e/history-load.spec.ts
@@ -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)
+})
diff --git a/package.json b/package.json
index 1f518449..02b9dd93 100644
--- a/package.json
+++ b/package.json
@@ -37,5 +37,8 @@
"playwright": "1.60.0",
"react-devtools-core": "^7.0.1",
"vite-plugin-pwa": "^1.2.0"
+ },
+ "patchedDependencies": {
+ "@assistant-ui/tap@0.3.5": "patches/@assistant-ui%2Ftap@0.3.5.patch"
}
}
diff --git a/patches/@assistant-ui%2Ftap@0.3.5.patch b/patches/@assistant-ui%2Ftap@0.3.5.patch
new file mode 100644
index 00000000..80f95a9c
--- /dev/null
+++ b/patches/@assistant-ui%2Ftap@0.3.5.patch
@@ -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,
diff --git a/web/e2e-fixtures/history-load-fixture.html b/web/e2e-fixtures/history-load-fixture.html
new file mode 100644
index 00000000..cf0841b1
--- /dev/null
+++ b/web/e2e-fixtures/history-load-fixture.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ HAPI history load e2e fixture
+
+
+
+
+
+
diff --git a/web/e2e-fixtures/history-load-fixture.tsx b/web/e2e-fixtures/history-load-fixture.tsx
new file mode 100644
index 00000000..ec60931c
--- /dev/null
+++ b/web/e2e-fixtures/history-load-fixture.tsx
@@ -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'] {
+ 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 => {
+ 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