diff --git a/CHANGELOG.md b/CHANGELOG.md index 278995c..9037da6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -246,6 +246,15 @@ 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 +- **Switching to a cloud model no longer repeats the subscription notice.** Picking a cloud model + announced that cloud models need an ollama.com subscription. It was meant to say so once, but the + "already said it" memory belonged to a single agent rather than the app, so every agent you + switched said it again — and a tab restored onto a cloud model marked the notice as shown without + displaying it, suppressing it for the rest of that session. The notice is gone entirely rather + than repaired: the model chip already marks a model as `cloud`, and if a cloud request actually + fails you now get the only message that was ever actionable — that the account is signed in but + has no active subscription. The setup wizard still explains cloud versus local while you are + choosing, and still offers to sign you in when you are not. - **Deleting a snapshot, past conversation, or note no longer resets the list.** These panels rebuilt themselves after every deletion, so the surviving cards slid back up and expanded project groups collapsed — losing your place in the middle of tidying up. The lists now update in place: diff --git a/src/MandoCode.Desktop.Tests/ModelNoticeReplayTests.cs b/src/MandoCode.Desktop.Tests/ModelNoticeReplayTests.cs index c05e92e..b97bafe 100644 --- a/src/MandoCode.Desktop.Tests/ModelNoticeReplayTests.cs +++ b/src/MandoCode.Desktop.Tests/ModelNoticeReplayTests.cs @@ -20,6 +20,23 @@ public void RepeatedRestoresDropAllHistoricalSetupNoticesButKeepConversation() Assert.Equal(replay, replay.Where(h => !ModelNoticeReplay.IsTransient(h))); } + [Fact] + public void TheRetiredCloudNoticeIsStrippedInBothOfItsWordings() + { + // The notice is no longer produced at all — a model switch says nothing about cloud + // subscriptions now. But it sits in the journals of anyone who switched to a cloud model + // before it was removed, and replay is the ONLY thing keeping it off screen, so both + // shipped wordings have to stay recognised. + Assert.True(ModelNoticeReplay.IsTransient( + Notice("Cloud models run on ollama.com and need an active cloud subscription."))); + + // The 0.14.x wording. Journaled HTML-ENCODED, because it contains an apostrophe — matching + // it against a raw literal silently fails, which is how it would come back on restore. + Assert.True(ModelNoticeReplay.IsTransient( + Notice("Cloud model — runs on ollama.com's servers and needs an account with an " + + "active cloud subscription. Without one, requests return 403 Forbidden."))); + } + [Fact] public void QuotedWordingAndActualWarningsArePreserved() { @@ -28,5 +45,13 @@ public void QuotedWordingAndActualWarningsArePreserved() Assert.False(ModelNoticeReplay.IsTransient(Notice(Sizing, "warn"))); Assert.False(ModelNoticeReplay.IsTransient(Notice("The context window is too small for this request."))); Assert.False(ModelNoticeReplay.IsTransient(Notice("Project root changed to: C:\\project"))); + + // The setup wizard's cloud-vs-local explainer is also a dim notice and mentions the same + // subscription. It is part of a walkthrough the user went through, so it is history and + // must survive — the match is on the exact retired notices, not on the word "cloud". + Assert.False(ModelNoticeReplay.IsTransient(Notice( + "Cloud models run on ollama.com's servers: more capable, no GPU needed, but they " + + "require an ollama.com account with an active cloud subscription. Local models run " + + "privately on your own hardware, free — bigger is smarter but needs more memory."))); } } diff --git a/src/MandoCode.Desktop/Services/ModelNoticeReplay.cs b/src/MandoCode.Desktop/Services/ModelNoticeReplay.cs index c55b7eb..52ee00e 100644 --- a/src/MandoCode.Desktop/Services/ModelNoticeReplay.cs +++ b/src/MandoCode.Desktop/Services/ModelNoticeReplay.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Text.RegularExpressions; namespace MandoCode.Desktop.Services; @@ -5,6 +6,20 @@ namespace MandoCode.Desktop.Services; /// Old model setup notices describe a prior runtime, not the restored conversation. public static class ModelNoticeReplay { + /// + /// The cloud-subscription notice a model switch used to emit. No longer produced — the model + /// chip already marks a model as cloud, and ResponseStreamer says the actionable version if a + /// 403 actually arrives — but it sits in the journals of anyone who switched to a cloud model + /// before it was removed, so replay must still recognise it. Both wordings are listed: the + /// second shipped in 0.14.x and the first replaced it, and a journal can hold either. + /// + private static readonly string[] RetiredCloudNotices = + { + "Cloud models run on ollama.com and need an active cloud subscription.", + "Cloud model — runs on ollama.com's servers and needs an account with an active cloud " + + "subscription. Without one, requests return 403 Forbidden.", + }; + public static bool IsTransient(string html) { // Match only standalone system notices. Keep user messages, assistant explanations, @@ -12,8 +27,12 @@ public static bool IsTransient(string html) var match = Regex.Match(html, "\\A
[^<]*([^<]*)
\\z"); if (!match.Success) return false; - var text = match.Groups[1].Value; - return text == "Cloud models run on ollama.com and need an active cloud subscription." + + // Decoded, because the journal holds the ESCAPED text: the retired wording contains an + // apostrophe, which is written as ' and would never match a raw C# literal. + var text = WebUtility.HtmlDecode(match.Groups[1].Value); + + return RetiredCloudNotices.Contains(text) || Regex.IsMatch(text, @"\AContext window sized to \d+k tokens for this model tier \(applies from your next message\)\.\z"); } } diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.cs b/src/MandoCode.Desktop/ViewModels/ChatController.cs index 42a938a..c0720ed 100644 --- a/src/MandoCode.Desktop/ViewModels/ChatController.cs +++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs @@ -199,7 +199,6 @@ private void SetPlanProgress(int step, int total, bool running) /// public bool DeferModelAnnouncement { get; set; } - private bool _cloudNoticeShown; /// The single status line for the live model: state, image capability, where it runs. public void AnnounceModelStatus() @@ -1809,18 +1808,6 @@ private async Task ApplyModelSwitchAsync(string modelTag) _transcript.Append(_html.StatusChip(modelTag, ModelStatusDetail(modelTag), "ok")); - // Selection-time awareness, not just failure-time: cloud models require an active - // ollama.com cloud subscription — a signed-in account without one gets 403 Forbidden - // on its first message, which reads as the app breaking. Once per session is enough to - // establish that; the chip already marks every cloud model, and ResponseStreamer says - // the actionable version if a 403 actually arrives. - if (MandoCodeConfig.IsCloudModel(modelTag) && !_cloudNoticeShown) - { - _cloudNoticeShown = true; - if (!DeferModelAnnouncement) - _transcript.Append(_html.Dim("Cloud models run on ollama.com and need an active cloud subscription.")); - } - // Only mention the cleared context — and offer a snapshot — when there was actually a // conversation to clear. Switching an empty chat has nothing to salvage, so stay quiet. if (_pending != null)