Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion src/MandoCode.Desktop/MainWindow.Tabs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ private sealed class ChatTabEntry
/// <summary>Model to select once this tab's harness is initialized — set only for
/// tabs recreated from a saved workspace. Best-effort: unavailable model = default.</summary>
public string? RestoreModel { get; init; }

/// <summary>
/// True from creation until this tab has actually finished settling onto its own model.
/// While it is set, the workspace persists <see cref="RestoreModel"/> 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.
/// </summary>
public bool ModelRestorePending { get; set; }
}

private readonly List<ChatTabEntry> _tabs = new();
Expand Down Expand Up @@ -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);
Expand Down
42 changes: 41 additions & 1 deletion src/MandoCode.Desktop/MainWindow.Terminal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -212,7 +216,27 @@ private void Root_Loaded(object sender, RoutedEventArgs e)
RefreshHistoryBadge();
}

/// <summary>Tabs still moving from the default model onto their own saved one.</summary>
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,
Expand Down Expand Up @@ -241,12 +265,28 @@ private async Task InitTabAsync(ChatTabEntry entry)

/// <summary>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.</summary>
/// <summary>
/// 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.
/// </summary>
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));

Expand Down
Loading