Back to Dyad

Per-Chat Mode Persistence

plans/per-chat-mode-persistence.md

0.44.020.7 KB
Original Source

Per-Chat Mode Persistence

Generated by swarm planning session on 2026-04-20

Summary

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.

Problem Statement

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.

Scope

In Scope (MVP)

  • Add nullable chatMode column to the chats table.
  • On chat creation: snapshot getEffectiveDefaultChatMode() into the chat row.
  • On mode switch from inside a chat (dropdown or keyboard shortcut): persist the new mode to that chat's row.
  • On chat load: if chatMode is set, use it; if NULL, fall back to getEffectiveDefaultChatMode().
  • Existing chats keep chatMode NULL — no backfill. They behave the same as today (inherit the current default) until the user explicitly picks a mode for them.
  • Server resolves the effective mode per turn and echoes effectiveChatMode back in the first stream event; client reconciles.
  • Cmd/Ctrl+. keyboard shortcut continues to work, persists to the current chat row.
  • Unavailable stored mode (Pro lapsed, quota exhausted, provider missing): silent fallback to effective default + toast. User can send their message immediately. The fallback is read-side only — the stored chatMode is NOT overwritten, so the chat auto-recovers if the user becomes Pro / refills quota / re-adds the provider.
  • Deprecated agent value: migrate to build on read via StoredChatModeSchema, same pattern as settings.
  • Forked/compacted chats inherit the parent chat's chatMode.

Out of Scope (Follow-up)

  • Backfilling existing chats with the current global mode at migration time.
  • Renaming the defaultChatMode settings label to "Default mode for new chats."
  • Per-chat mode icon in the chat list.
  • Generalizing the mode-change toast with an Undo for all modes (current local-agent warning toast stays as-is).
  • Disabling the mode selector while a response is streaming.
  • Per-app "last used" mode default.
  • Explicit per-chat mode lock (protect from accidental changes).
  • Bulk mode change across all chats in an app.
  • Multi-window same-chat live sync (broadcast / pub-sub over IPC).
  • Persistent in-chat banner for unavailable modes (beyond the one-shot toast).

User Stories

  • As a power user juggling a planning chat and a build chat in the same app, I want each chat to remember its mode so that I don't send a planning prompt through the build pipeline.
  • As a user with a mix of ask-mode research chats and build-mode work chats, I want switching between them to Just Work so that I don't have to flip the selector every time.
  • As a returning user opening a months-old chat, I want it to behave sensibly (fall back to the current default) so that nothing breaks silently after upgrade.
  • As a user who changes mode mid-chat, I want that choice to stick when I come back so that my intent is preserved.
  • As a free user whose stored mode was 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.

UX Design

User Flow

  1. User opens a chat — header ChatModeSelector shows the chat's persisted mode (or the effective default if NULL for existing chats).
  2. User switches modes via the dropdown or Cmd/Ctrl+. — the new mode is persisted to this chat's row (optimistic client update, IPC write).
  3. User navigates to a different chat — the selector updates to that chat's mode.
  4. User creates a new chat via HomeChatInputcreateChat is called with initialChatMode = getEffectiveDefaultChatMode(), so the new row has a concrete mode from the start.
  5. User returns to any earlier chat — the header again reflects that chat's mode.

Key States

  • Default: selector shows the chat's current mode; behavior identical to today's selector, just sourced from the chat row instead of global settings.
  • Existing pre-migration chat (chatMode NULL): selector shows the resolved effective default. On first mode change, the row transitions from NULL to the chosen mode.
  • Loading: selector shows the last-known mode for the chat (from react-query cache) during navigation — no skeleton unless the chat itself is loading.
  • Empty (no chat open / home): selector still lives in HomeChatInput and reads/writes settings.selectedChatMode. Cmd/Ctrl+. continues to mutate the global setting while on home — out-of-scope to change.
  • Unavailable stored mode: server falls back to the effective default; response stream includes the effective mode plus a 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.

Interaction Details

  • Selector persists on commit (close / keyboard fire), not on hover preview.
  • Selector write happens even before the chat has any messages — a freshly-created empty chat with a mode change keeps that mode on return.
  • 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.
  • Existing local-agent warning toast (mid-chat switch to local-agent) remains as the only persistent mode-switch toast. Other mode switches are silent (status quo).
  • When server returns 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.

Accessibility

  • Selector's accessible name should be dynamic: "Chat mode: Build" so a screen reader focus on the button reveals the current chat's mode.
  • Do not announce the header change on chat navigation (would be fatigue-inducing). Changes are announced only when user-initiated.
  • Touch targets remain ≥ 44px (no visual change to the selector).
  • Availability toast remains keyboard-reachable for at least 8s; matches today's toast behavior.

Technical Design

Architecture

The server is the source of truth for the effective chat mode on each turn. The flow:

  1. Client calls streamMessage with requestedChatMode (read from the chat row via react-query).
  2. Server loads the chat, computes resolvedChatMode = resolveChatMode(chat, settings, env, quota):
    • If chat.chatMode is non-null and that mode is still available → use it.
    • Else → fall back to getEffectiveDefaultChatMode(settings, env, quota).
  3. Server emits an early 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.
  4. Server constructs the system prompt and routes to the appropriate stream using 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.

Components Affected

  • 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 agentbuild migrates transparently.
  • src/ipc/handlers/chat_handlers.tsgetChat, 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.

Data Model Changes

ts
// 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.

API Changes

  • 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.

Implementation Plan

Phase 1: Data layer

  • Add chatMode column to src/db/schema.ts and generate the drizzle migration.
  • Extend ChatSchema in src/lib/schemas.ts with chatMode: StoredChatModeSchema.nullable().
  • Update IPC contracts for getChat, getChats, updateChat, createChat to include chatMode.
  • Unit test: schema migration against a snapshot of an existing SQLite DB (existing rows get NULL, queries still succeed).

Phase 2: Server-side resolution

  • Implement resolveChatMode(chat, settings, env, quota): ChatMode with table-driven tests: null / pinned-and-available / pinned-unavailable / legacy "agent" / lapsed Pro.
  • Wire resolveChatMode into chat_stream_handlers.ts at every current settings.selectedChatMode read site.
  • Add the effectiveChatMode stream event emitted before system-prompt content.
  • Handler for updateChat({ chatMode }) round-trip.

Phase 3: Client selector + toggle

  • 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.
  • Fork path: pass parent's chatMode when duplicating a chat.

Phase 4: New chat seeding

  • HomeChatInput passes initialChatMode = getEffectiveDefaultChatMode(...) when creating a new chat.
  • Server-side default in createChat: if initialChatMode is omitted, resolve from settings at the server side.

Phase 5: Availability fallback UX

  • Client subscribes to the 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.
  • Also fire the toast on fresh chat load when the stored mode is already known to be unavailable client-side (via existing signals: isDyadProEnabled, useFreeAgentQuota, isOpenAIOrAnthropicSetup), so users see the fallback explanation before they type.
  • Do NOT persist the fallback back to the chat row — leave chat.chatMode untouched so the chat recovers automatically if the unavailability resolves.

Phase 6: Tests + docs

  • E2E: switch mode in chat A, switch to chat B (different mode), return to A — A's mode is preserved.
  • E2E: new chat created from home respects the current default.
  • E2E: change default in settings — existing chats with NULL pick it up on next send; chats with a pinned non-null mode do not.
  • E2E: Cmd/Ctrl+. inside a chat persists; on home it mutates settings.selectedChatMode (status quo).
  • Release notes: brief mention that chat mode is now per-chat, with an emphasis on "existing chats keep working; pick a mode inside a chat to make it sticky."

Testing Strategy

  • Unit tests for resolveChatMode (table-driven).
  • Unit test for the schema migration against an older DB snapshot (existing rows get NULL, queries pass, fork inherits mode).
  • Unit test that useChatModeToggle branches correctly on selectedChatIdAtom.
  • IPC integration test: updateChat({ chatMode }) round-trips; setting null resets to fallback resolution.
  • E2E (Playwright): the four scenarios in Phase 6.
  • Regression: existing readSettings tests stay green; add an equivalent for the per-chat read path.

Risks & Mitigations

RiskLikelihoodImpactMitigation
Existing chat's NULL mode silently shifts when user changes defaultChatModeMMDocumented 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 usingLLServer 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 desyncLLDocumented limitation; refresh to reconcile. Not worth IPC pub/sub in v1.
Pinned local-agent mode + Pro lapsed silently runs build without the user noticingMMServer 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 persistedLLServer 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 oneMLTreat 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.

Open Questions

  • Remove 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.
  • Should we later add a persistent in-chat banner for unavailable modes? The user explicitly chose toast-only for v1. Revisit if fallback cases become confusing in practice.
  • Should we later show a mode icon in the chat list? Deferred; re-evaluate after per-chat mode adoption is measured.
  • 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.

Decision Log

  • Migration: leave existing chats NULL, resolve at load. User choice. Team ultimately leaned toward "backfill with current global mode at first-run" for more determinism, but the user opted for the zero-upgrade-day-impact path. Consequence: existing chats continue to follow the user's current default until the user explicitly changes their mode in that chat.
  • New chats: snapshot the effective default at creation (copy-at-write). User choice; PM and Eng agreed in final debate. The chat's mode is fixed for its lifetime unless the user changes it. Consistent with how other per-chat fields (initialCommitHash) work.
  • Unavailable-mode behavior: silent fallback + one-shot toast. User choice. UX advocated for a persistent inline banner / disabled composer, citing that toasts dismiss and the next session won't know about the fallback. User decided the simpler fallback + toast is sufficient for v1; a persistent banner can be added later if complaints surface.
  • No "disable selector during streaming" for v1. User explicitly deselected. Server locks mode at turn start, which provides correctness; UI-side guard is a polish item.
  • No chat-list mode indicator for v1. User deselected. The header selector remains the primary locus of the mode decision.
  • No rename of "Default chat mode" setting. User deselected. Copy stays as-is; can revisit if confusion arises.
  • No neutral "Chat switched · Undo" toast generalization. User deselected. Current local-agent warning toast remains the only mode-switch toast.
  • Server is authoritative for resolved mode; client sends a hint; server echoes back via effectiveChatMode event. Engineering design decision. Prevents client-server drift and gives a clean hook for fallback UX.
  • Cmd/Ctrl+. inside a chat persists to the chat row; on home it mutates 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.
  • Forked/compacted chats inherit the parent chat's mode. A fork is a continuation; mode should follow.
  • Two-window live sync deferred. Low incidence; IPC pub/sub adds complexity disproportionate to the benefit for v1.

Generated by dyad:swarm-to-plan