Skip to content

Conversation

@ThomasK33
Copy link
Member

Persist per-workspace model and thinking/reasoning level on the backend so that opening the same workspace from another browser/device restores the same AI configuration.

Changes

  • New shared schema WorkspaceAISettings (model, thinkingLevel) added to workspace config and metadata schemas.
  • New ORPC endpoint workspace.updateAISettings to explicitly persist settings changes.
  • Backend safety net: sendMessage and resumeStream also persist "last used" settings for older/CLI clients.
  • Frontend thinking is now workspace-scoped (thinkingLevel:{workspaceId}) with migration from legacy per-model keys.
  • LocalStorage seeding from backend metadata ensures cross-device convergence.
  • UI changes back-persist model and thinking to backend immediately.
  • Creation flow copies project-scoped preferences and best-effort persists to backend.

Testing

  • Updated ThinkingContext.test.tsx for workspace-scoped thinking + migration.
  • Added WorkspaceContext.test.tsx test for backend metadata seeding localStorage.
  • Added IPC test tests/ipc/workspaceAISettings.test.ts verifying persistence + list/getInfo.

📋 Implementation Plan

Persist per-workspace model + reasoning (thinking) on the backend

Goal

Make model selection and thinking/reasoning level:

  • Workspace-scoped (not per-model, not global)
  • Persisted server-side so opening the same workspace from another browser/device restores the same configuration

Non-goals (for this change): persisting provider options (e.g. truncation), global default model, or draft input.


Recommended approach (net +250–400 LoC product)

1) Define a single workspace AI settings shape (shared types)

Why: avoid ad-hoc keys and keep the IPC/API boundary strongly typed.

  • Add a reusable schema/type (common):

    • WorkspaceAISettings:
      • model: string (canonical provider:model, not mux-gateway:provider/model)
      • thinkingLevel: ThinkingLevel (off|low|medium|high|xhigh)
  • Extend persisted config shape:

    • WorkspaceConfigSchema (in src/common/orpc/schemas/project.ts): add optional aiSettings?: WorkspaceAISettings
  • Extend workspace metadata returned to clients:

    • WorkspaceMetadataSchema (in src/common/orpc/schemas/workspace.ts): add optional aiSettings?: WorkspaceAISettings
    • (Frontend schema automatically inherits via FrontendWorkspaceMetadataSchema.extend(...))

2) Backend: persist + serve workspace AI settings

Persistence location: ~/.mux/config.json under each workspace entry (alongside runtimeConfig, mcp, etc.)

2.1 Add an API to update settings explicitly

  • Add a new ORPC endpoint:

    • workspace.updateAISettings (name bikesheddable)
    • Input: { workspaceId: string, aiSettings: WorkspaceAISettings }
    • Output: Result<void, string>
  • Node implementation (WorkspaceService):

    • Validate workspace exists via config.findWorkspace(workspaceId).
    • config.editConfig(...) to locate the workspace entry and set workspaceEntry.aiSettings = normalizedSettings.
    • Normalize defensively:
      • model = normalizeGatewayModel(model) (from src/common/utils/ai/models.ts)
      • thinkingLevel = enforceThinkingPolicy(model, thinkingLevel) (single source of truth)
    • After save, re-fetch via config.getAllWorkspaceMetadata() and emit onMetadata update only if the value changed (avoid spam).

2.2 Also persist “last used” settings on message send (safety net)

Even if a client forgets to call updateAISettings (CLI/extension/old client), the backend should learn the last used values.

  • In WorkspaceService.sendMessage(...) and resumeStream(...):
    • Extract options.model + options.thinkingLevel and write to workspaceEntry.aiSettings (same normalization as above).
    • Only write when different.

This makes “chatted with it earlier” reliably populate the backend state.

3) Frontend: treat backend as source of truth, but keep localStorage as a fast cache

3.1 Change thinking persistence to be workspace-scoped

  • Add a new storage helper:

    • getThinkingLevelKey(scopeId: string): stringthinkingLevel:${scopeId}
    • Update PERSISTENT_WORKSPACE_KEY_FUNCTIONS to include it (so fork copies it, delete removes it).
  • Update ThinkingProvider to use scope-based keying:

    • Scope priority similar to ModeProvider:
      • workspace: thinkingLevel:{workspaceId}
      • creation: thinkingLevel:__project__/{projectPath}
    • Remove the current “key depends on selected model” behavior.
  • Update non-React send option reader:

    • getSendOptionsFromStorage(...) should read thinking via getThinkingLevelKey(scopeId).
  • Update UI copy:

    • Thinking slider tooltip text from “Saved per model” → “Saved per workspace”.

3.2 Seed localStorage from backend workspace metadata

Where: WorkspaceContext.loadWorkspaceMetadata() + the workspace.onMetadata subscription handler.

  • When metadata arrives for a workspace:
    • If metadata.aiSettings?.model exists → write it to localStorage key model:{workspaceId}.
    • If metadata.aiSettings?.thinkingLevel exists → write it to thinkingLevel:{workspaceId}.
    • Only write when the value differs (avoid unnecessary re-renders from updatePersistedState).

This ensures:

  • new device with empty localStorage adopts backend settings
  • existing device with stale localStorage is corrected to backend

3.3 Persist changes back to the backend when the user changes the UI

  • Model changes:

    • In ChatInput’s setPreferredModel(...) (workspace variant only):
      • Update localStorage as today
      • Call api.workspace.updateAISettings({ workspaceId, aiSettings: { model, thinkingLevel: currentThinking } })
  • Thinking changes:

    • Wrap setThinkingLevel in ThinkingProvider (workspace variant only):
      • Update localStorage
      • Call api.workspace.updateAISettings(...) with { model: currentModel, thinkingLevel: newLevel }

Notes:

  • Use enforceThinkingPolicy client-side too (keep UI/BE consistent) but backend remains final authority.
  • If api is unavailable, keep localStorage update (offline-friendly) and rely on sendMessage persistence later.

3.4 Creation flow

  • Keep project-scoped model + thinking in localStorage while creating.
  • When a workspace is created:
    • Continue copying project-scoped values into the new workspace-scoped keys (update syncCreationPreferences to include thinking).
    • Optionally call workspace.updateAISettings(...) right after creation (best effort) so the workspace is immediately portable even before the first message sends.

4) Migration + compatibility

  • Config.json: new aiSettings fields are optional → old configs load fine; old mux versions should ignore unknown fields.
  • localStorage: migrate legacy per-model thinking into the new per-workspace key:
    • On workspace open, if thinkingLevel:{workspaceId} is missing:
      • read model:{workspaceId} (or default)
      • read old thinkingLevel:model:{model}
      • set thinkingLevel:{workspaceId} to that value

Put this migration in a single place (e.g., the same “seed from metadata” helper) so it runs once.


Validation / tests

  • Update unit tests that assert per-model thinking:
    • src/browser/contexts/ThinkingContext.test.tsx should instead verify thinking is stable across model changes (except clamping).
  • Add a backend test that workspace.updateAISettings:
    • persists into config
    • is returned by workspace.list/getInfo
  • Add a lightweight frontend test that WorkspaceContext seeding writes the expected localStorage keys when metadata contains aiSettings.

Alternatives considered

A) Persist only on sendMessage (no new endpoint) (net +120–200 LoC)

  • Backend writes aiSettings from sendMessage/resumeStream options.
  • Frontend only seeds localStorage from metadata when local keys are missing.

Pros: less surface area.
Cons: doesn’t sync if user changes model/thinking but hasn’t sent yet; stale localStorage on an existing device may never converge.

B) Remove localStorage for model/thinking entirely (net +500–900 LoC)

  • Replace usePersistedState(getModelKey(...)) usage with a workspace settings store sourced from backend.

Pros: true single source of truth.
Cons: much bigger refactor; riskier.


Generated with mux • Model: openai:gpt-5.2 • Thinking: high

Change-Id: I20f621f85e3475ca30f07a64d0e50822cbc59641
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33 ThomasK33 added this pull request to the merge queue Dec 17, 2025
Merged via the queue into main with commit 1ae0377 Dec 17, 2025
20 checks passed
@ThomasK33 ThomasK33 deleted the persisted-workspace-config branch December 17, 2025 17:40
ammar-agent added a commit that referenced this pull request Dec 20, 2025
Restore the per-model thinking behavior that was changed in #1203. While
that PR added valuable backend persistence for AI settings, it also
changed thinking levels from per-model to per-workspace scoping. This
was an unintended UX regression - users expect different models to
remember their individual thinking preferences.

### Changes

- **ThinkingContext**: Use per-model localStorage keys
  (`thinkingLevel:model:{model}`) instead of workspace-scoped keys
- **WorkspaceContext**: Seed per-model thinking from backend metadata
- **Storage**: Remove workspace-scoped thinking key from persistent keys
  (thinking is global per-model, not workspace-specific)
- **sendOptions**: Simplify to read per-model thinking directly
- **useCreationWorkspace**: Remove workspace-scoped thinking sync
- **ThinkingSlider**: Update tooltip to "Saved per model"

### Backend persistence preserved

Backend still stores `aiSettings.thinkingLevel` per workspace (the
last-used value) and seeds it to new devices via the per-model key.
This maintains cross-device sync while restoring the expected per-model
UX.

### Tests

- Updated ThinkingContext tests to verify per-model behavior
- Updated WorkspaceContext test to seed per-model key
- Updated useCreationWorkspace test to remove migration assertion
ammar-agent added a commit that referenced this pull request Dec 21, 2025
When compaction is triggered with a specific model (e.g., /compact -m
openai:gpt-4.1-mini), the backend was incorrectly persisting that model
to the workspace's AI settings, overwriting the user's preferred model.

This was a regression introduced in 1ae0377 (#1203) which added a
"safety net" to persist AI settings from sendMessage/resumeStream for
cross-device consistency. The fix skips this persistence for:
- sendMessage with muxMetadata.type === "compaction-request"
- resumeStream with options.mode === "compact"

Added test to verify compaction requests don't override workspace AI
settings.
ammar-agent added a commit that referenced this pull request Dec 21, 2025
When compaction is triggered with a specific model (e.g., /compact -m
openai:gpt-4.1-mini), the backend was incorrectly persisting that model
to the workspace's AI settings, overwriting the user's preferred model.

This was a regression introduced in 1ae0377 (#1203) which added a
"safety net" to persist AI settings from sendMessage/resumeStream for
cross-device consistency. The fix skips this persistence for:
- sendMessage with muxMetadata.type === "compaction-request"
- resumeStream with options.mode === "compact"

Added test to verify compaction requests don't override workspace AI
settings.
ammario pushed a commit that referenced this pull request Dec 21, 2025
## Problem

When compaction is triggered with a specific model (e.g., `/compact -m
openai:gpt-4.1-mini`), the backend was incorrectly persisting that model
to the workspace's AI settings, overwriting the user's preferred model.

After compaction completed, the workspace would be set to use the
compaction model instead of the model the user had selected.

## Root Cause

Regression introduced in 1ae0377 (#1203) which added a "safety net" to
persist AI settings from `sendMessage`/`resumeStream` for cross-device
consistency. This safety net didn't account for compaction requests
which intentionally use a different (often cheaper/faster) model.

cc @tomaskoubek (regressor)

## Fix

Skip persisting AI settings when:
- `sendMessage` is called with `muxMetadata.type ===
"compaction-request"`
- `resumeStream` is called with `options.mode === "compact"`

## Testing

- Added IPC test verifying compaction requests don't override workspace
AI settings
- Existing `updateAISettings` test still passes

---
_Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking:
`high`_
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