diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml b/src/MandoCode.Desktop/Controls/ChatTabView.xaml
index 6026c5b..81f9283 100644
--- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml
@@ -12,10 +12,11 @@
+
-
-
-
@@ -132,7 +129,64 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
index 9af4ac8..8626fac 100644
--- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
+++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
@@ -112,6 +112,7 @@ public ChatTabView(Window owner, AgentSession session, TranscriptHtmlBuilder htm
_controller.McpEditorRequested += OnMcpEditorRequested;
_controller.ClipboardCopyRequested += OnClipboardCopy;
_controller.ExitRequested += OnExitRequested;
+ _controller.SnapshotOfferChanged += OnSnapshotOfferChanged;
UpdateHeader();
}
@@ -126,6 +127,7 @@ public ChatTabView(Window owner, AgentSession session, TranscriptHtmlBuilder htm
private void OnMcpEditorRequested(string? name) => OnUi(() => McpEditorRequested?.Invoke(name));
private void OnClipboardCopy(string text) => OnUi(() => ClipboardCopyRequested?.Invoke(text));
private void OnExitRequested() => OnUi(() => ExitRequested?.Invoke());
+ private void OnSnapshotOfferChanged() => OnUi(RefreshSnapshotOffer);
private void OnUi(Action action)
{
@@ -278,6 +280,7 @@ public void Shutdown()
_controller.McpEditorRequested -= OnMcpEditorRequested;
_controller.ClipboardCopyRequested -= OnClipboardCopy;
_controller.ExitRequested -= OnExitRequested;
+ _controller.SnapshotOfferChanged -= OnSnapshotOfferChanged;
_controller.CancelActiveRequest();
@@ -327,12 +330,91 @@ private async void ClearTranscript()
catch { }
}
- /// Snapshots the transcript document to a standalone .html file. The highlight
- /// classes and CSS are already baked into the DOM, so the saved page keeps its colors.
- private void SaveTranscript_Click(object sender, RoutedEventArgs e) => _ = ExportTranscriptAsync();
+ /// Offer to snapshot this tab's conversation (the "Take snapshot" tab action) — pops the
+ /// opt-in create card so the user can pick a summarizer model.
+ public void TakeSnapshotManually() => _ = _controller.OfferManualSnapshotAsync();
- /// Manually snapshot this tab's conversation (the "Take snapshot" tab action).
- public void TakeSnapshotManually() => _ = _controller.CaptureManualSnapshotAsync();
+ // ============================================================
+ // Create-snapshot offer card — shown when the controller buffers a conversation (on a model
+ // switch or "Take snapshot"). The user picks a summarizer model and creates, or dismisses to
+ // discard. Snapshots are born summarized; there is no light/un-enhanced state.
+ // ============================================================
+
+ /// Shows or hides the offer card to match the controller's pending buffer, and (when
+ /// shown) loads the model picker.
+ private void RefreshSnapshotOffer()
+ {
+ var offer = _controller.PendingOffer;
+ if (offer == null)
+ {
+ SnapshotOfferCard.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ SnapshotOfferSubtitle.Text =
+ $"{offer.MessageCount} message{(offer.MessageCount == 1 ? "" : "s")} from {offer.OriginModel} — "
+ + "pick a model to write the recap, or dismiss to discard.";
+ SnapshotCreateButton.Content = "Create";
+ SnapshotNameBox.Text = ""; // a fresh offer starts unnamed
+ SnapshotOfferCard.Visibility = Visibility.Visible;
+ _ = LoadSnapshotModelsAsync(offer.OriginModel);
+ }
+
+ /// Populates the model picker without making the card wait on a network round-trip: the
+ /// model that had the conversation is shown selected instantly, then the full installed-model list
+ /// (an Ollama /api/tags fetch, slow on cloud setups) streams in behind it for "pick another."
+ private async Task LoadSnapshotModelsAsync(string originModel)
+ {
+ // Instant: seed with just the current model so the card is usable with zero lag.
+ var current = new ModelChoice(originModel, MandoCodeConfig.IsCloudModel(originModel));
+ SnapshotModelCombo.ItemsSource = new List { current };
+ SnapshotModelCombo.SelectedIndex = 0;
+ SnapshotModelCombo.IsEnabled = true;
+ SnapshotCreateButton.IsEnabled = true;
+
+ // Background: fetch the rest so the dropdown fills in for choosing another model.
+ var result = await _controller.LoadAvailableModelsAsync();
+ if (!result.Ok || result.Models.Count == 0) return; // keep the single current entry
+
+ // Guard against a race: if a newer offer/switch swapped models while we were fetching, don't
+ // clobber its selection with this stale list.
+ if ((SnapshotModelCombo.SelectedItem as ModelChoice)?.Name != originModel) return;
+
+ var choices = result.Models
+ .Select(m => new ModelChoice(m, MandoCodeConfig.IsCloudModel(m)))
+ .ToList();
+ if (!choices.Any(c => string.Equals(c.Name, originModel, StringComparison.OrdinalIgnoreCase)))
+ choices.Insert(0, current); // keep the current model even if the list omits it
+
+ SnapshotModelCombo.ItemsSource = choices;
+ SnapshotModelCombo.SelectedItem =
+ choices.First(c => string.Equals(c.Name, originModel, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private async void SnapshotCreate_Click(object sender, RoutedEventArgs e)
+ {
+ if (SnapshotModelCombo.SelectedItem is not ModelChoice choice)
+ {
+ _transcript.Append(_html.Warn("Pick a model to summarize with first."));
+ return;
+ }
+
+ SnapshotCreateButton.IsEnabled = false;
+ SnapshotCreateButton.Content = "Creating Snapshot...";
+
+ var error = await _controller.CreateSnapshotAsync(choice.Name, SnapshotNameBox.Text);
+ if (error != null)
+ {
+ SnapshotCreateButton.IsEnabled = true;
+ SnapshotCreateButton.Content = "Create";
+ _transcript.Append(_html.Warn(error));
+ }
+ // On success the controller clears the offer → SnapshotOfferChanged → RefreshSnapshotOffer
+ // hides the card, and a "Snapshot saved" chip lands in the transcript.
+ }
+
+ private void SnapshotOfferDismiss_Click(object sender, RoutedEventArgs e)
+ => _controller.DismissSnapshotOffer();
/// Saves this tab's transcript as a standalone HTML page. Shared by the header save
/// button and the tab's options menu.
diff --git a/src/MandoCode.Desktop/MainWindow.xaml b/src/MandoCode.Desktop/MainWindow.xaml
index ed670d3..41c3ff9 100644
--- a/src/MandoCode.Desktop/MainWindow.xaml
+++ b/src/MandoCode.Desktop/MainWindow.xaml
@@ -70,7 +70,7 @@
-
@@ -100,21 +100,44 @@
Padding="12,10">
-
+
+
-
+ Background="{StaticResource MandoPanelBrush}"
+ ToolTipService.ToolTip="Model that wrote this recap">
+
-
-
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -168,6 +191,9 @@
+
+
+
@@ -191,8 +217,8 @@
-
+
@@ -392,6 +418,9 @@
+
+
+
diff --git a/src/MandoCode.Desktop/MainWindow.xaml.cs b/src/MandoCode.Desktop/MainWindow.xaml.cs
index 9885c4c..e418b9f 100644
--- a/src/MandoCode.Desktop/MainWindow.xaml.cs
+++ b/src/MandoCode.Desktop/MainWindow.xaml.cs
@@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
+using System.Diagnostics;
using System.Text.Json;
using MandoCode.Models;
using MandoCode.Desktop.Services;
@@ -10,6 +11,7 @@
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
using Microsoft.UI.Xaml.Shapes;
using Windows.ApplicationModel.DataTransfer;
using Windows.System;
@@ -23,6 +25,13 @@ public sealed class CommandSuggestion
public string Description { get; init; } = "";
}
+/// Row model for the snapshot summarizer dropdown — a model name plus whether it's a cloud
+/// model (which may spend tokens) or a local one (free).
+public sealed record ModelChoice(string Name, bool IsCloud)
+{
+ public string Tag => IsCloud ? "cloud · uses tokens" : "local · free";
+}
+
/// Row model for diff lines shown in the approval overlay.
public sealed class DiffLineVm
{
@@ -71,6 +80,16 @@ public sealed partial class MainWindow : Window
private readonly Microsoft.UI.Dispatching.DispatcherQueue _dispatcher;
private bool _snapshotsPanelOpen;
+ // Slide animation state for the snapshots panel. The column width is tweened per-frame off
+ // CompositionTarget.Rendering so the panel glides in/out instead of snapping. Width is always
+ // held in pixels during and after the tween (never star) so an interrupted open/close can read
+ // the current width and continue smoothly from wherever it is.
+ private readonly Stopwatch _snapAnimClock = new();
+ private EventHandler? _snapAnimHandler;
+ private double _snapAnimFrom, _snapAnimTo;
+ private bool _snapAnimHideOnDone;
+ private const double SnapAnimDurationMs = 220;
+
///
/// Settings and MCP edit the app-global config, but they still need a controller to route
/// through — it owns ConfigKeySetter, the MCP coordinator, and a transcript to report into.
@@ -87,7 +106,7 @@ public MainWindow()
// ONE window-level subscription to the static ThemeChanged event. Chat tabs must not
// subscribe individually — the handler would outlive every closed tab and leak.
ThemeManager.ThemeChanged += () => OnUi(ApplyThemeToAllTabs);
- SettingsTabs.SelectedItem = Tab_Appearance;
+ SettingsTabs.SelectedItem = Tab_Connection; // the setup that matters most opens first
ThemeList.ItemsSource = UiTheme.All.Select(t => new ThemeVm { Theme = t }).ToList();
ModelCombo.Loaded += (_, _) => ApplyModelComboTarget();
S_WindowOpacity.Value = ThemeManager.WindowOpacity * 100;
@@ -227,8 +246,13 @@ private ChatTabEntry CreateChatTab()
private string _currentPage = "chat";
private void NavChat_Click(object sender, RoutedEventArgs e) => SwitchPage("chat");
- private void NavSettings_Click(object sender, RoutedEventArgs e) => SwitchPage("settings");
- private void NavMcp_Click(object sender, RoutedEventArgs e) => SwitchPage("mcp");
+
+ // Settings/MCP act as toggles: clicking the one you're already on closes it and returns to the
+ // last active agent, rather than reloading the page in place.
+ private void NavSettings_Click(object sender, RoutedEventArgs e)
+ => SwitchPage(_currentPage == "settings" ? "chat" : "settings");
+ private void NavMcp_Click(object sender, RoutedEventArgs e)
+ => SwitchPage(_currentPage == "mcp" ? "chat" : "mcp");
private void SwitchPage(string page)
{
@@ -238,6 +262,11 @@ private void SwitchPage(string page)
SettingsPage.Visibility = page == "settings" ? Visibility.Visible : Visibility.Collapsed;
McpPage.Visibility = page == "mcp" ? Visibility.Visible : Visibility.Collapsed;
+ // Glide the full-screen page in from the rail side (translate + fade). Both run on the
+ // composition thread, so the whole page slides smoothly regardless of how much it holds.
+ if (page == "settings") SlideInPage(SettingsPage, SettingsPageTransform);
+ else if (page == "mcp") SlideInPage(McpPage, McpPageTransform);
+
// Every agent view stays loaded; only the selected one shows, and only on the chat page.
// Collapsing rather than removing is what keeps each WebView2's transcript alive.
foreach (var tab in _tabs)
@@ -261,6 +290,39 @@ private void SwitchPage(string page)
}
}
+ /// Slides a full-screen page (Settings/MCP) into view from the rail side, with a short
+ /// fade. Translate and Opacity are independent animations, so this stays smooth on the
+ /// composition thread no matter how much the page contains.
+ private static void SlideInPage(UIElement page, TranslateTransform transform)
+ {
+ var ease = new CubicEase { EasingMode = EasingMode.EaseOut };
+
+ var slide = new DoubleAnimation
+ {
+ From = -48,
+ To = 0,
+ Duration = new Duration(TimeSpan.FromMilliseconds(260)),
+ EasingFunction = ease,
+ };
+ Storyboard.SetTarget(slide, transform);
+ Storyboard.SetTargetProperty(slide, "X");
+
+ var fade = new DoubleAnimation
+ {
+ From = 0,
+ To = 1,
+ Duration = new Duration(TimeSpan.FromMilliseconds(200)),
+ EasingFunction = ease,
+ };
+ Storyboard.SetTarget(fade, page);
+ Storyboard.SetTargetProperty(fade, "Opacity");
+
+ var sb = new Storyboard();
+ sb.Children.Add(slide);
+ sb.Children.Add(fade);
+ sb.Begin();
+ }
+
private void RefreshNavIcons()
{
var accent = (SolidColorBrush)Application.Current.Resources["MandoAccentBrush"];
@@ -294,18 +356,51 @@ private void NavSnapshots_Click(object sender, RoutedEventArgs e)
private void OpenSnapshots()
{
_snapshotsPanelOpen = true;
- SnapshotsColumn.Width = new GridLength(0.6, GridUnitType.Star); // ~37% of the content area
SnapshotsPanel.Visibility = Visibility.Visible;
PopulateSnapshots();
RefreshNavIcons();
+ // Target ~37% of the content area (everything right of the 48px rail), matching the old
+ // 0.6* / 1* split. Computed in pixels at open time so the tween can drive the column.
+ double target = Math.Max(320, (Root.ActualWidth - 48) * 0.375);
+ AnimateSnapshotsColumn(target, hideOnDone: false);
}
private void CloseSnapshots()
{
_snapshotsPanelOpen = false;
- SnapshotsColumn.Width = new GridLength(0);
- SnapshotsPanel.Visibility = Visibility.Collapsed;
RefreshNavIcons();
+ AnimateSnapshotsColumn(0, hideOnDone: true);
+ }
+
+ /// Tweens the snapshots column width to with an ease-out curve,
+ /// gliding the panel open or closed. Re-entrant: a click mid-slide retargets from the current
+ /// width rather than restarting from the edge.
+ private void AnimateSnapshotsColumn(double toPx, bool hideOnDone)
+ {
+ // Drop any in-flight tween so rapid toggles can't stack Rendering handlers.
+ if (_snapAnimHandler != null) CompositionTarget.Rendering -= _snapAnimHandler;
+
+ _snapAnimFrom = SnapshotsColumn.Width.IsAbsolute ? SnapshotsColumn.Width.Value : 0;
+ _snapAnimTo = toPx;
+ _snapAnimHideOnDone = hideOnDone;
+ _snapAnimClock.Restart();
+
+ _snapAnimHandler = (_, _) =>
+ {
+ double t = Math.Clamp(_snapAnimClock.Elapsed.TotalMilliseconds / SnapAnimDurationMs, 0, 1);
+ double eased = 1 - Math.Pow(1 - t, 3); // ease-out cubic
+ double w = _snapAnimFrom + (_snapAnimTo - _snapAnimFrom) * eased;
+ SnapshotsColumn.Width = new GridLength(w, GridUnitType.Pixel);
+
+ if (t >= 1)
+ {
+ CompositionTarget.Rendering -= _snapAnimHandler;
+ _snapAnimHandler = null;
+ _snapAnimClock.Stop();
+ if (_snapAnimHideOnDone) SnapshotsPanel.Visibility = Visibility.Collapsed;
+ }
+ };
+ CompositionTarget.Rendering += _snapAnimHandler;
}
private void OnSnapshotsChanged()
@@ -621,6 +716,10 @@ private void SettingsTabs_SelectionChanged(SelectorBar sender, SelectorBarSelect
TabPanel_Limits.Visibility = s == Tab_Limits ? Visibility.Visible : Visibility.Collapsed;
TabPanel_Integrations.Visibility = s == Tab_Integrations ? Visibility.Visible : Visibility.Collapsed;
TabPanel_Appearance.Visibility = s == Tab_Appearance ? Visibility.Visible : Visibility.Collapsed;
+
+ // Appearance is app-wide (a window property), not a per-agent setting, so "Make Default for
+ // New Agents" has nothing to save there — hide it on that tab to avoid a no-op button.
+ MakeDefaultButton.Visibility = s == Tab_Appearance ? Visibility.Collapsed : Visibility.Visible;
}
private void WindowOpacity_Changed(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e)
diff --git a/src/MandoCode.Desktop/Services/ContextSnapshot.cs b/src/MandoCode.Desktop/Services/ContextSnapshot.cs
index 94b3596..d610bc5 100644
--- a/src/MandoCode.Desktop/Services/ContextSnapshot.cs
+++ b/src/MandoCode.Desktop/Services/ContextSnapshot.cs
@@ -1,46 +1,40 @@
namespace MandoCode.Desktop.Services;
///
-/// A captured "history point": the conversation as it stood the instant the user switched an
-/// agent's model. Switching a model clears the live context (a different model mid-history is a
-/// different conversation), so this is the salvaged copy — the user can re-import it into another
-/// model later, or delete it.
+/// A captured "history point": an LLM-written recap of a conversation, saved on demand. Switching an
+/// agent's model clears the live context (a different model mid-history is a different conversation);
+/// rather than auto-salvaging a deterministic dump, the user is offered the chance to snapshot that
+/// conversation — summarized by a model of their choice. A snapshot is therefore always born with a
+/// real ; there is no "light"/"un-enhanced" state.
///
-/// Pure data, no UI types, because it is created on a background thread during the switch.
-/// is the free deterministic summary captured immediately;
-/// is the full serialized transcript, kept so a richer LLM summary can be
-/// generated on demand later ( ) without needing the original conversation to
-/// still be alive.
+/// Pure data, no UI types — it is created on a background thread while the summary is generated.
///
public sealed class ContextSnapshot
{
public required int Id { get; init; }
public required DateTimeOffset CapturedAt { get; init; }
- /// The model whose conversation this summarizes.
+ /// The model whose conversation this recaps.
public required string OriginModel { get; init; }
- /// The model the user switched TO (context for why the snapshot exists).
- public required string SwitchedToModel { get; init; }
+ /// The model that generated (may differ from the origin — the user
+ /// can summarize with a lighter/cheaper local model, or a stronger one).
+ public required string SummarizerModel { get; init; }
- /// Deterministic recap (ported from the harness). Always present, capped small.
- public required string LightRecap { get; init; }
+ /// The LLM-generated recap — the whole point of the snapshot, and what Import carries.
+ public required string Recap { get; init; }
- /// Full serialized history, kept so an AI summary can be generated later.
- public required string RawHistory { get; init; }
+ /// Optional user-given name. Null/empty when the user didn't name it (then the card
+ /// falls back to the origin model as its title).
+ public string? Name { get; init; }
/// Conversation length (messages, excluding the system prompt).
public required int MessageCount { get; init; }
- /// LLM-generated recap, once "Enhance" has run. Null until then.
- public string? AiRecap { get; set; }
+ // ---- display helpers for the snapshots panel ----
- /// "Light" until enhanced, then "AI".
- public string Tag => AiRecap != null ? "AI" : "Light";
+ /// Card title: the user's name if given, else the model that had the conversation.
+ public string DisplayTitle => string.IsNullOrWhiteSpace(Name) ? OriginModel : Name!;
- /// The recap to re-import — the richer AI one if it exists, else the light one.
- public string BestRecap => AiRecap ?? LightRecap;
-
- // ---- display helpers for the snapshots flyout ----
public string TimeLabel => CapturedAt.LocalDateTime.ToString("MMM d · h:mm tt");
}
diff --git a/src/MandoCode.Desktop/Services/HistorySummarizer.cs b/src/MandoCode.Desktop/Services/HistorySummarizer.cs
index 6f689da..38d82b9 100644
--- a/src/MandoCode.Desktop/Services/HistorySummarizer.cs
+++ b/src/MandoCode.Desktop/Services/HistorySummarizer.cs
@@ -4,16 +4,13 @@
namespace MandoCode.Desktop.Services;
///
-/// Desktop port of AIService.SynthesizeHistorySummary , which is private in the pinned
-/// harness. It walks a chat history and produces a compact recap. Fed by the public
-/// AIService.GetHistoryAsync() , so it needs no harness change.
+/// Flattens a chat history into a plain-text transcript, fed by the public
+/// AIService.GetHistoryAsync() . Snapshots buffer this dump and hand it to
+/// to summarize — the LLM does the recap, so nothing here truncates.
///
-/// Two flavors: — truncated per line and capped overall, the instant/free
-/// snapshot taken on every model switch — and — untruncated, stored alongside so
-/// a richer LLM summary can be generated later without the original conversation still being live.
-///
-/// Behavioural port: if the harness's original changes when the submodule pin is rolled, re-check
-/// this against it (same class of "ported seam" as ChatController / WinUiApprovalService).
+/// (The deterministic per-line/overall truncation this once did — a port of the harness's compaction
+/// summary — was dropped when snapshots moved to LLM summaries: it kept the oldest turns and cut the
+/// most recent, which is backwards for a resumption recap.)
///
public static class HistorySummarizer
{
@@ -27,11 +24,7 @@ public static bool HasContent(IReadOnlyList history, int sta
return false;
}
- /// Truncated, capped recap — mirrors the harness's compaction summary.
- public static string Light(IReadOnlyList history, int startIndex = 1, int maxChars = 1500)
- => Build(history, startIndex, lineMax: 180, maxChars: maxChars);
-
- /// Full untruncated dump — stored for a possible later AI enhancement.
+ /// Full untruncated dump — the text handed to the summarizer.
public static string Full(IReadOnlyList history, int startIndex = 1)
=> Build(history, startIndex, lineMax: int.MaxValue, maxChars: int.MaxValue);
diff --git a/src/MandoCode.Desktop/Services/SnapshotEnhancer.cs b/src/MandoCode.Desktop/Services/SnapshotEnhancer.cs
new file mode 100644
index 0000000..f2b98ce
--- /dev/null
+++ b/src/MandoCode.Desktop/Services/SnapshotEnhancer.cs
@@ -0,0 +1,141 @@
+using System.Text;
+using Microsoft.SemanticKernel;
+using Microsoft.SemanticKernel.ChatCompletion;
+using Microsoft.SemanticKernel.Connectors.Ollama;
+
+namespace MandoCode.Desktop.Services;
+
+///
+/// One-off LLM summarizer that turns a buffered conversation into a snapshot's recap. Builds a bare
+/// Ollama kernel with no plugins, tools, filters, or shared history, so summarizing can never touch a
+/// live agent's conversation or trigger a tool call. Fed the full untruncated history from
+/// ChatController.PendingSnapshot ; the result becomes .
+///
+/// Summarizes the ENTIRE conversation via map-reduce: the history is chunked, each chunk is
+/// summarized, then the partial summaries are reduced into one final recap. This mirrors the shape
+/// of Semantic Kernel's ConversationSummaryPlugin (chunk → summarize → combine) but is
+/// hand-rolled here so it needs no extra alpha package (Plugins.Core / TextChunker), and uses a
+/// prompt tuned for coding transcripts rather than SK's generic one. If we later pull in
+/// Plugins.Core, this is the natural swap point.
+///
+/// Lives in the Desktop tree because the harness AIService exposes no one-shot completion.
+///
+public static class SnapshotEnhancer
+{
+ // Chunk sizing. Kept modest so a lightweight local model comprehends each chunk well, while the
+ // per-conversation chunk COUNT is capped so a huge history can't fan out into dozens of calls —
+ // instead chunks grow coarser. Either way the whole conversation is covered, never truncated.
+ private const int MinChunkChars = 6000; // ~1.5k tokens — comfortable for small models
+ private const int MaxChunks = 16; // bounds the number of local calls on giant histories
+
+ // A snapshot recap is Imported and silently prepended to ANOTHER model's next message, so the
+ // prompts frame it as a HANDOFF BRIEFING to an AI assistant — not a human-facing summary. They're
+ // domain-agnostic (coding, research Q&A, debugging, plain chat), weight the most recent turns
+ // (where the current state lives), preserve specifics verbatim, and emit PLAIN PROSE (the card
+ // renders the recap as plain text, so markdown/asterisks would leak).
+ private const string StyleRules =
+ " Write plain, dense prose addressed to the assistant (e.g. \"The user is building…\"). No " +
+ "markdown, headings, bullets, asterisks, or backticks. Preserve specifics VERBATIM — file " +
+ "paths, names, numbers, versions, URLs, identifiers, exact decisions. Make explicit what is " +
+ "already DONE versus still UNFINISHED, so the assistant knows what to work on next. Be " +
+ "self-contained: don't refer to \"the conversation above.\" Use only what's actually in the " +
+ "transcript — never invent or assume. Don't describe what the conversation was NOT about; " +
+ "capture what it WAS about.";
+
+ private const string MapPrompt =
+ "Summarize this SEGMENT of a longer conversation as raw material for a later handoff. Capture " +
+ "the substantive content: what the user wants, key facts, answers, decisions, code, files, " +
+ "values, and errors, plus anything left open. State facts only — no guessing about other " +
+ "segments." + StyleRules;
+
+ private const string ReducePrompt =
+ "Below are ordered segment summaries of one conversation. Merge them into a single briefing " +
+ "that will be handed to another AI assistant so it can continue this conversation seamlessly. " +
+ "Cover: what the user is trying to do, the key facts / decisions / code established so far, " +
+ "any preferences or constraints the user stated, and the immediate open thread or next step " +
+ "(including any unanswered question). Give extra weight to the most recent exchanges — that's " +
+ "where things currently stand." + StyleRules + " Keep it to a tight paragraph or two; length " +
+ "should match how much actually happened.";
+
+ private const string SinglePrompt =
+ "You are writing a briefing that will be silently handed to another AI assistant so it can " +
+ "continue this conversation without missing a beat. From the transcript below, write what " +
+ "that assistant needs to know: what the user is trying to do, the key facts / answers / " +
+ "decisions / code established so far, any preferences or constraints the user stated, and the " +
+ "immediate open thread or next step (including any unanswered question). Give extra weight to " +
+ "the most recent exchanges — that's where things currently stand." + StyleRules + " Keep it " +
+ "to a tight paragraph or two; length should match how much actually happened.";
+
+ /// Generates an AI recap of using the given Ollama model.
+ /// Throws on connection/model failure; the caller decides how to surface it.
+ public static async Task SummarizeAsync(
+ string endpoint, string model, string rawHistory, CancellationToken ct = default)
+ {
+ var kernel = Kernel.CreateBuilder()
+ .AddOllamaChatCompletion(modelId: model, endpoint: new Uri(endpoint))
+ .Build();
+
+ var chat = kernel.GetRequiredService();
+
+ var chunks = Chunk(rawHistory);
+
+ // Short conversation — one pass, straight to a final-shaped recap.
+ if (chunks.Count <= 1)
+ return await SummarizeOneAsync(chat, kernel, SinglePrompt, rawHistory, ct);
+
+ // Map: summarize each segment independently.
+ var partials = new List(chunks.Count);
+ for (int i = 0; i < chunks.Count; i++)
+ {
+ var part = await SummarizeOneAsync(chat, kernel, MapPrompt, chunks[i], ct);
+ if (!string.IsNullOrWhiteSpace(part))
+ partials.Add($"Segment {i + 1}/{chunks.Count}:\n{part}");
+ }
+
+ if (partials.Count == 0) return "";
+
+ // Reduce: fold the segment summaries into one recap.
+ return await SummarizeOneAsync(chat, kernel, ReducePrompt, string.Join("\n\n", partials), ct);
+ }
+
+ /// One chat round: system instruction + the text to summarize. Stateless — a fresh
+ /// history each call, so nothing leaks between chunks.
+ private static async Task SummarizeOneAsync(
+ IChatCompletionService chat, Kernel kernel, string instruction, string text, CancellationToken ct)
+ {
+ var history = new ChatHistory();
+ history.AddSystemMessage(instruction);
+ history.AddUserMessage(text);
+
+ // Low temperature — a recap should be faithful, not creative.
+ var settings = new OllamaPromptExecutionSettings { Temperature = 0.2f };
+
+ var result = await chat.GetChatMessageContentAsync(history, settings, kernel, ct);
+ return result.Content?.Trim() ?? "";
+ }
+
+ /// Splits the history into chunks on line boundaries. Chunk size grows if needed so the
+ /// count never exceeds — the entire conversation is always covered.
+ private static List Chunk(string history)
+ {
+ history = history?.Trim() ?? "";
+ if (history.Length <= MinChunkChars) return new List { history };
+
+ // Grow the chunk size so a very large history still fits in MaxChunks pieces.
+ int chunkSize = Math.Max(MinChunkChars, (int)Math.Ceiling((double)history.Length / MaxChunks));
+
+ var chunks = new List();
+ var sb = new StringBuilder(chunkSize + 256);
+ foreach (var line in history.Split('\n'))
+ {
+ if (sb.Length > 0 && sb.Length + line.Length + 1 > chunkSize)
+ {
+ chunks.Add(sb.ToString());
+ sb.Clear();
+ }
+ sb.Append(line).Append('\n');
+ }
+ if (sb.Length > 0) chunks.Add(sb.ToString());
+ return chunks;
+ }
+}
diff --git a/src/MandoCode.Desktop/Services/SnapshotStore.cs b/src/MandoCode.Desktop/Services/SnapshotStore.cs
index c6769c7..8e24003 100644
--- a/src/MandoCode.Desktop/Services/SnapshotStore.cs
+++ b/src/MandoCode.Desktop/Services/SnapshotStore.cs
@@ -29,7 +29,8 @@ public int Count
get { lock (_lock) return _items.Count; }
}
- public ContextSnapshot Add(string originModel, string switchedTo, string lightRecap, string rawHistory, int messageCount)
+ public ContextSnapshot Add(string originModel, string summarizerModel, string recap, int messageCount,
+ string? name = null)
{
ContextSnapshot snapshot;
lock (_lock)
@@ -39,10 +40,10 @@ public ContextSnapshot Add(string originModel, string switchedTo, string lightRe
Id = ++_nextId,
CapturedAt = DateTimeOffset.Now,
OriginModel = originModel,
- SwitchedToModel = switchedTo,
- LightRecap = lightRecap,
- RawHistory = rawHistory,
+ SummarizerModel = summarizerModel,
+ Recap = recap,
MessageCount = messageCount,
+ Name = string.IsNullOrWhiteSpace(name) ? null : name.Trim(),
};
_items.Insert(0, snapshot); // newest first
}
diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.cs b/src/MandoCode.Desktop/ViewModels/ChatController.cs
index f5a8a1c..346dcc9 100644
--- a/src/MandoCode.Desktop/ViewModels/ChatController.cs
+++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs
@@ -38,10 +38,12 @@ public sealed partial class ChatController
private readonly McpCoordinator _mcp;
private readonly SnapshotStore _snapshots;
- /// Recap armed by "Import" — prepended (invisibly) to this agent's next message.
- /// There is no public harness API to inject a message into a fresh conversation, so we ride it
- /// along on the next send instead.
- private string? _armedContext;
+ /// Recaps armed by "Import" — prepended (invisibly) to this agent's next message.
+ /// There is no public harness API to inject a message into a fresh conversation, so we ride them
+ /// along on the next send instead. Multiple imports ACCUMULATE (each is a distinct past
+ /// conversation), and all ride along together on the next send.
+ private readonly List _armedContexts = new();
+ private readonly HashSet _armedSnapshotIds = new(); // dedupe: don't queue the same snapshot twice
private CancellationTokenSource? _requestCts;
private bool _isProcessing;
@@ -342,15 +344,19 @@ public async Task SubmitAsync(string input)
var needsPlanning = _taskPlanner.RequiresPlanning(input);
var processedInput = ProcessFileReferences(input);
- // An imported snapshot (from "Import" in the Snapshots panel) rides along ONCE, as
- // background the model already knows — the user's echoed message stays their own text.
- if (_armedContext is { Length: > 0 })
+ // Imported snapshots (from "Import" in the Snapshots panel) ride along ONCE, as background
+ // the model already knows — the user's echoed message stays their own text. Multiple
+ // imports accumulate and are all sent together, each kept as a distinct recap.
+ if (_armedContexts.Count > 0)
{
+ var noun = _armedContexts.Count == 1 ? "recap" : "recaps";
processedInput =
- "[Imported context recap from a previous model. Treat it as background you " +
- "already have; do not reply to it directly.]\n" + _armedContext +
+ $"[Imported context — {_armedContexts.Count} {noun} from previous conversations. " +
+ "Treat as background you already have; do not reply to it directly.]\n" +
+ string.Join("\n\n", _armedContexts) +
"\n\n[Current request:]\n" + processedInput;
- _armedContext = null;
+ _armedContexts.Clear();
+ _armedSnapshotIds.Clear(); // a new batch can re-import the same snapshots next time
}
if (needsPlanning)
@@ -1125,11 +1131,12 @@ private async Task HandleModelCommandAsync(string rawArgs)
///
private async Task ApplyModelSwitchAsync(string modelTag)
{
- // Snapshot the outgoing conversation BEFORE anything clears it. ReinitializeAsync below
- // wipes the live history (a different model mid-history is a different conversation), so
- // this is the one chance to salvage it for a later re-import.
+ // Buffer the outgoing conversation BEFORE anything clears it. ReinitializeAsync below wipes
+ // the live history (a different model mid-history is a different conversation), so this is the
+ // one chance to grab it. It is NOT auto-saved — we offer the user a snapshot (summarized by a
+ // model of their choice) after the switch; if they ignore the offer, the buffer is discarded.
var previousModel = _config.GetEffectiveModelName();
- var captured = await CaptureContextSnapshotAsync(previousModel, modelTag);
+ _pending = await BufferConversationAsync(previousModel);
_config.ModelName = modelTag;
_config.ModelPath = null;
@@ -1155,55 +1162,119 @@ private async Task ApplyModelSwitchAsync(string modelTag)
}
_transcript.Append(_html.StatusChip(modelTag, "now active", "ok"));
- _transcript.Append(captured
- ? _html.StatusChip("Context cleared", "snapshot saved", "")
- : _html.StatusChip("Context cleared", "new model starts fresh", ""));
+
+ // 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)
+ {
+ _transcript.Append(_html.StatusChip("Context cleared", "create a snapshot?", ""));
+ SnapshotOfferChanged?.Invoke();
+ }
+
StateChanged?.Invoke();
}
- ///
- /// Captures the outgoing conversation as a before a model switch
- /// clears it. Returns false (and stores nothing) when there is nothing worth keeping. Never
- /// throws — a failed snapshot must not block the switch.
- ///
- private async Task CaptureContextSnapshotAsync(string originModel, string switchedTo)
+ /// The outgoing conversation buffered on a model switch (or the live one, for a manual
+ /// snapshot), held only until the user creates a snapshot from it or ignores the offer. Pure
+ /// opt-in: it is NOT auto-saved, and is discarded on the next switch or when the app closes.
+ public sealed record PendingSnapshot(string OriginModel, string RawHistory, int MessageCount);
+
+ private PendingSnapshot? _pending;
+
+ /// The conversation currently on offer to snapshot, or null if there's nothing pending.
+ public PendingSnapshot? PendingOffer => _pending;
+
+ /// Raised when appears or clears, so the tab can show/hide its
+ /// "create a snapshot?" card. Fires on the calling thread.
+ public event Action? SnapshotOfferChanged;
+
+ /// Buffers the outgoing conversation before a switch clears it. Returns null (nothing
+ /// buffered) when there's nothing worth keeping. Never throws — must not block the switch.
+ private async Task BufferConversationAsync(string originModel)
{
try
{
var history = await _ai.GetHistoryAsync();
- if (!HistorySummarizer.HasContent(history)) return false;
-
- _snapshots.Add(
- originModel,
- switchedTo,
- HistorySummarizer.Light(history),
- HistorySummarizer.Full(history),
- history.Count - 1); // exclude the system prompt at index 0
- return true;
+ if (!HistorySummarizer.HasContent(history)) return null;
+ // Full (untruncated) — this is only ever fed to the summarizer, never stored on a snapshot.
+ return new PendingSnapshot(originModel, HistorySummarizer.Full(history), history.Count - 1);
}
catch
{
- return false;
+ return null;
}
}
- ///
- /// Snapshots the current conversation on demand — without switching models or clearing it.
- /// The "Take snapshot" tab action. Notes the outcome in the transcript.
- ///
- public async Task CaptureManualSnapshotAsync()
+ /// Offers to snapshot the CURRENT conversation on demand (the "Take snapshot" tab action),
+ /// without switching models or clearing it. Shows the create card, or notes there's nothing to save.
+ public async Task OfferManualSnapshotAsync()
{
- var captured = await CaptureContextSnapshotAsync(ModelName, ModelName);
- _transcript.Append(captured
- ? _html.StatusChip("Snapshot saved", "in Snapshots", "ok")
- : _html.StatusChip("Nothing to snapshot", "start a conversation first", "warn"));
+ _pending = await BufferConversationAsync(ModelName);
+ if (_pending == null)
+ {
+ _transcript.Append(_html.StatusChip("Nothing to snapshot", "start a conversation first", "warn"));
+ return;
+ }
+ SnapshotOfferChanged?.Invoke();
+ }
+
+ /// Creates a snapshot from the pending conversation, summarized by
+ /// (the origin model, the user's favorite, or an explicit pick).
+ /// Born summarized — there is no light/un-enhanced state. Returns null on success, else an error.
+ public async Task CreateSnapshotAsync(string summarizerModel, string? name = null)
+ {
+ var pending = _pending;
+ if (pending == null) return "Nothing to snapshot.";
+ if (string.IsNullOrWhiteSpace(summarizerModel)) return "Pick a model to summarize with.";
+
+ try
+ {
+ // The endpoint is shared (Ollama routes local and cloud models alike), so any installed
+ // model summarizes fine regardless of which model the chat is on.
+ var recap = await SnapshotEnhancer.SummarizeAsync(
+ _config.OllamaEndpoint, summarizerModel, pending.RawHistory);
+
+ if (string.IsNullOrWhiteSpace(recap))
+ return "The model returned an empty recap.";
+
+ _snapshots.Add(pending.OriginModel, summarizerModel, recap, pending.MessageCount, name);
+ _pending = null;
+ SnapshotOfferChanged?.Invoke();
+ var label = string.IsNullOrWhiteSpace(name) ? $"summarized by {summarizerModel}" : $"\"{name.Trim()}\"";
+ _transcript.Append(_html.StatusChip("Snapshot saved", label, "ok"));
+ return null;
+ }
+ catch (Exception ex)
+ {
+ return $"Snapshot failed: {ex.Message}";
+ }
+ }
+
+ /// Declines the pending snapshot offer — pure opt-in, so the buffered conversation is
+ /// discarded.
+ public void DismissSnapshotOffer()
+ {
+ if (_pending == null) return;
+ _pending = null;
+ SnapshotOfferChanged?.Invoke();
}
/// Arms a snapshot's recap so it rides along (invisibly) with this agent's next message.
public void ImportContext(ContextSnapshot snapshot)
{
- _armedContext = snapshot.BestRecap;
- _transcript.Append(_html.StatusChip("Context imported", $"from {snapshot.OriginModel}", ""));
+ // Skip a snapshot that's already queued for the next send, so a double-click (or re-import)
+ // doesn't stack the same recap twice.
+ if (!_armedSnapshotIds.Add(snapshot.Id))
+ {
+ _transcript.Append(_html.StatusChip("Already imported", $"{snapshot.DisplayTitle} is queued", ""));
+ return;
+ }
+
+ // Accumulate — importing several snapshots stacks them, each labeled so the model can tell
+ // the distinct past conversations apart. They all ride along on the next send.
+ _armedContexts.Add($"From \"{snapshot.DisplayTitle}\":\n{snapshot.Recap}");
+ // Show the snapshot's name when it has one, else the model it came from.
+ _transcript.Append(_html.StatusChip("Context imported", $"from {snapshot.DisplayTitle}", ""));
}
private async Task HandleLearnCommandAsync()