refactor(chat): TanStack AI 0.28 bump, cross-app hardening, and device-local tab-manager chat - #1924
Merged
Merged
Conversation
…nion toUiMessage returns @tanstack/ai-svelte's UIMessage, whose parts are @tanstack/ai-client's MessagePart, but the drift check imported the structurally separate server union from @tanstack/ai. Derive the part type from UIMessage['parts'][number] so the check and the cast guard the union the UI actually consumes. Drop the dead MessagePart re-export (zero importers) and fix the stale @see path in definition.ts.
…arts
MessagePart narrows cleanly on part.type, so the tool-call,
tool-result, and thinking casts were noise. Widen ToolCallPart's prop
to the untyped ToolCallPart since the component never narrows by tool
name, which lets the parent pass the narrowed part directly.
Add an explicit {:else} fallback through a (part: never) helper: a new
TanStack part type is now a compile error in the template, and parts
persisted by a newer build render an unsupported-part label instead of
nothing.
Inline the shallow approval alias, fold four repeated
shouldAutoApprove lookups into one isAutoApproved derived, and fold
the secondary badge override into badgeVariant. Approve and deny still
guard on part.approval?.id and pass exactly that id.
Lockstep bump across the catalog and the per-app pins so client and server speak the same stream protocol: @tanstack/ai 0.28.0, ai-client 0.16.3, ai-svelte 0.13.4, ai-anthropic 0.15.1, ai-openai 0.14.1, ai-gemini 0.15.1, ai-grok 0.11.2. The local patch carried PR #395 (immutable tool-call message updaters) backported onto 0.8.1; 0.28.0 ships the fix upstream, so the patch and its patchedDependencies entry are deleted. The MessagePart union gained structured-output, which tripped the drift checks in tab-manager and opensidian and tab-manager's template exhaustiveness guard, exactly as designed. Add the member to both ExpectedPartTypes lists and a render branch in tab-manager (a muted label; nothing sets outputSchema, so the part only appears if a future build persists one).
Port the tab-manager pattern to the sibling chat stacks. Both apps now derive the part type from UIMessage['parts'][number] so their drift checks guard @tanstack/ai-client's union, the one the UI consumes, instead of the server union in @tanstack/ai. Zhongwen previously had no drift check at all despite skipping runtime validation of persisted parts. Opensidian also gets the rendering collapse: drop the casts that defeated discriminated-union narrowing, widen ToolCallPart's prop to the untyped union it actually uses, and add the exhaustive unknown-part fallback so parts persisted by newer builds surface as a label instead of rendering nothing.
…part boundary Greenfield pass over chat persistence after the TanStack AI bump. ui-message.ts previously owned only the read direction; the write direction leaked into chat-state as a raw 'as JsonValue[]' cast in onFinish, an unchecked part literal in sendMessage, and a duck-typed parts cast in lastMessagePreview. Add toPersistedParts as the inverse cast and route both writers through it, which also checks written parts against the TanStack AI union at compile time. The preview now reads through toUiMessage instead of its own informal boundary. The new ChatClientPersistence adapter in ai-client 0.16 was evaluated and refused: it writes the full message list on every stream change, which amplifies into Yjs update history for CRDT-backed storage, and it has no change-subscription hook, so the table observer plus refreshFromDoc path would survive adoption anyway. The refusal and its revisit trigger are recorded next to createChat.
…ning forever A denied approval leaves the tool-call part at state 'approval-responded' with approval.approved false and no output: the runtime counts it settled (areAllToolsComplete) and continues the run, but output never lands. The output-based isRunning derivation therefore showed a running spinner and ellipsis on every denied call, permanently. Derive isDenied from approval.approved === false, carve it (and the approval prompt) out of isRunning, and render a muted shield with a Denied line. Output-based settling stays for everything else because the 0.28 runtime still settles errored calls at 'input-complete' with the error in output, so state alone cannot distinguish settled from running across live and persisted rows.
…ristic The heuristic showed typing dots when status was 'ready' and the last part was a tool-result, to bridge the gap before the continuation stream. Grilled against the installed 0.16 client, that gap cannot paint: checkForContinuation runs in streamResponse's finally block and the continuation sets status 'submitted' synchronously at its top, so the whole handoff is one microtask chain with no render in between. Run state cannot replace the heuristic either; sessionGenerating empties its activeRunIds on every RUN_FINISHED regardless of finishReason, and connectionStatus only tracks the subscribe() loop, which these apps do not use. Where the heuristic DID fire was the durable case: a conversation hydrated with a trailing tool-result row (popup closed mid-flow, or a run that finished with reason 'stop' after a tool). Nothing resumes those, so the dots showed forever while the heuristic also suppressed the one affordance that recovers the conversation, Regenerate. Delete the case; trailing tool-result at 'ready' now offers Regenerate.
…ation teardown createChat mounts a devtools bridge that registers the client in a globalThis registry; ChatClient.dispose() is the documented teardown (unsubscribe plus bridge dispose). destroyConversation only called stop(), which aborts the stream but leaves the registry entry alive, so every deleted conversation leaked its client, processor, and message arrays for the session's lifetime. Add a dispose() that stops then disposes (dispose alone does not abort an in-flight stream) and route conversation teardown through it in all three apps. No $effect.root is needed here: the installed ai-svelte createChat uses only $state and $derived, which are garbage-collected with the handle once the registry reference is gone.
The boundary port in 1995c7f only covered the read direction; both apps still wrote parts through a raw 'as JsonValue[]' cast in onFinish and an unchecked part literal in sendMessage. Add toPersistedParts as the inverse of toUiMessage's cast in each app's ui-message.ts and route both writers through it, so written parts are checked against the TanStack AI union at compile time before they become untyped JSON, matching tab-manager. Also drop opensidian's toUiMessages helper (zero callers) and unexport UiMessagePart everywhere; no file imports it, both boundary functions carry it in their signatures.
The handle registry was a plain Map, so getters like active and isLoading never re-evaluated when a handle was created or destroyed, only when activeConversationId itself changed. Concretely: opening the app on a ?chat= URL creates the handle in the whenLoaded reconcile without touching searchParams, and a template that already read chatState.active stays undefined until an unrelated invalidation. Tab-manager and zhongwen already use SvelteMap here; match them.
…rsation rows getStringValue and getNumberValue predate the typed conversations schema; the rows coming out of fromTable are InferTableRow values with string and number columns, so every call site collapses to plain property access with a ?? fallback, the same shape tab-manager and zhongwen already use. getProviderValue stays: provider is a plain string in the durable schema, and the helper does real narrowing into the Provider union for rows written by builds with a different provider list. Its parameter tightens from JsonValue to string, which also drops the file's last wellcrafted/json import.
…n verified behavior The ChatClientPersistence refusal now cites what the installed 0.16 client actually does: setItem rides the StreamProcessor's per-chunk change events with no debounce, and the persistor's write queue swallows adapter errors by design, two grounds the original comment left implicit. refreshFromDoc records why transaction-origin filtering was refused as a replacement for the isLoading guard: origin separates local echo from remote writes, but a remote write landing mid-stream must be deferred too, so timing covers both.
The three chat stacks deliberately differ in tool support, and nothing in the code said so. Opensidian's toolTrust table is schema-only (no surface reads or writes it; every call asks for approval), and zhongwen's message dispatch renders text parts only. Name both as decisions at the divergence points, with tab-manager as the reference shape if either app grows toward it, so the gaps read as chosen rather than forgotten.
Chat transcripts leave the workspace Y.Doc. They are single-writer, append-mostly, device-scoped logs, and a future local-model path means a turn may never touch the server, so CRDT storage paid tombstone and sync costs for no conflict-resolution benefit: every onFinish row re-set and updatedAt touch left a permanent GC tombstone, and deleting a conversation tombstoned every message forever. Message bodies now persist through TanStack AI's ChatClientPersistence into plain IndexedDB, keyed by conversation id. The earlier refusal of this adapter was conditional on CRDT-backed storage; over IndexedDB its per-chunk full-list writes are fine, and the client now owns the whole write path: sends, streamed chunks, and reload truncation all land through its ordered setItem queue. Conversation deletion routes through chat.clear() so the queue's generation counter invalidates in-flight writes; a direct adapter removeItem would race them and could resurrect history the user asked to remove. The synced conversations table keeps only metadata. chatMessages stays as a named migration source: the adapter imports a conversation's rows on its first getItem and deletes them so their content is garbage collected out of the doc; every handle hydrates at startup, so the table drains on this build's first run. What this deletes: ui-message.ts (both boundary casts; the drift check moves next to the one remaining cast in the migration reader), the chatMessages observer plus refreshFromDoc and its isLoading race guard, the sendMessage write-ordering dance, the onFinish row write, and reload's manual row delete. Chat is single-context (sidepanel only), so no cross-context writer exists.
…nching schema Zero users releases the durable-data contract, so the migration reader goes, and everything it was keeping alive goes with it in one cascade: - persistence.ts no longer imports legacy Y.Doc rows; getItem is a plain IndexedDB read. With its only dependency (tabManager) gone, the createChatPersistence factory collapses to an exported const adapter. The compile-time drift check goes too: it guarded boundary casts that no longer exist, and the new-part-type alarm is already owned by MessageParts' (part: never) exhaustiveness guard. - chatMessagesTable leaves the workspace schema; the migration was its last reader. The ChatMessageId brand family follows: sendMessage no longer passes an explicit id (it existed to correlate the chat message with its Y.Doc row; the client generates its own), and asChatMessageId already had zero callers. - The speculative branching columns (parentId, sourceMessageId, systemPrompt) had zero writers and zero UI consumers, so they leave the conversations table along with their handle getters, the systemPrompt fallback in the request body, and createConversation's entire options object, whose every parameter was dead. - switchConversation had become a renamed assignment after losing its refresh job; inlined into its three callers. definition.ts drops its typebox and jsonValue imports with the parts column, the last consumer of both.
The trust column was a suspicious union: get() returned 'ask' for absent rows, so an 'ask' row was indistinguishable from no row, and TrustSettings revoked by WRITING 'ask', accumulating junk rows plus Y.Map tombstone history on every toggle. The table is really a set of auto-approved tool names, so make it one: drop the column, allow() adds the row, revoke() deletes it, shouldAutoApprove() is a has(). The render-time auto-approve effect in ToolCallPart deliberately stays: definition-time approval already exists (the tool bridge marks mutations needsApproval), and the effect is what lets Always Allow take effect mid-session without rebuilding the session's tools.
…Id from the chat request The ai route validated an optional conversationId and nothing read it, not the route body, not the billing policies. TanStack AI already sends threadId on the wire for run correlation. Remove the field from the schema and from both clients that sent it (zhongwen never did). Arktype ignores undeclared keys by default, so an old client sending the field against a new server keeps working during rollout.
…ocal chat store The conversations table had become a denormalization with two owners: after message bodies moved to IndexedDB, every column except the model choice was derivable from the messages (title from the first user message, recency from the last message's createdAt), and syncing it bought other devices a list of chat titles whose bodies they cannot read. reconcileHandles existed only to keep the table and the handle registry, two representations of the same list, in sync. Make the handle registry the list. The chat store gains a settings store (conversationId to provider/model, the one non-derivable fact) and a startup enumeration; chat-state hydrates handles from it once, activates the most recent conversation, and sweeps settings rows orphaned by drafts. A new conversation is an in-memory draft that persists nothing until its first message lands through the adapter, so empty New Chat rows are never stored and the ensureDefaultConversation dance is gone. The ConversationId brand moves to chat/persistence.ts because the chat store owns the key space now. Deleted: the conversations table and Conversation type, fromTable mirror and its observer, reconcileHandles, ensureDefaultConversation, updateConversation, the onFinish updatedAt touch, the title write in sendMessage, the rename action (zero consumers), the isModelRestricted getter (zero consumers), and the dependency on idb.whenLoaded; chat startup no longer waits on the workspace at all.
…cture Remove the last migration in the codebase: the chat store's version-2 upgrade handler deleted v1 stores that only ever existed on a dev machine during this branch's own development. Greenfield means there is no v1; the database opens at version 1 and creates its two stores. Fix an invariant the restructure introduced: startup hydration unconditionally activated the most recent stored conversation, which would yank the UI away from a draft the user created while the read was in flight. The assignment now only runs when nothing is active. Delete three zero-consumer surfaces found by the sweep: the handle's createdAt getter (its fallback renames to lastActivityFallback to match its one remaining job), the public get(id) method (ConversationHandle now derives from the active getter), and billingUrl (AiChat carries a TODO where its consumer would be). Collapse the duplicated keys-values zip in the store's two bulk reads into one readAllRows helper.
…ndaries ui-message.ts no longer exists in tab-manager; its store moved to chat/persistence.ts behind ChatClientPersistence, and the persisted-message boundary example now lives in opensidian.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This branch started as a TanStack AI 0.28.0 bump and ended with tab-manager's chat rebuilt on a different storage owner. Twenty commits, three arcs, every load-bearing claim grounded in the installed client source rather than memory.
The bump and the part boundary. All seven TanStack AI packages move in lockstep (
@tanstack/ai0.28.0,ai-client0.16.3,ai-svelte0.13.4, plus the four provider adapters), and the local 0.8.1 patch is deleted because the fix it carried (TanStack/ai#395, immutable tool-call message updaters) ships upstream in 0.28.0. The newstructured-outputpart member tripped the compile-time drift checks exactly as designed. Those drift checks now derive fromUIMessage['parts'][number], the@tanstack/ai-clientunion the UI actually consumes, instead of the structurally similar server union. Template dispatch is exhaustively guarded by a(part: never)helper, so the next new part type is a compile error in the file that must respond, with a runtime unknown-part fallback because persisted parts can outrun a build's known types. The same hardening is ported to opensidian and zhongwen, including the write direction (toPersistedParts) that the original port missed: both apps were still writing parts through a rawas JsonValue[]cast.A grill of the running system found three real bugs. Denied tool approvals spun forever: a denial settles at state
'approval-responded'withoutputpermanently null, and the runtime counts it complete and moves on, so the output-basedisRunningderivation showed an eternal spinner. The derivation itself survives the grill, because 0.28 still settles errored calls at'input-complete'with the error inoutput(message-updaters.js), so state alone cannot distinguish settled from running; denial just needed its own carve-out. Second, theisAwaitingContinuationtyping-dots heuristic is deleted: the tool-result-to-continuation gap is one microtask chain (checkForContinuationruns instreamResponse's finally and the continuation sets'submitted'synchronously) so it can never paint, while the state it actually matched, a conversation hydrated with a trailing tool-result row, showed permanent typing dots and suppressed the one affordance that recovers it, Regenerate. Third,destroyConversationleaked every deleted conversation's client:createChatmounts a devtools bridge registered in aglobalThisregistry, and onlydispose()releases it;stop()alone does not.Then the storage question, and the refusal that inverted. This branch originally refused
ChatClientPersistencebecause itssetItemrides the StreamProcessor's per-chunk change events (25 emit sites, no debounce) and a full message list per chunk amplifies into Yjs update history forever. Grilling the refusal exposed the real problem: the model, not the adapter. Chat transcripts are single-writer, append-mostly, device-scoped logs, every row re-set and every conversation delete left permanent CRDT tombstones, and a plausible local-model path (the client'sChatTransportalready accepts afetcherreturningAsyncIterable<StreamChunk>) means a turn may never touch the server at all, so server-side or synced storage bets against the most private future. Message bodies now live in extension-local IndexedDB behind the adapter, where per-chunk full-list writes are fine:With zero users, the clean break cascaded with no migration code anywhere: the
chatMessagestable, theChatMessageIdbrand family, and the speculative branching columns (parentId,sourceMessageId,systemPrompt, zero writers) are deleted outright, and finally theconversationstable itself, which had become a denormalization with two owners. Title derives from the first user message, recency from the last message'screatedAt(the processor stamps it), the only stored metadata is the per-conversation provider/model pick, and the handle registry is the conversation list;reconcileHandlesexisted only to keep two representations of the same list in sync. A new conversation is an in-memory draft that stores nothing until its first message. Tool trust collapses from a'ask' | 'always'column, where revoking wrote junk'ask'rows indistinguishable from absence, to a presence set.workspace tables before devices, savedTabs, bookmarks, conversations, chatMessages, toolTrust('ask' | 'always') workspace tables after devices, savedTabs, bookmarks, toolTrust (presence set) chat storage after IndexedDB 'tab-manager-chat' messages conversationId -> UIMessage[] settings conversationId -> { provider, model }The server also sheds an unread field: the AI route validated an optional
conversationIdthat nothing read (TanStack already correlates bythreadId), so it leaves the schema and both clients that sent it. Opensidian and zhongwen deliberately keep their workspace-backed chat for now, recorded as a decision at the divergence points rather than drift.Verified at every commit: tab-manager, opensidian, zhongwen, and api typechecks at zero errors, plus the server (98) and workspace (476) test suites. Net diff is -229 lines while the dep bump and a storage rework rode along; tab-manager's chat-state went from owning three hand-written persistence sites, an observer, and a documented race guard to owning none of them.
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.