plans/per-chat-mode-persistence.md
Generated by swarm planning session on 2026-04-20
Persist a chat's mode (build / ask / local-agent / plan) on the chat row itself, so that returning to a chat restores its mode automatically. New chats snapshot the user's current effective default mode at creation. Existing chats migrate as NULL and continue to follow the current default until the user explicitly changes their mode.
Chat mode is currently a global user setting (selectedChatMode in user-settings.json). If a user is in plan mode in Chat A, switches to Chat B and changes to build, then returns to Chat A, Chat A is silently in build too. Their next message runs through the wrong pipeline — different system prompt, different tool access, different stream routing. The failure mode is silent (no error, just a wrong-shaped response) and violates the product principle "no unexpected behavior." Each chat should own its own mode.
chatMode column to the chats table.getEffectiveDefaultChatMode() into the chat row.chatMode is set, use it; if NULL, fall back to getEffectiveDefaultChatMode().chatMode NULL — no backfill. They behave the same as today (inherit the current default) until the user explicitly picks a mode for them.effectiveChatMode back in the first stream event; client reconciles.Cmd/Ctrl+. keyboard shortcut continues to work, persists to the current chat row.chatMode is NOT overwritten, so the chat auto-recovers if the user becomes Pro / refills quota / re-adds the provider.agent value: migrate to build on read via StoredChatModeSchema, same pattern as settings.chatMode.defaultChatMode settings label to "Default mode for new chats."local-agent but who's out of quota, I want a toast explaining the fallback so that I understand why my next reply is different.ChatModeSelector shows the chat's persisted mode (or the effective default if NULL for existing chats).Cmd/Ctrl+. — the new mode is persisted to this chat's row (optimistic client update, IPC write).HomeChatInput — createChat is called with initialChatMode = getEffectiveDefaultChatMode(), so the new row has a concrete mode from the start.HomeChatInput and reads/writes settings.selectedChatMode. Cmd/Ctrl+. continues to mutate the global setting while on home — out-of-scope to change.fallbackReason. Client shows a toast with the specific cause — e.g. "Agent v2 unavailable (Pro required). Using Build mode.", "Quota exhausted. Using Build mode.", or "No provider configured. Using Build mode." — with a [Switch mode] action. The toast re-fires each fresh time the user opens a chat whose stored mode is unavailable (bounded because the toast self-dismisses). The selector keeps showing the stored mode (not the fallback) so the chat auto-recovers if the unavailability resolves.Cmd/Ctrl+. in a chat cycles the chat's mode and persists. It does not touch settings.selectedChatMode. On home (no chat open), behavior is unchanged.effectiveChatMode that differs from the client's request (unavailability fallback), client shows the availability toast and invalidates the chat's query so the selector shows the effective mode."Chat mode: Build" so a screen reader focus on the button reveals the current chat's mode.The server is the source of truth for the effective chat mode on each turn. The flow:
streamMessage with requestedChatMode (read from the chat row via react-query).resolvedChatMode = resolveChatMode(chat, settings, env, quota):
chat.chatMode is non-null and that mode is still available → use it.getEffectiveDefaultChatMode(settings, env, quota).effectiveChatMode event in the stream (with an optional fallbackReason: "pro-required" | "quota-exhausted" | "no-provider" when resolution fell back) so the client can reconcile the selector and show a specific fallback toast when applicable.resolvedChatMode, replacing ~15 current reads of settings.selectedChatMode.settings.selectedChatMode remains in v1 as the home-page new-chat seed — out of scope to remove.
src/db/schema.ts — add chatMode: text("chat_mode") to chats (nullable, no default).drizzle/ — auto-generated migration: ALTER TABLE chats ADD COLUMN chat_mode TEXT;src/lib/schemas.ts — add chatMode to ChatSchema; reuse StoredChatModeSchema at DB-read boundary so legacy agent → build migrates transparently.src/ipc/handlers/chat_handlers.ts — getChat, getChats, updateChat, createChat all gain the chatMode field. updateChat accepts chatMode: ChatMode | null. createChat accepts optional initialChatMode.src/ipc/handlers/chat_stream_handlers.ts — introduce resolveChatMode(chat, settings, env, quota); replace all settings.selectedChatMode reads (~15 sites) with the resolved value; add an effectiveChatMode event at the start of the stream.src/ipc/ipc_client.ts / contracts — extend types for getChat, getChats, updateChat, createChat, streamMessage.src/components/ChatModeSelector.tsx — when on /chat?id=…, read/write chat row via a new useChatMode(chatId) hook (react-query mutation on updateChat). When on home, keep existing settings.selectedChatMode behavior.src/hooks/useChatModeToggle.ts — branch on active chatId: chat → chat-row mutation; no chat → settings.src/components/chat/HomeChatInput.tsx — when creating a new chat, pass initialChatMode = getEffectiveDefaultChatMode() to createChat.src/ipc/handlers/fork_chat.ts (or wherever chat duplication lives) — copy parent's chatMode to the new row.// src/db/schema.ts
export const chats = sqliteTable("chats", {
// ...existing columns...
chatMode: text("chat_mode"), // nullable; null = inherit default
});
Migration: nullable column, no data backfill. Existing rows have chatMode = NULL after the ALTER.
On read, the boundary schema (StoredChatModeSchema) maps legacy "agent" values to "build" (if any ever leaked into a future import path). NULL passes through unchanged.
getChat response: adds chatMode: ChatMode | null.getChats response: each chat includes chatMode: ChatMode | null in its columns selection.updateChat request: adds optional chatMode: ChatMode | null (passing null resets to default-resolving behavior).createChat request: adds optional initialChatMode: ChatMode. Default: resolved via getEffectiveDefaultChatMode() server-side if omitted.streamMessage request: adds requestedChatMode?: ChatMode (client hint).streamMessage stream: adds an early event { type: "effectiveChatMode", mode: ChatMode, fallbackReason?: "pro-required" | "quota-exhausted" | "no-provider" } before content starts. Additive; older clients ignore the field.chatMode column to src/db/schema.ts and generate the drizzle migration.ChatSchema in src/lib/schemas.ts with chatMode: StoredChatModeSchema.nullable().getChat, getChats, updateChat, createChat to include chatMode.resolveChatMode(chat, settings, env, quota): ChatMode with table-driven tests: null / pinned-and-available / pinned-unavailable / legacy "agent" / lapsed Pro.resolveChatMode into chat_stream_handlers.ts at every current settings.selectedChatMode read site.effectiveChatMode stream event emitted before system-prompt content.updateChat({ chatMode }) round-trip.useChatMode(chatId) hook with react-query mutation; optimistic update with rollback on error.ChatModeSelector: branch on selectedChatId; when a chat is active, read/write the chat row; when not, keep current settings behavior.useChatModeToggle: same branching for the keyboard shortcut.chatMode when duplicating a chat.HomeChatInput passes initialChatMode = getEffectiveDefaultChatMode(...) when creating a new chat.createChat: if initialChatMode is omitted, resolve from settings at the server side.effectiveChatMode stream event. When fallbackReason is present (and thus the effective mode differs from the stored mode), show a cause-specific toast: pro-required → "Agent v2 unavailable (Pro required). Using Build mode.", quota-exhausted → "Quota exhausted. Using Build mode.", no-provider → "No provider configured. Using Build mode." Each toast has a [Switch mode] action that opens/focuses the selector.isDyadProEnabled, useFreeAgentQuota, isOpenAIOrAnthropicSetup), so users see the fallback explanation before they type.chat.chatMode untouched so the chat recovers automatically if the unavailability resolves.settings.selectedChatMode (status quo).resolveChatMode (table-driven).useChatModeToggle branches correctly on selectedChatIdAtom.updateChat({ chatMode }) round-trips; setting null resets to fallback resolution.readSettings tests stay green; add an equivalent for the per-chat read path.| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
Existing chat's NULL mode silently shifts when user changes defaultChatMode | M | M | Documented as expected v1 behavior: existing chats inherit the current default until explicitly set. Users who want stability explicitly pick a mode. |
| Mid-stream mode toggle produces confusion about which mode the running response is using | L | L | Server locks mode at turn start; echoed via effectiveChatMode event. Client changes affect next turn only. Explicitly deferred UI guard (disable during stream) to follow-up. |
| Two renderer windows on the same chat desync | L | L | Documented limitation; refresh to reconcile. Not worth IPC pub/sub in v1. |
Pinned local-agent mode + Pro lapsed silently runs build without the user noticing | M | M | Server always emits effectiveChatMode with a fallbackReason; client shows a cause-specific toast each fresh time the user opens a chat whose stored mode is unavailable. Stored chatMode is not overwritten, so the chat auto-recovers on re-entitlement. Per-chat inline banner is a follow-up if complaints arrive. |
Server/client race: client's requestedChatMode was just-changed but not yet persisted | L | L | Server reads the chat row as the source of truth; requestedChatMode is a hint only. Optimistic client update reconciles to server via effectiveChatMode. |
~15 read sites in chat_stream_handlers.ts miss one | M | L | Treat resolveChatMode as the only way to obtain the turn's mode; remove/deprecate direct settings.selectedChatMode reads in that file. Search selectedChatMode should return zero matches in the stream handler after the refactor. |
settings.selectedChatMode entirely? After the refactor, its only remaining purpose is seeding new chats from home. Engineering lead flagged this as a potential cleanup — doable in v1 as a follow-up commit if the dust settles cleanly, but out of v1 scope as currently specified.Cmd/Ctrl+. on home currently mutates settings.selectedChatMode. Could plausibly be rerouted to defaultChatMode in a follow-up if we remove selectedChatMode — out of v1 scope.initialCommitHash) work.effectiveChatMode event. Engineering design decision. Prevents client-server drift and gives a clean hook for fallback UX.settings.selectedChatMode (status quo). Keeps the keyboard shortcut's semantics consistent with the visible selector on the same route. Avoids the "one keybind does two things in different contexts" concern UX raised and then withdrew.Generated by dyad:swarm-to-plan