Virtualizing a streaming agent chat: gaps between TanStack Virtual and an AI rendering layer
维护者通常 1 天内回复
@AlemTuzlak 已经在做这个了。
开始于 2026年9月17日。
评估
这个 Issue 还没有评估数据。
描述
Summary
We run a production agent chat UI (long-lived Claude Code sessions, server-authoritative transcripts) built on @tanstack/react-virtual. In wiring Virtual behind a streaming agent chat we hit a recurring class of problems that a generic virtualizer doesn't solve and that a chat-state hook (useChat) doesn't touch either — they live in the seam between the two.
Since TanStack AI is positioned to own that seam, this is a writeup of the gaps we hand-built, sorted by which surface (Virtual / AI / the adapter between them) would most naturally own each. Hoping it's useful input for a Virtual-into-AI rendering story. Happy to co-design or contribute.
Why agent chat ≠ traditional chat (Slack)
One axis flips everything: in traditional chat a row is write-once; in agent chat a row is write-many.
- Rows grow after insert. A streaming turn is inserted at a low estimate, then grows over hundreds of token deltas while pinned at the bottom. Every measure-once-cache-forever shortcut that's legal in Slack is illegal here.
- Mutation is high-frequency and single-row. Token-paced deltas hammer one live row, not human-paced terminal messages.
- Height variance is extreme. Code blocks, tables, reasoning, tool widgets, gates — cold-mount estimate error is huge, so first-frame total-size accuracy is load-bearing.
- Landing is semantic. You want to land on the part that needs the user (a pending tool/permission gate, or the top of the latest answer), not "last read line."
- The transcript is a projection. Edit-resubmit / fork / rewind mean the rendered rows can change identity and order; message identity must survive that.
Slack's hard problem is scale over immutable rows; agent chat's is mutation of live rows. They stress different parts of the stack. Virtual nails the first axis; the second is the gap.
The gaps (and who should own each)
Virtual already owns / nearly owns
- Persistent measurement seed. A pluggable store that seeds
estimateSize()from previously-measured heights sogetTotalSize()is accurate on frame 1 of a cold remount of a long history. We back ours with localStorage keyed by(itemId, contentSig). Critical constraint: it must be a seed, never a measurement bypass —useCachedMeasurements: trueskips DOM measurement and causes growing rows to paint over the rows below (overlapping bubbles). A streaming list must keep measuring every frame. - Content-keyed measurement invalidation. A row that grows under a stable item key serves a stale measured height. We attach a content version (a hash of
seq:parts:textLen:partsHash) so a delta orphans the old height and forces a re-measure. A per-item "content version" input to Virtual would generalize this. - Single scroll authority (a documented contract).
anchorTo: 'end'+followOnAppendalready let virtual-core absorb streaming-growth deltas into the offset — instant for mid-row growth, smooth for whole-message append. The footgun is that nothing stops app code from also writingscrollTop, which produces yank-and-jerk. We tried springs and manual tweens and reverted both. Worth making the "only the virtualizer writes scrollTop" rule explicit.
TanStack AI should own (chat-semantic, no place in a generic virtualizer)
- Semantic boundary anchor. Choose the landing target by content/status: pending gate → align end; else latest answer → align start; else bottom. "Scroll to the thing blocking progress" is a chat concept.
- One-shot, intent-driven landing controller. Fire exactly one
scrollToIndexper mount/transition; surrender on user scroll; guard with a "landed" latch + timeout fallback so it never loops or hangs. Everyone reinvents this badly (effect loops, re-pin wars). - Hydration completeness gate. Land only when history is complete enough — we gate on a
cold → hydrating → livemachine so you never anchor against partial/replaying history, with a fast path for warm cache revisits.useChatexposesstatusbut no "safe to land now" signal. - Write-many message identity. Stable keys across resubmit/branch, optimistic→echo dedup, server-minted rows that don't collide with client-stamped ones. A client-id-keyed, write-once identity model breaks under branching and server-authored messages.
The adapter between them (the real integration surface)
- Chunk → row assembly. The virtualizer counts assembled messages, but growth arrives as fragment appends underneath. The assembly layer is what produces stable
getItemKey, the content version for (2), and optimistic dedup for (7). This seam is currently unowned, and if it's wrong everyone rebuilds 4–7 by hand.
What we'd suggest
- Virtual: a persistent estimate-provider hook + a per-item content-version input, and a docs note on why
useCachedMeasurementsis unsafe for growing rows. - AI: a small, transport-agnostic scroll/landing controller (semantic anchor + one-shot landing + completeness gate) that drives a Virtual instance.
- Adapter: a documented chunk→row assembly contract as the integration point.
We're independently extracting our own version of this into a pure state-machine core + swappable reactive shell + animation engine, so we'd be glad to share that design or contribute toward whatever shape the maintainers prefer. Thanks for the great libraries.
- 主要语言
- TypeScript
- 星标
- 3.3k
- 派生
- 378
- 平均合并
- 1 天 2 小时
- 30 天内合并 PR
- 219
环境准备
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
TanStack/ai 的其他 Issue
-
openai-base: a Responses max_output_tokens cutoff ends as RUN_ERROR without usage instead of finishReason 'length'可能已有人在做 @tombeckenham 于 4 天前认领。 未关闭has-pr waiting-on: maintainer
难度 2/5 1-3 小时 新手友好度 74/100
TanStack/ai#1916 · 1 条评论 · 已指派 1 人 ·
维护者通常 1 天内回复
-
openai-base: leaving a Responses stream at `response.completed` aborts its request, so complete calls are logged as client-closed (499)可能已有人在做 @AlemTuzlak 于 1 天前认领。 未关闭has-pr waiting-on: maintainer
维护者通常 1 天内回复
-
StreamProcessor folds long text and tool-call streams in quadratic memory and time可能已有人在做 @jherr 于 1 天前认领。 未关闭waiting-on: maintainer
维护者通常 1 天内回复
-
ai-harness: view.dispose() doesn't cancel snapshot, transcript or describe requests可能已有人在做 @AlemTuzlak 于 2 天前认领。 未关闭needs-repro waiting-on: maintainer
TanStack/ai#1960 · 2 条评论 · 已指派 1 人 ·
维护者通常 1 天内回复
-
ai-anthropic: 400 on signed thinking blocks after compaction rewrites history (missing block_binding drop_block)可能已有人在做 @tombeckenham 于 2 天前认领。 未关闭waiting-on: maintainer
维护者通常 1 天内回复
相似的 Issue
-
needs-area
难度 2/5 1-3 小时 新手友好度 68/100
Azure/typespec-azure#5936 ·
维护者通常 1 天内回复
-
难度 2/5 半天 新手友好度 72/100
vercel-labs/scriptc#2910 ·
维护者通常 1 天内回复
-
automation missing-model model-sync provider:fireworks-ai
难度 2/5 1-3 小时 新手友好度 72/100
anomalyco/models.dev#12458 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 62/100
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 72/100
维护者通常 1 天内回复