Skip to content

refactor(chat): TanStack AI 0.28 bump, cross-app hardening, and device-local tab-manager chat - #1924

Merged
braden-w merged 21 commits into
mainfrom
codex/tab-manager-ai-parts-collapse
Jun 13, 2026
Merged

refactor(chat): TanStack AI 0.28 bump, cross-app hardening, and device-local tab-manager chat#1924
braden-w merged 21 commits into
mainfrom
codex/tab-manager-ai-parts-collapse

Conversation

@braden-w

@braden-w braden-w commented Jun 12, 2026

Copy link
Copy Markdown
Member

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/ai 0.28.0, ai-client 0.16.3, ai-svelte 0.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 new structured-output part member tripped the compile-time drift checks exactly as designed. Those drift checks now derive from UIMessage['parts'][number], the @tanstack/ai-client union 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 raw as JsonValue[] cast.


A grill of the running system found three real bugs. Denied tool approvals spun forever: a denial settles at state 'approval-responded' with output permanently null, and the runtime counts it complete and moves on, so the output-based isRunning derivation showed an eternal spinner. The derivation itself survives the grill, because 0.28 still settles errored calls at 'input-complete' with the error in output (message-updaters.js), so state alone cannot distinguish settled from running; denial just needed its own carve-out. Second, the isAwaitingContinuation typing-dots heuristic is deleted: the tool-result-to-continuation gap is one microtask chain (checkForContinuation runs in streamResponse'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, destroyConversation leaked every deleted conversation's client: createChat mounts a devtools bridge registered in a globalThis registry, and only dispose() releases it; stop() alone does not.


Then the storage question, and the refusal that inverted. This branch originally refused ChatClientPersistence because its setItem rides 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's ChatTransport already accepts a fetcher returning AsyncIterable<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:

const chat = createChat({
	id: conversationId,
	persistence: chatPersistence, // IndexedDB; ordered setItem queue owns every write
	tools: sessionAiTools.tools,
	connection: fetchServerSentEvents(`${APP_URLS.API}/ai/chat`, ...),
});

With zero users, the clean break cascaded with no migration code anywhere: the chatMessages table, the ChatMessageId brand family, and the speculative branching columns (parentId, sourceMessageId, systemPrompt, zero writers) are deleted outright, and finally the conversations table itself, which had become a denormalization with two owners. Title derives from the first user message, recency from the last message's createdAt (the processor stamps it), the only stored metadata is the per-conversation provider/model pick, and the handle registry is the conversation list; reconcileHandles existed 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 conversationId that nothing read (TanStack already correlates by threadId), 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.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

braden-w and others added 21 commits June 12, 2026 06:38
…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.
@braden-w
braden-w merged commit 2fad9a8 into main Jun 13, 2026
3 of 9 checks passed
@braden-w
braden-w deleted the codex/tab-manager-ai-parts-collapse branch June 13, 2026 03:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant