From 47e697ef69267d9e98a6a7466765b157d3a2d70f Mon Sep 17 00:00:00 2001 From: DevMando Date: Mon, 7 Sep 2026 16:45:57 -0700 Subject: [PATCH] Keep each tab's model across a restart Restoring a workspace starts every tab at once, and a tab boots on the default model before InitTabAsync moves it onto its own saved one. SaveWorkspace records the current model of EVERY tab, and it is reached constantly during startup: StateChanged raises UpdateHeader, which raises HeaderChanged, which saves. Any of those firing mid-restore stamped the default over a tab that had not switched yet, and that tab came back on the default next launch with nothing to recover from. Workspace writes are now suppressed until the last tab finishes restoring, and the tab that finishes last performs one save with every model settled. A tab whose restore never completes is still sitting on the default it was seeded with, so persisting that would discard the user's choice for good. ModelRestorePending keeps the saved model in the workspace until RestoreTabAsync runs to completion; it is cleared after the await rather than in the finally, so a throw part way through leaves the saved model protected. This matters more now that the final save is guaranteed rather than incidental. --- CHANGELOG.md | 6 +++ src/MandoCode.Desktop/MainWindow.Tabs.cs | 16 +++++++- src/MandoCode.Desktop/MainWindow.Terminal.cs | 42 +++++++++++++++++++- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b30f647..7608747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,12 @@ for every approved plan. Desktop's version follows the engine generation, so it a sticky header, or the suggestion list a field opens when it is filled. ### Fixed +- **Per-tab model choices survive a restart.** Restoring a session initialized every tab at once, + and a tab boots on the default model before moving onto its own saved one. Any workspace write + during that window recorded the default over a tab's real model, so an agent you had switched + could come back on the default — permanently, since the saved value was gone. Workspace writes + are now held until every tab has settled, and a tab whose restore does not finish keeps its + saved model instead of having the fallback written over it. - **A restored tab no longer announces two different models at startup.** Restoring a session announced the default model, then immediately switched to the tab's saved model and announced that one as well. The first notice was obsolete the moment it appeared, and could advertise diff --git a/src/MandoCode.Desktop/MainWindow.Tabs.cs b/src/MandoCode.Desktop/MainWindow.Tabs.cs index 967a935..104ecc2 100644 --- a/src/MandoCode.Desktop/MainWindow.Tabs.cs +++ b/src/MandoCode.Desktop/MainWindow.Tabs.cs @@ -36,6 +36,15 @@ private sealed class ChatTabEntry /// Model to select once this tab's harness is initialized — set only for /// tabs recreated from a saved workspace. Best-effort: unavailable model = default. public string? RestoreModel { get; init; } + + /// + /// True from creation until this tab has actually finished settling onto its own model. + /// While it is set, the workspace persists rather than the live + /// model: a tab whose restore never completed is sitting on the default it was seeded with, + /// and writing that would discard the user's real choice permanently. Once restore + /// finishes — whether the model resolved or errored — the live model is the truth. + /// + public bool ModelRestorePending { get; set; } } private readonly List _tabs = new(); @@ -81,7 +90,12 @@ private ChatTabEntry CreateChatTab(string? projectRoot = null, string? title = n TabHost.Children.Add(view); var (header, label, badge) = BuildTabHeader(session.Title); - var entry = new ChatTabEntry { Header = header, Label = label, Badge = badge, View = view, RestoreModel = restoreModel }; + var entry = new ChatTabEntry + { + Header = header, Label = label, Badge = badge, View = view, + RestoreModel = restoreModel, + ModelRestorePending = !string.IsNullOrWhiteSpace(restoreModel), + }; _tabs.Add(entry); TabStrip.Children.Add(header); WireHeader(entry); diff --git a/src/MandoCode.Desktop/MainWindow.Terminal.cs b/src/MandoCode.Desktop/MainWindow.Terminal.cs index 7375474..23a1270 100644 --- a/src/MandoCode.Desktop/MainWindow.Terminal.cs +++ b/src/MandoCode.Desktop/MainWindow.Terminal.cs @@ -203,6 +203,10 @@ private void Root_Loaded(object sender, RoutedEventArgs e) _initialized = true; // Restored workspaces can open with several tabs — initialize them all (each owns // its WebView2 + harness, same cost as if the user had opened them by hand). + // Every tab boots on the DEFAULT model and only moves to its own saved model once + // InitTabAsync gets that far. Workspace writes are suppressed until the last tab settles + // — see SaveWorkspace for why a write inside that window loses a tab's model. + Volatile.Write(ref _restoringTabs, _tabs.Count); foreach (var entry in _tabs) _ = InitTabAsync(entry); InitBgPreview(); @@ -212,7 +216,27 @@ private void Root_Loaded(object sender, RoutedEventArgs e) RefreshHistoryBadge(); } + /// Tabs still moving from the default model onto their own saved one. + private int _restoringTabs; + private async Task InitTabAsync(ChatTabEntry entry) + { + try + { + await RestoreTabAsync(entry); + // Restore ran to completion, so whatever model this tab holds now is the one worth + // remembering — including a model that errored, since the name still resolved. A throw + // skips this line, leaving the saved model protected by SaveWorkspace instead. + entry.ModelRestorePending = false; + } + finally + { + // Last one out writes the workspace, now that every tab reports its real model. + if (Interlocked.Decrement(ref _restoringTabs) == 0) SaveWorkspace(); + } + } + + private async Task RestoreTabAsync(ChatTabEntry entry) { var controller = entry.View.Session.Controller; // Best-effort per-tab model restore: if the saved model is gone (Ollama not running, @@ -241,12 +265,28 @@ private async Task InitTabAsync(ChatTabEntry entry) /// Writes the current workspace shape (tabs + active) to disk. Called on close /// and after any structural change, so even a crash loses at most the latest tweak. + /// + /// The model to remember for a tab: its live model, except while a restore has not finished. + /// A tab mid-restore (or one whose restore threw) is still on the default it was seeded with, + /// and persisting that would replace the user's own choice with the default for good. + /// + private static string PersistedModelFor(ChatTabEntry tab) => + tab.ModelRestorePending && !string.IsNullOrWhiteSpace(tab.RestoreModel) + ? tab.RestoreModel + : tab.View.Session.Controller.ModelName; + private void SaveWorkspace() { + // This records the CURRENT model of EVERY tab, and a restore reaches it early: any tab's + // StateChanged during startup runs UpdateHeader -> HeaderChanged -> here, while other tabs + // are still sitting on the default. Writing then stamps the default over a tab's real + // model, and that tab comes back on the default next launch — the switch is simply lost. + if (Volatile.Read(ref _restoringTabs) > 0) return; + var tabs = _tabs.Select(t => new WorkspaceTabState( t.View.Session.Title, t.View.Session.ProjectRoot.ProjectRoot, - t.View.Session.Controller.ModelName, + PersistedModelFor(t), t.View.Session.PersistKey)).ToList(); var active = _selected == null ? 0 : Math.Max(0, _tabs.IndexOf(_selected));