Commit 1ae0377
authored
🤖 feat: persist per-workspace model + thinking (#1203)
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.
---
<details>
<summary>📋 Implementation Plan</summary>
# 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): string` →
`thinkingLevel:${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`.
---
<details>
<summary>Alternatives considered</summary>
### 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.
</details>
</details>
---
_Generated with `mux` • Model: `openai:gpt-5.2` • Thinking: `high`_
Signed-off-by: Thomas Kosiewski <tk@coder.com>1 parent 00940b3 commit 1ae0377
File tree
20 files changed
+594
-79
lines changed- src
- browser
- components
- ChatInput
- contexts
- utils/messages
- common
- constants
- orpc
- schemas
- node
- orpc
- services
- tests/ipc
20 files changed
+594
-79
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
37 | | - | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
38 | 42 | | |
| 43 | + | |
39 | 44 | | |
40 | 45 | | |
41 | 46 | | |
| |||
52 | 57 | | |
53 | 58 | | |
54 | 59 | | |
55 | | - | |
| 60 | + | |
56 | 61 | | |
57 | 62 | | |
58 | 63 | | |
| |||
293 | 298 | | |
294 | 299 | | |
295 | 300 | | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
296 | 309 | | |
297 | | - | |
298 | | - | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
299 | 320 | | |
300 | 321 | | |
301 | 322 | | |
| |||
308 | 329 | | |
309 | 330 | | |
310 | 331 | | |
311 | | - | |
| 332 | + | |
| 333 | + | |
312 | 334 | | |
313 | 335 | | |
314 | 336 | | |
315 | | - | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
316 | 347 | | |
317 | 348 | | |
318 | 349 | | |
319 | 350 | | |
320 | 351 | | |
321 | | - | |
| 352 | + | |
322 | 353 | | |
323 | 354 | | |
324 | 355 | | |
325 | 356 | | |
326 | | - | |
| 357 | + | |
327 | 358 | | |
328 | 359 | | |
329 | 360 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
23 | 26 | | |
24 | 27 | | |
25 | 28 | | |
| |||
133 | 136 | | |
134 | 137 | | |
135 | 138 | | |
| 139 | + | |
| 140 | + | |
136 | 141 | | |
137 | 142 | | |
138 | 143 | | |
| |||
333 | 338 | | |
334 | 339 | | |
335 | 340 | | |
336 | | - | |
337 | | - | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
338 | 359 | | |
339 | | - | |
| 360 | + | |
340 | 361 | | |
341 | 362 | | |
342 | 363 | | |
| |||
Lines changed: 34 additions & 4 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| 10 | + | |
10 | 11 | | |
11 | 12 | | |
12 | 13 | | |
| |||
83 | 84 | | |
84 | 85 | | |
85 | 86 | | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
86 | 91 | | |
87 | 92 | | |
88 | 93 | | |
89 | 94 | | |
90 | | - | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
91 | 99 | | |
92 | 100 | | |
93 | 101 | | |
| |||
114 | 122 | | |
115 | 123 | | |
116 | 124 | | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
117 | 128 | | |
118 | 129 | | |
119 | 130 | | |
| |||
124 | 135 | | |
125 | 136 | | |
126 | 137 | | |
| 138 | + | |
127 | 139 | | |
128 | 140 | | |
129 | 141 | | |
| |||
157 | 169 | | |
158 | 170 | | |
159 | 171 | | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
160 | 181 | | |
161 | 182 | | |
162 | 183 | | |
| |||
176 | 197 | | |
177 | 198 | | |
178 | 199 | | |
| 200 | + | |
179 | 201 | | |
180 | 202 | | |
181 | 203 | | |
| |||
213 | 235 | | |
214 | 236 | | |
215 | 237 | | |
| 238 | + | |
216 | 239 | | |
217 | 240 | | |
218 | 241 | | |
| |||
278 | 301 | | |
279 | 302 | | |
280 | 303 | | |
281 | | - | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
282 | 309 | | |
283 | 310 | | |
284 | 311 | | |
| |||
466 | 493 | | |
467 | 494 | | |
468 | 495 | | |
469 | | - | |
| 496 | + | |
470 | 497 | | |
471 | 498 | | |
472 | 499 | | |
| |||
510 | 537 | | |
511 | 538 | | |
512 | 539 | | |
513 | | - | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
514 | 544 | | |
515 | 545 | | |
516 | 546 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
| 4 | + | |
4 | 5 | | |
5 | 6 | | |
6 | 7 | | |
| |||
11 | 12 | | |
12 | 13 | | |
13 | 14 | | |
| 15 | + | |
14 | 16 | | |
15 | 17 | | |
16 | 18 | | |
| |||
45 | 47 | | |
46 | 48 | | |
47 | 49 | | |
48 | | - | |
49 | | - | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
50 | 57 | | |
51 | 58 | | |
52 | 59 | | |
| |||
196 | 203 | | |
197 | 204 | | |
198 | 205 | | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
199 | 219 | | |
200 | 220 | | |
201 | 221 | | |
| |||
239 | 259 | | |
240 | 260 | | |
241 | 261 | | |
| 262 | + | |
| 263 | + | |
242 | 264 | | |
243 | 265 | | |
244 | 266 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
199 | 199 | | |
200 | 200 | | |
201 | 201 | | |
202 | | - | |
| 202 | + | |
203 | 203 | | |
204 | 204 | | |
205 | 205 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
7 | | - | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
8 | 12 | | |
9 | 13 | | |
10 | 14 | | |
| |||
49 | 53 | | |
50 | 54 | | |
51 | 55 | | |
52 | | - | |
53 | | - | |
| 56 | + | |
54 | 57 | | |
55 | 58 | | |
56 | 59 | | |
| |||
79 | 82 | | |
80 | 83 | | |
81 | 84 | | |
| 85 | + | |
82 | 86 | | |
83 | | - | |
| 87 | + | |
84 | 88 | | |
85 | 89 | | |
86 | 90 | | |
87 | 91 | | |
88 | | - | |
| 92 | + | |
89 | 93 | | |
90 | 94 | | |
91 | | - | |
92 | 95 | | |
93 | | - | |
94 | | - | |
95 | | - | |
96 | | - | |
| 96 | + | |
97 | 97 | | |
98 | 98 | | |
99 | 99 | | |
| |||
102 | 102 | | |
103 | 103 | | |
104 | 104 | | |
105 | | - | |
| 105 | + | |
106 | 106 | | |
107 | 107 | | |
108 | | - | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
109 | 114 | | |
110 | 115 | | |
111 | 116 | | |
| |||
0 commit comments